软考软件设计师上午题里,数据结构部分有个小知识点,分值不高但出镜率稳定,就是稀疏矩阵。我当年备考的时候一开始没当回事,觉得不就是二维数组嘛,等刷了几套真题才发现,压缩存储后的地址计算、三元组的转置这些考点,坑还真不少,稍不注意就掉进下标计算错误里。这篇是软考备战系列第十一篇,专门把稀疏矩阵相关的考点完整梳理一遍,从定义到存储结构,从地址计算到运算实现,最后结合近几年真题的考法给出复习建议,希望能帮你用最短的时间把这一分稳稳拿到手。
1. 稀疏矩阵是什么:考了十几年到底在考什么
1.1 稀疏矩阵的定义与判定标准
先聊定义。一个矩阵里绝大多数元素都是零,只有少量非零元素,这样的矩阵就叫稀疏矩阵。考试里判断稀疏矩阵不是靠肉眼感觉,而是有一个量化指标——稀疏因子,一般用 δ 表示,δ = 非零元素个数 / 矩阵总元素个数。当 δ ≤ 0.05,也就是非零元占比不超过 5% 时,就认为这个矩阵是稀疏矩阵。
举个例子,一个 100×100 的矩阵,总共有 10000 个元素,如果非零元只有 300 个,稀疏因子就是 300/10000 = 0.03,小于 0.05,属于稀疏矩阵。如果是 10×10 的矩阵有 8 个非零元,δ = 0.08,那就不能算稀疏矩阵了,老老实实用二维数组存就行。
我备考时对这部分的理解是:稀疏矩阵本质上是在讨论"什么时候该用特殊存储方式"的问题。当矩阵规模大、非零元少,用常规二维数组存会浪费大量空间,这时候才需要设计专门的存储结构。软考考的不是"什么是稀疏矩阵"这种概念题,而是围绕"怎么存""怎么算"出题。
1.2 软考考察的核心意图
从历年真题来看,软考软件设计师对稀疏矩阵的考察集中在三个层面。
第一个层面是存储结构的设计,包括三元组表的结构体定义、非零元按什么顺序排列、十字链表的基本原理,这部分以概念和读代码为主,难度不高但容易混淆。
第二个层面是元素地址的计算。这个可以说是整章最重要的实操点,考的频次很高。题目通常会给你一个对称矩阵、下三角矩阵或者三对角矩阵,要求算某个元素 a[i][j] 按某种压缩方式存储后在一维数组中的下标。这里考的是你对行优先、列优先存储规则的理解,以及下标从 1 开始还是从 0 开始这种细节敏感度。
第三个层面是运算的算法逻辑与复杂度分析。比如稀疏矩阵转置的两种方式(普通转置和快速转置)、两个稀疏矩阵相加、相乘的基本思路。这部分在上午题里主要考时间复杂度、空间复杂度的判断,在下午题的数据结构大题里偶尔会要求手写关键代码。
整体来看,这个知识点的考察难度属于中等偏下,但正因为难度不高,反而容易因为粗心丢分。尤其是下标计算,一个不留神就把 1 和 0 混了,一题就没了。所以这篇文章后半部分我会用大量例题带你过一遍计算过程,把常见的坑都踩一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三元组和十字链表:两种存储方案详解
2.1 三元组表示法:数据结构定义与C语言实现
三元组是稀疏矩阵最基础也最常考的存储结构。核心思想很直白:只存非零元素,把每个非零元素的行号、列号、值三个信息记录成一个三元组 (row, col, value),然后按行优先的顺序依次放入一张顺序表中。
C语言结构体定义是这样的:
c复制#define MAXSIZE 1000
typedef struct {
int row; // 行下标
int col; // 列下标
int value; // 元素值
} Triple;
typedef struct {
Triple data[MAXSIZE]; // 非零元三元组表
int mu; // 矩阵行数
int nu; // 矩阵列数
int tu; // 非零元个数
} TSMatrix;
注意这个结构体里 data 数组是按行优先顺序存放非零元的,也就是说先存第 1 行的非零元,再存第 2 行的,同一行内按列号从小到大。这个顺序在转置、加法运算里非常关键,很多算法都依赖这个有序性。
给你举个例子。假设有个 4×5 矩阵:
code复制0 0 0 0 0
2 0 0 0 0
0 0 3 0 0
0 0 0 0 4
非零元有 3 个:(2,1,2)、(3,3,3)、(4,5,4)。三元组表就是按这个顺序排列的。注意行号从 1 开始还是从 0 开始,题目里通常会说明,如果是数组下标 1 开始(数学表示),就按上面的写法;如果是 0 开始(C 语言数组下标),那就得写成 (1,0,2)、(2,2,3)、(3,4,4)。考试中一旦遇到,先确认下标起点再动手。
2.2 十字链表表示法:原理需要了解多少
十字链表相对复杂,软考通常只考察它的基本思想,很少要求手写实现。它的核心是把稀疏矩阵的每一行和每一列都拉成一条链表,每个非零元素节点同时挂在行链表和列链表上,所以叫"十字"。
节点结构一般是这样:
c复制typedef struct OLNode {
int row, col; // 非零元素所在行列
int value; // 非零元素值
struct OLNode *right; // 指向同一行的下一个非零元
struct OLNode *down; // 指向同一列的下一个非零元
} OLNode;
每个节点有 right 和 down 两个指针,right 指向同行右侧的下一个非零元,down 指向同列下方的下一个非零元。再加两个头指针数组,分别存放每一行的链表头、每一列的链表头。
你可能会问:既然三元组表已经能存稀疏矩阵了,为什么还要十字链表?因为三元组表是顺序存储,做插入、删除操作时需要大量移动元素,效率低。而做矩阵加法、乘法这类运算时,非零元的个数和位置会频繁变化,十字链表用指针链接,插入删除只改指针,灵活得多。
软考对这个知识点的要求,我建议你掌握到"知道它是什么、节点由哪几个域组成、和三元组的区别"就足够了,手写十字链表的算法题很少出现,把时间花在更常考的三元组运算上更划算。
2.3 两种方案的对比:考试中怎么选
这里我整理了一张对比表,方便你复习时快速回顾:
| 对比项 | 三元组表 | 十字链表 |
|---|---|---|
| 存储方式 | 顺序存储(数组) | 链式存储(指针) |
| 空间复杂度 | O(tu),tu 为非零元个数 | O(tu),但每个节点多两个指针域,实际占用更大 |
| 优点 | 结构简单,适合固定矩阵 | 插入删除方便,适合动态变化 |
| 缺点 | 插入删除要移动元素 | 实现复杂,代码量大 |
| 软考考察度 | 高,经常考地址计算和转置 | 低,一般只考概念 |
软考里有个常见的隐含考点:如果用三元组表存一个 m×n、非零元个数为 tu 的矩阵,到底需要多少存储空间?记住,空间只看 tu,不看 m×n。这就是稀疏存储的价值所在。但当 tu 很大,比如超过数组容量 MAXSIZE,或者 δ 超过 5% 时,用三元组表反而可能比二维数组更浪费空间,因为每个非零元要额外记录行号和列号。这个对比在选择题里出现过,别忽略。
3. 压缩存储与地址计算:选择题的高频考点
3.1 二维数组的地址计算基础
在说压缩存储之前,必须先打好二维数组地址计算的地基。这个知识点每年都考,不只是稀疏矩阵这一章,串、树的存储里也会用到。
二维数组有两种存储方式:行优先和列优先。行优先就是先存完第 1 行再存第 2 行;列优先就是先存完第 1 列再存第 2 列。
假设有一个 m×n 的二维数组 A,每个元素占 L 个字节,a[i][j] 的存储地址(其中 i、j 从 1 开始):
按行优先存储:
code复制LOC[i][j] = LOC[1][1] + ((i-1)*n + (j-1)) * L
按列优先存储:
code复制LOC[i][j] = LOC[1][1] + ((j-1)*m + (i-1)) * L
如果 i、j 从 0 开始,公式会变成:
code复制按行优先:LOC[i][j] = LOC[0][0] + (i*n + j) * L
按列优先:LOC[i][j] = LOC[0][0] + (j*m + i) * L
做题第一步永远是确定下标起始。我自己的习惯是把公式里的 (i-1) 和 (j-1) 或者 i、j 单独圈出来,再代入计算,绝不心算,因为这种题错一个数字就前功尽弃。
3.2 对称矩阵、三角矩阵、对角矩阵的压缩映射
普通二维数组每个元素都存,但有特殊结构的矩阵可以压缩到一维数组里,关键就是推导出 a[i][j] 和一维数组下标 k 之间的映射关系。
对称矩阵的压缩是考得最多的。n×n 对称矩阵,满足 a[i][j] = a[j][i],只需要存下三角(含对角线)的元素就够了,一维数组长度是 n(n+1)/2。
按行优先存储下三角时,a[i][j](i ≥ j)在一维数组中的位置 k(下标从 0 开始)为:
code复制k = i*(i-1)/2 + (j-1)
这个公式的推导思路是:第 1 行有 1 个元素,第 2 行有 2 个,第 3 行有 3 个……前 i-1 行一共有 1+2+...+(i-1) = i(i-1)/2 个元素;第 i 行第 j 列是这一行的第 j 个元素,所以在整个一维数组中排第 i(i-1)/2 + j 个,下标再减 1 得到 k = i(i-1)/2 + (j-1)。
我建议你把推导过程记住,而不是死记公式。因为考试题目可能让你求上三角的映射,也可能把 k 定义为从 1 开始,死记公式容易翻车。
三角矩阵压缩:上三角矩阵只存上三角,下三角矩阵只存下三角,另外还需要一个位置存常数 c。软考真题里出现较多的是下三角矩阵,公式和对称矩阵下三角的映射一样。
三对角矩阵(带状矩阵)也偶尔考。n×n 三对角矩阵中,非零元只分布在主对角线、主对角线上方一条线和下方一条线上。按行优先压缩时,a[i][j](|i-j| ≤ 1)在一维数组中的位置 k(下标从 0 开始)为:
code复制k = 2*i + j - 3
验证一下:a[1][1] => k = 2 + 1 - 3 = 0,a[1][2] => k = 2 + 2 - 3 = 1,a[2][1] => k = 4 + 1 - 3 = 2,a[2][2] => k = 3。符合行优先的排布。
3.3 稀疏矩阵的存储效率分析
前面说了稀疏因子 δ ≤ 0.05 才考虑用稀疏存储。但很多同学忽略了一点:用三元组表存储后,每个非零元不再只存一个数值,而是存了行号、列号、值三个数据。如果矩阵的非零元不是特别少,稀疏存储可能并不省空间。
咱们算一笔账。一个 100×100 的矩阵,如果直接用二维数组存,假设每个元素 4 字节,需要 100×100×4 = 40000 字节。如果用三元组表存,假设 200 个非零元,每个三元组需要三个 int 共 12 字节,一共 200×12 = 2400 字节,加上表头信息(行数、列数、非零元个数)12 字节,总共 2412 字节。这种情况下省了一大半空间。
但如果非零元有 3000 个,δ = 0.3,三元组表需要 3000×12 = 36000 字节,反而比二维数组的 40000 字节小不了多少,而且访问时还得做查找,时间效率也下降。
所以软考选择题里如果问"稀疏矩阵使用三元组表存储的意义是什么",核心答案是减少存储空间,而不是提升运算速度。运算速度上,顺序表存储的稀疏矩阵做查找反而比二维数组慢,这是它的劣势。这个认知做题时非常有用。
4. 核心运算实现:转置、加法、乘法
4.1 普通转置与快速转置
转置是稀疏矩阵运算里最经典的考点,软考上午题喜欢考复杂度,下午题偶尔让写代码。
普通转置的思路是:转置后的矩阵,行数、列数互换,非零元的三元组中 row 和 col 对调。但直接对调后,三元组表就不再按行优先排列了,所以需要按原矩阵的列号从小到大依次收集元素。
具体做法是:遍历原三元组表,先找所有原列号为 1 的非零元(对应新矩阵第 1 行),按顺序放入新三元组表;再找列号为 2 的,以此类推。这种做法的双重循环导致时间复杂度为 O(nu × tu),nu 是原矩阵列数,tu 是非零元个数。
快速转置是优化的重点,它能在 O(nu + tu) 时间内完成转置。核心思路一改:不再反复扫描原三元组表,而是提前统计好每一列非零元的个数,并计算出每一列第一个非零元在转置后三元组表中的起始位置。
步骤拆解:
- 遍历原三元组表,统计每一列的非零元个数,记入 num[col]。
- 计算每一列第一个非零元在转置后表中的起始位置 cpot[col]。cpot[0] = 0,cpot[col] = cpot[col-1] + num[col-1]。
- 再次遍历原三元组表,对每个元素,取出其列号 col,它在转置后表中的位置就是 cpot[col],放置后 cpot[col] 自增 1,指向该列下一个空位。
C 语言实现:
c复制void FastTranspose(TSMatrix A, TSMatrix *B) {
int num[MAXSIZE] = {0};
int cpot[MAXSIZE];
int col, t, pos;
B->mu = A.nu;
B->nu = A.mu;
B->tu = A.tu;
if (A.tu == 0) return;
// 1. 统计每一列非零元个数
for (t = 0; t < A.tu; t++) {
num[A.data[t].col]++;
}
// 2. 计算每一列第一个非零元的起始位置
cpot[0] = 0;
for (col = 1; col < A.nu; col++) {
cpot[col] = cpot[col - 1] + num[col - 1];
}
// 3. 放置元素
for (t = 0; t < A.tu; t++) {
col = A.data[t].col;
pos = cpot[col];
B->data[pos].row = A.data[t].col;
B->data[pos].col = A.data[t].row;
B->data[pos].value = A.data[t].value;
cpot[col]++;
}
}
这里要注意 cpot 数组的大小,列号可能从 1 开始,也可能从 0 开始,数组分配要留出余量。另外,转置后 B 的三元组表依然按行优先排列,这就是 cpot 数组保证的,后面的元素不会覆盖前面的。
我在做真题时发现,上午题经常给出 num 数组和 cpot 数组的部分值,让你推导某个具体元素转置后的位置。这时候你必须搞懂 cpot[col] 存的到底是"下标"还是"序号"。上面代码里 cpot 存的是下标(从 0 开始),如果题目按序号(从 1 开始)描述,结果要相应加 1。审题时这点要格外留意。
4.2 稀疏矩阵加法:归并思路的应用
两个稀疏矩阵相加,前提是行数、列数相同。用三元组表实现加法,本质上是一个"归并"的过程,和合并两个有序数组的思路一模一样。
同时扫描两个三元组表 A 和 B,比较当前指向元素的行列号,根据比较结果决定怎么处理,具体规则如下:
| 比较情况 | 处理方式 |
|---|---|
| A 的行号小于 B 的行号,或行号相同但 A 的列号小于 B 的列号 | 把 A 的元素复制到结果矩阵 C |
| 同理,B 的行列号更小 | 把 B 的元素复制到 C |
| A、B 行列号都相同 | 值相加,如果结果不为 0 则存入 C |
实现时注意一点:如果 A 或 B 中有一个已经扫描完,就把另一个剩余元素全部复制到 C,不需要再比较了。
c复制void AddMatrix(TSMatrix A, TSMatrix B, TSMatrix *C) {
int i = 0, j = 0, k = 0;
C->mu = A.mu;
C->nu = A.nu;
C->tu = 0;
while (i < A.tu && j < B.tu) {
if (A.data[i].row < B.data[j].row ||
(A.data[i].row == B.data[j].row && A.data[i].col < B.data[j].col)) {
C->data[k++] = A.data[i++];
} else if (B.data[j].row < A.data[i].row ||
(B.data[j].row == A.data[i].row && B.data[j].col < A.data[i].col)) {
C->data[k++] = B.data[j++];
} else {
int sum = A.data[i].value + B.data[j].value;
if (sum != 0) {
C->data[k].row = A.data[i].row;
C->data[k].col = A.data[i].col;
C->data[k].value = sum;
k++;
}
i++;
j++;
}
}
while (i < A.tu) C->data[k++] = A.data[i++];
while (j < B.tu) C->data[k++] = B.data[j++];
C->tu = k;
}
软考里关于加法的选择题,最常问的是时间复杂度:一次加法最坏情况下要遍历 A、B 两个表,时间复杂度 O(tuA + tuB)。也有题目会问相加后的结果矩阵非零元个数怎么变化,注意当 A 和 B 的同一个位置都是非零元且相加为 0 时,结果矩阵中该位置就没有元素了,所以非零元个数不是简单的 tuA + tuB。
4.3 稀疏矩阵乘法:理解思路比背代码重要
矩阵乘法 A×B 的前提是 A 的列数等于 B 的行数。结果的第 i 行第 j 列元素等于 A 的第 i 行所有元素与 B 的第 j 列所有元素对应乘积之和。
用三元组表实现乘法的朴素思路是:遍历 A 的每个非零元,再遍历 B 的每个非零元,当 A 的非零元列号等于 B 的非零元行号时,两者乘积累加到结果矩阵对应位置。这个过程需要频繁判断和累加,时间复杂度比较高,最坏情况下 O(tuA × tuB)。
软考对乘法的考法集中在两点:一是概念判断,二是用逻辑推导。
概念判断方面,比如问"两个稀疏矩阵相乘,结果矩阵还是稀疏矩阵吗",答案是不一定。两个稀疏矩阵相乘后,结果中某些位置可能因为多个乘积累加而变成非零,非零元个数可能增加,甚至可能变成稠密矩阵。这个结论很多同学记反了,以为稀疏矩阵运算后一定还是稀疏的,做题时要注意。
逻辑推导方面,题目可能给出一个小规模的稀疏矩阵 A 和 B,要求写出结果矩阵 C 或者 C 中某个元素的值。这时候老老实实按矩阵乘法的定义算就行,不需要想着用什么优化算法。先把 A 的某一行非零元和 B 的某一列非零元对应上,乘积累加,逐个位置算出来。
我建议你把乘法的时间复杂度也记一下。朴素版是 O(tuA × tuB),如果题目优化到按行扫描再乘,可以到 O(tuA + tuB + 结果非零元个数) 这种更优级别,但软考上午题一般只考朴素版,心里有数即可。
5. 软考真题考法与备考建议
5.1 典型题型分布
我对近几年软件设计师真题做了个简单统计,稀疏矩阵相关题目几乎每套上午卷都会出现,分值 1~2 分,偶尔在下午卷的数据结构大题里出现,分值会到 5 分左右。常考题型可以分成三类。
第一类是概念题。考稀疏因子的定义、三元组结构体的域、十字链表的指针含义等,通常是一道快速判断的选择题。比如:"以下关于稀疏矩阵三元组表的说法,正确的是",选项里会出现"三元组表按列优先排列""三元组表只存储非零元素的值""稀疏矩阵转置后 tu 不变"这类描述,你需要快速识别对错。
第二类是计算题,主要考压缩存储后的地址映射。比如给一个 8×8 对称矩阵,按行优先存储下三角到一维数组 B,求某个 a[i][j] 在 B 中的下标。这类题只要公式用得熟练就能拿分,难点在于审题时看清楚按行优先还是列优先、下标从 1 还是 0 开始。
第三类是算法分析题,给出快速转置或加法的部分代码,问你某一步的作用,或者让你补充循环条件。这类题对读代码能力有要求,但代码逻辑本身不复杂,关键是理解每个变量(num、cpot)的含义。
5.2 易错点与避坑指南
整理了我在刷题和答疑过程中见过的高频错误,每一条都是真实踩过的坑。
下标从 0 还是从 1 开始没看清楚。 这是第一高频错误。很多同学公式背得滚瓜烂熟,一代入就错,原因就是没注意题目给的行列号是从 0 开始还是从 1 开始。我的习惯是做题前先在草稿纸上写清楚"行列号从几开始、一维数组下标从几开始、答案要求从几开始",写完了再计算。
对称矩阵上三角和下三角的映射区分不清。 题目说存储下三角,你用上三角的公式算,自然结果不对。给定 a[i][j] 时,先用 if (i >= j) 判断它在下三角还是上三角,如果存储的是下三角而元素在上三角,就需要用 a[j][i] 来映射。
k 和序号混为一谈。 一维数组下标 k 从 0 开始,元素序号从 1 开始,两者相差 1。公式里最后一步经常有人忘记减 1 或者多减 1。建议记忆公式时统一按"下标从 0"来记,多做几道题形成肌肉记忆。
快速转置中 cpot 的更新顺序。 遍历原三元组表放置元素时,cpot[col] 要先使用再自增,顺序反了会导致所有元素都放到同一个位置,互相覆盖。这类题如果给你代码让你选错误点,往往会在这里做文章。
加法、乘法中稀疏因子变化的判断。 前面提到过,加法中如果同位置非零元相加为 0,结果的非零元个数会减少;乘法中结果矩阵可能变稠密。这些反直觉的结论是出题人喜欢的陷阱。
5.3 复习优先级与时间安排建议
如果你的备考时间紧张,我给这个知识点排个优先级。
最高优先级是压缩存储的地址计算,尤其是对称矩阵的下三角映射公式。这是历年出镜率最高的考点,也是复习收益最高的部分,花半小时把公式推导搞清楚,再做 5 道真题巩固,基本能拿住这一分。
第二优先级是三元组表的数据结构和快速转置算法。重点理解 num 和 cpot 数组的构建过程,会分析时间复杂度 O(nu + tu) 优于普通转置的 O(nu × tu)。这部分上午题常考,但不需要手写完整代码。
第三优先级是加法和乘法的思路。能看懂代码、知道时间复杂度、能判断非零元个数变化即可,不用花太多时间。十字链表更是如此,了解原理就够。
软考里面试复习讲究"抓大放小",稀疏矩阵在整个数据结构体系里不算最核心的章节,但它有明确的常考题型和固定的解题套路,属于"花小钱办大事"的性价比考点。在复习时,我建议你把真题里所有关于稀疏矩阵的题目集中做一遍,做完之后你会发现,出题方式几乎就是围绕上述几类在循环,套路清楚了,考试时看到稀疏矩阵四个字就能嘴角上扬。
最后再分享一个我自己的小习惯:考前最后一天不要死磕复杂算法,把对称矩阵映射公式、快速转置的 cpot 构建过程在草稿纸上默写一遍,比刷十道新题更稳。数据结构的很多失分不是不会,而是基础公式和关键步骤在考场上突然卡壳。把这个知识点的核心脉络理清楚,你进考场心里就有底了。
