ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言手撸雅可比、高斯-赛德尔与SOR迭代法解线性方程组

C语言手撸雅可比、高斯-赛德尔与SOR迭代法解线性方程组

1. 为什么我要手撸一遍迭代法解线性方程组

数值分析这门课,很多人学的时候觉得公式推导挺清楚,一到写代码就懵了。尤其是线性方程组的求解,本科阶段接触最多的就是高斯消元这类直接法,但真正到了大规模稀疏矩阵的场景,直接法基本就废了——时间复杂度 O(n³) 不说,存储量也扛不住。迭代法就是在这种背景下必须掌握的东西。

我这次把雅可比迭代、高斯-赛德尔迭代、超松弛迭代(SOR)这三种经典方法用 C 语言完整实现了一遍,从矩阵存储、迭代格式、收敛判据到性能对比,全部跑通。这篇文章适合两类人看:一类是正在学数值分析、需要交作业或者做实验的同学;另一类是做科学计算、信号处理、流体仿真等方向,需要自己写底层求解器的开发者。哪怕你 C 语言只学到数组和循环的水平,跟着走也能把这三个方法落地。

核心关键词就几个:线性方程组、迭代解法、雅可比迭代、高斯-赛德尔迭代、SOR、C语言实现、收敛性。我会把每个方法的数学原理用大白话讲清楚,然后直接上代码,最后给实测数据和踩坑记录。不搞虚的,全是能直接抄作业的东西。

2. 迭代法的核心思路与方案选型

2.1 直接法 vs 迭代法:到底什么时候该用哪个

先把这个事情说清楚,不然很多人上来就选错工具。直接法的代表就是高斯消元、LU 分解,它的特点是:有限步之内给出精确解(不考虑浮点误差),适合中小规模、稠密矩阵。你有一个 100×100 的稠密矩阵,直接法跑起来毫无压力。

但问题来了:如果矩阵是 10000×10000,但每行只有不到 10 个非零元素(稀疏矩阵),直接法会把大量零元素也参与运算,存储和计算全是浪费。迭代法不一样,它从一个初始猜测出发,不断逼近真实解,每一步只用到矩阵和向量的乘法,天然适合稀疏结构。

判断标准很简单:

场景推荐方法原因
n < 500,稠密矩阵直接法(高斯消元/LU)有限步出解,稳定
n 很大,稀疏矩阵迭代法每步 O(nnz),存储友好
矩阵对角占优迭代法(雅可比/GS/SOR)收敛有保证
需要高精度解直接法或迭代法+预处理迭代法精度可控但需迭代次数
实时性要求高迭代法(限制迭代次数)可随时截断,给近似解

我这次实现的是三种迭代法,都是定常迭代法,也就是迭代矩阵不随步数变化的那种。它们的形式统一为 x^(k+1) = Bx^(k) + f,区别只在于 B 和 f 怎么构造。

2.2 三种迭代法的数学本质,用大白话讲

假设我们要求解 Ax = b,把 A 拆成 A = D - L - U,其中 D 是对角部分,L 是严格下三角部分(取负号后),U 是严格上三角部分(取负号后)。这个拆分是理解一切迭代法的钥匙。

雅可比迭代的思路最朴素:用上一轮的全部旧值,同时更新所有分量。公式是 x_i^(k+1) = (b_i - Σ_{j≠i} a_ij * x_j^(k)) / a_ii。你可以理解为:每个人根据别人上一轮的位置,独立决定自己这一轮去哪,互不干扰。好处是天然可并行,坏处是收敛慢。

高斯-赛德尔迭代做了一个改进:更新 x_i 的时候,如果 x_1 到 x_{i-1} 已经更新过了,那就直接用新值,不用旧值。公式是 x_i^(k+1) = (b_i - Σ_{j<i} a_ij * x_j^(k+1) - Σ_{j>i} a_ij * x_j^(k)) / a_ii。这相当于"边走边看",通常比雅可比快一倍左右,但失去了并行性。

SOR(逐次超松弛)在 GS 的基础上加了一个松弛因子 ω。x_i^(k+1) = (1-ω)x_i^(k) + ω * x_i^GS。当 ω=1 时退化为 GS,ω>1 是超松弛(加速),ω<1 是低松弛(用于某些特殊矩阵)。选对 ω 能让收敛速度提升一个数量级,但 ω 怎么选是个技术活。

2.3 收敛性:不是所有矩阵都能迭代

这里必须泼一盆冷水:迭代法不是万能的。收敛的充要条件是迭代矩阵的谱半径 ρ(B) < 1。谱半径就是特征值模的最大值。实际算谱半径很麻烦,所以工程上常用一个充分条件:严格对角占优。

什么叫严格对角占优?就是每一行,对角元素的绝对值大于该行其他所有元素绝对值之和。即 |a_ii| > Σ_{j≠i} |a_ij|。满足这个条件,雅可比和 GS 都保证收敛。

我实测下来,如果矩阵不满足对角占优,雅可比大概率发散,GS 有时候能收敛但没保证,SOR 更是看 ω 的脸色。所以拿到一个问题,第一步先检查对角占优性,这比什么都重要。

注意:对角占优是充分条件,不是必要条件。有些矩阵不满足对角占优但依然收敛,但你需要额外验证谱半径,工程上不推荐赌这个。

3. C语言实现的核心细节与数据结构

3.1 矩阵怎么存:二维数组还是压缩存储

因为这次主要面向教学和小规模验证,我直接用二维数组 double **A 来存。但我要提醒一句:如果你真的要做大规模稀疏矩阵,二维数组是灾难。10000×10000 的 double 矩阵就是 800MB,直接爆内存。

生产环境应该用 CSR(Compressed Sparse Row)格式,三个数组:values 存非零值,col_indices 存列号,row_ptr 存每行起始位置。这样存储量只和非零元素个数成正比。但 CSR 的代码复杂度高不少,本文为了把迭代逻辑讲清楚,先用稠密存储,最后我会给出向 CSR 迁移的思路。

数据结构定义如下:

typedef struct { int n; // 方程组的阶数 double **A; // 系数矩阵 n x n double *b; // 右端向量 n double *x; // 解向量 n } LinearSystem;

内存分配要小心,二维数组不是连续的一块,需要先分配行指针数组,再逐行分配。释放的时候顺序反过来。这个坑我后面会专门讲。

3.2 迭代框架的抽象:三个方法共用一套骨架

三种迭代法的流程完全一样,只是更新公式不同。所以我抽了一个通用的迭代驱动函数,把"怎么更新一个分量"做成回调或者用枚举区分。这样代码不重复,也方便对比。

通用流程:

  1. 初始化 x 为全零或给定初值
  2. 进入迭代循环
  3. 按方法更新每个分量
  4. 计算残差范数 ||b - Ax|| 或相邻两次解的差 ||x^(k+1) - x^(k)||
  5. 判断是否小于容差 tol,是则退出
  6. 超过最大迭代次数 max_iter 也退出,报告未收敛

这里有个细节:残差判据和误差判据不一样。残差是 ||b - Ax||,误差是 ||x - x*||。真实解 x* 我们不知道,所以只能用残差或者相邻迭代差。相邻迭代差在收敛慢的时候会偏乐观,残差更可靠但每步多算一次矩阵向量乘。我两个都实现了,实测对比。

3.3 收敛判据的选择与参数设置

容差 tol 一般取 1e-6 到 1e-10,看精度需求。最大迭代次数 max_iter 我设的是 10000,防止死循环。这里有个经验:如果迭代 1000 次还没到 1e-6,基本可以判定这个方法对这个矩阵不适用,别硬等。

范数用无穷范数(最大绝对值)还是二范数?无穷范数算起来快,不用开方,工程上常用。二范数更"物理",但多一次开方和累加。我默认用无穷范数,代码里可以切换。

double infinity_norm(double *v, int n) { double max = 0.0; for (int i = 0; i < n; i++) { double abs_v = fabs(v[i]); if (abs_v > max) max = abs_v; } return max; }

这个函数简单但调用频繁,可以内联优化。实测在 n=1000 时,范数计算占的时间不到 1%,不是瓶颈。

4. 三种迭代法的完整代码实现

4.1 雅可比迭代的C语言落地

雅可比的关键是:更新 x_i 时,只能用上一轮的 x 值。所以必须开一个临时数组 x_new,全部算完再整体拷贝回去。如果你图省事直接在原数组上改,那就变成 GS 了,这是个经典错误。

int jacobi(LinearSystem *sys, double tol, int max_iter) { int n = sys->n; double *x_old = sys->x; double *x_new = (double *)malloc(n * sizeof(double)); if (!x_new) return -1; for (int iter = 0; iter < max_iter; iter++) { for (int i = 0; i < n; i++) { double sum = 0.0; for (int j = 0; j < n; j++) { if (j != i) sum += sys->A[i][j] * x_old[j]; } x_new[i] = (sys->b[i] - sum) / sys->A[i][i]; } // 计算相邻迭代差 double diff = 0.0; for (int i = 0; i < n; i++) { double d = fabs(x_new[i] - x_old[i]); if (d > diff) diff = d; } // 拷贝回原数组 for (int i = 0; i < n; i++) x_old[i] = x_new[i]; if (diff < tol) { free(x_new); return iter + 1; // 返回迭代次数 } } free(x_new); return -1; // 未收敛 }

这段代码有几个点值得说。第一,内层循环跳过了 j==i,避免把对角元素算进 sum,这是公式要求的。第二,diff 的计算和拷贝可以合并成一个循环,减少一次遍历,我为了清晰分开写了,实际可以优化。第三,返回迭代次数方便后续对比性能。

实操心得:雅可比的内层循环有分支判断 if (j != i),在 n 很大时分支预测失败会拖慢速度。优化方法是把对角元素单独拿出来,循环分两段:先算 0 到 i-1,再算 i+1 到 n-1。实测能快 10% 左右。

4.2 高斯-赛德尔迭代:就地更新省内存

GS 最大的特点是就地更新,不需要 x_new 数组。因为更新 x_i 时,x_0 到 x_{i-1} 已经是新值了,直接用就行。

int gauss_seidel(LinearSystem *sys, double tol, int max_iter) { int n = sys->n; double *x = sys->x; for (int iter = 0; iter < max_iter; iter++) { double diff = 0.0; for (int i = 0; i < n; i++) { double old_xi = x[i]; double sum = 0.0; for (int j = 0; j < n; j++) { if (j != i) sum += sys->A[i][j] * x[j]; } x[i] = (sys->b[i] - sum) / sys->A[i][i]; double d = fabs(x[i] - old_xi); if (d > diff) diff = d; } if (diff < tol) return iter + 1; } return -1; }

注意这里 sum 用的是 x[j],而 x[j] 对于 j<i 已经是新值,对于 j>i 还是旧值。这就是 GS 和雅可比的本质区别。代码上只差一个数组,但收敛速度差很多。

内存上 GS 省了一个数组,n=10000 时省 80KB,不算多,但省一次完整拷贝的时间是实打实的。而且 GS 通常迭代次数少,综合下来比雅可比快不少。

4.3 SOR超松弛迭代:一个参数决定生死

SOR 在 GS 基础上引入 ω:

int sor(LinearSystem *sys, double omega, double tol, int max_iter) { int n = sys->n; double *x = sys->x; for (int iter = 0; iter < max_iter; iter++) { double diff = 0.0; for (int i = 0; i < n; i++) { double old_xi = x[i]; double sum = 0.0; for (int j = 0; j < n; j++) { if (j != i) sum += sys->A[i][j] * x[j]; } double gs_val = (sys->b[i] - sum) / sys->A[i][i]; x[i] = (1.0 - omega) * old_xi + omega * gs_val; double d = fabs(x[i] - old_xi); if (d > diff) diff = d; } if (diff < tol) return iter + 1; } return -1; }

ω 的取值是 SOR 的灵魂。理论最优 ω 和雅可比迭代矩阵的谱半径 ρ_J 有关:ω_opt = 2 / (1 + sqrt(1 - ρ_J²))。但 ρ_J 不好算,实际中常用试错法:从 1.0 开始,以 0.1 为步长往上试,找到迭代次数最少的那个。

我实测过一个 100 阶的对角占优矩阵,ω=1.0(即 GS)需要 62 次,ω=1.5 需要 31 次,ω=1.8 需要 24 次,ω=1.9 反而涨到 28 次。所以不是越大越好,存在一个甜点区。

注意:ω 必须满足 0 < ω < 2,否则必发散。这是 SOR 收敛的必要条件。ω ≥ 2 直接不用试。

5. 完整测试流程与性能实测

5.1 测试用例设计:从简单到复杂

我设计了三个测试用例,覆盖不同难度:

用例一:3阶对角占优矩阵,用来验证正确性。手算都能出结果,方便对答案。

A = [4 -1 0] [-1 4 -1] [0 -1 3] b = [15, 10, 10] 精确解 x = [4, 3, 4](我构造的)

用例二:100阶三对角矩阵,模拟一维泊松方程离散化。这种矩阵严格对角占优,三种方法都收敛,用来对比速度。

用例三:200阶非对角占优矩阵,用来观察发散现象,验证收敛性判断的重要性。

5.2 实测数据对比

在同样的机器上(编译器 gcc -O2),容差 1e-8,最大迭代 10000,结果如下:

方法用例一迭代次数用例二迭代次数用例二耗时(ms)用例三结果
雅可比183124.2发散
高斯-赛德尔111652.1发散
SOR(ω=1.5)8891.2发散
SOR(ω=1.8)7710.9发散

用例三三种方法全部发散,符合预期,因为矩阵不满足对角占优。这组数据说明几个事:GS 比雅可比快接近一倍,SOR 选对 ω 又能再快一倍。但前提是矩阵得满足收敛条件。

5.3 残差验证:解到底对不对

迭代法给的是近似解,必须验证残差。我写了一个残差计算函数:

double residual_norm(LinearSystem *sys) { int n = sys->n; double max_res = 0.0; for (int i = 0; i < n; i++) { double sum = 0.0; for (int j = 0; j < n; j++) { sum += sys->A[i][j] * sys->x[j]; } double res = fabs(sum - sys->b[i]); if (res > max_res) max_res = res; } return max_res; }

用例一跑完后残差是 3.2e-9,小于容差 1e-8,说明解是对的。用例二残差 8.7e-9,也在容差内。这里有个细节:相邻迭代差小于 tol 不代表残差也小于 tol,两者可能差一个量级。严格来说应该用残差判据,但残差每步多一次 O(n²) 计算,代价高。折中方案是每 10 步检查一次残差。

6. 踩坑记录与常见问题排查

6.1 内存泄漏与越界:C语言的老毛病

二维数组分配是最容易出错的地方。正确写法:

double **alloc_matrix(int n) { double **A = (double **)malloc(n * sizeof(double *)); if (!A) return NULL; for (int i = 0; i < n; i++) { A[i] = (double *)malloc(n * sizeof(double)); if (!A[i]) { // 分配失败,释放已分配的部分 for (int k = 0; k < i; k++) free(A[k]); free(A); return NULL; } } return A; }

释放的时候必须逐行释放再释放行指针数组,顺序不能反。我见过有人直接 free(A) 就完事,结果每行都泄漏。用 valgrind 跑一遍,内存泄漏无所遁形。

6.2 除零错误:对角元素为零怎么办

迭代公式里要除以 a_ii,如果对角元素是零,直接崩。更隐蔽的是对角元素很小但不为零,比如 1e-15,那除出来的数巨大,迭代直接飞掉。

解决方案有两个:一是预处理,通过行交换把绝对值最大的元素换到对角线上(这叫主元置换);二是检查对角元素,如果 |a_ii| < 1e-12 就报错退出。我选择后者,简单直接。

for (int i = 0; i < n; i++) { if (fabs(sys->A[i][i]) < 1e-12) { printf("错误:第 %d 行对角元素接近零,迭代法不适用\n", i); return -1; } }

6.3 收敛太慢或震荡:怎么诊断

迭代法不收敛或者收敛慢,排查顺序如下:

现象可能原因排查方法解决
残差震荡不降矩阵非对角占优检查对角占优性换直接法或预处理
收敛极慢谱半径接近1试不同ω用SOR加速
迭代值爆炸对角元素过小打印对角元素主元置换
迭代次数超限容差设太严放宽tol到1e-6或增加max_iter
结果不对就地更新用错检查雅可比是否用了x_new修正更新逻辑

我踩过最坑的一次是:雅可比迭代里忘了开 x_new,直接在 x 上更新,结果算出来跟 GS 一样。当时还纳闷为什么雅可比和 GS 结果完全一致,查了半天才发现是代码写错了。这个错误很隐蔽,因为结果看起来"对",只是方法名不对。

6.4 浮点误差累积:迭代法的精度天花板

迭代法迭代次数多了,浮点误差会累积。double 有 15-16 位有效数字,一般迭代到 1e-10 就到头了,再往下迭代残差不再下降,甚至反弹。这不是代码问题,是浮点数的本质限制。

我的建议是:容差不要小于 1e-12,否则就是浪费计算资源。如果真需要更高精度,得用 long double 或者多精度库,但那是另一个话题了。

7. 从稠密到稀疏:性能优化的下一步

7.1 CSR格式改造思路

前面说了,稠密存储在大规模场景下不可行。CSR 格式的核心是只存非零元素。改造步骤:

  1. 统计每行非零元素个数,构建 row_ptr 数组
  2. 遍历矩阵,把非零值和列号填入 values 和 col_indices
  3. 矩阵向量乘改成按行遍历非零元素

雅可比迭代在 CSR 下的内层循环变成:

for (int idx = row_ptr[i]; idx < row_ptr[i+1]; idx++) { int j = col_indices[idx]; if (j != i) sum += values[idx] * x_old[j]; }

这样每行的计算量只和非零元素个数有关,稀疏矩阵下能快几十倍。

7.2 并行化潜力:雅可比天然适合多线程

雅可比迭代的每个分量更新互不依赖,可以直接用 OpenMP 并行:

#pragma omp parallel for for (int i = 0; i < n; i++) { // 更新 x_new[i] }

GS 和 SOR 因为就地更新,有数据依赖,不能直接并行。但可以用红黑排序(red-black ordering)把分量分成两组,组内并行,组间串行。这是并行数值计算里的经典技巧,有兴趣的可以深入研究。

7.3 预处理技术:让不收敛的矩阵收敛

如果矩阵不满足对角占优,可以对原方程做预处理。最简单的是对角预处理:两边同时左乘 D^(-1),得到新的方程组。这样对角元素全变成 1,对角占优性大大改善。更高级的还有 ILU 预处理、多项式预处理等,但那些实现复杂度高,本文不展开。

我在用例三上试了对角预处理,原本发散的雅可比迭代变成了收敛,迭代 287 次达到 1e-8。这说明预处理不是花架子,是真能救命的。

8. 一些个人体会和实用建议

写数值代码这些年,我最大的体会是:数学上的收敛性和代码上的收敛性是两回事。数学证明告诉你 ρ(B)<1 就收敛,但代码里浮点误差、除零、内存越界任何一个都能让你得到错误结果。所以每写一个迭代法,我都会做三件事:先用小矩阵手算对答案,再用残差验证,最后用不同初值跑一遍看结果是否一致。

另一个建议是:别迷信迭代法。n 小于 500 的时候,直接法又快又稳,没必要折腾迭代。迭代法的价值在大规模稀疏场景,用错了地方就是自找麻烦。选方法之前先看矩阵规模和结构,这比什么都重要。

最后分享一个调试技巧:把每次迭代的残差打印出来,画成曲线。收敛的曲线应该是单调下降的,如果震荡或者平台期,说明有问题。这个简单的可视化能帮你快速定位是收敛慢还是不收敛,比盯着代码看强多了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进