☰
C++高效求逆:SPD矩阵的Cholesky分解实现与避坑指南
2026/10/20 8:49:51 网站建设 项目流程

前阵子做高斯过程回归的调参实验,有一段代码要反复求协方差矩阵的逆。协方差矩阵是对称正定(SPD)的,这个性质大家都会背,可真到写 C++ 代码的时候,我发现不少人还是直接调一个通用求逆函数完事。如果只是千八百维、偶尔调一次,差别确实不大;可一旦矩阵规模上千、又处在循环体内,通用求逆和专用求逆之间可能会差出一倍多的运行时间,稳定性也有明显差距。这篇文章就围绕“C++ 计算 SPD 矩阵的逆矩阵”这件事,把原理、源码、验证和坑一次讲清楚。适合正在写科学计算、机器学习、图形学或仿真代码的开发者参考,也适合那些准备自己撸数值代码、不想被第三方库绑架的读者。

1. 先搞清楚:什么样的矩阵才是“对称正定”

1.1 定义与直觉

对称正定矩阵,英文叫 Symmetric Positive Definite,缩写就是标题里的 SPD。定义听起来有点绕:一个实对称矩阵 A,如果对任意非零向量 x 都有 xᵀAx > 0,它就是正定的。再加一个对称条件,就是对称正定。

很多第一次接触这个概念的同学会卡在“为什么要求严格大于零”上面。用一个生活化的类比解释:如果把矩阵看作一个二次型的系数,xᵀAx 就是某个能量函数。SPD 矩阵保证这个能量函数有唯一的全局最小值,而且它对应的“地形”是个碗状,不是马鞍也不是平面。求逆这类操作在这个“碗”里做,数值性质会非常好。

1.2 判断一个实矩阵是不是 SPD 的实用手段

理论上有好几种判定方式:所有特征值为正、所有顺序主子式为正、存在可逆下三角矩阵 L 使得 A = LLᵀ。但在工程上,最实用的判定手段就是直接尝试 Cholesky 分解。能分解成功,且分解过程中所有主元都严格大于零,那它一定是 SPD;反过来,分解一旦失败,矩阵要么不对称、要么非正定、要么数值上已经退化。

我平时会在代码里写一个这样的守卫函数:

#include <Eigen/Dense> bool isSPD(const Eigen::MatrixXd& A) { if (!A.isApprox(A.transpose(), 1e-12)) { return false; } Eigen::LLT<Eigen::MatrixXd> llt(A); return llt.info() == Eigen::Success; }

有一点要注意:浮点世界里没有绝对的正定,只有“在当前精度下仍能保持正定”。一个理论上 SPD、但最小特征值只有 1e-16 量级的矩阵,在 double 精度下可能怎么分解都失败。这不是数学错了,而是数值精度撑不住,后面我会专门讲这种情况怎么处理。

1.3 工程里哪些矩阵“天生”是 SPD

SPD 矩阵并不是教科书里才有。概率统计里的协方差矩阵、核方法里的 Gram 矩阵、有限元里的刚度矩阵、最小二乘正规方程中的 AᵀA、牛顿法里的 Hessian 近似,这些在正常条件下全都是 SPD。换句话说,只要你写过高斯过程、做过卡尔曼滤波、跑过有限元仿真,你手边大概率就躺着至少一个 SPD 矩阵。

也是因为 SPD 太常见,C++ 里对它做专用处理才显得必要。普通矩阵求逆和 SPD 矩阵求逆,从算法层面就不是一回事,性能差距也不是一点半点。

2. 求逆路线对比:为什么 Cholesky 是 SPD 的最优解

2.1 几种常见求逆路线的运算量

先列一下我实际用过的几种矩阵求逆路线,分别是 Gauss-Jordan 消元、LU 分解、Cholesky 分解和 LDLT 分解。这里的运算量统一用“乘法次数”来讨论,读代码的时候更好对照。

方法乘法次数量级是否利用对称性是否需要选主元
Gauss-Jordan 消元约 n³否需要
LU 分解(部分主元)约 (2/3)n³否需要
Cholesky 分解约 (1/3)n³是不需要
LDLT 分解约 (1/6)n³ 级别是不需要

为什么 Cholesky 能省一半还多?核心原因是它只计算一个下三角矩阵 L,而 L 和 Lᵀ 互为转置,这意味着只需要处理原来一半的数据。Gauss-Jordan 和 LU 从头到尾都要处理完整的矩阵元素,对称性对它俩来说形同虚设。所以在“已知矩阵是 SPD”的前提下,不选 Cholesky 有点说不过去。

2.2 数值稳定性:不需要选主元的底气

做过线性代数数值实验的同学一定听过“行交换”“主元不能太小”这类经验。高斯消元和 LU 分解里,主元太小会造成误差爆炸,所以才要选主元。为什么 Cholesky 不需要?

因为 SPD 矩阵的所有顺序主子式都严格大于零。这意味着 Cholesky 分解过程中,每个主元在数学上天然大于零,而且不会发生“刚好为零需要换行”的尴尬。更妙的是,SPD 矩阵的主元在数值上也比较“温和”,不会像一般矩阵那样出现极端的主元大小差,这为跳过选主元过程提供了底气。我最初看到 Cholesky 居然不选主元时也有点怀疑,后来在随机生成的一堆 SPD 矩阵上反复测试,确实没出现过一次因为未选主元而炸掉的情况。

2.3 我为什么不推荐在 SPD 场合用 LU 或 Gauss-Jordan

LU 和 Gauss-Jordan 本身没有错,但用在 SPD 矩阵上属于“能解决问题,却浪费了性质”。我见过不少代码,协方差矩阵求逆直接调用某个通用线性代数库的 inv 函数,底层走的不是 LU 就是 Gauss-Jordan,性能白白损失。更现实的问题是,在有限元或者高斯过程这类大矩阵场景里,n 到 5000 之后,多出来的运算量会让单次计算立刻变得笨重。一个本来只需要十几毫秒的操作,可能会被拖到几十毫秒甚至上百毫秒,而代码本身完全没做错什么,只是没用对方法。

所以结论很直接:只要确认矩阵是 SPD,Cholesky 就是首选,没有之一。

3. 利用 Cholesky 求逆的数学分解

3.1 Cholesky 分解的推导

Cholesky 分解的目标是把 SPD 矩阵 A 写成 A = LLᵀ,其中 L 是下三角矩阵,对角线元素大于零。它有点像“矩阵版的平方根”,因为 L 就是 A 的“平方根”。

用公式推一遍更清楚。设 A 是 n 阶 SPD 矩阵,按行计算 L:

  • 对角线元素:L[i][i] = sqrt(A[i][i] - Σ(L[i][k]²), k 从 0 到 i-1)
  • 非对角线元素:L[i][j] = (A[i][j] - Σ(L[i][k] L[j][k]), k 从 0 到 j-1) / L[j][j],其中 j < i

这两个公式其实就是把 LLᵀ 的逐元素等式展开,再按顺序解出 L 的每一个元素。因为 L 是下三角,所以算 L[i][j] 时,右边只会用到已经算出来的 L[j][k] 和 L[i][k],天然满足依赖顺序。这也是 Cholesky 能原地完成的原因:A 的下三角部分可以逐步覆写成 L,只要实现时小心处理即可。

3.2 从 A = LLᵀ 到逆矩阵的两步操作

有了 A = LLᵀ,求逆就变成:

A⁻¹ = (LLᵀ)⁻¹ = (Lᵀ)⁻¹ L⁻¹ = (L⁻¹)ᵀ L⁻¹

这个式子表达的意思是:先求下三角矩阵 L 的逆 L⁻¹,得到一个下三角矩阵;然后把它转置,再和自己做一次矩阵乘法,就得到对称的逆矩阵。

3.3 一个很容易犯错的顺序:为什么不能先算 L⁻¹ 再乘 Lᵀ 的逆

我看到过很多同学在这个公式上栽跟头,原因是把顺序记反了。矩阵乘法的逆有反转律,即 (AB)⁻¹ = B⁻¹A⁻¹。所以对 A = LLᵀ 求逆,必须先对 Lᵀ 取逆,再对 L 取逆,顺序是 L⁻ᵀ 乘 L⁻¹,不是 L⁻¹ 乘 L⁻ᵀ。

用 2x2 例子验证一下就知道错在哪。假设 L 是一个简单的下三角矩阵,如果把 A⁻¹ 写成 L⁻¹(Lᵀ)⁻¹,算出来的矩阵不会等于真正的逆矩阵,而且通常不对称。SPD 矩阵的逆必须仍然是对称的,所以看到结果不对称时,第一反应就该检查是不是顺序写反了。

3.4 一个工程习惯:能不显式求逆就不显式求逆

这里想先插入一个非常重要的观点:很多时候,你要的其实不是逆矩阵本身,而是“逆矩阵乘某个向量”的结果。这个需求千万别绕道求逆再乘,正确做法是直接用 Cholesky 分解去解方程。

比如要求 A⁻¹b,就先用 Cholesky 分解 A=LLᵀ,然后解两个三角方程:Ly = b,再解 Lᵀx = y。整个过程只有 O(n²) 的回代运算,比“求逆 + 矩阵向量乘法”省掉整整一个量级的计算量。我在自己的代码里会尽量把使用方引导到 solve 接口上,而不是让所有人都去拿逆矩阵。这一点对性能敏感的系统尤其重要。

4. C++ 实现:Eigen 库源码与验证

4.1 环境准备

Eigen 是一个基于模板的头文件库,不需要编译和链接,只要把头文件路径包含进来就能用。我最常用的是 Dense 模块,下面这段代码在 CMake 里只要:

find_package(Eigen3 REQUIRED) target_link_libraries(your_target Eigen3::Eigen)

或者在编译器里加上 -I 参数指向 Eigen 头文件目录,然后:

g++ -O2 -std=c++17 invert_spd_demo.cpp -o invert_spd_demo

-O2 不要省。数值计算代码不开优化跑起来性能会差很多,Debug 下测性能结果也会误导人。

4.2 核心源码

下面是一个完整的单文件示例:构造一个 SPD 测试矩阵,用 LLT 求逆,再做残差验证。

#include <Eigen/Dense> #include <iostream> #include <stdexcept> using Eigen::MatrixXd; MatrixXd invertSPD(const MatrixXd& A) { Eigen::LLT<MatrixXd> llt(A); if (llt.info() != Eigen::Success) { throw std::runtime_error("Cholesky 分解失败,矩阵不是数值意义下的 SPD"); } int n = A.rows(); return llt.solve(MatrixXd::Identity(n, n)); } // 用 M^T * M + lambda*I 构造一个严格 SPD 的矩阵 MatrixXd makeSPD(int n, double lambda = 1.0) { MatrixXd M = MatrixXd::Random(n, n); return M.transpose() * M + lambda * MatrixXd::Identity(n, n); } double residualError(const MatrixXd& A, const MatrixXd& Ainv) { int n = A.rows(); MatrixXd R = A * Ainv - MatrixXd::Identity(n, n); return R.norm() / std::sqrt((double)n); // 相对残差 } int main() { int n = 200; MatrixXd A = makeSPD(n); MatrixXd Ainv = invertSPD(A); std::cout << "相对残差 ||A * Ainv - I||_F / sqrt(n) = " << residualError(A, Ainv) << std::endl; std::cout << "逆矩阵对称性误差 max(|Ainv - Ainv^T|) = " << (Ainv - Ainv.transpose()).cwiseAbs().maxCoeff() << std::endl; return 0; }

这里最关键的一行是:

return llt.solve(MatrixXd::Identity(n, n));

它直接用 Cholesky 分解去“解” n 个右侧向量组成的单位矩阵,结果就是 A⁻¹。这比先取 L,再手动算 L⁻¹ 和 L⁻ᵀL⁻¹ 要更稳定,也是官方推荐的写法。

4.3 验证与结果判断

我跑上面这个示例时的典型输出大致是:

相对残差 ||A * Ainv - I||_F / sqrt(n) = 3.7e-13 逆矩阵对称性误差 max(|Ainv - Ainv^T|) = 1.5e-14

相对残差在 1e-13 到 1e-14 量级,说明求逆结果是可靠的。判断求逆正确性时,我一般看两条:

  • 相对残差是否在 1e-12 以下;
  • 逆矩阵是否仍然高度对称。

如果残差到了 1e-6 量级,别急着怀疑算法,先检查矩阵是否已经病态,或者代码里是否传入了非 SPD 的矩阵。残差只能说明“这个逆和 A 的乘积接近单位阵”,不能说明“逆真的解除了你的业务问题”,这一点要分清楚。

4.4 实用习惯:别为了解方程而显式求逆

延续 3.4 的思路,如果调用方只是需要 A⁻¹b,我推荐直接这样写:

Eigen::VectorXd solveSPD(const MatrixXd& A, const Eigen::VectorXd& b) { Eigen::LLT<MatrixXd> llt(A); if (llt.info() != Eigen::Success) { throw std::runtime_error("Cholesky 分解失败"); } return llt.solve(b); }

当矩阵 n=500,这个写法比“先求逆再乘向量”的速度优势非常明显。显式求逆需要对整个右侧矩阵做一次完整回代,还要额外做一次大矩阵乘法;而直接 solve 只是两个三角方程回代,运算量差距到了肉眼可见的程度。

5. 手写完整实现:不依赖 Eigen 也能算

考虑到某些项目不能用第三方库,我给出一个自包含的核心实现,只依赖标准库的 vector、cmath 和 exception。这样无论是嵌入式环境还是教学演示,都能直接参考。

5.1 手写 Cholesky 分解

先定义二维数组别名,然后实现分解:

#include <vector> #include <cmath> #include <stdexcept> using Mat = std::vector<std::vector<double>>; // 对 A 做 Cholesky 分解,结果写入 L // A 使用完整二维矩阵存储,算法只读取 i>=j 的下三角部分 bool choleskyDecompose(const Mat& A, Mat& L) { int n = static_cast<int>(A.size()); L.assign(n, std::vector<double>(n, 0.0)); for (int i = 0; i < n; ++i) { for (int j = 0; j <= i; ++j) { double sum = A[i][j]; for (int k = 0; k < j; ++k) { sum -= L[i][k] * L[j][k]; } if (i == j) { if (sum <= 0.0) { return false; // 主元不为正,非 SPD } L[i][j] = std::sqrt(sum); } else { // 这里 L[j][j] 一定已经算出,且为正数 L[i][j] = sum / L[j][j]; } } } return true; }

这段代码的依赖顺序很关键。算 L[i][j] 时,内层循环只会用到 L 中已经确定出来的元素;算对角线时,sum 就是 A[i][i] 减去前面已经计算完成的 L[i][k]² 的累加。如果 sum 出现非正数,说明矩阵在这个局部不是正定的,直接返回失败即可。

5.2 手写下三角矩阵求逆

下一个环节是求 L 的逆。下三角矩阵的求逆比一般矩阵容易得多,因为对角线元素直接取倒数,其余元素可以逐列回代:

Mat invertLowerTriangular(const Mat& L) { int n = static_cast<int>(L.size()); Mat invL(n, std::vector<double>(n, 0.0)); for (int i = 0; i < n; ++i) { if (L[i][i] == 0.0) { throw std::runtime_error("三角矩阵奇异,无法求逆"); } invL[i][i] = 1.0 / L[i][i]; for (int j = 0; j < i; ++j) { double s = 0.0; for (int k = j; k < i; ++k) { s += L[i][k] * invL[k][j]; } invL[i][j] = -s / L[i][i]; } } return invL; }

这个思路可以用行视角理解:因为 L·invL = I,逐行对比元素,就能把 invL 的每个元素通过已求得的行元素回代出来。下三角矩阵求逆的结果仍然是下三角矩阵,所以遍历范围只需要维护 i >= j 的区域。

5.3 组装完整的 SPD 求逆函数

需要三个工具函数:矩阵转置、矩阵乘法,以及解包整个流程:

Mat transpose(const Mat& X) { int n = static_cast<int>(X.size()); Mat Xt(n, std::vector<double>(n, 0.0)); for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) { Xt[j][i] = X[i][j]; } } return Xt; } Mat multiply(const Mat& X, const Mat& Y) { int n = static_cast<int>(X.size()); Mat Z(n, std::vector<double>(n, 0.0)); 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 += X[i][k] * Y[k][j]; } Z[i][j] = s; } } return Z; } Mat invertSPD(const Mat& A) { Mat L; if (!choleskyDecompose(A, L)) { throw std::runtime_error("Cholesky 分解失败,矩阵不是 SPD"); } Mat invL = invertLowerTriangular(L); Mat invLt = transpose(invL); // 即 (L^{-1})^T return multiply(invLt, invL); // A^{-1} = (L^{-1})^T * L^{-1} }

最后一步的顺序就是 3.3 里强调过的:先转置逆下三角,再在左边乘。

5.4 手写版本的验证和优化空间

验证手写版本也很简单,用同样的构造方法生成 SPD 矩阵,然后计算相对残差。我在某一轮测试中,n=100 时相对残差大约在 2e-12 量级;n=500 时会到 1e-10 附近。这个精度在很多业务场景里够用,但如果追求更高的稳定性或性能,可以从两个方向优化:

  • 把二维 vector 换成连续的一维数组,按行优先或列优先存储,减少内存跳跃,访存效率会有明显提升;
  • 矩阵乘法部分用缓存友好的分块算法,或者直接利用对称性,只算一半的乘积结果。

手写版本最大的价值不是替代 Eigen,而是让你彻底看明白分解和回代的过程。调库出问题的时候,能回到这个基础版本去对照检查。

6. 实战中的边界情况与避坑

6.1 LLT 分解失败意味着什么

llt.info() != Eigen::Success这个判断看起来简单,但实际上信息量不小。它告诉你:矩阵在当前浮点精度下不是数值正定的。可能的原因有三类:

  • 矩阵本来就非正定;
  • 矩阵接近半正定,主元在浮点误差中掉到零以下;
  • 输入矩阵的数据类型或对齐方式有问题。

处理原则是:不要在失败后强行继续求逆。半正定矩阵数学上没有真逆,强行算只会得到垃圾结果。对接近半正定的核矩阵,我通常会在对角线上加一个很小的正则项,比如 A + 1e-8·I,把特征值抬离零点再算。这也是统计和机器学习里常用的“jitter”技巧。

6.2 条件数很大的“半 SPD”矩阵

SPD 矩阵条件数定义是最大特征值除以最小特征值。条件数越大,求逆对舍入误差越敏感。最小特征值如果是 1e-14,而其他特征值是 1,那么即使 Cholesky 分解本身没问题,求逆结果的有效位数也不会太多。

这种情况下,追求更精准的逆矩阵没有太大意义,重点应该放在数据端:检查特征是否归一化、是否有多重共线性、是否应该降低矩阵维数。如果一定需要逆,可以试试更高的 long double 精度,但性能会下降;或者改用 LDLT 形式,避免开平方操作带来额外误差。LDLT 分解把 A 写成 LDLᵀ,L 仍然是下三角,D 是对角矩阵。它在处理对角线接近零的情况时更直观,也经常被用作 Cholesky 的稳健替补。

6.3 数值误差的累积和大矩阵操作

当 n 到几千甚至更高时,即使每个环节都很稳,误差依然会通过大量浮点运算累积。我的习惯是:

  • 优先使用 double,别在没确定必要之前用 float;
  • 求逆后一定算一遍相对残差,作为发布前的 sanity check;
  • 如果业务允许,用直接 solve 代替求逆,减少一次矩阵乘法级别的误差累积。

另一个容易被忽略的细节是:Ainv在数值上可能不是严格对称的,尽管误差很小。如果需要把它当作协方差矩阵继续参与后续运算,可以顺手做一次(Ainv + Ainv.transpose()) / 2,把对称性“焊死”。这一步花费极小,但能避免下游算法因为一点点非对称误差而出现奇怪行为。

6.4 一个关于“求逆之后做什么”的经验

讲个我自己踩过的坑:早期在做某个模拟项目时,代码里确实用了 SPD 矩阵 Cholesky 求逆,但调用方拿到 Ainv 之后的主要操作竟然是反复计算 Ainv 乘不同的向量。后来一分析,这些向量在不同的迭代轮次里变化,但矩阵 A 不变。也就是说,完全可以把 A 的 Cholesky 分解结果缓存下来,每次只做两个三角回代,根本不需要每次都重建分解和逆矩阵。

所以我现在的代码库里,能够“反复利用 Cholesky 分解”的地方,就绝不允许每次都重建分解。这一点对性能的影响,往往比选哪个求逆库还要大。遇到这种场景,我会先缓存 LLT 对象或者把 L 和 invL 存起来,让调用方直接使用,而不是每次重新从 A 开始算。

在我自己维护的数值工具库里,现在关于 SPD 求逆的基本规则浓缩成三句话:先确认 SPD,再选 Cholesky 或 LDLT,最后能不解逆就不解逆。这三句话看起来简单,但每一条背后都有对应的性能和稳定性代价。如果你写完某个求逆函数却不确定结果靠不靠谱,我建议第一步就是打印相对残差和对称性误差,这两个数字会诚实地告诉你代码是否还处在安全区间内。

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

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

立即咨询