第 06 讲

数组与特殊矩阵压缩存储

数组看起来平平无奇,可它是所有高级结构的「地基」:顺序表、栈、队列、堆、哈希桶,最后都要落到一块连续内存上。 本章先把「数组为什么能 O(1) 随机存取」这件事从公式层面讲透,再解决一个更实际的问题—— 当矩阵里存在大量重复元素或零元素时,怎么只存有用的那部分,并且尽量保住随机存取能力。 对称矩阵、三角矩阵、三对角矩阵、稀疏矩阵的压缩方案与下标映射公式,是历年考试的高频计算题。

预计 120 分钟 前置:第 02 讲线性表、第 01 讲算法与复杂度 关键词:行优先 · 列优先 · 下标映射 · 三元组 · 十字链表
本章导读
  • 6.1 数组的定位:一维数组就是线性表,二维数组是「元素为线性表的线性表」。
  • 6.2 行优先 / 列优先的地址公式推导 —— 本章第一类必考计算题,包括三维数组。
  • 6.3 压缩存储总论:什么叫压缩、靠什么压缩、压完之后还能不能随机存取。
  • 6.4 对称矩阵 —— 重点k = i(i+1)/2 + j 这个公式必须能自己推出来,而不是背下来。
  • 6.5 三角矩阵:下三角与上三角两套公式,注意常数 c 单独占一个单元。
  • 6.6 三对角(带状)矩阵:长度 3n−2,公式 k = 2i + j,以及它为什么仍能 O(1) 访问。
  • 6.7 稀疏矩阵:三元组表、快速转置、行逻辑链接、十字链表、工程中的 CSR 格式。
  • 6.8 四类矩阵横向对比:一张表看清长度、映射公式与随机存取能力。
  • 6.9 工程视角:行优先 / 列优先的性能后果、对称与三对角结构的真实用途、 COO / CSR / CSC / BSR 的分工,以及「稠密 vs 稀疏」的临界点。
  • 6.10 易错点、考点与自测题。

6.1 数组:线性表的推广

6.1.1 一维数组:它本来就是线性表

数组(array)是由类型相同的数据元素构成的有限序列。请注意这个定义里的两个关键词: 「类型相同」意味着每个元素占用的字节数一样,这是后面一切地址公式的前提; 「有限序列」意味着元素个数是固定的,一旦建立就不能随意增减。

一维数组 a[0..n-1] 的逻辑结构和线性表完全一致:有唯一的第一个元素、唯一的最后一个元素, 除首尾之外每个元素都有且仅有一个直接前驱和一个直接后继。所以我们可以直接说: 一维数组就是一种线性表,只是它额外约束了「元素等长」并且通常不允许插入删除。

那二维数组呢?A[m][n] 看上去是一张表格,有行有列,似乎比线性表复杂。但换个角度看: 如果我们把第 i 行的 n 个元素 a[i][0..n-1] 打包看成「一个元素」, 那么整个二维数组就退化成了一个长度为 m 的一维数组,只不过它的每个元素本身又是一个长度为 n 的线性表。换句话说:

二维数组 = 每个数据元素都是线性表的线性表

这句话可以一直套下去:三维数组的每个元素是二维数组,二维数组的每个元素是一维数组…… 所以教材里那句「数组是线性表的推广」,说的正是这种「元素本身又是线性表」的递归结构。 理解了这一层,后面「行优先 / 列优先」不过是选择了先走哪一层而已。

① 一维数组 a[0..6]:就是一条线性表 a0 a1 a2 a3 a4 a5 a6 下标 0 1 2 3 4 5 6 — 除首尾外,每个元素有且只有一个前驱和一个后继 ⇒ 线性表 每个元素等长(比如 4 字节) 这一点是随机存取的根基 ② 二维数组 A[3][4]:把每一行当成一个元素,它就是一个长度为 3 的线性表 R0 R1 R2 a00 a01 a02 a03 a10 a11 a12 a13 a20 a21 a22 a23 每一行 Ri 是一个长度为 4 的线性表 A = (R0, R1, R2) 又是一个长度为 3 的线性表 同理也可以把「每一列」当成元素,得到列向量视角 行视角 → 行优先存储;列视角 → 列优先存储
图 6-1 数组是线性表的推广:一维数组本身是线性表,二维数组的元素也是线性表

6.1.2 二维数组的两种视角

图 6-1 给了我们两种看待 A[m][n] 的方式,它们直接对应两种存储方案:

行向量视角(把行当元素)
A = (R0, R1, …, Rm−1),其中每个 Ri = (a[i][0], a[i][1], …, a[i][n−1])。 按这个顺序把元素一个个排进内存,就是行优先存储
列向量视角(把列当元素)
A = (C0, C1, …, Cn−1),其中每个 Cj = (a[0][j], a[1][j], …, a[m−1][j])。 按这个顺序排进内存,就是列优先存储

两种视角在数学上是完全对称的,没有谁更「正确」。但现实中的编程语言必须选一个作为默认约定, 这就造成了 C/C++/Java/Python 与 Fortran/MATLAB 之间的差异,也造成了考试里最爱考的那一类题: 「已知某元素地址,求另一元素地址」。做题前第一件事永远是看清楚题目说的是行优先还是列优先。

6.1.3 随机存取从哪来:等长元素 + 连续存放

线性表那一章我们讲过:顺序表可以 O(1) 按下标取值,链表不行。 数组是纯粹的「顺序存储」,它的随机存取能力来自两个条件同时成立:

  1. 所有元素等长:每个元素占 L 个字节,谁也不多谁也不少;
  2. 所有元素连续存放:第 k 个元素紧跟在第 k−1 个元素后面,中间没有空隙。

只要这两条成立,「第 k 个元素在哪里」就不再需要「从头一个个数过去」,而可以直接乘出来:

LOC(a[k]) = LOC(a[0]) + k × L

这就是随机存取(random access):访问任意一个元素所需的计算量完全相同, 与 k 的大小无关,所以是 O(1)(见图 6-2)。 反过来说,只要破坏其中任何一条,随机存取立刻失效: 链表的结点是 new 出来的,地址不连续,只能顺藤摸瓜, 于是按下标访问退化成 O(n);变长字符串数组因为元素不等长,也没法用一次乘法定位。

等长元素 + 连续存放 ⇒ 一次乘法就能定位 a[0] a[1] a[2] a[3] a[4] a[5] 1000 1004 1008 1012 1016 1020 每个元素 4 字节 要取 a[3],不必从 a[0] 一路数过去,直接算: LOC(a[3]) = LOC(a[0]) + 3 × 4 = 1000 + 12 = 1012 链式存储做不到这一点:结点的地址是 new 出来的,只能顺着指针走,代价 O(n)。
图 6-2 随机存取的根基:只要「等长 + 连续」,地址就是下标的线性函数

6.1.4 数组的 ADT:为什么通常只做「取值」和「赋值」

翻开任何一本教材,数组的抽象数据类型都短得可怜,基本只有两个操作: Value(A, index) 取值、Assign(A, index, e) 赋值。 (有的书写成 GetValue / SetValue,或者干脆用 A[i] 表示。) 与之配套的还有 InitArray(初始化)、DestroyArray(销毁), 但它们属于「生老病死」,不算常规操作。

为什么数组不像线性表那样提供 Insert / Delete?原因有三条,一条比一条本质:

一句话串起来 数组 = 定长 + 等长 + 连续。定长让它没有插入删除,等长 + 连续让它有 O(1) 随机存取。 本章后面所有的压缩存储方案,本质上都是在「元素不再等长 / 不再连续」的情况下, 人为地重新造出一个下标映射函数,把随机存取能力抢回来。

6.2 多维数组的存储:行优先与列优先

6.2.1 行优先(Row-Major):C/C++ 的选择

二维数组在逻辑上是二维的,但计算机内存是一维的,一根长长的地址轴。 所谓「存储」,就是把这 m×n 个元素排成一个队列塞进这根地址轴里。 排列方式不止一种,最常见的就是行优先(row-major order): 先存第 0 行,再存第 1 行……同一行里从左到右依次存。

现在推导地址公式。设 A[m][n],首元素 a[0][0] 的地址是 LOC(a[0][0]),每个元素占 L 个字节。要求 a[i][j] 的地址, 只需要回答一个问题:排在 a[i][j] 前面的元素一共有多少个?

  1. 0 行到第 i−1 行都是完整地排在它前面的,一共 i 行, 每行 n 个元素,所以有 i × n 个;
  2. 在它自己所在的第 i 行里,列号比 j 小的还有 j 个元素 (列号从 0 到 j−1);
  3. 合计前面有 i × n + j 个元素,每个占 L 字节, 所以地址要在首地址上加上 (i × n + j) × L
LOC(a[i][j]) = LOC(a[0][0]) + (i × n + j) × L

公式里的 i × n + j 其实就是元素 a[i][j] 在「拉直成一行」之后的下标, 我们把它叫做线性下标一维序号,记作 k = i × n + j。 考试时如果你一时想不起公式,最快的办法就是举个小例子现场数: 比如 A[3][4] 里的 a[1][2],前面有 1 整行(4 个)+ 本行 2 个 = 6 个, 于是 k = 6,与 1×4+2 = 6 完全一致。

行优先(Row-Major):先横着存完一整行,再存下一行 j= 0 1 2 3 i=0 (0,0) (0,1) (0,2) (0,3) i=1 (1,0) (1,1) (1,2) (1,3) i=2 (2,0) (2,1) (2,2) (2,3) 红色格子 a[1][2]: 前面有 1 整行(4 个)+ 本行 2 个 = 6 个元素 k = i*n + j = 1*4 + 2 = 6 内存里的样子(一条连续的内存带) (0,0) (0,1) (0,2) (0,3) (1,0) (1,1) (1,2) (1,3) (2,0) (2,1) (2,2) (2,3) k=0 k=1 k=2 k=3 k=4 k=5 k=6 k=7 k=8 k=9 k=10 k=11 行优先下,同一行的元素在内存里是紧挨着的 —— 这正是「按行遍历比按列遍历快」的根本原因。
图 6-3 A[3][4] 的行优先内存排布:a[1][2] 落在 k = 6 的位置
为什么 C 语言「按行遍历」更快 行优先意味着 a[i][0..n−1] 在内存里是连续的一整段。 按行遍历时 CPU 缓存一次读入一整条 cache line(通常 64 字节,能装 16 个 int), 后面十几个元素都是白送的;而按列遍历时,每次访问都要跳到 n × L 字节之外, 缓存几乎次次不命中。所以在 C/C++ 里写双重循环时,内层循环一定要遍历最后一个下标

6.2.2 列优先(Column-Major):Fortran / MATLAB 的选择

列优先的排法刚好反过来:先存第 0 列,再存第 1 列,同一列里从上到下依次存。 推导完全对称,只是「整块」的单位从「行」变成了「列」:

  1. 0 列到第 j−1 列都是完整的 j 列,每列 m 个元素,共 j × m 个;
  2. 本列中行号比 i 小的还有 i 个;
  3. 合计 j × m + i 个元素排在前头。
LOC(a[i][j]) = LOC(a[0][0]) + (j × m + i) × L

请特别留意:行优先的公式里出现的是列数 n,列优先的公式里出现的是行数 m。 这是最容易写错的地方,因为直觉上我们总觉得「求 (i,j) 的地址应该用到 i 前面的东西、乘上列数」, 但在列优先里,跨一整列要跨过的是 m 个元素。记忆口诀:行优先「乘列数」,列优先「乘行数」

列优先(Column-Major):先竖着存完一整列,再存下一列 j= 0 1 2 3 i=0 (0,0) (0,1) (0,2) (0,3) i=1 (1,0) (1,1) (1,2) (1,3) i=2 (2,0) (2,1) (2,2) (2,3) 红色格子 a[1][2]: 前面有 2 整列(每列 3 个)+ 本列 1 个 = 7 个元素 k = j*m + i = 2*3 + 1 = 7 同一个 a[1][2],行优先时 k = 6,列优先时 k = 7 内存里的样子(一条连续的内存带) (0,0) (1,0) (2,0) (0,1) (1,1) (2,1) (0,2) (1,2) (2,2) (0,3) (1,3) (2,3) k=0 k=1 k=2 k=3 k=4 k=5 k=6 k=7 k=8 k=9 k=10 k=11 列优先下,同一列的元素紧挨着。MATLAB 里「整列操作比整行操作快」就是这个道理。
图 6-4 A[3][4] 的列优先内存排布:同一个 a[1][2] 落到 k = 7

下面这段代码把两种排布都打印出来,可以直接对照上面两张图看:

#include <iostream>
using namespace std;

/* ============================================================
   二维数组的地址计算:行优先 vs 列优先
   设 A 是 m 行 n 列的二维数组,第一个元素 a[0][0] 的地址是 base,
   每个元素占 L 个字节(所有元素等长)。

   行优先 Row-Major(C / C++ / Java / Python 采用):
       LOC(a[i][j]) = base + (i * n + j) * L
   列优先 Column-Major(Fortran / MATLAB 采用):
       LOC(a[i][j]) = base + (j * m + i) * L
   ============================================================ */

long long rowMajorAddr(long long base, int n, int i, int j, int L) {
    return base + (1LL * i * n + j) * L;      /* 前面有 i 整行,本行再走 j 格 */
}

long long colMajorAddr(long long base, int m, int i, int j, int L) {
    return base + (1LL * j * m + i) * L;      /* 前面有 j 整列,本列再走 i 格 */
}

int main() {
    const int m = 3, n = 4, L = 4;            /* A[3][4],每个 int 占 4 字节 */
    const long long base = 1000;              /* 假设 LOC(a[0][0]) = 1000 */

    cout << "A[3][4], base = " << base << ", L = " << L << "\n";

    cout << "row-major order: ";
    for (int i = 0; i < m; ++i)
        for (int j = 0; j < n; ++j)
            cout << "a[" << i << "][" << j << "]@" << rowMajorAddr(base, n, i, j, L) << "  ";
    cout << "\n";

    cout << "col-major order: ";
    for (int j = 0; j < n; ++j)
        for (int i = 0; i < m; ++i)
            cout << "a[" << i << "][" << j << "]@" << colMajorAddr(base, m, i, j, L) << "  ";
    cout << "\n";

    /* 单独验算 a[1][2]:行优先排在它前面的元素是 1*4+2 = 6 个 */
    cout << "a[1][2] row-major: (1*4+2)*4 = " << (1 * n + 2) * L
         << " bytes, addr = " << rowMajorAddr(base, n, 1, 2, L) << "\n";
    /* 列优先排在它前面的是 2*3+1 = 7 个 */
    cout << "a[1][2] col-major: (2*3+1)*4 = " << (2 * m + 1) * L
         << " bytes, addr = " << colMajorAddr(base, m, 1, 2, L) << "\n";

    /* 经典考题:题目不给首地址,只给某个元素的地址,问另一个元素的地址。
       做法是"只算偏移量之差",base 会在相减时消掉。 */
    long long loc32 = rowMajorAddr(base, n, 3, 2, L);              /* 已知 a[3][2] 的地址 */
    long long delta = (1LL * 1 * n + 3) - (1LL * 3 * n + 2);       /* a[1][3] 与 a[3][2] 的元素序号差 */
    cout << "given a[3][2]@" << loc32 << ", a[1][3]@" << loc32 + delta * L
         << " (delta = " << delta << " elements)\n";
    return 0;
}
考点:谁来存第一个元素? 教材上的公式常写成 LOC(a[i][j]) = LOC(a[0][0]) + (i×n+j)×L, 但考题里也可能给出 LOC(a[1][1]) 或者干脆只给 a[3][2] 的地址。 这时不要慌:先把你手上的已知点算成「线性下标」或者「元素序号」, 再做差值。首地址在相减时自然会被消掉:
LOC(a[i₂][j₂]) − LOC(a[i₁][j₁]) = [(i₂×n + j₂) − (i₁×n + j₁)] × L
这个「只算增量」的技巧适用于任何维数、任何优先顺序,是考场上的救命公式。

6.2.3 推广到 k 维数组:从最后一维开始递推

现在把二维的结论推广到 k 维。设数组是 d1 × d2 × … × dk, 下标记作 (i1, i2, …, ik),各维下标都从 0 开始。 在行优先(最后一维变化最快)的约定下,线性序号是:

k线性 = i1·(d2d3…dk) + i2·(d3…dk) + … + ik−1·dk + ik
LOC = 首地址 + k线性 × L

这个公式不要死记,用递推(从最后一维往前套)的方式想,三秒钟就能重建出来:

用二维验证一下:d1=m, d2=n,则 i1 的权重是 d2 = ni2 的权重是 1,于是 k = i×n + j,正是行优先公式,完全吻合。 如果换成列优先,规律反过来:第一维变化最快,所以第一维权重为 1,越靠前的维度权重越小

#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   k 维数组的行优先地址
   设数组是 d1 x d2 x ... x dk,下标 (i1, i2, ..., ik) 均从 0 开始,
   则线性序号(第几个元素)= i1*(d2*d3*...*dk) + i2*(d3*...*dk) + ... + ik
   LOC = base + 线性序号 * L

   递推理解:最后一维 ik 每走一步只跨 1 个元素;
             倒数第二维 i(k-1) 每走一步要跨 dk 个元素;
             再往前一层,每走一步跨 dk*d(k-1) 个……
             于是从最后一维开始往前"套",就得到了上面的公式。
   ============================================================ */

long long kdLinearIndex(const vector<int>& dim, const vector<int>& idx) {
    long long lin = 0;
    for (size_t t = 0; t < dim.size(); ++t) {
        long long stride = 1;                       /* 第 t 维的步长 = 它后面所有维度的乘积 */
        for (size_t u = t + 1; u < dim.size(); ++u) stride *= dim[u];
        lin += 1LL * idx[t] * stride;
    }
    return lin;
}

/* 反向操作:已知线性序号,拆回各维下标(从最后一维往前取余、取商) */
vector<int> kdDecode(const vector<int>& dim, long long lin) {
    vector<int> idx(dim.size(), 0);
    for (size_t t = dim.size(); t-- > 0; ) {
        idx[t] = (int)(lin % dim[t]);
        lin /= dim[t];
    }
    return idx;
}

int main() {
    vector<int> dim = {3, 4, 5};                 /* 三维数组 A[3][4][5] */
    vector<int> idx = {1, 2, 3};                 /* 求 a[1][2][3] */
    const long long base = 1000, L = 4;

    long long lin = kdLinearIndex(dim, idx);
    cout << "A[3][4][5] a[1][2][3]: linear index = "
         << "1*(4*5) + 2*5 + 3 = " << lin << "\n";                 /* 20 + 10 + 3 = 33 */
    cout << "  LOC = " << base << " + " << lin << "*" << L << " = "
         << base + lin * L << "\n";                                /* 1132 */

    vector<int> back = kdDecode(dim, lin);
    cout << "decode(" << lin << ") = [" << back[0] << ", " << back[1]
         << ", " << back[2] << "]\n";

    /* 列优先(第一维变得最快):线性序号 = i1 + i2*d1 + i3*d1*d2 */
    long long colLin = idx[0] + 1LL * idx[1] * dim[0] + 1LL * idx[2] * dim[0] * dim[1];
    cout << "a[1][2][3] col-major: 1 + 2*3 + 3*3*4 = " << colLin
         << ", LOC = " << base + colLin * L << "\n";               /* 43, 1172 */

    /* 相邻元素验证:把整块内存按行优先铺开,看看每个位置属于哪个下标 */
    cout << "row-major memory layout of A[2][3][2]:\n";
    vector<int> d2 = {2, 3, 2};
    for (long long p = 0; p < 2 * 3 * 2; ++p) {
        vector<int> ij = kdDecode(d2, p);
        cout << p << ":a[" << ij[0] << "][" << ij[1] << "][" << ij[2] << "]  ";
    }
    cout << "\n";
    return 0;
}

6.2.4 四道地址计算例题(务必自己先算一遍)

例 6-1 设二维数组 A[4][5](4 行 5 列),每个元素占 4 个字节, LOC(a[0][0]) = 1000。求 a[2][3] 在行优先与列优先下的地址。

查看解答

行优先:先算线性下标,排在 a[2][3] 前面的元素有 2 整行 + 本行 3 个:

k = i × n + j = 2 × 5 + 3 = 13
LOC = 1000 + 13 × 4 = 1000 + 52 = 1052

列优先:这里「整块」是列,每列 4 个元素(行数 m = 4):

k = j × m + i = 3 × 4 + 2 = 14
LOC = 1000 + 14 × 4 = 1000 + 56 = 1056

注意两者的差别只有 1 个元素(4 字节)——因为这两种排布在这里「恰好」很接近, 但千万不要以为它们总是一样,下一题就会拉开差距。

例 6-2A[5][6]行优先存储,每个元素 2 字节, LOC(a[0][0]) = 2000。求 a[3][4]a[4][5] 的地址, 并问:从 a[3][4]a[4][5] 跨过了多少个元素?

查看解答
k(a[3][4]) = 3 × 6 + 4 = 22 ⇒ LOC = 2000 + 22 × 2 = 2044
k(a[4][5]) = 4 × 6 + 5 = 29 ⇒ LOC = 2000 + 29 × 2 = 2058

两者相差 29 − 22 = 7 个元素,共 14 字节。

另一种更快的算法:a[4][5] 只比 a[3][4] 靠后 「本行剩下的 1 个元素(列 5)+ 下一行的 6 个元素」= 7 个位置,与上面一致。 考场上用「数格子」验证公式,是最稳的习惯。

例 6-3 设三维数组 A[3][4][5],每个元素 4 字节,LOC(a[0][0][0]) = 1000。 求 a[1][2][3] 在行优先下的地址;若改为列优先,地址又是多少?

查看解答

行优先(最后一维变化最快):各维权重分别是 d2d3 = 4×5 = 20d3 = 51

k = 1 × (4 × 5) + 2 × 5 + 3 = 20 + 10 + 3 = 33
LOC = 1000 + 33 × 4 = 1000 + 132 = 1132

列优先(第一维变化最快):权重反过来, i1 权重 1、i2 权重 d1 = 3i3 权重 d1d2 = 12

k = 1 × 1 + 2 × 3 + 3 × (3 × 4) = 1 + 6 + 36 = 43
LOC = 1000 + 43 × 4 = 1000 + 172 = 1172

怎么防止记混?记住一句话:哪个下标「变化最快」,它的权重就是 1; 权重沿着「慢 → 快」的方向依次乘以各维长度。

例 6-4 设二维数组 A[10][20]行优先存储,每个元素占 1 个字节。 已知 a[3][5] 的地址是 1010,求 a[5][8] 的地址。

查看解答

题目没有给首地址,这正是「只算增量」技巧的用武之地。设首地址为 base,则:

LOC(a[3][5]) = base + (3 × 20 + 5) × 1 = base + 65 = 1010 ⇒ base = 945
LOC(a[5][8]) = 945 + (5 × 20 + 8) × 1 = 945 + 108 = 1053

更快的写法(推荐):直接算两个线性下标之差:

Δ = (5 × 20 + 8) − (3 × 20 + 5) = 108 − 65 = 43
LOC(a[5][8]) = 1010 + 43 × 1 = 1053

两种做法都对,但第二种不需要求 base,少一步、少一个出错点。 这类题在选择题里出现频率极高,务必练熟。

易错点集中营
  • 把行优先的 n 写成列优先的 m。再念一遍:行优先乘列数,列优先乘行数
  • 下标从 0 开始还是从 1 开始没看清。本章统一使用 0 基下标(与 C/C++ 一致)。 如果题目声明「下标从 1 开始」,公式要改成 (i−1)×n + (j−1)
  • 字节数 L 忘了乘。算出来的是「第几个元素」,题目问「地址」时必须再乘 L
  • 三维数组照搬二维公式。三维必须老老实实按「权重 = 后面所有维度长度之积」来算。
  • A[3][4] 当成「3 列 4 行」。A[m][n] 中 m 是行数、n 是列数, 这是约定,考试不会额外说明。

6.2.5 动画演示:同一份数据,两种压平方式

下面这个动画把 A[3][4] 的 12 个格子逐个「搬」到底下的内存带上。 先按行优先走一遍,再按列优先走一遍,请留意每一步里 二维下标 → 线性下标 k → 内存偏移 k×L 这三者的对应关系:

6.3 矩阵的压缩存储:压什么、怎么找回来

6.3.1 什么叫压缩存储

矩阵是数值计算的主角,一个 m × n 的矩阵用二维数组存下来要占 m × n × L 个字节。1000×1000 的 double 矩阵就是 8 MB, 听起来还行;但工程里动辄几十万阶的矩阵,直接开二维数组会瞬间把内存吃光。 于是我们自然会问:这些空间里,有多少是在存「废话」?

答案是很多。看几个例子:

针对这些情况,教材给出了压缩存储的定义:

压缩存储(compressed storage) 为多个值相同的元素只分配一个存储空间,对零元素不分配存储空间。 例如对称矩阵只需要存下三角(含对角线)的 n(n+1)/2 个元素, 三角矩阵里的常数 c 只占一个单元,稀疏矩阵只存非零元。

这个定义里有两个关键词值得抠一抠。第一是「多个值相同的元素只分配一个空间」—— 注意它说的是「值相同」,而不是「位置相邻」。对称矩阵里 a[i][j]a[j][i] 位置上离得很远,但值一定相同,于是我们可以只留一个。 第二是「零元素不分配空间」——注意这在数学上是有前提的: 只有当「0 是这批数据的默认值」时,不存 0 才能靠「没存的就是 0」把信息补回来。 如果矩阵里 0 和别的数一样是「有意义的数据」,那就不能这么干。

6.3.2 压缩的前提:元素分布必须有规律

压缩存储不是变魔术,它必须付出代价。代价是什么?是「要知道某个位置的元素在哪里, 得先算一算」。而这个「算」能不能在 O(1) 时间内完成, 完全取决于元素分布的规律性。

✅ 有规律,可以压缩

  • 对称矩阵:非零(不重复)区域由 i ≥ j 严格刻画。
  • 三角矩阵:常数区由 i < ji > j 刻画。
  • 带状矩阵:非零区由 |i − j| ≤ b 刻画。
  • 共同点:「哪些元素要存」可以用下标的不等式写出来, 于是「前面有多少个元素」可以用等差数列求和算出来。

❌ 没规律,只能换一种存法

  • 一般稀疏矩阵:零元素东一个西一个,位置随机,规律是「没有规律」。
  • 既然无法用不等式描述,就无法用公式计算位置。
  • 退而求其次:把「位置」也一起存下来,即存成 (行号, 列号, 值) 三元组。
  • 代价:每个非零元多了两个整数的开销,而且随机存取能力通常保不住。

所以「压缩存储」实际上分成两大流派: 公式法(顺序存储 + 下标映射)用于对称 / 三角 / 带状这类结构规整的矩阵; 列表法(三元组 / 链表)用于稀疏矩阵。前者能保住 O(1) 随机存取, 后者不能。这也正是本章 6.4–6.6 与 6.7 的分界线。

6.3.3 下标映射函数:压缩存储的灵魂

压缩之后,原来的二维下标 (i, j) 不再等于内存位置,我们需要一个函数把它翻译过去:

k = f(i, j) (二维下标 → 一维数组下标)

这个 f 就叫下标映射函数(index mapping function)。有了它, get(i,j) 就是 sa[f(i,j)]set(i,j,v) 就是 sa[f(i,j)] = v,两条语句、常数时间。整个压缩存储方案的价值, 全都压在这个函数身上。

好消息是:所有这类函数的推导套路都是同一套三步法,掌握之后可以临场推、不用背:

  1. 第一步:确定「存哪些」。画出矩阵,把要存的区域涂上颜色, 用下标不等式描述它(比如对称矩阵存 i ≥ j)。 顺便数一数总共有多少个元素,得到数组长度
  2. 第二步:确定「按什么顺序存」。绝大多数教材用行优先: 先存第 0 行里要存的那些,再存第 1 行……这一步决定了「整块」是行还是列。
  3. 第三步:数「排在 (i,j) 前面的元素有多少个」。 把它拆成「前面完整的若干行」+「本行内排在它前面的几个」, 前者是一个等差数列求和,后者往往就是几。加在一起就是 k
第三步的万能公式
k = (前 i 行要存的元素总数)+ (第 i 行内排在 j 前面的元素个数)
只要「每行要存的元素个数」构成等差数列,「前 i 行总数」就能一步算出来。 对称矩阵每行存 1,2,3,…,n 个(公差 1),三对角矩阵每行存 2,3,3,…,3,2 个 (中间公差 0,首尾特殊),这就是它们公式长得不一样的根本原因。

6.3.4 压完之后还能随机存取吗

这是考试里最爱问的辨析题,答案要看「f 能不能 O(1) 算出来」:

矩阵类型压缩方式按下标取值原因
对称矩阵下三角压进一维数组 保留 O(1) k = i(i+1)/2 + j 是闭式公式,一次乘加即可
三角矩阵三角区 + 一个常数单元 保留 O(1) 常数区直接返回 c,非常数区用公式
三对角矩阵三条对角线压进一维数组 保留 O(1) k = 2i + j;带外元素直接返回 0
稀疏矩阵三元组顺序表(按行有序) 不保留 要找到 (i,j) 得顺序查找,最坏 O(tu); 若再建 rpos[],可先定位行、再在行内查找
十字链表 不保留(但很好用) 可从 rhead[i] 沿 right 找,代价与行内非零元个数成正比
CSR(行压缩) 部分保留 按行访问 O(该行 nnz);直接按 (i,j) 定位仍需行内二分或哈希

一句话总结:压缩存储是否保留随机存取,取决于压缩后「位置信息」是否被一个 O(1) 可算的公式完整保留下来。对称 / 三角 / 带状矩阵的规律是「全局的」, 一个公式管全部;稀疏矩阵的规律是「局部的」,只能靠额外的索引结构(rpos[]、 十字链表、哈希)去补救。

6.4 对称矩阵的压缩存储

6.4.1 定义与压缩方案

An 阶方阵,如果对所有 0 ≤ i, j ≤ n−1 都有

a[i][j] = a[j][i]

则称 A对称矩阵(symmetric matrix)。它是沿主对角线「照镜子」左右对称的, 所以只要知道一半,另一半就全知道了。

压缩方案的选择很自然:只存下三角(含主对角线),按行优先顺序 压进一个一维数组 sa[]。为什么不存上三角?纯粹是习惯—— 教材和考试统一按下三角来,你跟着来就行(存上三角也完全可以,公式对称地改一下即可)。

为什么是「含主对角线」?因为对角线上的元素 a[i][i] 满足 i = j, 它在镜子上的像是它自己,只存一次正好,丢掉就再也找不回来了。

对称矩阵 A(5 阶):只存下三角(含对角线),按行优先压进 sa[] j=0 1 2 3 4 i=0 00 10 20 30 40 i=1 10 11 31 31 41 i=2 20 21 22 32 42 i=3 30 31 32 33 43 i=4 40 41 42 43 44 ■ 蓝色 = 下三角(含对角线),共 15 个,实际存储 ■ 灰色 = 上三角,a[i][j] 恒等于 a[j][i],一个字节都不存 ■ 红色 = a[1][3] 与 a[3][1]:值相同,压缩后共用同一格 一维数组 sa[](长度 15) i=0 k=0 i=1 k=1 k=2 i=2 k=3 k=4 k=5 i=3 k=6 k=7 k=8 k=9 i=4 k=10 k=11 k=12 k=13 k=14 第 i 行的第一个格子是 k = i(i+1)/2:行 0 从 0 开始,行 3 从 6 开始 a[3][1] 与 a[1][3] 都落在 k = 7 存储长度:1 + 2 + 3 + 4 + 5 = 15 = n(n+1)/2 (原本要 5×5 = 25 个单元,省掉 40%) i >= j:k = i(i+1)/2 + j    i < j:k = j(j+1)/2 + i (利用 a[i][j] = a[j][i]) 注意灰格里写的是「对称位置的那个值」,例如 a[1][3] 写的 31 其实就是 a[3][1] 的值。
图 6-5 对称矩阵的压缩存储与下标映射:下三角按行优先压进 sa[],上三角查表时转成对称位置

6.4.2 下标映射公式的完整推导

现在按 6.3.3 的三步法,把 k = f(i, j) 推出来。这一段请务必跟着算一遍, 它是本章所有公式的模板。

第一步:存哪些?共多少个?

存下三角(含对角线),即所有满足 i ≥ j 的元素。数一数每行有几个: 第 0 行有 1 个(a[0][0]),第 1 行有 2 个(a[1][0], a[1][1]), …,第 i 行有 i+1 个,…,第 n−1 行有 n 个。 总数是一个等差数列之和:

总数 = 1 + 2 + 3 + … + n

等差数列求和公式(首项 + 末项)× 项数 ÷ 2:

S = (1 + n) × n / 2 = n(n+1)/2

所以一维数组 sa[] 的长度是 n(n+1)/2, 下标范围是 0 … n(n+1)/2 − 1。 例如 n = 5 时长度是 5×6/2 = 15,与图 6-5 一致。 (顺带记一个常用结论:1+2+…+i = i(i+1)/2, 这个「三角形数」在后面反复出现,是本章最常用的求和式。)

第二步:按什么顺序存?

按行优先——先把第 0 行里要存的元素从左到右放好,再放第 1 行的,依此类推。 于是在一维数组里,第 0 行占据 sa[0..0], 第 1 行占据 sa[1..2],第 2 行占据 sa[3..5], 第 i 行占据 sa[i(i+1)/2 .. i(i+1)/2 + i]。 这正是图 6-5 右边那个「阶梯」形状的来历。

第三步:数一数 (i, j) 前面有多少个元素。

分两种情况讨论。

情况 A:i ≥ j(元素本来就在下三角里,值就存在 sa 中)。 排在它前面的元素由两部分组成:

两部分相加:

k = i(i+1)/2 + j  (当 i ≥ j)

情况 B:i < j(元素在上三角,本身不存储)。 由对称性 a[i][j] = a[j][i],而 (j, i) 满足 j > i, 即 j ≥ i,一定落在下三角里(含对角线),可以直接套用情况 A 的公式, 只要把 ij 的角色对调:

k = j(j+1)/2 + i  (当 i < j)

把两个式子合起来写,就是对称矩阵的下标映射函数:

f(i, j) = i(i+1)/2 + j (i ≥ j) ; f(i, j) = j(j+1)/2 + i (i < j)
用几个小数据自检
  • f(0,0) = 0×1/2 + 0 = 0 ✅ 第一个元素当然在 0 号位。
  • f(1,0) = 1×2/2 + 0 = 1 ✅ 第 1 行的第一个。
  • f(1,1) = 1 + 1 = 2 ✅ 第 1 行的第二个。
  • f(2,0) = 2×3/2 + 0 = 3 ✅ 前面正好装下第 0、1 行的 1+2 = 3 个。
  • f(i, i) = i(i+1)/2 + i = i(i+3)/2,正好是第 i 行的最后一个位置, 也是前 i+1 行的总数 (i+1)(i+2)/2 − 1,两者相等 ✅
自己动手代几个数,比背十遍公式都管用。

6.4.3 两道手算例题

例 6-5 n = 5 的对称矩阵按「下三角 + 行优先」压缩进一维数组 sa[], 问 a[3][1]a[1][3] 分别存在 sa 的哪个位置? 数组总长是多少?

查看解答

求 a[3][1]:此时 i = 3, j = 1,满足 i ≥ j,用情况 A:

k = i(i+1)/2 + j = 3 × 4 / 2 + 1 = 6 + 1 = 7

求 a[1][3]:此时 i = 1, j = 3,满足 i < j,用情况 B (本质上就是去查 a[3][1]):

k = j(j+1)/2 + i = 3 × 4 / 2 + 1 = 6 + 1 = 7

两者当然相同——因为它们本来就是同一个值,压缩存储后共用 sa[7] 这一个单元。

数组总长:n(n+1)/2 = 5 × 6 / 2 = 15,合法下标 0 ~ 14。

验证:数一数 sa[7] 的位置——第 0 行 1 个、第 1 行 2 个、第 2 行 3 个, 共 6 个占掉 sa[0..5];第 3 行从 sa[6] 开始, sa[6] = a[3][0]sa[7] = a[3][1] ✅ 完全吻合。

例 6-6 n = 6 的对称矩阵压缩后,a[5][5]a[2][5]a[4][2] 分别存在哪里?一维数组最大下标是多少?

查看解答

数组长度 6 × 7 / 2 = 21,最大下标 20

a[5][5]i = j = 5,下三角,是最后一行最后一个元素):

k = 5 × 6 / 2 + 5 = 15 + 5 = 20 ✅ 正好是最大下标,逻辑自洽。

a[2][5]i = 2 < j = 5,上三角,转成 a[5][2]):

k = j(j+1)/2 + i = 5 × 6 / 2 + 2 = 15 + 2 = 17

a[4][2]i = 4 ≥ j = 2,下三角):

k = 4 × 5 / 2 + 2 = 10 + 2 = 12

反向检查:第 4 行从 k = 4×5/2 = 10 开始, sa[10] = a[4][0]sa[11] = a[4][1]sa[12] = a[4][2] ✅ 正确。

例 6-7(反向题) 一个 6 阶对称矩阵压缩后得到 sa[0..20]。已知某个值存在 sa[12], 它在原矩阵中的下标 (i, j) 是什么(只写下三角的那个)?

查看解答

反向题的思路是「找行首」:第 i 行的第一个元素在 i(i+1)/2, 我们要找到一个 i,使得

i(i+1)/2 ≤ 12 < (i+1)(i+2)/2

逐个试:i = 4 时左边是 4×5/2 = 10 ≤ 12i = 5 时左边是 5×6/2 = 15 > 12,停。

所以它在第 4 行,行内偏移 j = 12 − 10 = 2, 即 sa[12] 存的是 a[4][2](也就等于 a[2][4])。

这类「由 k 反求 (i, j)」的题在填空题里很常见,操作上就是一次简单的试根, 熟悉后可以心算:√(2k) 附近找一找就行。

6.4.4 C++ 实现:全局数组 + 下标映射函数

把公式写进代码只有两行的事。竞赛里的写法非常直接:开一个全局数组 sa[],再写一个 idx(i, j) 把二维下标映射成一维下标, 存取各写一个自由函数就行——不封类、不模板、不抛异常,代码能在考场上三分钟敲完。 要注意的只有两点:下标越界的检查(越界只提示、不崩溃)和 映射函数要单独写出来,方便和手算的 k 对照。

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   对称矩阵的压缩存储(竞赛写法:全局数组 + 下标映射函数)
   定义:n 阶方阵 A 满足 a[i][j] == a[j][i],只需存下三角(含对角线)。

   按行优先把下三角压进一维数组 sa[0 .. n(n+1)/2 - 1]:
       i >= j (下三角):k = i*(i+1)/2 + j
       i <  j (上三角):k = j*(j+1)/2 + i     (利用 a[i][j] = a[j][i])

   存储长度 1 + 2 + ... + n = n(n+1)/2,比原来的 n^2 省了近一半。
   例如 n = 5:长度 15,a[3][1] 与 a[1][3] 都映射到 k = 7,共用同一个单元。
   ============================================================ */

const int N  = 1005;                  /* 最大阶数 */
const int SA = N * (N + 1) / 2;       /* 一维数组长度:n(n+1)/2 个单元 */
int sa[SA];                           /* 压缩数组:行优先存下三角(含对角线) */
int n;                                /* 矩阵阶数 */

/* 下标映射函数:把二维下标 (i, j) 映射成一维下标 k
   下三角:前面有 i 整行共 1+2+...+i = i(i+1)/2 个元素,本行再走 j 格
   上三角:换成对称位置 (j, i) 套同一个公式 —— 「对称性免费」就是这么来的 */
inline int idx(int i, int j) {
    return (i >= j) ? (i * (i + 1) / 2 + j)      /* 下三角 */
                    : (j * (j + 1) / 2 + i);     /* 上三角 */
}

/* 边界检查:竞赛里不抛异常,越界就打印提示并拒绝这次操作 */
bool inRange(int i, int j) {
    if (0 <= i && i < n && 0 <= j && j < n) return true;
    printf("  [warn] (%d, %d) out of range\n", i, j);
    return false;
}

/* 存:只需写一次,对称位置「自动」也是同一个值 */
void setVal(int i, int j, int v) {
    if (!inRange(i, j)) return;
    sa[idx(i, j)] = v;
}

/* 取:上、下三角走同一套映射,O(1)(一次乘法 + 一次加法) */
int getVal(int i, int j) {
    if (!inRange(i, j)) return 0;
    return sa[idx(i, j)];
}

/* 打印整个一维数组,方便与手算结果对照 */
void dump() {
    int len = n * (n + 1) / 2;
    for (int k = 0; k < len; ++k) printf("%d%c", sa[k], k + 1 == len ? '\n' : ' ');
}

/* 函数名为什么叫 setVal 而不是 set?
   using namespace std 之后,自由函数 set 会和 std::set 撞名,
   g++ 直接报 "reference to 'set' is ambiguous",所以换个名字。 */

int main() {
    n = 5;
    printf("n = %d, storage size = %d (n(n+1)/2 = %d)\n",
           n, n * (n + 1) / 2, n * (n + 1) / 2);          /* 15 */

    /* 手算例题:n = 5 时 a[3][1] 与 a[1][3] 存在哪个位置? */
    printf("a[3][1] -> k = %d\n", idx(3, 1));             /* 3*4/2+1 = 7 */
    printf("a[1][3] -> k = %d\n", idx(1, 3));             /* 3*4/2+1 = 7 */

    /* 按下三角的行优先顺序填入 1,2,3,...,再打印一维数组对照 */
    int val = 0;
    for (int i = 0; i < n; ++i)
        for (int j = 0; j <= i; ++j)
            setVal(i, j, ++val);
    printf("sa[] = ");
    dump();                                               /* 1 2 3 ... 15 */

    /* 验证对称性:任意 (i,j) 都有 getVal(i,j) == getVal(j,i) */
    bool ok = true;
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < n; ++j)
            if (getVal(i, j) != getVal(j, i)) ok = false;
    printf("symmetry check: %s\n", ok ? "PASS" : "FAIL");

    /* 写一次 a[3][1],读 a[1][3] 也是同一个值:两次访问命中同一个内存单元 */
    setVal(3, 1, 41);
    printf("set a[3][1] = 41, then a[1][3] = %d\n", getVal(1, 3));   /* 41 */

    /* 边界检查:越界只提示、不崩溃 */
    printf("a[5][0] = %d  (out of range)\n", getVal(5, 0));
    return 0;
}

这段代码里有三个细节值得单独说:

6.4.5 动画演示:映射过程与对称性

下面的动画把 5 阶对称矩阵的下三角逐个搬进 sa[]。 每次处理 (i, j) 时,画面会同时标出它的「镜像位置」(j, i), 并显示两者算出来的 k 完全相同——这就是对称矩阵能省一半空间的直观解释:

易错:i ≥ j 还是 i > j? 存的是「下三角含对角线」,所以判据是 i ≥ j(取等号那一半归下三角)。 写成 i > j 会把对角线丢掉,那 a[i][i] 就查不到了。 对应的长度公式 n(n+1)/2 里那个 +n 正是对角线贡献的, 如果写成 n(n−1)/2,说明你把对角线漏了。

6.5 三角矩阵的压缩存储

6.5.1 两种三角矩阵与压缩方案

三角矩阵(triangular matrix)分两种,定义只差一个方向:

下三角矩阵(lower triangular)
主对角线以上(不含主对角线)的元素全是同一个常数 c, 即当 i < j 时恒有 a[i][j] = c。 主对角线及以下的部分是「真实数据」。
上三角矩阵(upper triangular)
主对角线以下(不含主对角线)的元素全是同一个常数 c, 即当 i > j 时恒有 a[i][j] = c

注意定义里那句「不含主对角线」——对角线永远是真实数据,必须老老实实存下来。 最常见的常数是 c = 0(例如高斯消元过程中出现的上三角方程组系数矩阵), 但定义本身对 c 没有限制,所以我们的方案必须能存下任意常数。

压缩方案与对称矩阵几乎一样(两种矩阵的对照见图 6-6),只有一处不同:

  1. 把真实数据区(下三角或上三角,含对角线)按行优先压进 sa[0 … n(n+1)/2 − 1],共 n(n+1)/2 个单元;
  2. 常数 c 单独占用最后一个单元 sa[n(n+1)/2]

所以三角矩阵的压缩数组长度是 n(n+1)/2 + 1。 为什么常数区已经「全都一样」了,还要单独留一个位置?因为「全都一样」不等于 「等于多少已知」。对称矩阵的上三角可以靠 a[j][i] 推算出来, 而三角矩阵的常数区推算不出来,必须把 c 的值存一份。 (如果题目明确说了 c = 0,实现时当然可以省掉这个单元, 但考试里请按通式 +1 回答。)

三角矩阵的压缩:真实数据区按行优先压平,常数 c 只占一个单元 下三角矩阵(i < j 处全是 c) j=0 1 2 3 4 i=0 a00 c c c c i=1 a10 a11 c c c i=2 a20 a21 a22 c c i=3 a30 a31 a32 a33 c i=4 a40 a41 a42 a43 a44 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 c sa[]:15 个真实数据 + 1 个常数单元 = n(n+1)/2 + 1 = 16 i >= j:k = i(i+1)/2 + j  i < j:k = 15(常数单元) 上三角矩阵(i > j 处全是 c) j=0 1 2 3 4 i=0 a00 a01 a02 a03 a04 i=1 c a11 a12 a13 a14 i=2 c c a22 a23 a24 i=3 c c c a33 a34 i=4 c c c c a44 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 c 同样是 15 + 1 = 16 个单元,只是压平的顺序不同 i <= j:k = i(2n−i+1)/2 + (j−i)  i > j:k = 15(常数单元) 注意两种矩阵里常数区的位置刚好相反:下三角矩阵的常数在上半部,上三角矩阵的常数在下半部。 灰色格子里写的 c 就是那个常数,压缩后它们全部塌缩成数组末尾的同一个单元。
图 6-6 三角矩阵的压缩存储:真实数据区按行优先压平,常数 c 单独占一个单元

6.5.2 下三角矩阵:公式与长度

下三角矩阵要存的部分正好是 i ≥ j,和对称矩阵存的区域一模一样, 所以下标公式也一模一样:

k = i(i+1)/2 + j (i ≥ j,即在下三角内)

唯一的区别出在 i < j 的时候:对称矩阵会「照镜子」去查 a[j][i], 而下三角矩阵里这些位置的值是常数 c,直接返回 sa[n(n+1)/2] 即可。 写成代码就是一句话:

k = (i >= j) ? (i*(i+1)/2 + j) : (n*(n+1)/2)

数组长度 n(n+1)/2 + 1:前 n(n+1)/2 个单元放真实数据, 最后 1 个单元放常数。

6.5.3 上三角矩阵:公式推导

上三角矩阵的推导要用到 6.3.3 的同一个套路,只是「每行有几个元素」不再是 1,2,3,…, 而是递减的:

第一步:存哪些、共多少个?

按行优先存 i ≤ j 的部分。第 i 行要存的是 j = i, i+1, …, n−1,个数是 n − i。 于是各行个数依次是 n, n−1, n−2, …, 1,总数仍是

n + (n−1) + … + 1 = n(n+1)/2 (与下三角一样多,只是顺序反过来)

第二步:按行优先存。i 行的起点是「前 i 行的总数」。

第三步:数 (i, j) 前面有多少个元素。分成两块:

两式相加,得到上三角矩阵的下标映射函数:

k = i(2n − i + 1)/2 + (j − i) (i ≤ j)

i > j 时,元素落在常数区,k = n(n+1)/2。 数组长度同样是 n(n+1)/2 + 1

公式自检(n = 5)
  • f(0,0) = 0×11/2 + 0 = 0 ✅ 第 0 行第 1 个。
  • f(0,4) = 0 + 4 = 4 ✅ 第 0 行有 5 个元素,占 sa[0..4],最后一个正是它。
  • f(1,1) = 1×(10−1+1)/2 + 0 = 5 ✅ 第 1 行从 sa[5] 开始。
  • f(4,4) = 4×(10−4+1)/2 + 0 = 4×7/2 = 14 ✅ 15 个真实数据的最后一个,正好是 sa[14]
注意那个 i(2n−i+1)/2 里的乘积一定是偶数(i 为偶数时显然; i 为奇数时 2n−i+1 必为偶数),所以整数除法不会丢精度。

6.5.4 两道手算例题

例 6-8 n = 5 的下三角矩阵(常数 c = 0)按行优先压缩, 求 a[3][2]a[4][4]a[0][4] 的存储位置, 以及数组总长度。

查看解答

数组长度 n(n+1)/2 + 1 = 15 + 1 = 16,常数 c 存在 sa[15]

a[3][2]:i=3 ≥ j=2 ⇒ k = 3×4/2 + 2 = 6 + 2 = 8
a[4][4]:i=4 ≥ j=4 ⇒ k = 4×5/2 + 4 = 10 + 4 = 14(最后一个真实数据)
a[0][4]:i=0 < j=4 ⇒ 落在常数区 ⇒ k = 15,值就是 c

检查:第 4 行从 k = 4×5/2 = 10 开始,本行有 5 个元素 (sa[10] ~ sa[14]),刚好把 15 个数据位用完,常数单元 sa[15] 紧随其后 ✅

例 6-9 同样 n = 5,但换成上三角矩阵(常数 c = 0)。 求 a[1][3]a[0][4]a[4][4]a[3][1] 的位置。

查看解答

上三角公式:k = i(2n−i+1)/2 + (j−i),其中 n = 5,故 2n+1 = 11

a[1][3]:k = 1×(10−1+1)/2 + (3−1) = 5 + 2 = 7
a[0][4]:k = 0×11/2 + 4 = 4
a[4][4]:k = 4×(10−4+1)/2 + 0 = 14 + 0 = 14
a[3][1]:i=3 > j=1 ⇒ 落在常数区 ⇒ k = 15

用「数格子」验证 a[1][3]:第 0 行有 5 个(sa[0..4]), 第 1 行从 sa[5] 开始放 a[1][1], a[1][2], a[1][3], a[1][4], 于是 a[1][1]→5a[1][2]→6a[1][3]→7 ✅ 与公式一致。

易错:上三角的公式里那个「j − i」 很多人会顺手写成 k = i(2n−i+1)/2 + j,那就错了。 因为第 i 行是从列 i 开始存的,a[i][i] 才是这一行的第一个, 所以行内偏移必须是 j − i 而不是 j。 下三角之所以不用减,是因为第 i 行确实从列 0 开始存。
记忆方法:「上三角每行起步晚,要减掉起步的 i」

6.5.5 C++ 实现:两套映射函数与边界检查

下三角与上三角各给一套映射函数(lowIdx / upIdx)和两个数组 (saL[] / saU[])。除了公式本身,请重点看边界检查是怎么做的: 下标越界要提示;往常数区写一个不等于 c 的值,也要立刻报错(这里用返回 false 表示拒绝)——因为那说明调用者的逻辑已经错了,静默接受只会让 bug 藏得更深。 竞赛代码不抛异常,用返回值或打印提示来表达「这次操作不合法」,一样能起到暴露错误的作用。

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   三角矩阵的压缩存储(竞赛写法:两套映射函数 + 常数单元单独存)
   ------------------------------------------------------------
   下三角矩阵:主对角线以上(不含对角线)的元素全是同一个常数 c
       存法:saL[0 .. n(n+1)/2 - 1] 按下三角行优先存;
             saL[n(n+1)/2] 单独存常数 c。
       i >= j : k = i*(i+1)/2 + j
       i <  j : k = n(n+1)/2             (常数单元)
       数组长度 n(n+1)/2 + 1

   上三角矩阵:主对角线以下(不含对角线)的元素全是同一个常数 c
       存法:按行优先存上三角,第 i 行存 j = i .. n-1,共 n-i 个元素;
             前 i 行共 sum_{t=0}^{i-1}(n-t) = i*n - i(i-1)/2 = i(2n-i+1)/2 个元素。
             注意第 i 行是从列 i 开始存的,所以行内偏移是 (j-i) 而不是 j。
       i <= j : k = i*(2n-i+1)/2 + (j-i)
       i >  j : k = n(n+1)/2
       数组长度同样 n(n+1)/2 + 1
   ============================================================ */

const int N  = 1005;                  /* 最大阶数 */
const int SA = N * (N + 1) / 2 + 1;   /* 长度:n(n+1)/2 个真实数据 + 1 个常数单元 */
int saL[SA];                          /* 下三角矩阵的压缩数组 */
int saU[SA];                          /* 上三角矩阵的压缩数组 */
int n;                                /* 阶数 */
int cst;                              /* 常数 c */

/* 常数单元的下标:下三角、上三角都是 n(n+1)/2 这一格 */
inline int constIdx() { return n * (n + 1) / 2; }

/* 下三角映射(i >= j 时有效):k = i*(i+1)/2 + j */
inline int lowIdx(int i, int j) { return i * (i + 1) / 2 + j; }

/* 上三角映射(i <= j 时有效):k = i*(2n-i+1)/2 + (j-i) */
inline int upIdx(int i, int j) { return i * (2 * n - i + 1) / 2 + (j - i); }

/* 边界检查:竞赛里不抛异常,越界就返回 false 让调用者自己处理 */
bool inRange(int i, int j) {
    if (0 <= i && i < n && 0 <= j && j < n) return true;
    printf("  [warn] (%d, %d) out of range\n", i, j);
    return false;
}

/* 初始化:把两个数组的常数单元都预置成 c,
   这样即使从没写过常数区,读出来的也是正确的 c */
void init(int nn, int c) {
    n = nn; cst = c;
    for (int k = 0; k <= constIdx(); ++k) { saL[k] = c; saU[k] = c; }
}

/* ---------- 下三角 ---------- */
/* 存:i >= j 写真实位置;i < j 落在常数区,只允许写 c */
bool lowSet(int i, int j, int v) {
    if (!inRange(i, j)) return false;
    if (i < j) {                     /* 这一类位置压缩后根本没有独立存储单元 */
        if (v != cst) {
            printf("  [warn] a[%d][%d] 在常数区,只能写 c = %d\n", i, j, cst);
            return false;            /* 静默接受只会让 bug 藏得更深 */
        }
        return true;                 /* 写的就是 c,等于没写 */
    }
    saL[lowIdx(i, j)] = v;
    return true;
}
int lowGet(int i, int j) {
    if (!inRange(i, j)) return 0;
    return i >= j ? saL[lowIdx(i, j)] : saL[constIdx()];
}

/* ---------- 上三角 ---------- */
bool upSet(int i, int j, int v) {
    if (!inRange(i, j)) return false;
    if (i > j) {                     /* 常数区 */
        if (v != cst) {
            printf("  [warn] a[%d][%d] 在常数区,只能写 c = %d\n", i, j, cst);
            return false;
        }
        return true;
    }
    saU[upIdx(i, j)] = v;
    return true;
}
int upGet(int i, int j) {
    if (!inRange(i, j)) return 0;
    return i <= j ? saU[upIdx(i, j)] : saU[constIdx()];
}

int main() {
    init(5, 0);                                            /* n = 5,常数 c = 0 */

    lowSet(3, 2, 7);
    lowSet(4, 4, 9);
    printf("lower triangular, n = %d\n", n);
    printf("  a[3][2] = %d, k = %d   (3*4/2 + 2 = 8)\n", lowGet(3, 2), lowIdx(3, 2));
    printf("  a[4][4] = %d, k = %d  (4*5/2 + 4 = 14)\n", lowGet(4, 4), lowIdx(4, 4));
    printf("  a[0][4] = %d, k = %d  (constant cell)\n", lowGet(0, 4), constIdx());
    printf("  storage size = %d = n(n+1)/2 + 1 = %d\n",
           constIdx() + 1, n * (n + 1) / 2 + 1);           /* 16 */

    upSet(1, 3, 5);
    upSet(0, 4, 6);
    printf("upper triangular, n = %d\n", n);
    printf("  a[1][3] = %d, k = %d   (1*(10-1+1)/2 + 2 = 7)\n", upGet(1, 3), upIdx(1, 3));
    printf("  a[0][4] = %d, k = %d   (0*11/2 + 4 = 4)\n", upGet(0, 4), upIdx(0, 4));
    printf("  a[4][4] = %d, k = %d  (4*(10-4+1)/2 + 0 = 14)\n", upGet(4, 4), upIdx(4, 4));
    printf("  a[4][0] = %d  (constant cell)\n", upGet(4, 0));
    printf("  storage size = %d\n", constIdx() + 1);

    /* 边界检查:越界、以及「往常数区写非 c 值」都应该被拦住 */
    printf("lowGet(5, 0)      -> %d\n", lowGet(5, 0));
    printf("lowSet(0, 4, 1)   -> %s\n", lowSet(0, 4, 1) ? "true" : "false");
    return 0;
}

三个要点:

6.6 对角矩阵(带状矩阵)的压缩存储

6.6.1 三对角矩阵的定义

如果矩阵中所有非零元素都集中在主对角线附近的若干条对角线上,就称它为 带状矩阵(band matrix),集中分布的这些对角线构成「带」。 最常见的是三对角矩阵(tridiagonal matrix),也叫带宽为 3 的带状矩阵:

当 |i − j| ≤ 1 时 a[i][j] 可能非零;当 |i − j| > 1 时 a[i][j] = 0

三条对角线各有名字:i = j 是主对角线,j = i + 1 是主对角线 上方的第一条,i = j + 1 是主对角线下方的第一条。 三对角矩阵在数值计算里出现的频率高得惊人:三次样条插值、一维热传导方程的差分格式、 隐式求解偏微分方程,最后都会归结为「解一个三对角线性方程组」, 而这类方程组可以用追赶法(Thomas 算法)在 O(n) 时间内解出—— 前提正是它被压缩存储了。

一个 n 阶三对角矩阵有 个元素,但真正可能非零的只有多少? 数一数:第 0 行 2 个;中间第 1 … n−2 行每行 3 个;最后一行 2 个。于是

2 + 3(n − 2) + 2 = 3n − 2

例如 n = 1000 时,n² = 10000003n − 2 = 2998, 压缩率高达 99.7%。这就是压缩存储的威力。

三对角矩阵(6 阶):只有 |i − j| ≤ 1 的三条带上有数据,共 3n − 2 = 16 个 j=0 1 2 3 4 5 i=0 0 1 0 0 0 0 i=1 10 11 12 0 0 0 i=2 0 21 22 23 0 0 i=3 0 0 32 33 34 0 i=4 0 0 0 43 44 0 i=5 0 0 0 0 54 55 蓝色 = 带状区(要存);灰色 = 带外的 0(一个字节都不存) 按行优先压进 sa[](长度 16) i=0 k=0 k=1 i=1 k=2 k=3 k=4 i=2 k=5 k=6 k=7 i=3 k=8 k=9 k=10 i=4 k=11 k=12 k=13 i=5 k=14 k=15 a[2][2] k = 2i + j = 2×2 + 2 = 6 各行的起始位置:第 0 行从 0 开始;第 i 行(i ≥ 1)从 3i − 1 开始 ⇒ k = (3i − 1) + (j − i + 1) = 2i + j 行内偏移 j − i + 1 的含义:第 i 行是从列 i−1 开始放的,所以 a[i][j] 是本行的第 (j − (i−1)) 个。 带外元素永远返回 0,不需要查找、不需要存储 —— 这就是三对角矩阵压缩后仍然保持 O(1) 随机存取的原因。
图 6-7 三对角矩阵的带状压缩:第 0 行 2 个、中间每行 3 个、最后一行 2 个,共 3n−2 个

6.6.2 下标公式的推导与长度 3n−2

还是那三步。这次「每行存几个」不再是等差的了,所以要分情况。

第一步:每行存几个?

总数:

2 + 3 × (n − 2) + 2 = 2 + 3n − 6 + 2 = 3n − 2

于是压缩数组 sa[] 的长度是 3n − 2,下标范围 0 … 3n−3n = 5 时是 13,n = 6 时是 16(与图 6-7 一致)。

第二步与第三步:算 k。

情况 1:i = 0第 0 行前面什么都没有,本行从列 0 开始放, 所以行内偏移就是 j

k = j (i = 0,j = 0 或 1)

情况 2:i ≥ 1i 行(第 0 行到第 i−1 行)的元素总数是 「第 0 行的 2 个 + 中间 (i−1) 行每行 3 个」:

前 i 行总数 = 2 + 3(i − 1) = 3i − 1

i 行是从i−1 开始放的(因为第一个元素是 a[i][i−1]), 所以 a[i][j] 在本行内是第 j − (i−1) = j − i + 1 个元素, 行内偏移就是 j − i + 1。两者相加:

k = (3i − 1) + (j − i + 1) = 3i − 1 + j − i + 1 = 2i + j

有意思的是,i = 02i + j = j,与情况 1 的结果完全一致。 所以两种写法可以统一成一个式子

k = 2i + j (只要 |i − j| ≤ 1)

为什么能统一?因为第 0 行「少的那一个元素」正好被 3i − 1i = 0 时 多减的 1 抵消掉了。这也解释了一个容易困惑的点:这个统一公式只在带内成立, 带外的 (i, j) 代进去会算出越界的 k(比如 a[0][5] 会算出 5, 而它其实应该是 0),所以实现时必须先判断 |i − j| ≤ 1

反向映射(由 k 求 i, j)。k = 2i + jj − i ∈ {−1, 0, 1} 联立,可以得到一个漂亮的闭式:

i = ⌊(k + 1) / 3⌋ (向下取整) , j = k − 2i

验证:k = 0 → i = 0, j = 0k = 2 → i = 1, j = 0k = 6 → i = 2, j = 2k = 12 → i = 4, j = 4 ✅ 这条公式在「已知某个一维下标,反求它在矩阵中的位置」这类题里非常好用。

考点:另一种常见的带状存法 B[3][n] 有些教材(尤其是数值计算方向)不用一维数组,而是用一个 3 × n 的二维数组 B 按「对角线」来存:
B[0][j] = a[j−1][j](上方那条对角线),B[1][j] = a[j][j](主对角线),B[2][j] = a[j+1][j](下方那条对角线)
于是 a[i][j] = B[j − i + 1][j](要求 |i−j| ≤ 1)。 这种存法一共 3n 个单元,比分情况压平的 3n − 2 多 2 个 (四个角上浪费了)。两种做法都正确,考试时看清题目要求的是哪一种: 问「数组长度」时,3n − 23n 是两个不同的答案。

6.6.3 两道手算例题

例 6-10 n = 5 的三对角矩阵按行优先压进 sa[]。 求 a[0][1]a[2][2]a[4][3] 的位置, 以及 a[3][0] 的值和数组长度。

查看解答

数组长度 3n − 2 = 3 × 5 − 2 = 13,下标 0 ~ 12。

a[0][1]:k = 2×0 + 1 = 1
a[2][2]:k = 2×2 + 2 = 6
a[4][3]:k = 2×4 + 3 = 11

a[3][0]|3 − 0| = 3 > 1,落在带外, 压缩后根本没有存储它,取值恒为 0

用「数行」验证 k = 11:第 0 行 2 个(k=0,1)、第 1~3 行各 3 个(k=2~10), 所以第 4 行从 k = 11 开始,放 a[4][3]a[4][4]

例 6-11(反向题) n = 6 的三对角矩阵压缩后得到 sa[0..15]。 问 sa[9]sa[15] 分别对应矩阵中的哪个元素?总长度是多少?

查看解答

总长度 3 × 6 − 2 = 16,所以 sa[0..15] 正好装满 ✅

用反向公式 i = ⌊(k+1)/3⌋,j = k − 2i

k = 9:i = ⌊10/3⌋ = 3,j = 9 − 6 = 3 ⇒ a[3][3](主对角线上的元素)
k = 15:i = ⌊16/3⌋ = 5,j = 15 − 10 = 5 ⇒ a[5][5](最后一个元素)

数行验证:第 0 行 k=0,1;第 1 行 k=2,3,4;第 2 行 k=5,6,7;第 3 行 k=8,9,10。 所以 k = 9 是第 3 行的中间那个,即 a[3][3]

6.6.4 C++ 实现,以及「为什么还能 O(1)」

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   三对角(带状)矩阵的压缩存储(竞赛写法:全局数组 + 映射函数)
   定义:|i - j| <= 1 的元素才可能非零,其余全为 0。
        第 0 行 2 个元素,第 1 .. n-2 行各 3 个,第 n-1 行 2 个,
        总长度 = 2 + 3(n-2) + 2 = 3n - 2。
   (若题目改成「按对角线存进 3×n 的数组」,长度就是 3n,看清题面)

   按行优先压进 sa[0 .. 3n-3]:
       i = 0  : k = j                     (j = 0, 1)
       i >= 1 : k = 3i - 1 + (j - i + 1)  (前 i 行共 2 + 3(i-1) = 3i-1 个元素,
                                            本行内偏移是 j - (i-1))
   两个式子可以统一写成:k = 2i + j   (在 |i - j| <= 1 的前提下)
   反向公式:i = floor((k+1)/3),j = k - 2i
   ============================================================ */

const int N = 1005;            /* 最大阶数 */
int sa[3 * N];                 /* 压缩数组,长度 3n - 2(开 3N 个单元够用) */
int n;                         /* 矩阵阶数 */

/* 一维下标公式:k = 2i + j,只要求 |i-j| <= 1 */
inline int idx(int i, int j) { return 2 * i + j; }

/* 带内判断:|i - j| > 1 的位置压缩后根本没存,逻辑上恒为 0 */
inline bool inBand(int i, int j) { return abs(i - j) <= 1; }

/* 反向映射:已知一维下标 k,反求它在矩阵中的位置 (i, j) */
inline void decode(int k, int &i, int &j) { i = (k + 1) / 3; j = k - 2 * i; }

/* 边界检查:竞赛里不抛异常,越界就打印提示 */
bool inRange(int i, int j) {
    if (0 <= i && i < n && 0 <= j && j < n) return true;
    printf("  [warn] (%d, %d) out of range\n", i, j);
    return false;
}

/* 存:带外的位置不存储,只允许写 0(写别的值说明调用者的逻辑已经错了) */
bool setVal(int i, int j, int v) {
    if (!inRange(i, j)) return false;
    if (!inBand(i, j)) {
        if (v != 0) {
            printf("  [warn] a[%d][%d] 在带外,必须是 0\n", i, j);
            return false;
        }
        return true;                     /* 带外的 0 根本不存储 */
    }
    sa[idx(i, j)] = v;
    return true;
}

/* 取:带外一律是 0,这个判断同样是 O(1) */
int getVal(int i, int j) {
    if (!inRange(i, j)) return 0;
    return inBand(i, j) ? sa[idx(i, j)] : 0;
}

/* 打印整个一维数组,方便与手算结果对照 */
void dump() {
    int len = 3 * n - 2;
    for (int k = 0; k < len; ++k) printf("%d%c", sa[k], k + 1 == len ? '\n' : ' ');
}

int main() {
    n = 5;
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < n; ++j)
            if (inBand(i, j)) setVal(i, j, i * 10 + j);   /* a[i][j] = i*10 + j */

    printf("n = 5, storage size = %d (3n - 2 = %d)\n", 3 * n - 2, 3 * n - 2);  /* 13 */
    printf("a[0][1] = %d, k = %d\n", getVal(0, 1), idx(0, 1));   /* 1, 1 */
    printf("a[2][2] = %d, k = %d\n", getVal(2, 2), idx(2, 2));   /* 22, 6 */
    printf("a[4][3] = %d, k = %d\n", getVal(4, 3), idx(4, 3));   /* 43, 11 */
    printf("a[3][0] = %d  (out of band -> 0, not stored)\n", getVal(3, 0));
    printf("sa[] = ");
    dump();                       /* 0 1 10 11 12 21 22 23 32 33 34 43 44 */

    printf("reverse mapping k -> (i, j):\n  ");
    for (int k = 0; k < 3 * n - 2; ++k) {
        int i, j;
        decode(k, i, j);
        printf("k%d=a[%d][%d]  ", k, i, j);
    }
    printf("\n");

    /* 带外写非 0 值会被拦住 */
    printf("setVal(3, 0, 99) -> %s\n", setVal(3, 0, 99) ? "true" : "false");
    return 0;
}

现在回答导读里埋的那个问题:带状矩阵压缩之后,为什么随机存取能力一点没丢? 原因可以拆成三条:

  1. 映射是闭式的。k = 2i + j 只包含一次乘法和一次加法, 与 n 无关,也与「前面存了多少」无关——不需要扫描、不需要累加、不需要查表。 计算机执行它就是一条 lea 指令的事。
  2. 带外的元素不需要「找」,逻辑上直接是 0。压缩存储的前提就是 「没存的位置就是 0」,所以 getVal 只要先判断 |i−j| ≤ 1, 不满足就 return 0。这个判断也是 O(1)
  3. 它和原来的二维数组一样是「连续 + 等长」的。压缩后仍然是一个普通的一维数组, sa[k] 本身还是 O(1)。压缩只是把「二维下标」翻译成「一维下标」, 并没有改变底层存储方式。

对比一下稀疏矩阵就清楚了:稀疏矩阵的非零元位置无法用不等式描述, 所以既写不出闭式 k = f(i,j),也无法用「带外恒为 0」这种简单规则兜底, 只能老老实实把位置存下来再查找。这就是本书 6.7 节要处理的问题。

6.6.5 动画演示:逐行压平与 k 的计算

下面的动画用一个 6 阶三对角矩阵,逐行、逐元素地演示压平过程。 每一帧都会显示 k 是按「前 i 行总数 = 3i − 1」加上「行内偏移 = j − i + 1」 算出来的,请对照公式 k = 2i + j 体会两者为什么等价:

易错:3n−2 还是 3n? 「按行压平、四个角不存」的长度是 3n − 2; 「按对角线存进 3×n 数组」的长度是 3n。 两个数字都常见,取决于题目描述的存法。另外别忘了: n = 13n − 2 = 1(只有一个元素), n = 23n − 2 = 4(矩阵本身就只有 4 个元素,等于没压缩), 这两个小边界自己代进去验证一遍,公式才算真懂了。

6.7 稀疏矩阵及其压缩存储

6.7.1 什么是稀疏矩阵:公式法为什么在这里失灵

稀疏矩阵(sparse matrix)是指非零元素个数远小于矩阵元素总数的矩阵。 国内教材通常给出一个量化标准:若非零元个数占元素总数的比例不超过 5% (即 tu / (mu × nu) ≤ 0.05),就认为它是稀疏的。

这个「5%」只是个工程上的经验界,不要当成数学定义去较真。真正要抓住的是两点:

现在回答关键问题:为什么对称矩阵、三角矩阵、三对角矩阵那套「公式法」在稀疏矩阵上不行?

回忆 6.3.3 的三步法。第一步要「确定存哪些元素」,也就是用下标不等式把非零区域描述出来。 对称矩阵能写成 i ≥ j,三对角能写成 |i − j| ≤ 1, 有了这样的不等式,第三步的「数前面有多少个元素」才能变成等差数列求和, 最终得到闭式的 k = f(i,j)

而一般稀疏矩阵的零元素是东一个西一个、毫无规律的:它可能出现在 (0,3),也可能出现在 (17,92),两个非零元之间没有任何位置上的约束关系。 既然写不出不等式,也就数不出「前面有多少个」,公式法直接失灵。

于是只剩一条路:把「位置」也存下来 既然位置算不出来,那就把位置和值一起存:每个非零元存成一个 (行号, 列号, 值) 的三元组。这样虽然每个元素多花了两个整数的开销, 但比起「用 mu × nu 个单元去存一大堆 0」,当稀疏度足够低时仍然是稳赚的。
代价是:按下标随机取值(随机存取)的能力基本上没有了——要找一个元素, 只能按某种顺序去查找。

6.7.2 三元组顺序表:最朴素也最常用的方案

三元组顺序表(sequential list of triples)是稀疏矩阵最经典的顺序存储结构。 它的定义短得可以背下来:用一个结构体数组,按行优先顺序 (行号升序;同一行内列号升序)存放全部非零元,每个元素包含 rowcolvalue 三个域; 另外用 munutu 记录行数、列数和非零元个数。

为什么要规定「按行优先有序」?因为有序带来三个直接好处: 可以按行处理(矩阵加法、乘法都要按行归并)、转置时能一次定位、 打印和调试时输出可读。代价是插入或修改一个元素时可能要搬移后续元素—— 和顺序表一样的老毛病。

稀疏矩阵 M(4 × 5,20 个元素,6 个非零元)与它的三元组顺序表 j=0 1 2 3 4 i=0 0 3 0 0 7 i=1 0 0 5 0 0 i=2 4 0 0 6 0 i=3 0 2 0 0 0 mu = 4, nu = 5, tu = 6 用二维数组存要 20 个单元;三元组表要 6 × 3 = 18 个整数 这个教学例子还不够稀疏(30%),真实场景里 nnz 常在 1% 以下, 那时三元组表的优势是压倒性的。 三元组顺序表 data[](按行优先有序) 下标 row col value 0 0 1 3 1 0 4 7 2 1 2 5 3 2 0 4 4 2 3 6 5 3 1 2 行号必须升序:0,0,1,2,2,3;同一行内列号也必须升序:1 < 4,0 < 3。 正是这个「有序」,让转置、加法、乘法都能按行归并着做。
图 6-8 稀疏矩阵的三元组顺序表:每个非零元存位置 + 值,整体按行优先有序

结构定义与「朴素转置」的实现在下面。请特别留意朴素转置的复杂度是怎么来的—— 它其实就是「按列扫描 + 全表查找」这个笨办法的代价:

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   稀疏矩阵的三元组顺序表 + 朴素转置(竞赛写法:全局数组 + 自由函数)
   三元组:(row, col, value),按"行优先"排列(行号升序,同行按列号升序)。
   struct Triple 只描述"一个非零元长什么样",属于结点型,保留;
   矩阵本体就是一个三元组数组 + 三个计数器(mu / nu / tu),不必再套一层 struct。

   朴素转置的思路:
       转置后 T 的第 col 行 = M 的第 col 列。
       于是依次枚举 M 的每一列 col = 0, 1, ..., nu-1,
       为了找到该列的所有非零元,每次都要把整个三元组表扫一遍。
       扫描次数 = nu * tu,所以时间复杂度 O(nu * tu)。
   ============================================================ */

struct Triple {                 /* 结点型:一个非零元 = 行号 + 列号 + 值 */
    int r, c, v;
};

const int MAXT = 100;           /* 数组开够:本题非零元个数 ≤ 100(竞赛里按题目上限开) */

Triple t[MAXT];                 /* 转置结果 T 的三元组表 */
int mu, nu, tu;                 /* T 的行数、列数、非零元个数 */

/* 往 T 里追加一个非零元 —— 三元组表的"插入"就是这一步 */
void pushTriple(int r, int c, int v) {
    t[tu].r = r; t[tu].c = c; t[tu].v = v;
    ++tu;
}

void printMatrix(const char* name) {
    cout << name << " : " << mu << " x " << nu << ", tu = " << tu << "\n";
    for (int p = 0; p < tu; ++p)
        cout << "   [" << p << "] (" << t[p].r << ", " << t[p].c
             << ", " << t[p].v << ")\n";
}

/* 朴素转置:把 src(stu 个非零元、smu 行 snu 列)转置后存进全局的 t[],O(snu * stu) */
void naiveTranspose(const Triple src[], int stu, int smu, int snu) {
    mu = snu; nu = smu;                 /* 行列互换 */
    tu = stu;
    if (stu == 0) return;
    int q = 0;
    for (int col = 0; col < snu; ++col)               /* 枚举原矩阵的每一列 */
        for (int p = 0; p < stu; ++p)                 /* 每列都重扫整个三元组表 */
            if (src[p].c == col) {
                t[q].r = src[p].c;                    /* 行列互换 */
                t[q].c = src[p].r;
                t[q].v = src[p].v;
                ++q;
            }
}

int main() {
    /* 4 行 5 列,6 个非零元;总元素 20 个,稀疏度 30%(教学用小例子,真实场景常 < 5%) */
    Triple M[MAXT];
    int Mtu = 0;
    M[Mtu++] = {0, 1, 3};
    M[Mtu++] = {0, 4, 7};
    M[Mtu++] = {1, 2, 5};
    M[Mtu++] = {2, 0, 4};
    M[Mtu++] = {2, 3, 6};
    M[Mtu++] = {3, 1, 2};

    /* 为了能同框打印两个矩阵,把 M 的内容也放进全局表里,用 printMatrix 输出 */
    mu = 4; nu = 5; tu = 0;
    for (int p = 0; p < Mtu; ++p) pushTriple(M[p].r, M[p].c, M[p].v);
    printMatrix("M");

    naiveTranspose(M, Mtu, 4, 5);
    printMatrix("T = M^T (naive transpose)");
    return 0;
}

6.7.3 转置:朴素算法为什么慢

矩阵转置(transpose)在数学上很简单:T[j][i] = M[i][j]。 对三元组表来说,把每个三元组的 rowcol 交换一下就行。 但请注意:光交换还不够。交换之后,数组里的顺序变成了「按原来的列优先」, 不再是「按行优先有序」了,而我们的结构约定要求它必须有序,所以还得重新排。

朴素算法的想法是:不去排序,而是按目标顺序直接产生。 转置后 T 的第 col 行来自 M 的第 col 列, 于是我按 col = 0, 1, …, nu−1 的顺序,依次把 M 中所有列号为 col 的三元组找出来、交换行列、追加到 T 里。 因为「先处理列号小的」,产出的 T 自然就是按行有序的。

问题在于「找出来」这一步:为了找第 col 列的所有非零元, 我们必须把整个三元组表从头到尾扫一遍。一共有 nu 列, 每列都扫 tu 个元素,于是:

时间复杂度 = O(nu × tu) 空间复杂度 = O(tu)(存放结果)

这个复杂度有多糟?考虑一个 n × n 的稀疏矩阵,若 tun 同阶 (比如每行平均只有常数个非零元),那 O(nu × tu) = O(n²)—— 和直接把稠密二维数组转置一遍一样慢,等于白折腾。显然需要改进。

6.7.4 快速转置:num[] 与 cpot[] 的递推

快速转置(fast transpose)的核心洞察只有一句话: 如果能事先知道「转置后第 col 行的第一个元素应该放在哪个位置」, 那么只需要扫一遍 M.data,边扫边放,转置就完成了。

为此需要两个辅助数组,长度都是 nu(转置后的行数):

num[col]
M 中第 col 的非零元个数。 因为 M 的列就是 T 的行,所以它同时也是 T 中第 col 行的元素个数。
cpot[col]
T 中第 col 行的第一个元素应该放在 T.data 的哪个下标处(cpot = column position)。

num 很好求:扫一遍 M.data,对每个三元组 ++num[它的 col] 即可。 cpot 则用前缀和递推出来:

cpot[0] = 0
cpot[col] = cpot[col−1] + num[col−1] (col = 1, 2, …, nu−1)

这两行的意思很直白:第 col 行的起点 = 第 col−1 行的起点 + 第 col−1 行有多少个元素。 就像排队时分座位:前面那队有多少人,我这队就从下一个空位开始坐。

用图 6-8 里的矩阵 M 实际推一遍(mu=4, nu=5, tu=6):

col01234说明
num[col] 12111 M 的第 0 列只有 (2,0,4);第 1 列有 (0,1,3)(3,1,2)……
cpot[col] 01345 逐项递推,见下
快速转置的预处理:num[] 统计每列有多少个,cpot[] 前缀和算出每行的落点 列号 col 0 1 2 3 4 num[col] 1 2 1 1 1 合计 6 = tu ✅ cpot[col] 0 1 3 4 5 5 + 1 = 6 = tu ✅ +num[0]=+1 +num[1]=+2 +1 +1 递推式:cpot[0] = 0;cpot[col] = cpot[col−1] + num[col−1] —— 第 col 行的起点 = 上一行起点 + 上一行元素个数 有了 cpot[],转置时每读到一个三元组就知道该放哪儿,整趟只需 O(nu + tu)。
图 6-9 快速转置的两个辅助数组:num[] 统计个数,cpot[] 前缀和算出每行的起始落点

快速转置的完整实现如下,三个步骤在注释里标了序号:

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   稀疏矩阵的快速转置 O(nu + tu)(竞赛写法:全局数组 + 自由函数)
   核心:先算出"转置后每一行从哪里开始放",就能一次扫描直接定位。

   num[col]  = 原矩阵第 col 列的非零元个数(也就是 T 中第 col 行的元素个数)
   cpot[col] = T 中第 col 行第一个元素在 t[] 中的下标(cpot = column position)
       递推:cpot[0] = 0
             cpot[col] = cpot[col-1] + num[col-1]     (col = 1 .. nu-1)

   然后按原三元组表的顺序扫一遍:
       对每个三元组 (r, c, v),它应该放在 t[cpot[c]],
       放完后 ++cpot[c],因为这一行的下一个元素要往后挪一格。
   ============================================================ */

struct Triple { int r, c, v; };   /* 结点型:一个非零元 */

const int MAXT = 100;             /* 数组开够:本题非零元个数 ≤ 100 */
const int MAXC = 100;             /* 列数上限:num / cpot 的长度依据 */

Triple t[MAXT];                   /* 转置结果 T 的三元组表 */
int mu, nu, tu;                   /* T 的行数、列数、非零元个数 */
int num[MAXC], cpot[MAXC];        /* ① 每列非零元个数  ② 每行起始下标 */

void pushTriple(int r, int c, int v) {   /* 三元组表的插入操作 */
    t[tu].r = r; t[tu].c = c; t[tu].v = v;
    ++tu;
}

void printMatrix(const char* name) {
    cout << name << " : " << mu << " x " << nu << ", tu = " << tu << "\n";
    for (int p = 0; p < tu; ++p)
        cout << "   [" << p << "] (" << t[p].r << ", " << t[p].c << ", " << t[p].v << ")\n";
}

/* 就地求 num[] / cpot[]:把 src 的列统计出来,供按行定位使用 */
void buildNumCpot(const Triple src[], int stu, int snu) {
    for (int col = 0; col < snu; ++col) num[col] = 0;
    for (int p = 0; p < stu; ++p) ++num[src[p].c];          /* ① 统计每列的非零元个数 */

    if (snu > 0) cpot[0] = 0;
    for (int col = 1; col < snu; ++col)                     /* ② 递推每行的起始位置 */
        cpot[col] = cpot[col - 1] + num[col - 1];
}

void fastTranspose(const Triple src[], int stu, int smu, int snu, bool verbose = false) {
    mu = snu; nu = smu;                                     /* 行列互换 */
    tu = stu;
    if (stu == 0) return;

    buildNumCpot(src, stu, snu);
    if (verbose) {
        cout << "col :"; for (int c = 0; c < snu; ++c) cout << "  " << c; cout << "\n";
        cout << "num :"; for (int c = 0; c < snu; ++c) cout << "  " << num[c]; cout << "\n";
        cout << "cpot:"; for (int c = 0; c < snu; ++c) cout << "  " << cpot[c]; cout << "\n";
    }

    for (int p = 0; p < stu; ++p) {                         /* ③ 一次扫描,直接定位 */
        int col = src[p].c;
        int q = cpot[col];
        t[q].r = src[p].c;
        t[q].c = src[p].r;
        t[q].v = src[p].v;
        ++cpot[col];                                        /* 该行的下一个空位 */
    }
}

int main() {
    Triple M[MAXT];
    int Mtu = 0;
    M[Mtu++] = {0, 1, 3};
    M[Mtu++] = {0, 4, 7};
    M[Mtu++] = {1, 2, 5};
    M[Mtu++] = {2, 0, 4};
    M[Mtu++] = {2, 3, 6};
    M[Mtu++] = {3, 1, 2};

    mu = 4; nu = 5; tu = 0;
    for (int p = 0; p < Mtu; ++p) pushTriple(M[p].r, M[p].c, M[p].v);
    printMatrix("M");

    fastTranspose(M, Mtu, 4, 5, true);
    printMatrix("T = M^T (fast transpose)");

    /* 快速转置的结果与"按行扫描"的期望完全一致:
       T 的第 0 行来自 M 的第 0 列 ... 依次类推 */
    return 0;
}
易错:最后一遍扫描的顺序不能乱 第三步必须M.data 原有的顺序(也就是行优先序)从头扫到尾。 为什么呢?因为 M.data 是按行号升序排列的, 所以当同一列(即 T 的同一行)的多个非零元被依次读到时, 它们的行号也一定是递增的,落进 T 的同一行后自然保持列号递增, 转置结果就仍然是有序的。
如果你自作聪明地按别的顺序扫描,转置结果的「行内有序」性质就没了, 后续的加法、乘法都会出错。
考点:两种转置的复杂度对比
算法时间辅助空间关键动作
朴素转置O(nu × tu)O(1)双重循环:外层枚举列,内层扫全表
快速转置O(nu + tu)O(nu)用 num/cpot 换时间(典型的空间换时间)
注意快速转置的空间是 O(nu),不是 O(1)—— 考试问「辅助空间」时要答 O(nu)(有些书写 O(n),n 指列数)。

6.7.5 行逻辑链接的顺序表:给三元组表加一个 rpos[]

三元组表虽然按行有序,但「第 i 行从哪儿开始」这件事它没说。 想知道第 3 行有哪些元素,你还是得从头扫或者二分查找,最坏 O(tu)。 解决办法和快速转置里用的一模一样——再加一个前缀和数组

rpos[i] = 第 i 行第一个非零元在 data[] 中的下标,且约定 rpos[mu] = tu

建立了 rpos 之后,第 i 行的所有非零元就是 data[rpos[i] .. rpos[i+1]−1],访问代价降为 O(该行非零元个数)。这种带行起始位置的三元组表,教材里叫 行逻辑链接的顺序表(row-logical linked list)—— 名字里的「链接」指的就是这个 rpos 起到的「逻辑链接」作用, 它并不是真的指针。

有了它,稀疏矩阵加法就能像归并两个有序数组那样按行做了: 对每一行 i,用两个指针分别扫 AB 在该行的元素, 列号小的先输出、列号相等就相加。复杂度 O(tuA + tuB)

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   行逻辑链接的顺序表(Row-Logical Linked List)
   在三元组顺序表上增加一个 rpos[] 数组:
       rpos[i] = 第 i 行第一个非零元在 t[] 中的下标,rpos[mu] = tu
   递推:先数出每行的非零元个数,再做前缀和。
   有了 rpos[],遍历第 i 行只需 O(该行非零元个数),不用再扫全表。
   有了它,稀疏矩阵加法就能按行归并:O(tuA + tuB)。

   竞赛写法:A、B、C 三个矩阵各用"一组全局数组 + 三个计数器"表示,
   不再有 SparseMatrix 这层 struct 壳;struct Triple 描述非零元,保留。
   ============================================================ */

struct Triple { int r, c, v; };   /* 结点型:一个非零元 = 行号 + 列号 + 值 */

const int MAXT = 100;             /* 数组开够:每个矩阵非零元个数 ≤ 100 */
const int MAXR = 100;             /* 行数上限:rpos 的长度依据 */

/* ---------- A ---------- */
Triple ta[MAXT];
int muA, nuA, tuA;
int rposA[MAXR];
/* ---------- B ---------- */
Triple tb[MAXT];
int muB, nuB, tuB;
int rposB[MAXR];
/* ---------- C = A + B ---------- */
Triple tc[MAXT];
int muC, nuC, tuC;
int rposC[MAXR];

void pushA(int r, int c, int v) { ta[tuA].r = r; ta[tuA].c = c; ta[tuA].v = v; ++tuA; }
void pushB(int r, int c, int v) { tb[tuB].r = r; tb[tuB].c = c; tb[tuB].v = v; ++tuB; }
void pushC(int r, int c, int v) { tc[tuC].r = r; tc[tuC].c = c; tc[tuC].v = v; ++tuC; }

/* 建 rpos[]:两遍扫描,O(mu + tu) */
void buildRposA() {
    for (int i = 0; i <= muA; ++i) rposA[i] = 0;
    for (int p = 0; p < tuA; ++p) ++rposA[ta[p].r + 1];       /* ① 数每行的个数 */
    for (int i = 1; i <= muA; ++i) rposA[i] += rposA[i - 1];  /* ② 前缀和 */
}
void buildRposB() {
    for (int i = 0; i <= muB; ++i) rposB[i] = 0;
    for (int p = 0; p < tuB; ++p) ++rposB[tb[p].r + 1];
    for (int i = 1; i <= muB; ++i) rposB[i] += rposB[i - 1];
}
void buildRposC() {
    for (int i = 0; i <= muC; ++i) rposC[i] = 0;
    for (int p = 0; p < tuC; ++p) ++rposC[tc[p].r + 1];
    for (int i = 1; i <= muC; ++i) rposC[i] += rposC[i - 1];
}

void printA(const char* name) {
    cout << name << " : " << muA << " x " << nuA << ", tu = " << tuA << "\n";
    for (int p = 0; p < tuA; ++p)
        cout << "   [" << p << "] (" << ta[p].r << ", " << ta[p].c << ", " << ta[p].v << ")\n";
    cout << "   rpos =";
    for (int i = 0; i <= muA; ++i) cout << " " << rposA[i];
    cout << "\n";
}
void printB(const char* name) {
    cout << name << " : " << muB << " x " << nuB << ", tu = " << tuB << "\n";
    for (int p = 0; p < tuB; ++p)
        cout << "   [" << p << "] (" << tb[p].r << ", " << tb[p].c << ", " << tb[p].v << ")\n";
    cout << "   rpos =";
    for (int i = 0; i <= muB; ++i) cout << " " << rposB[i];
    cout << "\n";
}
void printC(const char* name) {
    cout << name << " : " << muC << " x " << nuC << ", tu = " << tuC << "\n";
    for (int p = 0; p < tuC; ++p)
        cout << "   [" << p << "] (" << tc[p].r << ", " << tc[p].c << ", " << tc[p].v << ")\n";
    cout << "   rpos =";
    for (int i = 0; i <= muC; ++i) cout << " " << rposC[i];
    cout << "\n";
}

/* 按行访问 A:打印第 i 行的所有非零元,复杂度 O(该行非零元个数) */
void printRowA(int i) {
    cout << "row " << i << " :";
    for (int p = rposA[i]; p < rposA[i + 1]; ++p)
        cout << "  col" << ta[p].c << "=" << ta[p].v;
    cout << "\n";
}

/* 稀疏矩阵加法 C = A + B(同型),按行归并,O(tuA + tuB) */
void addMatrix() {
    if (muA != muB || nuA != nuB) { printf("shape mismatch\n"); return; }
    muC = muA; nuC = nuA; tuC = 0;
    for (int i = 0; i < muA; ++i) {
        int pa = rposA[i], ea = rposA[i + 1];     /* A 的第 i 行区间 */
        int pb = rposB[i], eb = rposB[i + 1];     /* B 的第 i 行区间 */
        while (pa < ea && pb < eb) {
            int ca = ta[pa].c, cb = tb[pb].c;
            if (ca < cb) { pushC(i, ca, ta[pa].v); ++pa; }
            else if (ca > cb) { pushC(i, cb, tb[pb].v); ++pb; }
            else {
                int s = ta[pa].v + tb[pb].v;
                if (s != 0) pushC(i, ca, s);   /* 相加为 0 就不必存了 —— 稀疏矩阵加法的关键细节 */
                ++pa; ++pb;
            }
        }
        for (; pa < ea; ++pa) pushC(i, ta[pa].c, ta[pa].v);
        for (; pb < eb; ++pb) pushC(i, tb[pb].c, tb[pb].v);
    }
}

int main() {
    muA = 4; nuA = 5; tuA = 0;
    pushA(0, 1, 3); pushA(0, 4, 7); pushA(1, 2, 5);
    pushA(2, 0, 4); pushA(2, 3, 6); pushA(3, 1, 2);

    muB = 4; nuB = 5; tuB = 0;
    pushB(0, 4, -7); pushB(1, 2, 5); pushB(2, 0, -4);
    pushB(2, 3, 6);  pushB(3, 2, 9);

    buildRposA(); buildRposB();
    printA("A"); printB("B");

    printRowA(0);
    printRowA(2);

    addMatrix();
    buildRposC();
    printC("C = A + B");

    /* 注意看:C 的第 0 行只剩 (0,1,3),
       因为 A(0,4,7) + B(0,4,-7) = 0,这个位置直接"消失"了。 */
    return 0;
}

运行结果里最值得看的一处是:C 的第 0 行只剩下 (0,1,3) 一项, 因为 A(0,4,7) + B(0,4,−7) = 0,这个位置在结果里直接消失了。 代码里那一句 if (s != 0) C.push(...) 就是稀疏矩阵运算的典型风格: 结果为零的位置绝不存储

6.7.6 十字链表:能按行也能按列,还便于增删

三元组表有两个绕不开的弱点:一是插入删除要搬移元素,二是按列访问很不方便。 而矩阵运算(尤其是乘法、加法这类会「边算边生成新非零元」的场景)恰恰需要频繁插入, 也常常需要按列扫描。于是有了链式方案——十字链表(orthogonal list)

它把矩阵的每个非零元做成一个结点,结点有五个域: row(行号)、col(列号)、value(值)、 down(指向同列下一个非零元)、right(指向同行下一个非零元)。 再加上两个表头指针数组:

两条链表在同一个结点上「十字交叉」,所以叫十字链表。 它的最大好处是:插入或删除一个非零元时,只需要改动常数个指针,不用搬移任何数据 (这也是所有链式结构的共同优点),而且「按行」和「按列」两条访问路径都是现成的。

十字链表:同一个结点同时挂在「行链表」和「列链表」上 chead[0] chead[1] chead[2] chead[3] chead[4] rhead[0] rhead[1] rhead[2] rhead[3] 013 047 125 204 236 312 right:同一行的下一个非零元 down:同一列的下一个非零元 每个结点 5 个域:row | col | value 加上 downright 两个指针 蓝色箭头 = rhead[i] 指向该行第一个非零元 紫色箭头 = chead[j] 指向该列第一个非零元 空白位置表示该处是 0,没有结点;插入新非零元时只要改动常数个指针即可。
图 6-10 稀疏矩阵的十字链表:行链表向右、列链表向下,结点同时属于两条链

实现十字链表的关键技巧是「用指针的指针找插入点」: 要往一条有序链表里插入,不必区分「插在头部」和「插在中间」两种情况, 只要用一个二级指针 OLNode** p 一路往后走,最后 *p = node 就完事了。 下面的代码同时演示了「按行列有序插入」和「同一位置重复插入时累加」两个细节:

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   十字链表(Orthogonal List)—— 稀疏矩阵的链式存储(竞赛写法)
   每个非零元是一个含 5 个域的结点:
       row, col, value, down, right
   right 把同一行的非零元串成链表(按列号升序);
   down  把同一列的非零元串成链表(按行号升序)。
   另外用两个指针数组作为表头:rhead[mu](行)、chead[nu](列)。

   优点:插入 / 删除非零元不用像顺序表那样成片搬移元素,
         而且「按行」和「按列」两条访问路径都是 O(1) 起步。
   代价:每个结点多两个指针域;按下标 (i,j) 随机取值退化成沿链查找。
   ============================================================ */

const int N = 1005;                 /* 行数、列数上限 */
struct OLNode {                     /* 结点:5 个域 */
    int r, c, v;                    /* 行号、列号、值 */
    OLNode *down, *right;           /* down = 同列下一个,right = 同行下一个 */
};

OLNode *rhead[N], *chead[N];        /* 行 / 列表头指针数组 */
int mu, nu, tu;                     /* 行数、列数、非零元个数 */

/* 初始化成 mu 行 nu 列的空矩阵:表头全部置空 */
void initMatrix(int m, int n) {
    mu = m; nu = n; tu = 0;
    for (int i = 0; i < mu; ++i) rhead[i] = nullptr;
    for (int j = 0; j < nu; ++j) chead[j] = nullptr;
}

/* 新建结点;竞赛里干脆不写 delete,程序结束内存就还给操作系统了 */
OLNode* newNode(int r, int c, int v) {
    OLNode* p = new OLNode;
    p->r = r; p->c = c; p->v = v;
    p->down = p->right = nullptr;
    return p;
}

/* 插入(或累加)一个非零元,保持行、列两条链表都有序 */
void insertNode(int r, int c, int v) {
    if (v == 0) return;                        /* 0 不是非零元,不入表 */

    /* ① 先在第 r 行的行链表里找插入点(按列号升序)
          「用指针的指针找插入点」:不必区分插在头部还是中间,
          一路往后走,最后 *rp = node 一句就搞定 */
    OLNode** rp = &rhead[r];
    while (*rp && (*rp)->c < c) rp = &(*rp)->right;
    if (*rp && (*rp)->c == c) {                /* 该位置已经有结点了 → 只累加值 */
        (*rp)->v += v;                         /* 列链表里挂的是同一个结点,不用再动 */
        return;
    }
    OLNode* node = newNode(r, c, v);
    node->right = *rp;                         /* 先接后面,再改前面,顺序不能反 */
    *rp = node;

    /* ② 再挂到第 c 列的列链表(按行号升序) */
    OLNode** cp = &chead[c];
    while (*cp && (*cp)->r < r) cp = &(*cp)->down;
    node->down = *cp;
    *cp = node;

    ++tu;
}

void printByRow() {
    printf("by row:\n");
    for (int i = 0; i < mu; ++i) {
        printf("  row %d :", i);
        for (OLNode* p = rhead[i]; p; p = p->right)
            printf("  (%d,%d,%d)", p->r, p->c, p->v);
        printf("\n");
    }
}
void printByCol() {
    printf("by col:\n");
    for (int j = 0; j < nu; ++j) {
        printf("  col %d :", j);
        for (OLNode* p = chead[j]; p; p = p->down)
            printf("  (%d,%d,%d)", p->r, p->c, p->v);
        printf("\n");
    }
}
int nonzeros() { return tu; }

int main() {
    initMatrix(4, 5);
    insertNode(0, 1, 3);
    insertNode(0, 4, 7);
    insertNode(1, 2, 5);
    insertNode(2, 0, 4);
    insertNode(2, 3, 6);
    insertNode(3, 1, 2);
    insertNode(0, 1, 10);          /* 同一位置的再次插入 → 值累加为 13 */
    insertNode(1, 1, 1);           /* 插在已存在的列链表中间,链式结构无需搬移 */

    printf("nonzeros = %d\n", nonzeros());
    printByRow();
    printByCol();
    return 0;
}
考点:十字链表的结点结构 这是最经典的画图题之一,务必能默画出来:五个域rowcolvaluedownright) 加上两个表头数组(rhead[]chead[])。 常见的追问有二:
  • 「十字链表能不能随机存取?」不能。要访问 a[i][j], 得从 rhead[i] 出发沿 right 逐个比较列号, 代价 O(该行非零元个数)
  • 「它比三元组表好在哪?」插入删除只改指针、不用搬移; 行、列两个方向都能高效遍历;非零元个数动态变化时不用预估容量。

6.7.7 工程视角:CSR / CSC 格式

前面讲的三种结构都是教材语言。真到了工程里(SciPy、PETSc、cuSPARSE、PyTorch 的稀疏张量), 用得最多的是 CSR(Compressed Sparse Row,压缩稀疏行)格式。 它其实是「三元组表 + rpos」的极致版本——把行号也省掉了(三个数组与原矩阵的对应关系见图 6-11):

数组长度含义
values[]nnz按行优先顺序存放的全部非零元的值
colIdx[]nnzvalues[] 一一对应的列号
rowPtr[]m + 1 i 行的非零元区间是 [rowPtr[i], rowPtr[i+1]); 约定 rowPtr[0] = 0rowPtr[m] = nnz

注意 rowPtr 和行逻辑链接里的 rpos 是同一个东西, 只是多存了一个哨兵 rowPtr[m] = nnz,这样每行的区间都能写成半开区间 [rowPtr[i], rowPtr[i+1]),代码里少一个 if

为什么说 CSR 是 cache 友好的?看稀疏矩阵向量乘 y = Ax 的循环:

for (int i = 0; i < m; ++i)
    for (int p = rowPtr[i]; p < rowPtr[i + 1]; ++p)  // 连续的一段!
        y[i] += values[p] * x[colIdx[p]];

内层循环在 values[]colIdx[] 上是严格顺序前进的, 这正是 CPU 预取器和缓存最喜欢的访问模式:读入 values[p] 时, 同一缓存行里的后面十几个元素也一起进了缓存,接下来的迭代几乎是零开销。 相比之下,十字链表每访问一个元素都要解引用一个指针,地址完全随机, 每次都可能是一次缓存未命中(cache miss);而三元组表虽然也顺序, 但每个元素多存了一个行号,且没有「按行定位」的能力。

代价是 CSR 的「结构是静态的」:rowPtr 靠前缀和一次性算好, 中途插入一个非零元会导致后面所有位置整体后移。所以 CSR 适合「结构定下来之后反复做运算」 (迭代求解、稀疏矩阵乘法、图算法),不适合「边算边改结构」。 列优先的版本叫 CSC(Compressed Sparse Column),把行、列的角色对调, 适用于按列访问更频繁的场合(比如 MATLAB 历史上就用 CSC)。

CSR:把行号省掉,用 rowPtr[] 的前缀和标出每一行的区间 j=0 1 2 3 4 i=0 0 3 0 0 7 i=1 0 0 5 0 0 i=2 4 0 0 6 0 i=3 0 2 0 0 0 A(4 × 5,nnz = 6) k : 0 1 2 3 4 5 values[] 3 7 5 4 6 2 colIdx[] 1 4 2 0 3 1 rowPtr[] 0 2 3 5 6 rowPtr 有 m+1 = 5 个元素,最后一个恒等于 nnz 第 i 行的值在 values[rowPtr[i] .. rowPtr[i+1]-1] 这一段里: 第 0 行 → values[0..1] = {3, 7} 第 1 行 → values[2..2] = {5} 第 2 行 → values[3..4] = {4, 6} 第 3 行 → values[5..5] = {2} 每一行都是 values[] 里连续的一段 —— 这就是 CSR 对缓存友好的根本原因。
图 6-11 CSR 格式:values / colIdx / rowPtr 三个数组与原矩阵的对应关系
#include <iostream>
#include <vector>
#include <iomanip>
using namespace std;

/* ============================================================
   CSR(Compressed Sparse Row,压缩稀疏行)
   现代工程里最常用的稀疏矩阵格式,由三个一维数组构成:

       values[] : 按行优先顺序排列的全部非零元的值
       colIdx[] : 与 values[] 一一对应的列号
       rowPtr[] : 长度为 m+1;第 i 行的非零元在 values[] 中的区间是
                  [rowPtr[i], rowPtr[i+1])

   为什么 cache 友好:
       第 i 行的数据在 values[]/colIdx[] 里是"连续的一整段",
       遍历一行时访存完全顺序,不像十字链表那样满内存跳指针;
       而且除了这三个数组外没有任何指针开销。
   ============================================================ */

struct Triple { int r, c, v; };

struct CSR {
    int m, n;
    vector<int> values, colIdx, rowPtr;
};

CSR buildCSR(int m, int n, const vector<Triple>& trips) {
    CSR A; A.m = m; A.n = n;
    A.rowPtr.assign(m + 1, 0);
    for (size_t p = 0; p < trips.size(); ++p) ++A.rowPtr[trips[p].r + 1];   /* ① 数每行个数 */
    for (int i = 1; i <= m; ++i) A.rowPtr[i] += A.rowPtr[i - 1];           /* ② 前缀和 */

    A.values.assign(trips.size(), 0);
    A.colIdx.assign(trips.size(), 0);
    vector<int> pos(A.rowPtr.begin(), A.rowPtr.end() - 1);                 /* ③ 每行的写入位置 */
    for (size_t p = 0; p < trips.size(); ++p) {
        int q = pos[trips[p].r]++;
        A.values[q] = trips[p].v;
        A.colIdx[q] = trips[p].c;
    }
    return A;
}

void printCSR(const CSR& A, const char* name) {
    cout << name << " (" << A.m << " x " << A.n << ", nnz = "
         << A.values.size() << ")\n";
    cout << "  rowPtr ="; for (size_t i = 0; i < A.rowPtr.size(); ++i) cout << " " << A.rowPtr[i]; cout << "\n";
    cout << "  colIdx ="; for (size_t i = 0; i < A.colIdx.size(); ++i) cout << " " << A.colIdx[i]; cout << "\n";
    cout << "  values ="; for (size_t i = 0; i < A.values.size(); ++i) cout << " " << A.values[i]; cout << "\n";
}

/* 稀疏矩阵 × 稠密向量 y = A x,复杂度 O(nnz),是迭代法解线性方程组的核心算子 */
vector<double> spmv(const CSR& A, const vector<double>& x) {
    vector<double> y(A.m, 0.0);
    for (int i = 0; i < A.m; ++i)
        for (int p = A.rowPtr[i]; p < A.rowPtr[i + 1]; ++p)
            y[i] += A.values[p] * x[A.colIdx[p]];
    return y;
}

int main() {
    vector<Triple> trips = {
        {0, 1, 3}, {0, 4, 7}, {1, 2, 5},
        {2, 0, 4}, {2, 3, 6}, {3, 1, 2}
    };
    CSR A = buildCSR(4, 5, trips);
    printCSR(A, "A");

    vector<double> x = {1, 2, 3, 4, 5};
    vector<double> y = spmv(A, x);
    cout << "y = A x =";
    for (size_t i = 0; i < y.size(); ++i) cout << " " << y[i];
    cout << "\n";     /* 41 15 28 4 —— 手算:3*2+7*5=41,5*3=15,4*1+6*4=28,2*2=4 */
    return 0;
}

6.7.8 稀疏矩阵的加法与乘法

加法在 6.7.5 已经见过:按行归并,O(tuA + tuB), 关键是「结果为零的位置不存」。这里再强调一个实战中的坑: 稀疏 + 稀疏未必还是稀疏。如果两个矩阵的非零位置几乎不重叠, 结果的非零元个数可能是两者之和(这叫 fill-in,填充), 极端情况下结果会变成稠密矩阵。所以工程库在处理前通常会先估算结果的 nnz 上界。

乘法稍微绕一点。设 C = A × BAm × nBn × p。如果直接照抄三重循环的稠密写法, 复杂度是 O(m × n × p),完全没利用稀疏性。 正确姿势是「以 A 的非零元为驱动」:

  1. 开一个长度为 p 的累加器数组 ctemp[],初值全 0;
  2. A 的第 i 行,遍历它的每个非零元 a[i][k]
  3. a[i][k] 乘到 Bk 行的每个非零元 b[k][j] 上, 累加进 ctemp[j]
  4. i 行处理完,把 ctemp 中的非零项收进 C 的第 i 行,然后把 ctemp 清零。

复杂度是 O(m × p + Σa[i][k]≠0 (B 第 k 行的非零元个数))—— 只有当 B 的行很稠密时才会退化。这一段代码里 rpos[] 再次出场, 用来 O(1) 定位 B 的每一行:

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   稀疏矩阵乘法 C = A x B(行逻辑链接版,竞赛写法:全局数组 + 自由函数)
   设 A 是 m x n,B 是 n x p。
   思路:对 A 的第 i 行的每个非零元 a[i][k],
         把它乘到 B 的第 k 行的每个非零元 b[k][j] 上,
         结果累加到临时行 ctemp[j] 里;第 i 行处理完,
         把 ctemp 中的非零项收集成 C 的第 i 行。
   复杂度:O(m*p + sum_{a[i][k] != 0} (B 第 k 行的非零元个数))

   这里 A、B、C 各用"三元组全局数组 + rpos[] + 三个计数器"表示;
   struct Triple 只描述非零元,属于结点型,保留。
   ============================================================ */

struct Triple { int r, c, v; };   /* 结点型:一个非零元 */

const int MAXT = 100;             /* 数组开够:每个矩阵非零元个数 ≤ 100 */
const int MAXR = 100;             /* 行数上限:rpos 的长度依据 */
const int MAXC = 100;             /* 列数上限:ctemp 的长度依据 */

/* ---------- A ---------- */
Triple ta[MAXT];
int muA, nuA, tuA, rposA[MAXR];
/* ---------- B ---------- */
Triple tb[MAXT];
int muB, nuB, tuB, rposB[MAXR];
/* ---------- C = A x B ---------- */
Triple tc[MAXT];
int muC, nuC, tuC, rposC[MAXR];

int ctemp[MAXC];                  /* 累加 C 当前这一行的结果 */

void pushA(int r, int c, int v) { ta[tuA].r = r; ta[tuA].c = c; ta[tuA].v = v; ++tuA; }
void pushB(int r, int c, int v) { tb[tuB].r = r; tb[tuB].c = c; tb[tuB].v = v; ++tuB; }
void pushC(int r, int c, int v) { tc[tuC].r = r; tc[tuC].c = c; tc[tuC].v = v; ++tuC; }

void buildRposA() {
    for (int i = 0; i <= muA; ++i) rposA[i] = 0;
    for (int p = 0; p < tuA; ++p) ++rposA[ta[p].r + 1];
    for (int i = 1; i <= muA; ++i) rposA[i] += rposA[i - 1];
}
void buildRposB() {
    for (int i = 0; i <= muB; ++i) rposB[i] = 0;
    for (int p = 0; p < tuB; ++p) ++rposB[tb[p].r + 1];
    for (int i = 1; i <= muB; ++i) rposB[i] += rposB[i - 1];
}
void buildRposC() {
    for (int i = 0; i <= muC; ++i) rposC[i] = 0;
    for (int p = 0; p < tuC; ++p) ++rposC[tc[p].r + 1];
    for (int i = 1; i <= muC; ++i) rposC[i] += rposC[i - 1];
}

void printA(const char* name) {
    cout << name << " : " << muA << " x " << nuA << ", tu = " << tuA << "\n";
    for (int p = 0; p < tuA; ++p)
        cout << "   (" << ta[p].r << ", " << ta[p].c << ", " << ta[p].v << ")\n";
}
void printB(const char* name) {
    cout << name << " : " << muB << " x " << nuB << ", tu = " << tuB << "\n";
    for (int p = 0; p < tuB; ++p)
        cout << "   (" << tb[p].r << ", " << tb[p].c << ", " << tb[p].v << ")\n";
}
void printC(const char* name) {
    cout << name << " : " << muC << " x " << nuC << ", tu = " << tuC << "\n";
    for (int p = 0; p < tuC; ++p)
        cout << "   (" << tc[p].r << ", " << tc[p].c << ", " << tc[p].v << ")\n";
}

void multiply() {
    if (nuA != muB) { printf("shape mismatch: A.nu != B.mu\n"); return; }
    muC = muA; nuC = nuB; tuC = 0;
    if (tuA == 0 || tuB == 0) return;

    for (int i = 0; i < muA; ++i) {
        for (int j = 0; j < nuB; ++j) ctemp[j] = 0;    /* 清空累加器 */
        for (int pa = rposA[i]; pa < rposA[i + 1]; ++pa) {
            int k = ta[pa].c;                         /* A 的列 = B 的行 */
            for (int pb = rposB[k]; pb < rposB[k + 1]; ++pb) {
                int j = tb[pb].c;
                ctemp[j] += ta[pa].v * tb[pb].v;
            }
        }
        for (int j = 0; j < nuB; ++j)
            if (ctemp[j] != 0) pushC(i, j, ctemp[j]);  /* 只保留非零元 */
    }
    buildRposC();
}

int main() {
    muA = 3; nuA = 4; tuA = 0;
    pushA(0, 0, 1); pushA(0, 2, 2); pushA(1, 1, 3);
    pushA(2, 0, 4); pushA(2, 3, 5);

    muB = 4; nuB = 2; tuB = 0;
    pushB(0, 0, 1); pushB(0, 1, 2); pushB(1, 1, 1);
    pushB(2, 0, 3); pushB(3, 0, 1); pushB(3, 1, 1);

    buildRposA(); buildRposB();
    printA("A"); printB("B");

    multiply();
    printC("C = A x B");
    /* 手算对照:
       C[0][0] = 1*1 + 2*3 = 7      C[0][1] = 1*2 + 2*0 = 2
       C[1][0] = 3*0 = 0(不存)    C[1][1] = 3*1 = 3
       C[2][0] = 4*1 + 5*1 = 9      C[2][1] = 4*2 + 5*1 = 13  */
    return 0;
}

6.7.9 动画演示:稀疏矩阵到三元组表的转换

最后用一个动画把「扫描稀疏矩阵、生成三元组表」的过程走一遍。 请留意两点:扫描顺序是行优先(保证了结果有序), 以及零元素被直接跳过(这就是省空间的来源):

四种稀疏结构的选型建议
  • 只读、要转置、要考试 → 三元组顺序表(写起来最简单,考试也只考它)。
  • 要按行反复访问 → 行逻辑链接的顺序表(加一个 rpos[],改动最小、收益最大)。
  • 结构频繁增删 → 十字链表。
  • 真刀真枪做数值计算 → CSR / CSC,配合稀疏 BLAS 库。

6.8 四种特殊矩阵横向对比

把本章讲过的方案放进一张表里对照。这张表建议直接背下来—— 考试里的计算题、选择题几乎都能在里面找到答案:

矩阵类型存储方案压缩后长度 下标映射公式随机存取时间 / 空间适用条件
对称矩阵 下三角(含对角线)按行优先压进一维数组 n(n+1)/2 i ≥ ji(i+1)/2 + j
i < jj(j+1)/2 + i
保留 O(1) 取 / 赋值 O(1);空间约为原来的 1/2 a[i][j] = a[j][i]
下三角矩阵 下三角按行优先 + 常数 c 单独一格 n(n+1)/2 + 1 i ≥ ji(i+1)/2 + j
i < jn(n+1)/2(常数单元)
保留 O(1) 取 / 赋值 O(1);空间约 1/2 i < j 处恒为常数 c
上三角矩阵 上三角按行优先 + 常数 c 单独一格 n(n+1)/2 + 1 i ≤ ji(2n−i+1)/2 + (j−i)
i > jn(n+1)/2(常数单元)
保留 O(1) 取 / 赋值 O(1);空间约 1/2 i > j 处恒为常数 c
三对角(带状)矩阵 三条对角线按行优先压平(四角不存) 3n − 2
(若按 3×n 对角线存则为 3n
k = 2i + j|i−j| ≤ 1
反向:i = ⌊(k+1)/3⌋,j = k − 2i
保留 O(1) 取 / 赋值 O(1);空间 O(n)(原来 O(n²) 只有 |i − j| ≤ 1 的元素可能非零
稀疏矩阵
三元组顺序表
(row, col, value) 结构体数组,按行优先有序 tu 个三元组
≈ 3·tu 个整数)
无闭式公式 不保留 (i,j) 查找 O(tu);转置朴素 O(nu·tu)、快速 O(nu+tu);有 rpos[] 时按行访问 O(行内 nnz) 非零元比例 ≤ 5%,且结构静态、只读或批量运算
稀疏矩阵
十字链表
结点 (row, col, value, down, right) + rhead[] / chead[] tu 个结点
≈ 5·tu 个域)
无闭式公式 不保留(行 / 列方向可遍历) 插入 / 删除只改指针;定位到某行某列 O(行 / 列内结点数) 非零元会频繁增删、需要按行也按列访问
稀疏矩阵
CSR / CSC
values[] + colIdx[] + rowPtr[] 2·nnz + (m+1) 按行区间访问:values[rowPtr[i] .. rowPtr[i+1]) 按行 O(行内 nnz);按 (i,j) 定位需再查找 SpMV O(nnz);构建 O(nnz + m);插入代价高 结构静态、需要反复做矩阵运算(工程默认选择)
从这张表里提炼出的三条规律
  1. 凡是「能用不等式描述非零区域」的矩阵,压缩后都保留 O(1) 随机存取。 因为下标映射函数是闭式的,算一次乘加就够。
  2. 凡是「位置无规律」的矩阵,都保不住随机存取。 只能把位置存下来查找,或者额外建索引(rpos[] / CSR)把「按行」这一维找回来。
  3. 省空间的手段无非两种:不存重复值(对称 / 三角),不存 0(带状 / 稀疏)。 前者的长度公式里出现 n(n+1)/2,后者出现 3n−2tu

6.9 工程视角:矩阵压缩在数值计算与推荐系统里怎么用

前面八节把矩阵压缩当成「考试计算题」讲完了。但矩阵和稀疏矩阵在真实世界里是另一种身份: 它们是科学计算、机器学习、图算法的基础设施。NumPy / SciPy 的底层是 BLAS 与 LAPACK, GPU 上的 cuSPARSE 与 cuBLAS,PageRank 的一次迭代、有限元的一次求解、推荐系统的一次召回, 全都落在本章讲的这些存储结构上。这一节换个视角,对六个工程落点逐条回答三个问题: 用什么结构 → 为什么必须用它 → 代价和陷阱是什么

6.9.1 行优先 vs 列优先:一次遍历凭什么差几倍

6.2 节讲了两种压平方式只是地址公式不同。到了工程里,这个「不同」直接换成钱和时间。 先看两个数量级(选 double,每个 8 字节):

事实数量级说明
L1 缓存命中3 ~ 4 ns 约 12 ~ 15 个时钟周期(3 GHz CPU)
主存访问 / 缓存未命中60 ~ 100 ns 18 倍于 L1 命中,这是下面所有结论的来源
一条 64 字节缓存行8 个 double 连续访问时,一次未命中换来 8 个元素

现在拿一个 1024 × 1024double 矩阵做实验。 它占 1024 × 1024 × 8 = 8388608 字节,也就是 8 MB, 远超典型 256 KB ~ 1 MB 的 L2 缓存,整个矩阵只能待在主存里。两种遍历方式写的代码几乎一样:

// 行优先遍历:内层 j 连续,一整行 8 KB 顺序读入
for (int i = 0; i < n; ++i) for (int j = 0; j < n; ++j) sum += a[i][j];

// 列优先遍历:内层 j 的步长是一整行 8 KB
for (int j = 0; j < n; ++j) for (int i = 0; i < n; ++i) sum += a[i][j];

按行遍历时,8 个 double 挤在同一条缓存行里,平均每 8 次访问才发生一次主存访问; 按列遍历时,a[0][j]a[1][j] 在内存里正好相隔 8 KB, 每一次访问都落在不同的缓存行上,于是 100 万次访问里有 100 万次是主存访问。 把 n = 1024 代进上面两个数字:1024 × 1024 ÷ 8 = 131072 次缓存未命中, 每次 60 ~ 100 ns,光等内存就是 8 ~ 13 ms;而按行遍历只有约 16 万次「有效访问」、 其中绝大多数命中 L1,总耗时不到 1 个周期 × 100 万次的量级。 实测结论很稳定:同一个矩阵、同一个求和结果,按错误顺序遍历会慢 3 ~ 5 倍

这跟第 01 讲讲的时间复杂度并不矛盾:两种写法的抽象操作次数完全一样, 变的只是每次操作的常数因子,而常数因子里藏着 18 倍的缓存代价。 大 O 描述增长趋势,缓存决定实际快慢,工程优化往往就发生在后者。

回到语言层面,这个坑之所以常见,是因为「哪一维连续」并不统一: C / C++ 的行优先把一行放在一起,而 Fortran、MATLAB、以及 NumPy 的默认布局是列优先。 同一个 A[3][4],C 里 A[1][2] 落在 (1×4 + 2)×L, Fortran 里落在 (2×3 + 1)×L——不是同一个位置。

NumPy 把这件事显式暴露出来:np.array(a, order='C') 是行优先(C order), np.asfortranarrayorder='F' 是列优先(Fortran order,F order)。 同一个矩阵两种布局,内存占用一模一样,但「按行求和」与「按列求和」谁快, 结果会整个反过来。检查一个数组到底是什么布局,靠 .flags['C_CONTIGUOUS'], 或者干脆看 .strides——步长是理解布局最直接的方式, 它比记「行优先还是列优先」更不容易搞错。

还有一条更真实的工程经验:调 BLAS 时,「矩阵该不该转置」是一个性能问题,而不是风格问题。 BLAS 为两种语言习惯提供了两套入口(dgemm 与 CBLAS 的 CblasRowMajor / CblasColMajor),LAPACK 则只有列优先版本。于是:

一行工程结论 存储顺序是一个「一次决定、长期受益」的选择:决定之前先问清楚「谁会来遍历我、按哪个方向遍历」, 决定之后就别在中途换来换去——转置拷贝本身不贵,贵的是转置发生在热循环里面。

6.9.2 对称矩阵的工程意义:一半内存,以及一半的麻烦

6.4 节说对称矩阵可以只存下三角,长度从 降到 n(n+1)/2。 在考试里这是省一半空间;在工程里,这条性质经常是「算得动」和「算不动」的分界。 三个典型来源:

算一个具体的数:

3.4 × 105 个自由度、平均每行 15 个非零元的三维有限元刚度矩阵
按稠密矩阵整体存(3.4 × 105)² × 8 字节 ≈ 930 GB(单机根本装不下)
按 CSR 稀疏整体存5.1 × 106 个非零元 × 12 字节 ≈ 61 MB
再只存下三角:约 2.7 × 106 个 × 12 字节 ≈ 32 MB

从 930 GB 到 61 MB 是「稀疏」的功劳,属于 6.7 节;而从 61 MB 到 32 MB 才是对称性的功劳。 在后者的语境里,省下一半往往比看上去重要得多:显存是 GPU 上最稀缺的资源, 一个 24 GB 显存能装下的问题,只存一半就意味着可以一次性多放一倍规模的网格, 或者腾出空间同时容纳迭代求解所需的额外向量(Krylov 子空间每迭代一步就多一个向量)。 在分布式求解里同理:一半的内存占用=一半的通信量, 而大规模并行求解的时间几乎全花在通信上。

但对称压缩的利益不是白拿的,代价有三条,最后一条最坑:

  • 随机访问多一次分支。读 a[i][j] 要先判断 i ≥ j 还是 i < j, 非对角线元素绕道取 a[j][i]。对稠密矩阵,编译器基本能把这个分支预测掉, 代价很小;对稀疏矩阵就麻烦得多——
  • 稀疏情形下「只存一半」会破坏 SpMV 的规律性。CSR 的 row_ptr 本来能保证 「第 i 行是一段连续区间」,如果只保留下三角, 原来第 3 行的 (3,1)(3,2) 这两个元素实际挂在第 1、2 行上, 按行做 y = Ax 时必须额外把它们的贡献散射回去(scatter), 访问模式立刻从「顺序前进」退化成「随机写 y」,并行度也掉下来。 很多库因此选择「存整个矩阵、不利用对称性」——用 2 倍内存换算法简洁与稳定性能。
  • 上三角 / 下三角的约定差异是最容易埋 bug 的地方LAPACKuplo 参数、scipy.linalg.eighlower=Truenumpy.linalg.cholesky、各种有限元库的 symmetric_storage 开关, 每一家的「只存一半」都可能指不同的那一半。更糟的是:把上三角当三角形下三角读, 在数学上依然是一个完整的、合法的对称矩阵,程序不会崩、不会报错, 只会安静地算出一个错误答案。所以工程上的自检手段是固定的: 造一个已知答案的小矩阵(例如 A = I 或随机对称矩阵), 算出 A - AT 的最大绝对值,它必须恰好是 0, 而不是「小于某个容差」。

6.9.3 三对角与带状:用结构把复杂度降一档

6.6 节讲过三对角矩阵的长度是 3n − 2。它在数值计算里出现的频率高得离谱:

  • 三次样条插值:要让 n + 1 个点的分片三次多项式在内部结点处 二阶连续可导,每个内部点会写出一条只含相邻三个未知量的方程, 拼起来正好是三对角方程组。
  • 一维热传导方程的隐式差分格式:Crank–Nicolson 里每一个格点的新时刻值 只依赖自己和左右邻居,也是三对角。
  • 常微分方程的两点边值问题一维薛定谔方程的离散化三次自然样条的边界条件, 最后都归结到同一个形状。

给定三对角方程组,Thomas 算法(追赶法)做的其实就是高斯消元, 只不过因为第 i 行只有 3 个非零元,消元的每一行只花常数量时间:

追赶法(Thomas):两趟扫描,O(n),额外空间 O(n)
通用高斯消元(LU 分解):O(n³) 时间、O(n²) 空间

n = 1000 代进去看两个数字:

算法代价量级n = 1000 时的实际数字
追赶法O(n)、空间 O(n) 5n ≈ 5000 次浮点运算、4n ≈ 24 KB 内存
通用高斯消元O(n³)、空间 O(n²) n³/3 ≈ 3.3 × 108 次浮点运算、8 MB 内存

运算量差 6.7 万倍,内存差 340 倍。但更要命的是内存层级: 24 KB 装得进 L1/L2 缓存,8 MB 只能待在主存;每次访问都要去主存取, 一个 O(n³) 算法的常数因子会被放大到几十倍。 「利用结构把复杂度降一档」的收益,从来不只是渐近线好看,而是数据结构小到能装进缓存。 这就是本章前半部分那些「省空间」的公式在工程里的真正价值。

把带再放宽一点,五对角、七对角的带状矩阵同样可以带状 LU 分解, 时间与空间都是 O(n × b²)b 是带宽):带宽决定一切。 这也是有限元做网格编号时要专门做「带宽最小化重排序」的原因—— 同一套方程,把节点编号换一换,带宽可能从上千降到几十,求解时间跟着掉一个数量级。 顺带一提,这类「用结构换效率」的思路在第 13 讲也出现过: 区间 DP 用四边形不等式把 O(n³) 压到 O(n²), 机制不同,动机一模一样。

6.9.4 三种稀疏格式的工程分工:COO 建、CSR 算、CSC 按列、BSR 上 GPU

6.7.7 已经介绍过 CSR 的三个数组。但在工程里,问题不是「CSR 好不好」, 而是「什么时候用哪一种」——同一个稀疏矩阵在不同阶段会被换好几次格式:

格式结构内存(每个非零元)它存在的理由
COO
坐标格式
row[] / col[] / val[] 三个平行数组 3 个整数 + 1 个 double ≈ 20 字节 构建阶段:三个数组各自 append 就行,好写、好并行, 而且天然允许元素乱序、允许重复(重复项一般做「求和合并」)
CSR
行压缩
val[] / col_idx[] / row_ptr[] 2 个整数 + 1 个 double ≈ 16 字节 计算阶段:SpMV 的内层循环在 val / col_idx 上 严格顺序前进,缓存与预取器都满意,是稀疏 BLAS 的默认格式
CSC
列压缩
val[] / row_idx[] / col_ptr[] 同上 ≈ 16 字节 按列访问更频繁时用:求转置只相当于换一次口径, 按列切片、按列做 ATx 都很顺(MATLAB 历史上就是列优先)
BSR
块稀疏
把非零元按 r × c 的小稠密块分组, 再对「块」套一层 CSR 取决于块大小,2×2 时与 CSR 相当 GPU 友好:每个线程处理一个块,块内是稠密小矩阵, 用上了向量化和共享内存,算力/带宽比明显改善

这张表最关键的一行是「格式转换本身有成本」。COO 转 CSR 并不搬运数据, 只是重新索引,需要:

① 一趟扫描统计每行非零元个数 O(nnz)
② 对行计数做前缀和得到 row_ptr[] O(m)
③ 第二趟扫描把每个元素放进它该在的位置 O(nnz)
合计 O(nnz + m)需要额外一趟完整的内存读写——这是纯粹的搬运,没有产生任何计算价值。
COO → CSR 的转换与 SpMV:三元组按行分桶放进 values / colIdx,rowPtr 记住每行的起点 ① 原矩阵 A(5 × 5,nnz = 7) j0 j1 j2 j3 j4 i0 i1 i2 i3 i4 4 0 6 0 0 0 5 0 0 0 1 0 0 7 0 0 0 2 0 0 0 0 0 0 3 25 个格子里只有 7 个非零 → 稀疏度 72% ② COO:三元组列表 row col val 0 0 4 0 2 6 1 1 5 2 0 1 2 3 7 3 2 2 4 4 3 好追加,但查 (i, j) 要扫全表 O(nnz) 按行分桶 ③ CSR:三个数组 p=0 1 2 3 4 5 6 i = 0 i = 1 i = 2 i = 3, 4 values[] 4 6 5 1 7 2 3 colIdx[] 0 2 1 0 3 2 4 rowPtr[] 0 2 3 5 6 7 ← 哨兵 = nnz rowPtr 是「每行起点」的前缀和:第 i 行就是 p ∈ [rowPtr[i], rowPtr[i+1]) 这一段。 ④ SpMV:y = A · x 给定 x = (1, 2, 3, 4, 5)ᵀ y[0] = 4·x0 + 6·x2 = 4 + 18 = 22 y[1] = 5·x1 = 10 y[2] = 1·x0 + 7·x3 = 1 + 28 = 29 y[3] = 2·x2 = 6 y[4] = 3·x4 = 15 每个 p 只做一次乘加;x[colIdx[p]] 是唯一的间接访问 7 次乘加换掉稠密的 25 次,且内层 p 连续前进 → 缓存友好 y[] = 22 10 29 6 15 代价:rowPtr 额外占 m+1 个整数;结构一旦定下就不便插入——插入一个非零元会让它后面的位置整体后移。 收益:只存 7 个非零元而不是 25 个格子;乘加次数从 m×n 降到 nnz,且内层在 values / colIdx 上顺序前进。 工程流水线:COO 建计数 + 前缀和转 CSRCSR 反复算 同一个矩阵换一种存法:内存一样,速度可能差几倍。
图 6-12 COO → CSR 的转换与 SpMV:三元组按行分桶放进 values / colIdx,rowPtr 划出每一行的区间

所以工程上的标准流水线是三段式,每一段都只在必要的时候做一次

稀疏矩阵的三段式流水线 ① COO 建(读文件、装配有限元单元、收集用户行为,来一条追加一条)→ ② 转成 CSR 算(一次计数 + 前缀和,之后所有 SpMV / SpGEMM / 迭代求解都用它)→ ③ 需要按列时再转 CSC,但要注意 CSC ↔ CSR 的互转就是一次转置, 成本同样是 O(nnz)不要在热循环里来回换
如果用的是 GPU 且矩阵有明显的块结构(有限元、图神经网络的邻接块), 第二步可以改成「转 BSR」,一个块一个线程,反而比 CSR 更快。

把上面这张图的四步写成代码:先用三元组给一个 5 × 5 稀疏矩阵, 统计非零元个数 cnt[],做前缀和得到 rowPtr[], 再把每个元素填进它该在的位置得到 values[]colIdx[], 最后用 CSR 完成一次 y = A·x。 注意解压后的所有临时数组都是全局的、函数都是自由函数——考试和竞赛里不需要任何封装:

/* ============================================================
   csr_spmv.cpp —— COO → CSR 转换 + 稀疏矩阵乘向量 SpMV

   输入:5 x 5 稀疏矩阵的三元组(行、列、值)
   输出:CSR 的三个数组 values / colIdx / rowPtr,以及 y = A * x

   编译: g++ -std=c++17 -O2 -o csr_spmv csr_spmv.cpp
   ============================================================ */
#include <cstdio>

const int MAXN   = 100;    /* 行数上界 */
const int MAXNNZ = 1000;   /* 非零元个数上界 */

/* COO 三元组:行、列、值 */
struct Triple { int row, col, val; };

int values[MAXNNZ];      /* 非零元的值 */
int colIdx[MAXNNZ];      /* 与 values 一一对应的列号 */
int rowPtr[MAXN + 1];    /* 第 i 行的区间是 [rowPtr[i], rowPtr[i+1]) */

/* ---------- COO -> CSR:计数 + 前缀和 + 按行放置 ---------- */
void cooToCsr(const Triple t[], int nnz, int rows) {
    for (int i = 0; i <= rows; ++i) rowPtr[i] = 0;

    /* 1) 统计每一行有几个非零元:先记在 rowPtr[row + 1] 上 */
    for (int k = 0; k < nnz; ++k) rowPtr[t[k].row + 1]++;

    /* 2) 前缀和:rowPtr[i + 1] = 0..i 行的非零元总数,即第 i+1 行的起点 */
    for (int i = 0; i < rows; ++i) rowPtr[i + 1] += rowPtr[i];

    /* 3) 再扫一遍三元组,把元素放进它该在的位置。
          rowPtr[row] 先当写入游标,写一个就往右挪一格,
          全部写完时它恰好等于第 row+1 行的起点 */
    for (int k = 0; k < nnz; ++k) {
        int p = rowPtr[t[k].row]++;
        values[p] = t[k].val;
        colIdx[p] = t[k].col;
    }

    /* 4) 把游标整体右移一位还原成行起点,并补上哨兵 rowPtr[rows] = nnz */
    for (int i = rows; i > 0; --i) rowPtr[i] = rowPtr[i - 1];
    rowPtr[0] = 0;
}

/* ---------- SpMV:y = A * x,按行扫描 CSR ---------- */
void spmvCsr(int rows, const int x[], int y[]) {
    for (int i = 0; i < rows; ++i) {
        int sum = 0;
        /* 第 i 行的非零元就是 values[rowPtr[i] .. rowPtr[i+1] - 1] 这一段,
           连续访问,不需要任何查找 */
        for (int p = rowPtr[i]; p < rowPtr[i + 1]; ++p)
            sum += values[p] * x[colIdx[p]];
        y[i] = sum;
    }
}

int main() {
    /* 5 x 5 稀疏矩阵,7 个非零元,按行优先给出三元组 */
    const Triple coo[] = {
        {0, 0, 4}, {0, 2, 6},
        {1, 1, 5},
        {2, 0, 1}, {2, 3, 7},
        {3, 2, 2},
        {4, 4, 3}
    };
    const int nnz = 7, rows = 5;
    const int x[5] = {1, 2, 3, 4, 5};
    int y[5];

    cooToCsr(coo, nnz, rows);

    printf("values  :");
    for (int p = 0; p < nnz; ++p) printf(" %2d", values[p]);
    printf("\ncolIdx  :");
    for (int p = 0; p < nnz; ++p) printf(" %2d", colIdx[p]);
    printf("\nrowPtr  :");
    for (int i = 0; i < rows; ++i) printf(" %2d", rowPtr[i]);
    printf(" %2d", nnz);          /* 哨兵 rowPtr[rows] */
    printf("\n");

    spmvCsr(rows, x, y);
    printf("x       :");
    for (int i = 0; i < rows; ++i) printf(" %2d", x[i]);
    printf("\ny = A*x :");
    for (int i = 0; i < rows; ++i) printf(" %2d", y[i]);
    printf("\n");
    return 0;
}

/* 运行结果(g++ -std=c++17 -O2,与图 6-12 完全对应):
     values  :  4  6  5  1  7  2  3
     colIdx  :  0  2  1  0  3  2  4
     rowPtr  :  0  2  3  5  6  7
     x       :  1  2  3  4  5
     y = A*x : 22 10 29  6 15
   手工核对第 0 行:y[0] = values[0]*x[colIdx[0]] + values[1]*x[colIdx[1]]
                       = 4*x[0] + 6*x[2] = 4 + 18 = 22  ✔
   注意 rowPtr[] 的长度是 rows + 1:多出来的那个哨兵 nnz = 7 让每行的区间
   都能写成半开区间,内层循环因此少一个 if —— 这就是 CSR 比三元组表简洁的地方。 */

6.9.5 SpMV 与真实系统:稀疏度 99.99% 意味着什么

把上面几种结构推到真实规模,看一组数字,就能理解为什么「稀疏」不是一个学术概念:

系统矩阵规模与稀疏度稠密存储CSR 存储
PageRank
网页链接矩阵
6 × 109 个网页、平均每个页面约 10 条出链
矩阵共有 3.6 × 1017 个格子,非零元 6 × 1010
稀疏度 ≈ 99.99998%
3.6 × 1017 × 8 字节
288 万 TB
6 × 1010 × 12 字节
720 GB
有限元
刚度矩阵
3.4 × 105 个自由度、平均每行约 15 个非零元
非零元约 5 × 106 个,稀疏度 > 99.99%
930 GB 60 MB
推荐系统
用户–物品交互矩阵
109 用户 × 106 物品 = 1015 个格子
平均每个用户 100 条行为 → 非零元 1011
稀疏度 > 99.99%
1015 × 8 字节
800 万 TB
1011 × 12 字节
1.2 TB
图算法
邻接矩阵
邻接矩阵就是天然的稀疏矩阵:第 i 行的非零元 就是顶点 i 的邻居 O(deg(i))
社交图平均度约 200,稀疏度 > 99.99%
顶点数的平方 边数的两倍量级

「288 万 TB」与「720 GB」差了 400 万倍,这不是优化问题,是「换一种存法才能存在」的问题。 而所有四个系统最终都要落到同一个算子——SpMV:稀疏矩阵乘向量 y = Ax

  • PageRank:一次迭代 = 一次 SpMV + 一次向量归一化, 收敛要跑几十次迭代,稀疏链接矩阵 G 自然要用 CSR / CSC 存; 它同时也是第 08 讲图基础与第 09 讲图论算法里的老话题——CSR 就是邻接表压平后的样子
  • 有限元迭代求解:共轭梯度法(CG)每步一次 SpMV,矩阵结构直接决定一次求解要多久。
  • 推荐系统的召回与图神经网络:用户-物品矩阵上的 A x 就是「按用户聚合物品向量」,本质是一次稀疏矩阵乘法。
  • 图算法:BFS 的一层扩散、Personalized PageRank、 图卷积的一跳聚合,都能写成一次 SpMV——稀疏矩阵确实是图算法的公共底座。
为什么这四个系统都停在 SpMV 上 因为 SpMV 的每一行非零元在 row_ptr 里是连续区间, 天然支持「按行并行」,而不同行的长度不同只是负载均衡问题, 不影响访问模式的规律性。稀疏矩阵真正难加速的不是 SpMV, 而是稀疏矩阵乘稀疏矩阵(SpGEMM):输出规模事先未知、结构无法预测, 这也是它至今仍是研究热点的原因。

6.9.6 稠密 vs 稀疏的临界点:稀疏格式不一定更快

到这里很容易得出一个错误结论:稀疏格式更省内存,所以应该总是用它。 不对。「省内存」和「更快」是两件事,本章最后这一小节专门拆开它。

先算纯内存的临界点。设矩阵 m × n、非零元 nnz 个:

稠密行优先:m × n 个 double
CSR:nnz 个 double + nnz 个列号 + (m + 1) 个行指针
→ 用 4 字节整数、8 字节 double 折算成 double 个数:1.5 × nnz + m / 2

让两者相等:mn ≈ 1.5 nnz + m/2, 方阵情形下的解是 密度 < 1/1.5 ≈ 67%,也就是理论临界点约 67%。 但这只是「内存打平」,还没算性能账。加上索引开销与访问模式损失, 实际工程经验值低得多:密度超过 5% ~ 10% 时,稠密格式往往反而更快

算一算具体的内存账,把临界点看得更清楚:

矩阵密度稠密行优先CSR结论
10000 × 100001% 800 MB约 12 MB稀疏赢 66 倍
10000 × 1000010% 800 MB约 120 MB稀疏赢 6.7 倍,性能已开始接近
10000 × 1000050% 800 MB约 600 MB只赢 1.3 倍,但性能可能更慢
10000 × 1000090% 800 MB约 1080 MB稀疏反而更费内存 + 更慢

为什么密度一高稀疏就可能更慢?因为稠密格式能用上 BLAS,而 BLAS 是几十年工程优化的结晶: 分块(blocking)让数据复用、SIMD 向量化一次算 4 ~ 8 个 double、 多线程并行。到了 SpMV 上,情形完全反过来——SpMV 是典型的内存受限操作, 算力用不上:

算子每字节数据换来的浮点运算实测能达到的峰值利用率
稠密矩阵乘法(dgemm16 ~ 32 可达机器峰值的 60% ~ 90%
稀疏 SpMV0.05 次(12 字节换 2 次乘加) 通常只有峰值的 1% ~ 5%

两个数量级的差距。所以工程上的判断顺序是: 先看矩阵是不是真的稀疏(现成数字:低于 10% 才值得进入稀疏方案), 再看瓶颈是内存还是速度。如果内存够、只要算得快,稠密 BLAS 经常是更好的选择—— 甚至有人故意把稀疏矩阵补零成稠密再交给 BLAS,因为这样更快。

还有两个更细的陷阱,考试和面试都很爱问:

  • 稀疏格式省的是内存,不是时间。如果矩阵的非零元分布极其不规则 (比如每一行的列号都是随机的),x[col_idx[p]] 这一步会在整个 x 上乱跳,缓存几乎全部失效。此时 CSR 的 nnz 次乘加 每一次都等一次主存,算下来可能比稠密的 m × n 次顺序乘加还慢。 工程上对治的办法是按行排序、对矩阵做重排序(RCM / AMD)降低带宽、 或者干脆改数据布局(把 x 按列号重新编号)。
  • 稀疏度必须说清楚分母。「稀疏度 99.99%」指的是非零元占格子总数的比例, 但矩阵规模相差悬殊时这个比例没有可比性。工程上更常用「平均每行非零元个数」 与 nnz 的绝对量,因为它们直接对应内存占用与访存次数。

6.9.7 一张工程选型表

最后把六种存储方式放进一张表。这张表不是用来背的,而是用来回答一个具体问题: 我手上这个矩阵,现在处于哪个阶段,该选哪种存法。

存储格式适用阶段内存开销随机访问 SpMV 效率是否适合 GPU典型库或系统
稠密行优先
C order
中小规模、结构密集;需要按行扫描(图像、逐行统计) m × n 个元素,最费内存 O(1),地址一次乘加 稠密乘向量本身就不划算 适合:连续内存、合并访存 C / C++ 原生二维数组、NumPy 默认、cuBLAS 行主序接口
稠密列优先
Fortran / F order
数值线性代数主线:矩阵分解、BLAS / LAPACK 调用 同上 O(1),公式换成 (j×m + i)×L 同上 适合:与列主序 BLAS 无缝对接 BLAS / LAPACK、MATLAB、Fortran、np.asfortranarray
COO
三元组
构建阶段:读文件、装配、收集,随时追加 每元 3 整数 + 1 值 ≈ 20 字节 (i,j) 要扫 O(nnz) 差:元素散乱、无行边界 一般:先转格式再用 SciPy coo_matrix、PETSc 装配阶段、PyTorch sparse_coo
CSR
行压缩
计算阶段:结构定下来后反复做乘法与迭代求解 每元 2 整数 + 1 值 ≈ 16 字节,另加 m + 1 按行区间 O(行内 nnz);按 (i,j) 定位要二分 最好:内层连续前进 很适合:每行一段、便于并行 SciPy csr_matrix、cuSPARSE、MKL、scipy.sparse.linalg、PageRank 类实现
CSC
列压缩
按列访问更频繁:ATx、按列切片、列主序生态 与 CSR 相同 按列区间 O(列内 nnz) 好:转成 CSR 即得转置的 SpMV 适合(同 CSR) MATLAB 稀疏矩阵、SciPy csc_matrix、SuiteSparse 部分工具
BSR
块稀疏
矩阵天然分块(有限元、多物理场、GNN 邻接块),或需要 GPU 加速 视块大小;块内零也存,通常比 CSR 略费 按块定位,块内 O(1) 很好:块内是稠密核 最合适:向量化 + 共享内存 cuSPARSE bsrmv、PETSc MATBAIJ、MKL 块稀疏接口
工程视角小结:本章的六个公式在真实系统里各自对应什么
  1. 行优先 / 列优先的地址公式 → 决定遍历顺序快几倍,也决定「要不要转置给 BLAS」, 以及 NumPy 里 C orderF order 的取舍。
  2. 对称矩阵只存一半 → 刚度矩阵、协方差矩阵、距离矩阵的常规操作; 代价是下标映射多一条分支、稀疏情形下会打乱访问模式、上三角/下三角约定极易出错。
  3. 三对角压缩与 3n − 2 → 样条插值、热传导隐式格式、边值问题的入口; 配上 Thomas 算法,把 O(n³) 变成 O(n),这是「结构换复杂度」最经典的一课。
  4. 三元组顺序表 → 工程里的 COO:负责「建」,不负责「算」。
  5. 行逻辑链接的 rpos[] → 工程里的 CSR row_ptr[]: 负责「算」,SpMV 的 O(nnz) 全靠它把每行圈成一段连续区间。
  6. 十字链表 → 结构频繁增删时的选择; 如果只是想「按行也能按列」,第 10 讲讲过的哈希表(键为 (i, j)unordered_map)在工程里同样常见,代价是失去顺序性。
本节的一句话总结 压缩存储的工程本质,是「用一次固定的格式转换,换掉之后成千上万次访问里的浪费」。 稠密、COO、CSR、CSC、BSR 之间没有优劣,只有「现在处在哪个阶段」; 真正需要判断的永远是那三个问题:数据有多少个非零元、谁会按什么顺序访问它、 省下来的内存能不能真的换成时间。

6.10 本章小结、易错点与自测

6.10.1 必须记住的六件事

概念与公式

  1. 数组是线性表的推广:一维数组是线性表,二维数组是元素为线性表的线性表
  2. 随机存取的根基是「等长 + 连续」,地址是下标的线性函数: LOC(a[k]) = LOC(a[0]) + k×L
  3. 二维地址公式:行优先 (i×n + j)×L,列优先 (j×m + i)×L行优先乘列数、列优先乘行数
  4. 压缩存储 = 「相同值只留一份 + 零元素不分配空间」,灵魂是下标映射函数 k = f(i,j)
  5. 对称矩阵 k = i(i+1)/2 + ji≥j),长度 n(n+1)/2
  6. 三角矩阵长度 n(n+1)/2 + 1(常数占一格); 上三角 k = i(2n−i+1)/2 + (j−i)
  7. 三对角矩阵长度 3n − 2k = 2i + j
  8. 稀疏矩阵三元组:(row, col, value);快速转置靠 num[]cpot[]O(nu+tu)

四个「一句话本质」

  • 压缩存储:把「用下标算地址」变成「用一个函数算下标」,函数好算就赚,算不出来就退回存位置。
  • 等差求和:所有下标映射公式的最后一步都是「前 i 行共多少个」, 而这永远是 1+2+…+i = i(i+1)/2 或它的变体。
  • 快速转置:先用 num/cpot 把「每个元素该放哪」算好,再一趟扫描直接落位—— 典型的空间换时间
  • 十字链表:一个结点同时挂在行链表和列链表上,于是「按行找」和「按列找」都变成了走链表。

6.10.2 易错点清单

致命错误(一犯就全错)
  • 行优先 / 列优先看反。题目说「按行优先存储」而你在用列优先公式, 后面每一步都错。做题第一件事:在草稿纸上写下用的是哪一套。
  • 忘记乘字节数 L。(i×n+j) 是「第几个元素」, 题目问「地址」时必须 × L。反过来,题目给的是地址、问的是位置时,要先 ÷ L
  • n(n+1)/2 写成 n(n−1)/2这是漏掉主对角线的典型症状。 自检方法:代入 n = 1,长度应该是 1 而不是 0。
  • 三对角矩阵的 k = 2i + j 用在带外。这个统一公式只在 |i−j| ≤ 1 时成立;带外元素必须直接返回 0,否则会算出越界的下标。
  • 快速转置第三步不按原顺序扫描。会导致转置结果不再按行有序, 后续的加法乘法全部失效。
细节坑(容易被扣分)
  • 下标的起始值。本章统一用 0 基;若题目声明「下标从 1 开始」, 所有公式都要把 i, j 换成 i−1, j−1
  • 三角矩阵多出来的那个单元。问「数组长度」要答 n(n+1)/2 + 1; 问「真实数据有多少个」才是 n(n+1)/2
  • 上三角公式里的 (j − i)漏掉它就等于假设每行都从第 0 列开始存了。
  • 带状矩阵的长度。3n − 2(按行压平)与 3n(按对角线存 3×n 数组) 是两个不同答案,看清题目的存储方案。
  • 稀疏矩阵相加时把 0 也存进去。结果位置相加为 0 时必须丢弃, 否则 tu 会虚高,稀疏结构逐渐退化。
  • 十字链表与三元组表的随机存取结论。两者都不保留 O(1) 随机存取, 别因为十字链表更高级就以为它能 O(1) 定位。

6.10.3 考点提示

历年高频考法
  1. 数组地址计算(几乎必考)。二维给首地址求某元素地址;或只给某一元素地址求另一元素 (用「增量法」)。三维数组也开始频繁出现,注意「哪一维变化最快,它的权重就是 1」。
  2. 对称矩阵的下标映射(最高频)。两种问法: ① 给 (i, j)k; ② 给 k 反求 (i, j)(用 i(i+1)/2 ≤ k < (i+1)(i+2)/2 试根)。
  3. 三角矩阵的长度与常数位置。送分题,但要看清是上三角还是下三角、常数是几。
  4. 三对角矩阵的长度 3n−2 与公式 k = 2i + j 常以填空形式出现,也常和「数组长度至少是多少」结合考。
  5. 稀疏矩阵的存储结构图。三元组表要能画出「有序」这个特点; 十字链表要能默画出五个域与两个表头数组,并说明 down / right 的作用。
  6. 快速转置的手算。给一个小矩阵,要求写出 num[]cpot[] 以及转置结果的三元组表。这类题按「统计 → 前缀和 → 顺序落位」三步走,不容易错。
  7. 辨析题:压缩存储后是否还能随机存取? 答「对称 / 三角 / 带状矩阵能(有闭式映射函数),稀疏矩阵不能(位置无规律)」。

6.10.4 自测题(先自己做,再展开看答案)

1. 【地址计算】二维数组 A[5][8],每个元素 4 字节,LOC(a[0][0]) = 2000。求 a[3][6] 在行优先和列优先下的地址。

行优先:排在 a[3][6] 前面的是 3 整行加本行 6 个:

k = i×n + j = 3×8 + 6 = 30 ⇒ LOC = 2000 + 30×4 = 2120

列优先:排在前面的是 6 整列加本列 3 个,每列有 m = 5 个元素:

k = j×m + i = 6×5 + 3 = 33 ⇒ LOC = 2000 + 33×4 = 2132

两者相差 12 字节,也就是 3 个元素。可见「行优先 / 列优先」绝不是可以随便混用的小事。

2. 【地址计算·三维】三维数组 A[4][3][6],每个元素 2 字节,LOC(a[0][0][0]) = 1000。求 a[2][1][4] 的地址(行优先);再求 a[3][2][5] 的地址。

行优先,各维权重分别是「后面所有维度长度之积」:

i₁ 的权重 = 3×6 = 18;i₂ 的权重 = 6;i₃ 的权重 = 1
a[2][1][4]:k = 2×18 + 1×6 + 4 = 36 + 6 + 4 = 46 ⇒ LOC = 1000 + 46×2 = 1092

求第二个时不必重算全部,用增量法更快:

a[3][2][5]:k = 3×18 + 2×6 + 5 = 54 + 12 + 5 = 71
Δk = 71 − 46 = 25 ⇒ LOC = 1092 + 25×2 = 1142

提醒:A[4][3][6] 中 4 是第一维长度、3 是第二维、6 是第三维, 不要顺手把 4 当成最后一维。仔细看 A[d₁][d₂][d₃] 的写法。

3. 【下标映射】n = 7 的对称矩阵按「下三角 + 行优先」压缩。求 a[5][3]a[3][5] 的位置,以及一维数组的长度和最大下标。

数组长度 n(n+1)/2 = 7×8/2 = 28,合法下标 0 ~ 27

a[5][3]:i = 5 ≥ j = 3 ⇒ k = 5×6/2 + 3 = 15 + 3 = 18
a[3][5]:i = 3 < j = 5 ⇒ k = 5×6/2 + 3 = 15 + 3 = 18

两者当然相同:它们是同一个值的两个「镜像位置」,压缩后共用 sa[18]

验证:第 5 行从 k = 5×6/2 = 15 开始, sa[15] = a[5][0]sa[16] = a[5][1]sa[17] = a[5][2]sa[18] = a[5][3]

4. 【三对角矩阵】n = 8 的三对角矩阵按行优先压进 sa[]。求 a[6][5] 的位置、数组长度,以及 a[0][3] 的值。

数组长度 3n − 2 = 3×8 − 2 = 22,下标 0 ~ 21。

a[6][5]:|6 − 5| = 1 ≤ 1,在带内 ⇒ k = 2×6 + 5 = 17

a[0][3]|0 − 3| = 3 > 1,在带外, 压缩后没有存储它,所以它的值恒为 0

反向自检:k = 17 ⇒ i = ⌊18/3⌋ = 6,j = 17 − 12 = 5 ✅ 与题目吻合。

再验算一个边界:k = 21 ⇒ i = ⌊22/3⌋ = 7,j = 21 − 14 = 7, 即 a[7][7],正好是最后一个元素 ✅

5. 【快速转置】稀疏矩阵 A(3 行 4 列)的三元组表为 (0,0,1) (0,3,2) (1,1,3) (2,0,4) (2,2,5)。 写出 num[]cpot[],并用快速转置算法求 T = Aᵀ 的三元组表。

第一步:统计 num[]A 各列的非零元个数,共 nu = 4 列):

第 0 列:(0,0)、(2,0) ⇒ 2  第 1 列:(1,1) ⇒ 1  第 2 列:(2,2) ⇒ 1  第 3 列:(0,3) ⇒ 1
num = [2, 1, 1, 1],合计 5 = tu ✅

第二步:前缀和求 cpot[]

cpot[0] = 0
cpot[1] = cpot[0] + num[0] = 0 + 2 = 2
cpot[2] = cpot[1] + num[1] = 2 + 1 = 3
cpot[3] = cpot[2] + num[2] = 3 + 1 = 4
校验:cpot[3] + num[3] = 4 + 1 = 5 = tu ✅

第三步:按 A.data 原顺序扫描,逐个落位 (落一个就把对应的 cpot 加 1):

读到col当前 cpot[col]写入 T 的位置写入内容
(0,0,1)00T[0](0,0,1)
(0,3,2)34T[4](3,0,2)
(1,1,3)12T[2](1,1,3)
(2,0,4)01T[1](0,2,4)
(2,2,5)23T[3](2,2,5)

T.data 下标排好,转置结果(4 行 3 列)为:

T = (0,0,1) (0,2,4) (1,1,3) (2,2,5) (3,0,2)

验证:A[2][0] = 4,转置后应该是 T[0][2] = 4, 结果里确实有 (0,2,4) ✅; A[0][3] = 2T[3][0] = 2,结果里有 (3,0,2) ✅。 并且 T 按行号严格升序 0,0,1,2,3,同行内列号升序 0 < 2 ✅ 有序性保持住了。

6.10.5 配套编程练习

练习任务提示 / 验收标准
练习 1 写一个函数 addrRow(base, m, n, i, j, L)addrCol(...), 对 A[3][4] 打印两种排布下的完整内存布局 对照图 6-3 / 图 6-4 逐格核对;顺便统计「按行遍历 vs 按列遍历」在 n = 4096 时的耗时差
练习 2 实现三维数组 A[d1][d2][d3] 的行优先线性化与反线性化(kdDecode 要求 decode(encode(i,j,k)) == (i,j,k) 对全部下标成立
练习 3 实现对称矩阵的 idx(i,j) 映射与 getVal / setVal, 并随机生成一个对称矩阵验证 getVal(i,j) == getVal(j,i),统计压缩率 再写一个「由 k 反求 (i, j)」的函数,与正向公式交叉验证 10 万次
练习 4 实现上 / 下三角矩阵的映射函数(upIdx / lowIdx)与常数单元, 越界和「往常数区写非 c 值」都要被拦住(返回 false 并提示) 写自测覆盖四个角:(0,0)、(0,n−1)、(n−1,0)、(n−1,n−1)
练习 5 实现三对角矩阵的映射函数 idx(i,j) = 2i + j 与反向 decode(k), 并用它写「追赶法(Thomas 算法)」解三对角方程组 复杂度必须是 O(n);用 n = 1000 的随机方程组与 高斯消元的结果对比
练习 6 实现三元组表的朴素转置与快速转置,在同一组数据上比较耗时 构造 500 × 500tu = 2000 的随机稀疏矩阵, 快速转置应该快出两个数量级
练习 7 实现十字链表的插入、删除、按行 / 按列打印,并用它完成两个稀疏矩阵的加法 与三元组加法版本的结果交叉验证;观察「插入不用搬移」带来的代码差异
练习 8 实现 CSR 的构建与 SpMV,并与稠密矩阵乘法结果对比 统计 values/colIdx/rowPtr 的总内存占用,与稠密存储对比, 画一张「nnz 比例 vs 内存占用」的曲线
洛谷练习建议 本章的压缩存储本身偏「数据结构实现」,在洛谷上直接的模板题不多, 建议在题库中搜索以下关键词(题号请以洛谷站内搜索结果为准): 「矩阵转置」「稀疏矩阵」「螺旋矩阵」(考的是二维下标的相互换算, 与本章同一套思维)、「矩阵加速」(矩阵乘法 + 快速幂,第 13 讲会展开)。 如果想练「下标映射」的手感,可以自己做一类题:给定压缩后的一维数组, 还原出整个矩阵并输出。