第6章串数组:稀疏矩阵三元组顺序表的行逻辑连接顺序表
2. 行逻辑链接的顺序表
(1)传统矩阵乘法及其在稀疏场景下的局限
首先回顾标准的矩阵乘法。假设有两个矩阵 A=(aij)m×r\pmb{A}=(a_{ij})_{m\times r}A=(aij)m×r 和 B=(bij)r×n\pmb{B}=(b_{ij})_{r\times n}B=(bij)r×n,它们的乘积为一个新矩阵 C=(cij)m×n\pmb{C}=(c_{ij})_{m\times n}C=(cij)m×n,记作 C=AB\pmb{C}=\pmb{AB}C=AB。
根据定义,结果矩阵 C\pmb{C}C 中的任意一个元素 cijc_{ij}cij(第 iii 行第 jjj 列的元素)是通过将矩阵 A\pmb{A}A 的第 iii 行与矩阵 B\pmb{B}B 的第 jjj 列的对应元素相乘再求和得到的。其数学公式为:
cij=∑k=1raikbkj=ai1b1j+ai2b2j+⋯+airbrj
c_{ij} = \sum_{k=1}^{r} a_{ik}b_{kj} = a_{i1}b_{1j} + a_{i2}b_{2j} + \cdots + a_{ir}b_{rj}
cij=k=1∑raikbkj=ai1b1j+ai2b2j+⋯+airbrj
这个定义可以直接转化为一个通用的计算算法,如下所示(注意:此处伪代码的数组索引从1开始,以贴合数学定义):
// 计算 C = A * B
for(i = 1; i <= m; ++i){ // 遍历A的每一行for(j = 1; j <= n; ++j){ // 遍历B的每一列C[i][j] = 0; // 初始化累加器for(k = 1; k <= r; ++k){ // 计算点积C[i][j] += A[i][k] * B[k][j];}}
}
该算法的时间复杂度为 O(m×n×r)O(m \times n \times r)O(m×n×r),因为它对 C\pmb{C}C 矩阵的 m×nm \times nm×n 个元素中的每一个都执行了 rrr 次乘法和加法。
然而,当这个算法应用于稀疏矩阵(即大部分元素为零的矩阵)时,其效率会变得非常低下。原因在于,无论 aika_{ik}aik 或 bkjb_{kj}bkj 是否为零,上述算法都会执行一次乘法运算。在稀疏矩阵中,大量的乘法都是 非零项 * 0
或 0 * 0
,这些都是无效计算,因为它们的乘积必然为零,对最终的累加和没有任何贡献,却白白消耗了计算资源。
(2)基于三元组表示的思考与新挑战
为了节省存储空间,通常使用三元组顺序表来表示稀疏矩阵,只存储非零元素的位置(行、列)和值。看一个具体的例子。假设有两个稀疏矩阵 M\pmb{M}M 和 N\pmb{N}N,求它们的乘积 Q=MN\pmb{Q}=\pmb{MN}Q=MN。
M=[30050−1002000]3×4,N=[0210−2400]4×2 ⟹ Q=MN=[06−1004]3×2
\pmb{M}=\begin{bmatrix}3&0&0&5\\0&-1&0&0\\2&0&0&0\end{bmatrix}_{3\times4}, \quad \pmb{N}=\begin{bmatrix}0&2\\1&0\\-2&4\\0&0\end{bmatrix}_{4\times2}
\quad \implies \quad \pmb{Q}=\pmb{MN}=\begin{bmatrix}0&6\\-1&0\\0&4\end{bmatrix}_{3\times2}
M=3020−100005003×4,N=01−2020404×2⟹Q=MN=0−106043×2
它们对应的三元组顺序表 M.data
, N.data
, Q.data
如图 6.7.2 所示:
为了避免无效计算,新思路应该是:只对两个矩阵中的非零元进行运算。
根据乘法公式 cij=∑aikbkjc_{ij} = \sum a_{ik}b_{kj}cij=∑aikbkj,要计算出结果矩阵 Q\pmb{Q}Q 中的一个非零元 QijQ_{ij}Qij,需要找到所有满足“M\pmb{M}M 的列号等于 N\pmb{N}N 的行号”的非零元对 (Mik,Nkj)(M_{ik}, N_{kj})(Mik,Nkj),然后将它们的乘积 Mik×NkjM_{ik} \times N_{kj}Mik×Nkj 累加起来。
例如,计算 Q12Q_{12}Q12:Q12=M11N12+M12N22+M13N32+M14N42Q_{12} = M_{11}N_{12} + M_{12}N_{22} + M_{13}N_{32} + M_{14}N_{42}Q12=M11N12+M12N22+M13N32+M14N42,在三元组表中,只关心非零项:
-
矩阵 M\pmb{M}M 的第1行有两个非零元:M11=3M_{11}=3M11=3 和 M14=5M_{14}=5M14=5。
-
需要找到 N\pmb{N}N 中行号为 1 和 4 的非零元。
- 遍历
N.data
,发现行号为 1 的非零元是 N12=2N_{12}=2N12=2。 - 遍历
N.data
,没有发现行号为 4 的非零元。
- 遍历
-
因此,Q12=M11×N12=3×2=6Q_{12} = M_{11} \times N_{12} = 3 \times 2 = 6Q12=M11×N12=3×2=6。
这个过程暴露了一个新的性能瓶颈:为了找到矩阵 N\pmb{N}N 中特定行(例如第 kkk 行)的所有非零元,必须从头到尾扫描整个 N.data
三元组表。如果矩阵 M\pmb{M}M 有很多非零元,这个查找过程需要反复进行,效率极低。
(3)解决方案:行逻辑链接的顺序表 (RLSMatrix)
为了解决上述查找效率低下的问题,需要一种能快速定位到矩阵 N\pmb{N}N 任意一行的所有非零元的数据结构。一个巧妙的解决方案是增加一个辅助数组,称之为 rpos
(row position),用来记录每一行的第一个非零元在三元组表 data
中的起始位置。将这种带有 rpos
数组的三元组表称为行逻辑链接的顺序表(Row-wise Logically Linked Sequential List)。
其数据结构定义如下:
typedef struct {Triple data[MAXSIZE + 1]; // 非零元三元组表(依然是核心)int rpos[MAXRC + 1]; // 各行第一个非零元的位置表(新增的“索引”)int mu, nu, tu; // 矩阵的行数、列数和非零元个数
} RLSMatrix;
这里的“行逻辑链接”并非指真正的指针链表,而是通过 rpos
数组建立了行号与 data
数组下标之间的逻辑关系,实现了快速跳转。
以矩阵 N\pmb{N}N 为例,它的 rpos
数组如下表所示:
行号 row | 1 | 2 | 3 | 4 | 5 (额外项) |
---|---|---|---|---|---|
rpos[row] | 1 | 2 | 3 | 5 | 5 |
这个 rpos
数组的含义是:
rpos[1] = 1
:第1行的第一个非零元在N.data
的第1个位置。rpos[2] = 2
:第2行的第一个非零元在N.data
的第2个位置。rpos[3] = 3
:第3行的第一个非零元在N.data
的第3个位置。rpos[4] = 5
:第4行没有非零元。我们约定,如果某一行没有非零元,它的rpos
值等于下一行非零元的起始位置。
一个非常关键的设计是,可以利用 rpos
数组轻松确定任一行的非零元区间:矩阵第 i
行的所有非零元,存储在 data
数组的 [rpos[i], rpos[i+1] - 1]
这个区间内。
- 第1行的非零元区间:
[rpos[1], rpos[2] - 1]
即[1, 1]
。 - 第2行的非零元区间:
[rpos[2], rpos[3] - 1]
即[2, 2]
。 - 第3行的非零元区间:
[rpos[3], rpos[4] - 1]
即[3, 4]
。 - 第4行的非零元区间:
[rpos[4], rpos[5] - 1]
即[5, 4]
,这是一个空区间,表示该行没有非零元。
为了让这个规则对最后一行也通用,通常会增加一个“哨兵”项 rpos[nu+1] = tu + 1
。这样,就能用统一的方式处理所有行,而无需在代码中为最后一行编写特殊逻辑。
(4)高效的稀疏矩阵乘法算法
有了行逻辑链接顺序表,就可以设计出高效的乘法算法了。其核心思想是:逐行计算结果矩阵 Q\pmb{Q}Q。
【算法步骤】
- 初始化:创建结果矩阵 Q\pmb{Q}Q,确定其维度为 m×nm \times nm×n。
- 逐行处理:遍历矩阵 M\pmb{M}M 的每一行(设为第
arow
行)。 - 设立累加器:为即将计算的 Q\pmb{Q}Q 的第
arow
行,准备一个临时的累加器数组ctemp
(长度为 Q\pmb{Q}Q 的列数n
),并全部清零。 - 计算乘积累加:
a. 遍历 M\pmb{M}M 在第arow
行的所有非零元 Marow,kM_{arow, k}Marow,k。
b. 对于每一个 Marow,kM_{arow, k}Marow,k,它的列号是k
。我们利用N.rpos
快速定位到 N\pmb{N}N 的第k
行的所有非零元 Nk,jN_{k, j}Nk,j。
c. 将乘积 Marow,k×Nk,jM_{arow, k} \times N_{k, j}Marow,k×Nk,j 累加到ctemp[j]
中。 - 压缩存储:当 M\pmb{M}M 的第
arow
行处理完毕后,ctemp
数组就完整地存储了 Q\pmb{Q}Q 的第arow
行的最终结果。此时,遍历ctemp
数组,将其中不为零的元素(arow, j, ctemp[j])
作为新的三元组存入Q.data
中。 - 循环:重复步骤 2-5,直到 M\pmb{M}M 的所有行都处理完毕。
【算法描述】
Status MultSMatrix(RLSMatrix M, RLSMatrix N, RLSMatrix &Q) {// 检查矩阵乘法条件if (M.nu != N.mu) return ERROR;// 初始化结果矩阵 QQ.mu = M.mu; Q.nu = N.nu; Q.tu = 0;if (M.tu == 0 || N.tu == 0) return OK; // 如果任一矩阵为空,结果必为空// 为 N 创建 rpos 数组(假设 N 已经是以 RLSMatrix 形式传入)// (若 N 不是 RLSMatrix,则需先预处理 N,生成其 rpos 数组)// 逐行计算 Q 的元素for (arow = 1; arow <= M.mu; ++arow) {// 1. 临时累加器数组,用于存放 Q 矩阵第 arow 行的计算结果float ctemp[Q.nu + 1] = {0};// 记录 Q 中当前行的第一个非零元将要存放的位置Q.rpos[arow] = Q.tu + 1;// 2. 遍历 M 矩阵第 arow 行的所有非零元// M 第 arow 行的非零元在 M.data 中的范围是 [M.rpos[arow], M.rpos[arow+1]-1]int p_end = (arow < M.mu) ? M.rpos[arow + 1] : M.tu + 1;for (p = M.rpos[arow]; p < p_end; ++p) {int brow = M.data[p].j; // M 元素的列号,即 N 元素的行号// 3. 利用 N.rpos 快速找到 N 矩阵第 brow 行的所有非零元并相乘累加int q_end = (brow < N.mu) ? N.rpos[brow + 1] : N.tu + 1;for (q = N.rpos[brow]; q < q_end; ++q) {int ccol = N.data[q].j; // 乘积元素在 Q 中的列号ctemp[ccol] += M.data[p].e * N.data[q].e;} // for q} // for p// 4. 将 ctemp 数组中的非零元压缩存储到 Q.data 中for (ccol = 1; ccol <= Q.nu; ++ccol) {if (ctemp[ccol] != 0) {if (++Q.tu > MAXSIZE) return ERROR; // 检查容量Q.data[Q.tu] = {arow, ccol, ctemp[ccol]}; // 构造并存入三元组}} // for ccol} // for arow// 设置 rpos 的哨兵项Q.rpos[Q.mu + 1] = Q.tu + 1;return OK;
}
算法要点总结:
- 累加后压缩:由于多个非零项相乘再累加后,结果可能恰好为零(例如
3 * 2 + (-6) * 1 = 0
),所以不能在计算过程中直接生成 Q\pmb{Q}Q 的三元组。必须先用临时数组ctemp
将一整行的结果完全计算出来,再判断哪些元素最终非零,然后进行存储。 - 行主序优势:由于算法是逐行处理 M\pmb{M}M 的,计算出的 Q\pmb{Q}Q 的非零元也是天然按行有序的,无需后续排序。
- 效率提升:该算法的核心优势在于,通过
N.rpos
将“在整个N.data
中搜索特定行”这一耗时操作,变为了“直接访问N.data
的一个连续子区间”,极大地提升了运算效率。
说明:关于稀疏矩阵的压缩方法,除了这里介绍的方法之外,还有其他方法。并且,有关稀疏矩阵的运算,在实际的开发工作中,可以使用有关的专门进行科学计算的库。这方面的详细内容,请参阅《机器学习数学基础》(齐伟,电子工业出版社)中对稀疏矩阵的专题介绍。