☰
稀疏矩阵与CSR格式:从原理到工程实现的存储优化指南
2026/10/14 23:40:34 网站建设 项目流程

如果你的代码里突然出现一个10000×10000的稠密二维数组,内存会被瞬间吃掉几百兆;当规模再翻到百万级行,光靠普通的数组存储就已经寸步难行。我今天想聊的,就是解决这类问题的关键概念:稀疏矩阵,以及工程界最常用的CSR格式(Compressed Sparse Row,按行压缩存储)。这篇内容会从为什么非压缩不可讲起,逐步拆解CSR三个数组的边界约定,带你把编解码和稀疏矩阵向量乘从头写一遍,再聊聊存储格式选型和实操中容易踩的坑。适合刚接触稀疏矩阵的算法工程师、做图计算或机器学习特征的开发者,也适合想彻底搞懂底层存储原理的初学者。

1. 稠密存储的膨胀困境:稀疏矩阵为什么需要专门格式

1.1 从内存账本看稠密数组的成本

一个 n 行 m 列的稠密矩阵,无论里面有多少个零值,都要实打实存下 n×m 个数值。这里容易被忽略的关键是:矩阵真正消耗的内存不是由“有效信息量”决定的,而是由“格子数量”决定的。我算过一笔实际的账,用 double 类型(8 字节)举例:

  • 1000×1000 的矩阵,大约 8 MB;
  • 10000×10000 的矩阵,约 800 MB;
  • 在推荐系统场景里常见的 100 万用户 × 100 万物品的评分矩阵,直接就是 8 TB 起步。

当矩阵来源是用户行为、图结构、文本特征或者数值模拟结果时,零元素的占比往往在 90% 以上,极端情况下能达到 99.99%。真正有信息量的只有那一小撮非零值,稠密存储却要为零值元素支付同样价位的“房租”。这种浪费一旦碰上大规模数据,就不再是理论问题,而是内存溢出和运行时长失控的现实问题。

1.2 稀疏矩阵通常在哪些场景出现

业界对“稀疏”并没有唯一的量化标准,一般约定非零元素占比低于 5% 时就应该考虑稀疏存储。在典型的图数据和推荐系统里,这个比例经常进一步降到 0.1% 甚至更低。我总结了几类最常见来源:

  • 图的邻接矩阵:顶点之间是否连通、边的权重是多少,绝大多数格子本来就是空;
  • 用户行为数据:每个用户只和极少数物品发生过交互;
  • 文本特征:词表动辄十万到百万级,但单个文档只包含其中很少的词;
  • 数值模拟:有限元、有限差分方法产生的矩阵通常具有带状或对称稀疏结构。

只要做过其中任何一类项目,几乎都会在某个阶段遇到同一个问题:数据逻辑上不大,怎么一存进内存就卡死了?根本原因就是选择了稠密存储,让无数个 0 占满了全部空间。

1.3 稀疏存储的核心思路

稀疏存储的思想概括成一句话就是:只保存非零元素的位置和值,把“零”这个信息本身当作不存在。这样做的关键要回答两个问题:每一个非零值落在矩阵的哪个位置;用什么规则把这些位置信息组织起来,才能让后续的读取和计算足够高效。CSR 格式就是对这两个问题给出的最经典答案之一:按行扫描非零元素,用行指针描述每一行的范围,用列索引记录每一列的位置。

理解了稀疏存储的必要性,接下来就可以沉下心拆解 CSR 的结构。这个格式看起来只有三个数组,但边界约定和命名容易让新手绕晕。

2. CSR 三数组拆解:values、indices、indptr 的每一个字节都在做什么

2.1 一个 3×4 矩阵的完整 CSR 表示

直接看具体例子最直观。假设有这样一个矩阵:

[ 0 1 0 2 ] [ 3 0 4 0 ] [ 5 6 0 7 ]

非零元素一共 7 个。CSR 用三个一维数组描述它:

  • values:按行顺序存储的非零值,长度等于非零元素个数 nnz;
  • indices:与 values 一一对应的列索引,长度同样是 nnz;
  • indptr:长度为“行数 + 1”的行起始指针数组,每个元素表示某一行第一个非零元素在 values/indices 中的起始位置。

这个矩阵的 CSR 具体数据是:

values = [1, 2, 3, 4, 5, 6, 7] indices = [1, 3, 0, 2, 0, 1, 3] indptr = [0, 2, 4, 7]

我来逐步解读。indptr[0] = 0表示第 0 行从 values 下标 0 开始;indptr[1] = 2表示第 1 行从 values 下标 2 开始。于是第 0 行的非零元素就是values[0:2] = [1, 2],它们对应的列索引是indices[0:2] = [1, 3],含义就是矩阵第 0 行第 1 列为 1、第 0 行第 3 列为 2。同理,indptr[2] = 4说明第 1 行范围是values[2:4] = [3, 4],列索引是indices[2:4] = [0, 2]。最后的 7 正好等于非零元素总数。

2.2 为什么 indptr 必须是“行数 + 1”个元素

刚接触 CSR 的人最容易卡在这个问题上:3 行的矩阵,为什么 indptr 里有 4 个数?原因在于提取第 i 行数据时要使用区间[indptr[i], indptr[i+1])。如果没有末尾那个“哨兵值”,最后一行就永远不知道自己的结束位置在哪里。打个比方,一本手册每章开头都标注了起始页码,如果想计算每一章有多少页,手册末尾就必须再加一个“全书总页数”作为结束标记。因此 indptr 的长度恒为行数加 1,最后一个元素恒等于 nnz。

这个设计虽然多存了一个整数,却带来了极好的访问体验:想取第 i 行,只要一次数组下标读取就能定位到 ranges,而不用扫描全表。这种小代价换高可用性的设计,在存储领域非常典型。

2.3 快速提取某一行:区间式访问

继续用上面的例子,提取第 2 行(行号从 0 开始):

  • 起点 start = indptr[2] = 4,终点 end = indptr[3] = 7;
  • 该行非零值就是 values[4:7] = [5, 6, 7];
  • 该行列索引就是 indices[4:7] = [0, 1, 3]。

所以第 2 行的完整非零结构是:列 0 有值 5、列 1 有值 6、列 3 有值 7,和原矩阵完全对应。定位一行的时间复杂度是 O(1),遍历一行的开销与该行非零个数成正比,与矩阵总共有多少列没有任何关系。这就是 CSR 在按行计算场景下如此高效的根本原因。

2.4 与行主序稠密存储的关系

如果把 CSR 和稠密矩阵的行主序存储对照看,逻辑非常清晰。稠密行主序里,第 i 行第 j 列元素的内存偏移是 i×n + j;CSR 里,第 i 行的第 j 个非零元素的列位置是 indices[indptr[i] + j]。两者都在“按行组织”,区别在于 CSR 把全是零的那些格子直接跳过了。额外付出的代价是存储列索引和行指针这些元数据,换来的是只处理有效数据、大幅降低内存和计算量。

三个数组的语义弄明白之后,下一步就是动手实现。只有亲手写过编码和解码,对边界的理解才算真正牢固。

3. 从 COO 到 CSR 的手写实现:编解码与稀疏向量乘法

3.1 先准备好 COO 三元组

实际工程里,最自然的输入形态往往不是 CSR,而是 COO。COO 用三个等长数组分别记录每个非零元素的行号、列号和值。比如前面那个矩阵,COO 写成:

rows = [0, 0, 1, 1, 2, 2, 2] cols = [1, 3, 0, 2, 0, 1, 3] vals = [1, 2, 3, 4, 5, 6, 7]

COO 的好处是直观、构建成本极低,数据从外部读进来时几乎不需要预处理。但它每次按行取数都要扫描整个数组,索引存储也比较浪费。所以标准做法是拿 COO 当中间格式,再转成 CSR 来支撑后续计算。

3.2 先处理重复下标,再做排序

转换前有一个容易被忽略的预处理步骤:同一个位置可能出现多个值。比如从日志里统计用户对物品的行为,两条记录可能落在同一个 (row, col) 上。多数稀疏库的默认行为是求和,而不是覆盖。手写转换时,第一步应该按 (row, col) 分组聚合:

from collections import defaultdict acc = defaultdict(float) for r, c, v in zip(rows, cols, vals): acc[(r, c)] += v rows2 = [k[0] for k in acc] cols2 = [k[1] for k in acc] vals2 = [acc[k] for k in acc]

第二步是按行排序,同一行内按列索引升序排列。排序后 CSR 的区间访问才能保证正确性,后续如果想用二分查找快速定位某个列,也有基础:

order = sorted(range(len(rows2)), key=lambda i: (rows2[i], cols2[i])) rows3 = [rows2[i] for i in order] cols3 = [cols2[i] for i in order] vals3 = [vals2[i] for i in order]

如果输入数据本身已经是按行有序的,这一步可以直接跳过。我在实际处理图数据导出文件时经常遇到按源节点自然排序的数据,排序省下来之后整体转换速度又快了不少。

3.3 分组统计与前缀和:构造 indptr

构造 indptr 的核心不是直接数出每个起点,而是先统计每行非零元素个数,再做前缀和。代码逻辑如下:

def coo_to_csr(sorted_rows, sorted_cols, sorted_vals, num_rows): indptr = [0] * (num_rows + 1) # 第一遍:统计每行非零元素个数 for r in sorted_rows: indptr[r + 1] += 1 # 前缀和:把频数转化为行起始位置 for i in range(1, num_rows + 1): indptr[i] += indptr[i - 1] indices = [0] * len(sorted_vals) values = [0] * len(sorted_vals) cursor = indptr[:-1].copy() # 第二遍:按行游标填充列索引和值 for r, c, v in zip(sorted_rows, sorted_cols, sorted_vals): pos = cursor[r] indices[pos] = c values[pos] = v cursor[r] = pos + 1 return indptr, indices, values

这里的巧妙之处在于 indptr 既是前缀和数组,又在第二轮填充时通过 cursor 充当每行的“可写入位置”。cursor 初始值等于 indptr 前 m 个元素,每个元素写入后自增,就能保证数据按行依次落位。测试调用:

indptr, indices, values = coo_to_csr(rows3, cols3, vals3, num_rows=3) print(indptr) # [0, 2, 4, 7] print(indices) # [1, 3, 0, 2, 0, 1, 3] print(values) # [1, 2, 3, 4, 5, 6, 7]

输出和手动推导完全一致,说明三个数组的构造逻辑没有问题。

3.4 解码:从 CSR 恢复成稠密矩阵

用 CSR 反推原矩阵,是验证自己是否真正理解三个数组关系的最好方法。实现不复杂,内外两层循环按行扫描非零段,写回对应列位置:

def csr_to_dense(indptr, indices, values, num_rows, num_cols): mat = [[0] * num_cols for _ in range(num_rows)] for i in range(num_rows): for p in range(indptr[i], indptr[i + 1]): mat[i][indices[p]] = values[p] return mat

在开发自定义稀疏模块时,这个函数就是“还原镜”。当我怀疑编码逻辑写错了,或者某个局部改动破坏了数组一致性,都会先转回稠密矩阵打印出来对比。虽然时间复杂度不低,但作为调试断言工具,价值非常高。

3.5 稀疏矩阵向量乘法(SpMV)

CSR 最典型的计算是稀疏矩阵乘以稠密向量。因为第 i 行非零元素已经被 indptr 精确圈定,可以逐行累加:

def csr_spmv(indptr, indices, values, x): m = len(indptr) - 1 y = [0] * m for i in range(m): total = 0 for p in range(indptr[i], indptr[i + 1]): total += values[p] * x[indices[p]] y[i] = total return y

这个操作的时间复杂度是 O(nnz),而不是稠密矩阵乘法的 O(m×n)。假设一个 10 万×10 万的矩阵,每行只有 20 个非零元素,稠密乘法约需要 100 亿次浮点运算,CSR 版本只需要约 200 万次,差了整整五个数量级。这就是为什么工程中处理大规模稀疏线性代数问题时,几乎不会选择把矩阵展开成二维数组来算。

手写实现跑通之后,很多人会开始纠结一个实际选型问题:CSR 虽然常用,但也不是唯一的答案。不同的稀疏格式各有擅长的场景,选错格式有时候比不用稀疏存储更难受。

4. CSR 和 COO、CSC、DIA 怎么选:存储格式的权衡与选型依据

4.1 四种典型稀疏格式的结构差异

我整理了一张常用对比表,把四种典型格式的核心数组、访问特征和典型用途列在一起:

格式核心数组读一行读一列构建成本典型用途
COOrows + cols + valuesO(nnz) 扫描O(nnz) 扫描低数据导入、中间态
CSRindptr + indices + valuesO(行非零数)慢中图算法、按行稀疏计算
CSCindptr + indices + values慢O(列非零数)中按列压缩,转置视角
DIAoffsets + values依赖非零对角线位置同左低对角占优矩阵,迭代法

COO 的构建成本最低,因为不需要计算行指针,数据原样存入三个数组即可。DIA 把所有非零元素按对角线组织,结构简单,但只适合非零元素集中在对角线附近的矩阵。

4.2 为什么 CSR 是默认选项

绝大多数科学计算和机器学习框架把 CSR 当作默认稀疏格式,不是没有原因的。首要原因是行访问是很多算法的核心操作。图遍历、PageRank、稀疏矩阵向量乘都需要逐行取数据,CSR 天然支持这种访问模式。其次是按行压缩后,同一行非零元素在内存中连续存放,遍历时缓存命中率很高,这一点在千万级非零规模下对性能影响极大。CSR 的结构复杂度也适中:比 COO 更紧凑,又不像某些面向专门运算的格式那样需要复杂的预处理。

4.3 CSC 与 CSR 的对称关系

CSC 其实就是“按列压缩的 CSR”。如果你把一个矩阵转置后再用 CSR 存储,得到的就是原矩阵的 CSC 结构。这个关系意味着:需要频繁按列计算时,比如从特征矩阵里取某一列做统计、做列归一化,或者在某类共现矩阵算法中按 item 维度聚合,CSC 的访问性能和 CSR 按行一样都是 O(该列非零数)。反过来,用 CSR 做按列访问就非常痛苦,每次都要遍历所有行去检查 indices 里是否有目标列,性能退化严重。

4.4 DIA 的适用边界

DIA 格式把所有非零元素按对角线位置组织成二维数组,配合一个偏移量数组表示每条对角线的位置。对有限差分里的三对角矩阵这类结构规则的数据,DIA 存储紧凑且迭代计算极快。但它有个致命问题:偏离对角线的散点也会被强塞进某条对角线槽位,即使那个位置本来是零,也要占一个存储单元。图数据这种毫无规律的稀疏结构,用 DIA 几乎必然造成严重浪费,选型时要特别注意。

4.5 一句话选型建议

数据导入、反复构建矩阵、写验证 demo 阶段,先用 COO 图省事;常规稀疏计算、图算法、需要高效按行访问的,直接选 CSR;以列为单位做分析和特征提取,转成 CSC;只有当非零元素明显聚拢在对角线附近时,才优先考虑 DIA。格式之间通常也能低成本互转,所以也不用把一次选型看成一锤子买卖,工程上往往是先 COO 累积数据,再转 CSR 做正式计算。

格式选型只是第一步,真正到了生产环境,CSR 使用不当照样会引发内存和性能问题。下面几个坑,是我在实际项目中真实遇到过的。

5. 实操中容易踩的坑:从 scipy 构造到缓存行为

5.1 坑一:从稠密数组构造 CSR,内存直接翻倍

很多新手图省事,会用这样的方式转换:

import numpy as np from scipy.sparse import csr_matrix dense = np.zeros((10000, 10000)) dense[0, 0] = 1 A = csr_matrix(dense) # 不推荐

看起来只是把 numpy 矩阵转成 CSR,实际过程是:dense 本身已经是约 800 MB 的稠密数组,转换时系统又要分配 CSR 的存储,同一份数据以两种形态同时存在于内存里。规模一大,内存直接被打满。正确做法是从稀疏三元组直接构造:

from scipy.sparse import csr_matrix row = [0, 1, 2] col = [1, 3, 3] data = [1, 2, 7] A = csr_matrix((data, (row, col)), shape=(3, 4))

这种构造方式不会产生任何临时稠密数组,内存占用保持在对非零元素数量的水平。

5.2 坑二:重复坐标默认求和而不是覆盖

scipy 在构造 CSR 时,如果同一个 (row, col) 位置出现多次,会把它们的值累加起来。举个例子:

row = [0, 0] col = [2, 2] data = [1, 3] A = csr_matrix((data, (row, col)), shape=(1, 4)) print(A.toarray()) # [[0, 0, 4, 0]]

构建邻接矩阵时特别容易触发这个问题:两条记录都指向同一个目标节点,如果你期望的是覆盖而不是求和,就必须先做去重,只保留最后一条记录。我之前处理原始日志时就因为忽略了这一点,统计出来的权重比实际值大了一倍,排查了很久才发现是重复坐标被自动累加导致的。

5.3 坑三:频繁就地修改 CSR 结构引发电警告

scipy 的 CSR 支持形如A[0, 1] = 5的就地赋值,但每次增删非零元素都可能触发稀疏结构的重排。如果写了一个循环不断往 CSR 里塞新元素,运行时会抛出 SparseEfficiencyWarning,性能急剧下降。

注意:如果发现自己正在循环里执行A[i, j] = v,十有八九是用错了格式。正确做法是先用 COO 或 LIL(List of Lists)积累变更,等所有节点都更新完,再一次转换回 CSR。

我在处理动态图时最初就直接改 CSR,数据规模不大时没感觉,涨到百万边以后每插入一条边都要复制大段索引,整个任务从几秒变成了几分钟。改成“缓冲区收集 + 定时重建”后,性能才恢复正常。

5.4 缓存友好与访问方向:为什么按行遍历不是口号

CSR 把同一行的非零元素在内存中连续存放,遍历一行时 CPU 缓存命中率非常高。但如果用 CSR 做大量按列访问,就会变成一次次随机跳跃,缓存行基本都在空转。这个差异在小矩阵上完全看不出来,到了千万非零级别,顺序访问和随机访问可能相差一个数量级。所以我在写图算法时始终强调沿出边方向遍历,也就是顺着 CSR 的行方向走,而不是反复跨行查询某个列。理解了这个底层行为,写循环时自然会倾向于“按数据布局安排访问顺序”。

弄懂原理、写好实现、避开常见坑之后,还应该看看 CSR 在工程里到底扮演什么角色。很多所谓的高深系统,底层存储抽丝剥茧之后就是这样一套简单的数组结构。

6. CSR 在工程里的真实角色:图算法与稀疏特征

6.1 邻接矩阵的 CSR 本质上是紧凑邻接表

图论里的邻接矩阵如果直接存成二维数组,亿级顶点的图根本放不进内存。但转成 CSR 后,顶点 i 的邻居恰好就是indices[indptr[i]:indptr[i+1]],这本质上就是一张紧凑邻接表的连续内存版本。很多工业界的图计算引擎底层都采用 CSR 或它的变体存储图结构。BFS、Dijkstra、PageRank 这类算法的核心循环,几乎全都落在“枚举某个顶点的所有出边”这个操作上,换成 CSR 就是一个区间扫描,极快且内存占用极低。我自己在好几次项目里把字典结构的邻接表改成 CSR 之后,内存占用直接降了一个量级,原本要搬到大集群跑的任务,单机就能扛下来。

6.2 机器学习特征矩阵的按行存储需求

在 CTR 预估、文本分类这类任务里,原始特征经过 one-hot 或哈希编码后,特征维度动辄百万到千万,但单个样本的非零特征往往只有几十个。这种矩阵的关键在于按样本取行:训练时按 batch 抽取样本,推理时逐个样本做预测,都是行访问模式,CSR 恰好正中下怀。反过来,如果某个流程需要按特征维度统计覆盖率、计算特征间共现,选择 CSC 会更顺手。很多稀疏特征运算库的默认参数都直接面向 CSR,也是因为这个原因。

6.3 推荐系统里的用户物品评分矩阵

用户物品评分矩阵是另一类典型的超稀疏矩阵。数据采集阶段通常以三元组形式落盘,也就是天然的 COO;经过数据清洗、去重合并后,转成 CSR 或 CSC 进入模型训练。还有一点好处值得提:当系统需要为新用户增量追加新行时,COO 累积再转 CSR 的链路依然稳定,不会因为行数增长而推倒重来。这种“先 COO 再 CSR”的模式几乎是行业标配,理解了 CSR 自然就理解了这套链路为什么存在。

7. 一点个人体会

自己在实际项目里把图数据从字典结构改成 CSR 之后,内存占用直接降了一个量级,很多原本要扛到集群上跑的任务,单机就够了。学 CSR 最忌讳的是只看别人的代码和公式,三个数组的边界关系最好亲手推一遍。拿出一张有零有非零的矩阵,先手动写出 values、indices、indptr,再写程序验证。只要你把 indptr 理解成“每行的起始位置,末尾再加一个总的结束哨兵”,这一套东西就算真正吃透了。掌握了 CSR,后面再看 CSC、BCSR、坐标压缩之类的变体,基本都是同样的思路套来套去,没有太多新概念。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询