做数值计算的人,十个里有八个都写过求逆矩阵的代码。提到“求逆”,第一反应多半是高斯消元,或者干脆掏出高数课上的伴随矩阵公式——但这两种思路,放到“对称正定(SPD)矩阵”这个特定场景下,都属于吃力不讨好。SPD矩阵在最小二乘、卡尔曼滤波、高斯过程、有限元计算里到处都是,它的求逆其实有公认的“最优解”:Cholesky分解。
这篇文章就从数学原理讲到C++源码,把SPD矩阵求逆这件事拆开揉碎。你会看到为什么Cholesky比高斯消元快,三角矩阵求逆的递推公式是怎么来的,以及一套不依赖任何第三方库、可以直接抄走的完整代码。看完以后,你既能跑起来这套源码,也能跟别人讲明白它每一步为什么这么写。
1. SPD矩阵求逆:为什么绕不开Cholesky分解
1.1 SPD矩阵到底长什么样,为什么值得专门处理
对称正定矩阵,英文叫Symmetric Positive Definite,简写SPD。它满足两个条件:第一,对称,A等于A的转置;第二,正定,对任意非零向量x,都有x的转置乘A乘x大于0。
单独看这两个条件有点抽象,换个说法。对称保证了矩阵的特征向量正交,正定保证了所有特征值严格大于0。你可以把它想象成一个“能量度量”:每一个非零方向都被它撑出一个正的能量,没有任何方向是“塌陷”的。协方差矩阵、Gram矩阵、刚度矩阵,还有A等于M的转置乘M再加上一个正数倍单位阵这种结构,都是典型的SPD矩阵。
正因为特征值全为正,SPD矩阵在数值上非常“温和”。它不需要高斯消元里的选主元操作,也不需要额外的行交换策略。这个性质直接给了我们一个巨大的便利:可以用Cholesky分解,把矩阵拆成一个下三角矩阵乘它自己的转置。
实际判断一个矩阵是不是SPD,最可靠的方式就是直接尝试Cholesky分解。分解成功,说明数值上是正定的;分解失败,说明至少有一个特征值在数值意义下小于等于0。这比先用特征值分解再一个个判断特征值要快得多,也稳定得多。
1.2 求逆的典型场景:从最小二乘到高斯过程
为什么要专门写一个“求SPD逆矩阵”的算法?因为用到它的地方太多了。
最小二乘的正规方程里,要求A的转置乘A的逆,而A的转置乘A天然对称半正定,加上正则化项后就是SPD。卡尔曼滤波的误差协方差矩阵更新,每一步几乎都在跟P矩阵求逆打交道。高斯过程回归里的核矩阵,也是SPD,算预测方差时必须求它的逆。还有统计学里的Fisher信息矩阵、弹性力学里的刚度矩阵,本质上都在做同一件事。
不过这里要插一句。很多场景下你做“求逆”其实只是为了解线性方程组Ax等于b。如果只是解方程,不要真的先去求逆再乘向量,正确做法是直接用Cholesky分解之后带回代求解,又快又省内存。只有当你确实需要拿到逆矩阵本身——比如要显式输出协方差矩阵、要做误差传播分析、要对多个右侧向量反复使用——才值得调用本文这套求逆流程。
1.3 为什么不建议用伴随矩阵法或普通高斯消元
先说伴随矩阵法。它的公式C的伴随矩阵除以行列式,写起来清爽,但计算量是阶乘级别。n等于100的时候就足以让任何计算机崩溃,属于只能活在课本里的方法。
再说普通高斯消元。对增广矩阵A拼单位阵做消元,复杂度大约是2n的三次方量级。它不利用A对称正定这个特性,大量计算都浪费在了本来可以省掉的元素上。还有一种常见做法是用LU分解,但LU分解也没利用对称性,复杂度同样是Cholesky的两倍左右。
Cholesky分解聪明在哪?它只计算下三角部分,直接省掉一半浮点运算,而且不选主元也能保证数值稳定。对SPD矩阵来说,这就是理论上的最优路径。
2. 算法核心:三步拆解Cholesky求逆
2.1 第一步:Cholesky分解把A拆成L乘L转置
Cholesky分解的目标是找到一个下三角矩阵L,使得A等于L乘L的转置。下三角意味着L矩阵对角线以上的元素全为0,对角线和对角线以下有值。为什么这样可行?因为A有对称性,任何对称正定矩阵都能这样拆。
具体公式可以这样理解。比较A等于L乘L转置展开后的元素,可以得到两个递推式子。
先算第j个对角线元素:L的第j行第j列等于A的第j行第j列减去前面j行贡献的平方和,再开根号。这就是L对角元的计算公式。
再算下三角中第i行第j列的元素:L的第i行第j列等于A的第i行第j列减去前面k从0到j减1的L第i行第k列乘L第j行第k列的累计和,再除以L的第j行第j列。这个公式是Cholesky分解的核心,代码里就是一层循环套一层循环。
写代码时,通常把L直接存在原矩阵A的下三角位置,边算边覆盖。不用担心A上三角的数据被破坏,因为后续计算只需要下三角部分。
2.2 第二步:下三角矩阵求逆的递推公式
L求出来以后,下一步是对下三角矩阵L求逆。这里有个非常好的性质:下三角矩阵的逆,仍然是下三角矩阵。这意味着我们只需要算出一半元素,另一半天然为0。
推导要用到L乘Linv等于单位阵这个事实。只看第j列,L的第i行和Linv的第j列做内积,结果应该等于单位阵里的对应项:当i不等于j时为0,等于j时为1。把这个条件逐行写开,就能整理出一个递推公式。
先算对角线。Linv的第j行第j列等于1除以L的第j行第j列。因为单位阵对角线上是1,L的对角线和Linv的相应行相乘,唯一留下的就是对角线。
再算对角线以下的元素。Linv的第i行第j列等于负的L第i行与Linv第j列的内积,再除以L的第i行第i列。这个公式是所有三角矩阵求逆的通用套路。实现时要注意循环顺序:固定列j,从上往下逐行推进,因为后面的值依赖前面已经算出来的值。
2.3 第三步:用Linv还原对称逆矩阵
拿到Linv,也就是L的逆,怎么得到A的逆?线性代数公式说得很明白:A逆等于L逆的转置乘L逆,也就是Linv转置乘Linv。
问题是怎么把这个乘法算得优雅。Linv是下三角,Linv转置是上三角,两者相乘结果一定是实对称矩阵。既然结果是实对称的,我们就只需要计算它的下三角部分,然后镜像填充到上三角,直接省掉一半乘法。
具体到代码里,Ainv第i行第j列的元素,等于Linv第i列和第j列的内积。因为Linv是下三角,所以当k小于i时Linv第k行第i列一定是0,内积的求和范围可以安全地从k等于i开始,到n减1结束。这个小优化虽然只是少算几个0,但对大矩阵来说省下的时间确实可感。
2.4 浮点运算量对比:选择Cholesky的理由
把三步加起来算一笔账。Cholesky分解的部分大约需要n的三次方除以3次浮点运算。下三角求逆同样约n的三次方除以3。最后的对称矩阵乘法利用对称性后,也是约n的三次方除以3。三步合计约n的三次方量级。
普通高斯消元求逆通常要约2n的三次方,Cholesky路线大约是它的一半。而且每一步都只操作下三角或对称存储,内存占用也几乎减半。对于一个5000乘5000的矩阵,这个差距意味着几秒和十几秒的区别,也意味着能不能在某台内存受限的机器上跑起来。
稳定性方面,Cholesky分解不选主元,这是它的优势。SPD矩阵的特征值全为正,数值上天然稳定。只要矩阵不是病态到离谱,这套算法在双精度下能把残差压到10的负15次方这个量级。
3. 完整C++实现与逐段解析
3.1 数据结构与函数接口设计
写C++数值代码,第一个要决定的就是矩阵怎么存。我推荐用一维数组,也就是std::vector,按行主序存。A的第i行第j列放到下标i乘n加j。
为什么不用二维数组?vector套vector虽然写起来直观,但每一行是独立分配的内存块,数据在内存里不连续,矩阵乘法时缓存命中率很惨。一维数组一块连续内存,既能传给BLAS这类底层库,也方便自己调优循环访问顺序。
函数接口我设计成下面这样。
bool invertSPD(const std::vector<double>& A, std::vector<double>& Ainv, int n, double tol = 1e-12);输入是原始矩阵A,输出是Ainv。函数返回true表示分解成功,返回false表示矩阵不是数值意义上的SPD。不用抛异常,因为数值库的调用方往往希望自己控制错误处理流程,返回布尔值配合errno或者日志都更灵活。
tol这个参数是容差。理论上对角元素只要大于0就能继续,但数值计算中可能会出现一个接近0的正数,再往下算会严重放大误差。设置一个相对容差,提前终止,比硬算到底好得多。
3.2 Cholesky分解的实现细节
先看第一段核心代码,它完成对A的Cholesky分解,结果存在L里。
// 用行主序存储矩阵,L 初始化为 n*n 的零矩阵 std::vector<double> L(n * n, 0.0); // 只复制 A 的下三角和对角线到 L,上三角保持 0 for (int j = 0; j < n; ++j) { for (int i = j; i < n; ++i) { L[i * n + j] = A[i * n + j]; } } // 外层循环按列推进 for (int j = 0; j < n; ++j) { // 计算对角元素:L[j][j]=sqrt(A[j][j]-sum(L[j][k]^2)) double diag = L[j * n + j]; for (int k = 0; k < j; ++k) { diag -= L[j * n + k] * L[j * n + k]; } if (diag <= tol) { return false; // 数值上非正定 } L[j * n + j] = std::sqrt(diag); // 计算第 j 列下半部分 for (int i = j + 1; i < n; ++i) { double val = L[i * n + j]; for (int k = 0; k < j; ++k) { val -= L[i * n + k] * L[j * n + k]; } L[i * n + j] = val / L[j * n + j]; } }为什么先复制下三角?为了不破坏外部传进来的A。A作为输入参数,调用方可能后面还要用,所以必须留一份副本。L提前初始化为全零,上三角部分永远是0,后续求逆时就不需要担心读到脏数据。我在实际项目中遇到过不少次因为漏清零导致的诡异bug,提前把上三角钉死干净会省心很多。
外层循环按列j推进,是Cholesky的经典写法。算第j列时,前面0到j减1列已经全部完成。计算对角线时从L的第j行第j列原始值出发,减掉同一行前面k位置的平方和。之后算下半部分时,L第i行第k列以及L第j行第k列都已经在前面的列更新完了,可以放心使用。
3.3 下三角矩阵求逆的实现细节
下一步对L求逆,存储到Linv。仍然用一维数组,下三角存放非零元素,其他位置保持0。
std::vector<double> Linv(n * n, 0.0); for (int j = 0; j < n; ++j) { // 对角线 Linv[j * n + j] = 1.0 / L[j * n + j]; // 第 j 列对角线以下 for (int i = j + 1; i < n; ++i) { double sum = 0.0; for (int k = j; k < i; ++k) { sum += L[i * n + k] * Linv[k * n + j]; } Linv[i * n + j] = -sum / L[i * n + i]; } }这个循环对应前面推导的递推公式。为什么内层k从j开始?因为L中第i行第j列右边没有元素,而Linv中第k行第j列在k小于j时也为0,所以不需要从0开始。求和到i减1,正好覆盖所有非零贡献。
这里有一个容易栽进去的坑:循环顺序必须列优先。也就是说,外层固定列j,然后逐行往下算。如果颠倒过来,先遍历行i,再遍历列j,那么算Linv第i行第j列时,可能会用到Linv第i行或者Linv第i加1行中还没算出来的值,结果全错。我在初学阶段在这上面折腾了大半天,最后把伪代码参照公式重新推导了一遍才明白。
3.4 对称组合与镜像填充
最后一步,用Linv乘Linv转置,并利用对称性只算下三角。这一步的代码非常干脆。
Ainv.assign(n * n, 0.0); for (int i = 0; i < n; ++i) { for (int j = 0; j <= i; ++j) { double sum = 0.0; for (int k = i; k < n; ++k) { sum += Linv[k * n + i] * Linv[k * n + j]; } Ainv[i * n + j] = sum; Ainv[j * n + i] = sum; // 镜像填充 } }数学上,Ainv第i行第j列等于Linv的转置第i行点乘Linv的第j列。展开后,就是k从0到n减1的Linv第k行第i列乘Linv第k行第j列。因为Linv是下三角,当k小于i时,Linv第k行第i列必然是0,所以求和从k等于i开始即可。
镜像填充那行代码,把下三角的值直接赋给上三角的对称位置。这样可以保证Ainv严格对称,避免因为浮点运算中乘法顺序不同导致上三角和下三角差出一点尾巴。很多人在测试时发现逆矩阵乘以原矩阵不是精确单位阵,追根溯源就是这里没做对称化。
3.5 可直接编译运行的完整源码
把上面三段拼起来,加上一个标准验证程序,就是一份可以扔进编译器直接运行的工具。下面是完整代码。
#include <iostream> #include <vector> #include <cmath> // 求对称正定矩阵的逆。A 按行主序存储,n 为矩阵阶数。 // 成功返回 true,Ainv 中保存计算结果;失败返回 false。 bool invertSPD(const std::vector<double>& A, std::vector<double>& Ainv, int n, double tol = 1e-12) { if (static_cast<int>(A.size()) < n * n || static_cast<int>(Ainv.size()) < n * n) { return false; } // ---------- 1. Cholesky 分解 A = L * L^T ---------- std::vector<double> L(n * n, 0.0); for (int j = 0; j < n; ++j) { for (int i = j; i < n; ++i) { L[i * n + j] = A[i * n + j]; } } for (int j = 0; j < n; ++j) { double diag = L[j * n + j]; for (int k = 0; k < j; ++k) { diag -= L[j * n + k] * L[j * n + k]; } if (diag <= tol) { return false; } L[j * n + j] = std::sqrt(diag); for (int i = j + 1; i < n; ++i) { double val = L[i * n + j]; for (int k = 0; k < j; ++k) { val -= L[i * n + k] * L[j * n + k]; } L[i * n + j] = val / L[j * n + j]; } } // ---------- 2. 求下三角矩阵 L 的逆,存在 Linv ---------- std::vector<double> Linv(n * n, 0.0); for (int j = 0; j < n; ++j) { Linv[j * n + j] = 1.0 / L[j * n + j]; for (int i = j + 1; i < n; ++i) { double sum = 0.0; for (int k = j; k < i; ++k) { sum += L[i * n + k] * Linv[k * n + j]; } Linv[i * n + j] = -sum / L[i * n + i]; } } // ---------- 3. Ainv = Linv^T * Linv,只算下三角再镜像 ---------- Ainv.assign(n * n, 0.0); for (int i = 0; i < n; ++i) { for (int j = 0; j <= i; ++j) { double sum = 0.0; for (int k = i; k < n; ++k) { sum += Linv[k * n + i] * Linv[k * n + j]; } Ainv[i * n + j] = sum; Ainv[j * n + i] = sum; } } return true; } int main() { // 一个 3x3 SPD 矩阵 std::vector<double> A = { 4.0, 2.0, 1.0, 2.0, 5.0, 3.0, 1.0, 3.0, 6.0 }; const int n = 3; std::vector<double> Ainv(n * n); if (!invertSPD(A, Ainv, n)) { std::cerr << "Cholesky 分解失败,矩阵可能非正定" << std::endl; return 1; } std::cout << "A^{-1}:" << std::endl; for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) { std::cout << Ainv[i * n + j] << " "; } std::cout << std::endl; } // 用 A * Ainv 验证残差,理想情况下应接近单位阵 std::cout << "\ncheck A * Ainv:" << std::endl; for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) { double s = 0.0; for (int k = 0; k < n; ++k) { s += A[i * n + k] * Ainv[k * n + j]; } std::cout << s << " "; } std::cout << std::endl; } return 0; }这段代码在常见的C++11及以上标准下都能编译。运行后,你会在终端看到A逆矩阵的具体数值,以及验证残差矩阵。残差矩阵的对角线接近1,非对角线接近0,就说明求逆结果正确。
我用这个3阶矩阵跟手算结果对照过,A逆的下三角元素大约是0.3134、负0.1343、0.3433、0.0149、负0.1493、0.2388,残差矩阵的对角线误差在10的负15次方附近,完全符合双精度计算的预期。
4. 数值验证与性能观察
4.1 用残差矩阵判断逆矩阵靠不靠谱
判断一个求逆实现是否正确,最直观的方式是计算A乘Ainv,看结果是否接近单位阵。理论上这等于单位阵,实际因为浮点舍入会有微小偏差。
实际操作中,我习惯算Frobenius范数,也就是残差矩阵所有元素平方和再开根号。这个范数越小越好。如果是良态的SPD矩阵,双精度下这个值通常在10的负14次方量级。
如果残差明显偏大,比如到10的负6次方以上,第一嫌疑不是求逆算法本身,而是矩阵的条件数太大。条件数等于最大特征值除以最小特征值,它决定了逆矩阵的数值精度上限。条件数越大,逆矩阵对原始矩阵元素的扰动越敏感,残差也会越大。这是数学本质决定的,换什么算法都救不回来。
4.2 与成熟数值库方案的对比认知
用C++写数值计算,很多朋友会直接想到成熟的线性代数库。比如某些开源库里的LDLT分解,一行代码就能算出逆矩阵,内部可能还调用了BLAS和LAPACK的高速实现。道理上,这些库用的核心算法和本文完全一致,复杂度也一样。
但自己实现这套代码依然有不可替代的价值。第一,它没有任何第三方依赖,交叉编译到某个受限环境时,一份标准C++源码直接拷贝就走。第二,它让人真正理解矩阵求逆的内部结构,调试其它数值问题时,你知道误差从哪一步产生、迭代优化该从哪个环节下手。第三,它结构简单,容易针对特定尺寸做定制优化,比如固定模板参数展开循环。
如果你的项目已经引入了大型数值库,并且没有依赖顾虑,那我建议直接调库。如果项目本身就没打算引入重依赖,或者你想彻底掌控数值流程,这套源码就是一个合理的选择。
4.3 缓存友好与内存占用:两个容易被忽视的点
性能优化有很多维度,但对这个算法来说,最值得注意的就是缓存命中。行主序的一维数组存储,让矩阵在内存中连续排列。循环访问时尽量按照存储方向遍历,避免跳跃式寻址,性能差距在小矩阵上不大,到大矩阵上可能拉开数倍。
另一个点是对称存储。理论上SPD矩阵只需要存储对角线及以下的n乘(n加1)除以2个元素,也就是约一半。本文为了代码清晰,还是用n乘n的完整方阵存储。如果你的内存非常紧张,比如要处理几个GB的大矩阵,可以把存储结构改成压缩对称格式。代价是取元素时要多写一个映射函数,代码复杂度明显上升。我一般会在矩阵规模超过一万阶时才认真考虑压缩存储,小矩阵不值得。
4.4 什么时候不要显式求逆
这一点值得反复强调:能不解方程就不要把逆矩阵显式算出来。求逆矩阵需要三步,每一步都是n的三次方量级,而用Cholesky分解之后直接做两次三角回代,复杂度也只有n的三次方量级,但常数小得多、精度更高。
如果只是想算某个向量x对应的x转置乘A逆乘x,完全可以让程序先解Ax等于b得到x,再做一次内积。如果要对多个右侧向量求解,应该保留L矩阵,对每个b重复回代。如果你用本文代码只是为了获得逆矩阵去乘某个向量,那我建议改成解方程组的方式,精度和速度都会更好。
5. 常见问题与排查技巧实录
5.1 分解失败:矩阵非正定的几个真实原因
如果是你自己构造的矩阵却返回false,首先要检查对称性。输入矩阵可能因为前面计算步骤的小误差不再严格对称,导致Cholesky对角线计算出现异常。我遇到过一次,某个数据预处理模块把矩阵元素做了交替舍入,上三角和下三角差出10的负16次方级别,Cholesky直接失败。解决方法是先做一次对称化处理,把A置为A加A转置的一半。
第二种常见原因是矩阵本身接近奇异。某些特征值虽然在数学上大于0,但在双精度下小于tol。这种情况好办,加一个小的正数倍单位阵做正则化,比如A加1e负8乘I,让所有特征值都往上抬一点。正则化量取多大,取决于你愿意接受多少偏差。
第三种原因是构造方式出了问题。不要用A转置乘A来构造协方差矩阵后直接求逆,因为这样做的条件数是原矩阵条件数的平方,稍微大一点的矩阵就会数值崩溃。更合理的做法是用某种分解形式或对角加秩的结构,绕开显式相乘。
5.2 结果精度不理想:先从量纲和条件数查起
如果你的验证残差一直压不到理想范围,先检查原始矩阵各元素的量级差异是否过大。比如有些元素是10的8次方,有些是10的负4次方,这种量级差本身就意味着条件数巨大。可以先把矩阵按行做归一化,或者全部除以最大绝对值,让元素量级归一化后再求逆,然后按比例还原。
迭代精化也是一个实用手段。先用普通算法算出一个逆矩阵,然后计算修正量,再叠加回去。迭代几次能把残差往下压。这个技巧在条件数中等且矩阵尺寸较大的场景下尤其有效。
最后检查一下求和顺序。三重循环里的累加顺序会影响舍入误差。必要时可以用Kahan求和补偿小量损失,代价是循环里多几条语句。对大多数应用来说双精度已经足够,但如果做高精度计算,这一步值得考虑。
5.3 大n场景下的内存与异常保护
矩阵阶数变大后,内存问题会提前找上门。一维数组能解决一部分问题,但也要小心vector扩容时产生的大量搬移。最好在构造时就reserve够大小,或者直接使用原生数组加智能指针。
异常保护方面,invertSPD返回false后,Ainv的内容是未定义状态的。我建议调用方在函数外先把Ainv清空,或者函数内部失败时把Ainv置零,避免下游代码把旧数据当新结果使用。写数值接口时,失败后的状态越清晰越好。
多线程并行是另一个值得探索的方向。Cholesky分解的每一列虽然依赖前面列,但同一列的多个行之间没有依赖,可以做OpenMP并行。矩阵乘法部分更是天然可并行。如果你有CPU资源,这三步都能提速。
5.4 一个容易踩的坑:C++数组顺序与数学下标
这个坑真的很常见。写数学公式时,Aij我们默认是第i行第j列,但C++一维数组的下标不区分行列,全看你怎么定义映射。行主序的i乘n加j和列主序的j乘n加i很容易混,一混就是灾难。
我的习惯是在代码头部写清楚注释,明确声明用的是行主序。每个涉及下标的函数都先画一个简单矩阵验证一下映射关系。测试阶段不要用3×3以上矩阵,手算能算清楚的最小尺寸最方便确认下标正确。
还有一个衍生坑:i从0到n减1,别在边界上搞出一场空。像k从i开始这种优化,在i等于n减1时只算一个元素,要注意内层循环不会越界。把n定义为int,避免和size_t混用时的符号警告。这些小细节看起来琐碎,但在大型数值代码里常常就是最难排查的bug来源。
最后再分享一点个人体会。我最初学着写这套代码时,总想一步到位把性能调到极致,后来发现先保证正确、再谈优化才是正道。Cholesky求逆的核心逻辑放到今天依然是很漂亮的算法,它把一个看似复杂的矩阵求逆问题,拆成三个边界清晰、稳定性有保障的步骤。你如果顺着这个思路自己推一遍公式,再对照源码逐行看下来,以后遇到再大再复杂的数值问题,心里都会更有底。本文这套代码虽然只是教学级完整度,但它的结构和思路可以直接扩展到大规模稀疏矩阵、分块并行等更高级的实现上,值得你花点时间吃透。