简介:这份资源是Reverse Cuthill-McKee(RCM)算法的C++实现代码,面向从事数值计算、稀疏矩阵处理与图论问题求解的开发者及学习者,用于解决矩阵带宽(bandwidth)过大导致存储与计算效率低下的问题。压缩包共8个文件,约105KB,以c与h源文件承载矩阵类定义、RCM主逻辑与测试程序,另附sh脚本便于编译运行、doc文档说明算法原理、txt输出示例展示重排结果,结构紧凑便于直接阅读与二次开发。已有465人学习下载,说明其在矩阵重整这一细分方向具有一定参考价值。代码完整呈现了起始节点选取、邻接关系遍历、已访问节点边删除与排列P生成等核心步骤,读者可据此理解低带宽矩阵在Gauss-Seidel、Jacobi迭代求解及最小生成树等问题中的加速作用,并掌握将RCM与现有数值程序结合的实用优化思路。
1. 从一份“只有标题”的源码说起:RCM 算法 C++ 实现能拿来干什么
第一次拿到这份资源时,我盯着标题看了半天——没有 README,没有依赖清单,只有“RCM 算法 C++ 源代码”这几个字。这种“裸源码”在工程圈其实很常见,作者把核心实现丢出来,剩下的靠你自己读。RCM 是 Reverse Cuthill-McKee 的缩写,做的是稀疏矩阵的带宽缩减:给定一个对称稀疏矩阵的邻接结构,重新排列节点编号,让非零元素尽量往对角线附近靠。别小看这个重排,有限元求解、电路仿真、图计算里,矩阵带宽直接决定缓存命中率和求解器 fill-in 的规模。这份代码适合两类人:一类是正在做稀疏线性代数、想把 RCM 接进自己求解流程的开发者;另一类是学生或刚转 C++ 数值方向、想找一个结构完整又不臃肿的算法实现来读的人。它不依赖大型框架,核心就是图遍历加队列,读起来门槛不高,但要把边界条件写对,血泪经验不少。
2. 算法骨架与工程结构:RCM 到底在图上做了什么
2.1 从 Cuthill-McKee 到 Reverse:一次 BFS 加一次翻转
RCM 的前身是 Cuthill-McKee 算法,本质是一次带排序的广度优先搜索。给定图的邻接表,选一个起始节点,按度从小到大把邻居压进队列,依次出队编号,得到一个排列。Reverse 这一步很关键:把 CM 得到的排列整体倒过来,带宽通常会更小。为什么?直观理解是 CM 从“边缘”往“中心”走,倒过来相当于从中心往外扩,边界节点被推到编号两端,中间区域的连接更紧凑。
代码里最容易被忽略的是“起始节点怎么选”。伪外围节点策略是常见做法:先随便找一个点做 BFS,取距离最远的点,再从那个点做一次 BFS 取更远的点,作为 RCM 的起点。这一步不做,遇到长条形网格时带宽会明显变差。我一般会在实现里留一个find_pseudo_peripheral函数,哪怕调用方不传起点也能兜底。
// 伪外围节点:两次 BFS 取最远点,降低起点选择对带宽的影响 int findPseudoPeripheral(const std::vector<std::vector<int>>& adj) { int n = adj.size(); auto bfsFar = [&](int start) { std::vector<int> dist(n, -1); std::queue<int> q; dist[start] = 0; q.push(start); int far = start; while (!q.empty()) { int u = q.front(); q.pop(); for (int v : adj[u]) { if (dist[v] == -1) { dist[v] = dist[u] + 1; if (dist[v] > dist[far]) far = v; // 记录最远点 q.push(v); } } } return far; }; int a = bfsFar(0); // 第一次:从任意点出发 return bfsFar(a); // 第二次:从最远点再出发 }这段代码里adj是邻接表,dist记录层数,far始终指向当前发现的最远节点。两次 BFS 的复杂度是 O(V+E),对稀疏矩阵完全可接受。参数上唯一要注意的是图必须连通;如果输入可能不连通,得在外面套一层循环,对每个未访问分量分别处理,否则dist里会残留 -1,后续排序会漏节点。
2.2 目录与模块划分:一个能编译的最小工程长什么样
这份源码没有构建系统,我按常见做法把它整理成三个部分:rcm.h放接口和类型别名,rcm.cpp放算法实现,main.cpp做命令行入口读 Matrix Market 格式。头文件里只暴露一个函数,签名大致是std::vector<int> rcmOrder(const CsrMatrix& mat),内部再拆成邻接表构建、伪外围点、CM 排序、反转四步。这样拆的好处是调用方不需要知道 BFS 细节,替换起点策略也不影响外部。
| 文件 | 职责 | 关键依赖 |
|---|---|---|
rcm.h | 声明CsrMatrix与rcmOrder | 无 |
rcm.cpp | 邻接表构建、BFS 排序、反转 | <queue><vector> |
main.cpp | 读入矩阵、调用、输出排列 | <fstream><sstream> |
CsrMatrix用三个数组rowPtr、colIdx、values表示,这是稀疏矩阵最通用的存储。构建邻接表时只取结构,不看数值,因为 RCM 只关心非零模式。注意对称矩阵要保证上下三角都进邻接表,否则度算出来偏小,排序质量会掉。
2.3 核心排序循环:度排序与队列的配合
CM 排序的主循环是整份代码的心脏。每次从队列取一个节点,把它的未访问邻居按度升序排好再入队。这里有个细节:度要用“当前剩余度”还是“原始度”?标准 CM 用原始度,实现简单且效果稳定;用动态度在某些图上更好,但会引入额外维护成本。我倾向原始度,代码可读性优先。
std::vector<int> cmOrder(const std::vector<std::vector<int>>& adj, int start) { int n = adj.size(); std::vector<int> order; order.reserve(n); std::vector<char> visited(n, 0); std::queue<int> q; q.push(start); visited[start] = 1; while (!q.empty()) { int u = q.front(); q.pop(); order.push_back(u); // 收集未访问邻居,按度升序 std::vector<int> nbrs; for (int v : adj[u]) if (!visited[v]) nbrs.push_back(v); std::sort(nbrs.begin(), nbrs.end(), [&](int a, int b){ return adj[a].size() < adj[b].size(); }); for (int v : nbrs) { if (!visited[v]) { visited[v] = 1; q.push(v); } } } return order; }visited用char而不是bool向量,是为了避免vector<bool>的位压缩带来的性能抖动,这在数值代码里算个小习惯。排序比较器捕获adj引用,按邻居数量升序,度小的先入队,这样编号靠前的节点连接更局部。循环结束后如果order.size() < n,说明图不连通,需要换起点继续,这一点在rcmOrder外层补。
3. 编译与跑通:从裸源码到可验证的排列输出
3.1 编译命令与最小测试矩阵
源码没有 CMake,我一般先写一个最朴素的编译命令验证能过,再补构建脚本。GCC 和 Clang 都试一遍,能暴露一些隐式类型转换问题。
g++ -std=c++17 -O2 -Wall -Wextra rcm.cpp main.cpp -o rcm_demo ./rcm_demo test.mtx-Wall -Wextra别省,稀疏矩阵代码里size_t和int混用很常见,警告能提前拦住越界隐患。测试矩阵建议先用手写的小规模:一个 5x5 的三对角矩阵,肉眼能验证排列是否把非零压到对角线附近。再拿一个 100x100 的二维网格拉普拉斯矩阵,看带宽从原来的约 100 降到 10 以内,基本就说明实现对了。
3.2 带宽计算与结果校验
跑通不等于跑对。RCM 的输出是一个排列perm,新矩阵的第 i 行对应原矩阵第perm[i]行。带宽定义为max |i - j|,其中(i,j)是新编号下的非零位置。写一个校验函数,把原矩阵按排列重排后算带宽,和理论下界对比。
// 按排列重排后计算带宽,用于验证 RCM 效果 int bandwidth(const CsrMatrix& mat, const std::vector<int>& perm) { std::vector<int> pos(mat.n); for (int i = 0; i < mat.n; ++i) pos[perm[i]] = i; // 原编号 -> 新位置 int bw = 0; for (int i = 0; i < mat.n; ++i) { for (int k = mat.rowPtr[i]; k < mat.rowPtr[i+1]; ++k) { int j = mat.colIdx[k]; bw = std::max(bw, std::abs(pos[i] - pos[j])); } } return bw; }pos数组做的是逆映射,这一步容易写反:perm[i]表示新编号 i 对应原节点,所以原节点perm[i]的新位置是 i。校验时拿原矩阵的每条边,映射到新位置算差值。如果带宽没降反升,八成是反转那步漏了,或者pos方向搞反了。
3.3 大矩阵下的内存与时间观察
小矩阵跑通后,拿一个几万节点的稀疏矩阵压一下。RCM 本身是 O(V+E) 加排序开销,瓶颈通常在邻接表的构建和std::sort的频繁调用。如果发现时间主要花在排序上,可以把邻居收集改成小根堆,或者对度做计数排序,因为度是整数且范围有限。内存方面,邻接表用vector<vector<int>>会有大量小分配,节点数上百万时建议换成 CSR 直接遍历,省掉中间结构。这些优化不必一开始就做,先保证正确,再按 profile 结果动刀。
4. 避坑与排查:RCM 实现里最容易翻车的几处
4.1 现象:带宽几乎没降,排列看起来“随机”
原因通常是起点选错。如果直接从节点 0 开始 BFS,遇到长条形或带状结构,CM 会从一端扫到另一端,反转后改善有限。解决方法是补上伪外围节点策略,两次 BFS 取最远点。另一个可能是邻接表只存了上三角,度算小了一半,排序顺序全乱。检查rowPtr遍历时是否把对称位置都加进去了。
4.2 现象:程序在某个矩阵上卡死或输出节点数不足
这是不连通图没处理。cmOrder只从一个起点出发,访问完所在分量就退出,order.size()小于 n。外层rcmOrder必须维护一个全局visited,对每个未访问节点重新调用cmOrder并把结果拼接。注意拼接后整体反转要在所有分量都排完之后做,不能每个分量单独反转,否则分量间的相对顺序会乱。
4.3 现象:std::sort比较器抛异常或结果不稳定
比较器里捕获了adj引用,如果adj在排序期间被修改就会出问题。RCM 场景下邻接表是只读的,一般不会触发,但多线程调用同一份adj时要注意。更隐蔽的是度相同的情况:std::sort不保证稳定,相同度的邻居顺序每次可能不同,导致排列不可复现。需要确定性输出时改用std::stable_sort,代价是少量额外内存。
4.4 现象:大矩阵下内存暴涨
vector<vector<int>>每个节点一个小 vector,节点数到百万级时分配器压力很大。解决方法是直接用 CSR 的rowPtr和colIdx做 BFS,邻居遍历就是一段连续区间,不需要额外邻接表。改造时注意 BFS 里收集邻居仍然要临时数组,但可以复用一个vector<int>并clear(),避免每轮分配。
5. 二次开发:把 RCM 接进自己的求解流程
5.1 接口改造:从返回排列到原地重排
实际项目里,调用方往往不想自己拿排列去重排矩阵,而是希望传进去一个 CSR,出来一个重排后的 CSR。可以在rcmOrder外面包一层permuteMatrix,按perm重建rowPtr、colIdx、values。重建时先算新行长度,做前缀和,再逐行填充。注意values要跟着列索引一起搬,别只搬结构。
// 按排列重建 CSR,供求解器直接使用 CsrMatrix permuteMatrix(const CsrMatrix& mat, const std::vector<int>& perm) { CsrMatrix out; out.n = mat.n; out.rowPtr.assign(mat.n + 1, 0); std::vector<int> pos(mat.n); for (int i = 0; i < mat.n; ++i) pos[perm[i]] = i; for (int i = 0; i < mat.n; ++i) out.rowPtr[i+1] = out.rowPtr[i] + (mat.rowPtr[i+1] - mat.rowPtr[i]); out.colIdx.resize(out.rowPtr.back()); out.values.resize(out.rowPtr.back()); std::vector<int> cursor = out.rowPtr; for (int i = 0; i < mat.n; ++i) { int ni = pos[i]; // 原行 i 的新行号 for (int k = mat.rowPtr[i]; k < mat.rowPtr[i+1]; ++k) { int nj = pos[mat.colIdx[k]]; int dst = cursor[ni]++; out.colIdx[dst] = nj; out.values[dst] = mat.values[k]; } } return out; }cursor是每行写入位置的游标,复制自rowPtr的前 n 项。pos把原编号映射到新编号,行列都要映射。这段代码假设输入是对称的,非对称矩阵重排后结构仍然合法,但 RCM 的带宽意义会打折扣,用之前确认一下矩阵性质。
5.2 验证策略:带宽、fill-in 与求解时间三件套
改完接口不能只看带宽。我一般做三层验证:第一层算重排前后带宽,确认下降;第二层用同一个稀疏 LU 或 Cholesky 分解,比较 fill-in 的非零数;第三层跑实际求解,看总时间。带宽降了但 fill-in 没降的情况存在,尤其是矩阵结构特殊时。三层都过再合入,能省掉后面排查“为什么求解反而变慢”的后悔药。
| 验证项 | 指标 | 预期 |
|---|---|---|
| 带宽 | max abs(pos[i]-pos[j]) | 明显下降 |
| fill-in | 分解后非零数 | 不增加或下降 |
| 求解时间 | 端到端耗时 | 持平或下降 |
5.3 一个具体技巧:分块 RCM 处理超大规模矩阵
矩阵大到单机内存吃紧时,整图 BFS 不现实。可以按几何或图分区先切块,块内做 RCM,块间用嵌套剖分排序。实现上先跑一次分区,得到块编号,再对每块内部调用rcmOrder,最后按块顺序拼接。这样带宽是块内局部最优加块间粗排,效果不如全局 RCM,但内存可控。分区那步可以复用现有图划分库,也可以自己写一个简单的 BFS 分层切分。
从那以后我每次接一个陌生算法源码,都强制先写一个最小可验证用例再谈优化——RCM 这种“看起来简单、边界一堆”的代码尤其如此,先让带宽数字说话,再动结构。希望帮到你。
本文还有配套的精品资源,点击获取