1. 项目概述:从数学公式到C++代码的旅程
“线性代数-LU分解(C++代码实现)”这个标题,对于任何从事科学计算、机器学习底层开发或者游戏引擎物理模拟的程序员来说,都像是一道熟悉的“家常菜”。它看似基础,却是连接理论数学与高性能计算的桥梁。LU分解,简单来说,就是把一个矩阵A分解成一个下三角矩阵L和一个上三角矩阵U的乘积,即A = L*U。这个分解是高斯消元法的矩阵形式,其核心价值在于,一旦完成分解,求解线性方程组Ax=b就变成了依次求解两个简单的三角方程组Ly=b和Ux=y,计算效率大幅提升。尤其是在需要反复求解具有相同系数矩阵A但不同右侧向量b的方程组时,LU分解只需计算一次,后续求解成本极低。
然而,从数学教材上那个简洁的公式,到一段能在计算机上稳定、高效运行的C++代码,中间隔着无数个“坑”。如何选择主元以保证数值稳定性?如何处理矩阵的稀疏性以节省内存和计算时间?如何设计接口使其既灵活又高效?这些问题,教科书往往一笔带过,但却是工程实践中的核心。本文将带你深入LU分解的C++实现腹地,不仅会给出可运行的代码,更会拆解每一步背后的设计考量、数值陷阱和性能权衡。无论你是正在学习数值计算的学生,还是需要为项目引入一个可靠线性求解器的工程师,这篇文章都将提供从理论到实践的完整路线图。
2. 核心原理与算法选择:不止是高斯消元
在动手写代码之前,我们必须明确要实现的算法变体。最基本的LU分解(也称为Doolittle分解)要求矩阵A是方阵且所有顺序主子式非零,但这在数值计算中几乎无法保证,因为舍入误差可能导致算法失败。
2.1 为什么需要选主元(Pivoting)?
想象一下,你在消元过程中,除数是0或者一个非常接近0的很小的数。在计算机中,除以一个极小的数会导致结果溢出或产生巨大的舍入误差,使得计算结果完全失真。这就是“数值不稳定”的典型场景。为了解决这个问题,我们必须引入选主元策略。
- 部分选主元(Partial Pivoting):这是最常用、最稳定的策略。在第k步消元时,我们并不直接使用A[k][k]作为除数,而是在第k列中,从第k行到第n行寻找绝对值最大的元素,将其所在行与第k行交换。这相当于在分解前左乘一个置换矩阵P,最终得到的是PA = LU。这里的L是单位下三角矩阵(对角线元素为1),其元素的绝对值均不大于1,U是上三角矩阵。部分选主元能有效控制增长因子,是实践中的黄金标准。
- 全选主元(Complete Pivoting):在第k步,在整个右下子矩阵中寻找绝对值最大的元素,同时进行行交换和列交换。这对应PAQ = LU,其中P和Q分别是行、列置换矩阵。它稳定性最好,但寻找最大元素的开销巨大,且列置换会改变未知数的顺序,后续处理麻烦,因此在实际中较少使用。
- 不选主元:仅当矩阵具有强对角优势或对称正定时才可考虑,一般不推荐。
实操心得:对于通用目的的实现,部分选主元(PA=LU)是必须的。忽略它,你的代码可能对某些测试矩阵工作良好,但一旦遇到病态矩阵,就会 silently 给出错误结果,调试起来极其困难。
2.2 算法流程详解(带部分选主元)
给定一个n×n的矩阵A,我们的目标是找到置换向量p(或矩阵P)、单位下三角矩阵L和上三角矩阵U,使得 A(p, :) = L * U。这里p是一个长度为n的向量,A(p, :)表示按p中索引重排A的行。
算法伪代码如下(使用1-based索引,但C++实现时需转为0-based):
初始化:U = A 的副本, L = 单位矩阵, p = [1, 2, ..., n] for k = 1 to n-1: // 1. 选主元 在 U 的第 k 列,行索引 [k, n] 范围内,找到绝对值最大的元素,设其行号为 max_row。 if (max_row != k): 交换 U 的第 k 行和 max_row 行(从第 k 列开始交换即可)。 交换 L 的第 k 行和 max_row 行(只交换前 k-1 列,因为L是下三角)。 记录交换:交换 p[k] 和 p[max_row]。 // 2. 检查主元是否为零(选主元后仍为零,则矩阵奇异) if (abs(U[k][k]) < epsilon) // epsilon 是一个极小阈值,如1e-12 抛出异常或返回错误:矩阵奇异或接近奇异。 // 3. 消元 for i = k+1 to n: L[i][k] = U[i][k] / U[k][k]; // 计算乘子,存入L的相应位置 for j = k to n: U[i][j] = U[i][j] - L[i][k] * U[k][j]; // 更新U的第i行最终,矩阵U的上三角部分(包括对角线)就是U,矩阵L的下三角部分(对角线为1)就是L。向量p记录了行交换的历史。
2.3 存储优化:原地(In-place)分解
仔细观察算法,你会发现更新后的U[i][j]覆盖了原来的A[i][j],而乘子L[i][k]存储在了A[i][k]被消为0的位置。因此,我们可以将L和U存储在同一个n×n矩阵中,实现原地分解。通常约定:该矩阵的严格下三角部分存储L的乘子,上三角部分(包括对角线)存储U。对角线上的1(L的单位对角元)不存储,因为它是已知的。
这种存储方式极其节省内存,也是LAPACK、Eigen等专业库采用的方法。我们的C++实现也将采用这种方式。
3. C++实现详解:从类设计到每一行代码
接下来,我们将把上述算法转化为工业级的C++代码。我们将实现一个LUDecomposition类,它封装分解过程,并提供求解、求逆、求行列式等接口。
3.1 类设计与接口
首先考虑我们的类需要什么:
- 输入:一个二维方阵(用
std::vector<std::vector<double>>或更好的,一维数组模拟的连续内存)。 - 输出:
- LU组合矩阵(原地存储)。
- 行置换向量
p(记录交换历史)。 - 一个奇异性标志或行列式符号(用于计算行列式)。
- 功能:
decompose(): 执行分解。solve(const std::vector<double>& b): 给定右侧向量b,返回解x。determinant(): 计算矩阵A的行列式。inverse(): 求逆矩阵(可选)。
我们选择使用一维数组(std::vector<double>)按行优先顺序存储矩阵,因为内存连续,缓存友好,性能远优于vector<vector<double>>。
// LUDecomposition.h #ifndef LUDECOMPOSITION_H #define LUDECOMPOSITION_H #include <vector> #include <stdexcept> #include <cmath> class LUDecomposition { public: // 构造函数,接受一个方阵A,立即进行LU分解 LUDecomposition(const std::vector<std::vector<double>>& A); // 求解线性方程组 Ax = b std::vector<double> solve(const std::vector<double>& b) const; // 计算矩阵A的行列式 double determinant() const; // (可选)计算矩阵A的逆 std::vector<std::vector<double>> inverse() const; // 获取分解后的LU组合矩阵(诊断用) const std::vector<double>& getLU() const { return LU_; } // 获取行置换向量 const std::vector<int>& getPivot() const { return pivot_; } private: size_t n_; // 矩阵维度 std::vector<double> LU_; // 存储L和U的组合矩阵,按行优先 std::vector<int> pivot_; // 行置换向量,pivot_[i] = j 表示第i行与第j行交换过 int sign_; // 行列式的符号,由行交换次数决定 bool singular_; // 标记矩阵是否奇异(或接近奇异) // 内部方法:前向替换(Ly = Pb)和后向替换(Ux = y) std::vector<double> forwardSubstitution(const std::vector<double>& b) const; std::vector<double> backwardSubstitution(const std::vector<double>& y) const; }; #endif // LUDECOMPOSITION_H3.2 核心分解实现
这是整个类的核心,严格实现带部分选主元的算法。
// LUDecomposition.cpp (部分关键代码) #include "LUDecomposition.h" #include <algorithm> #include <limits> LUDecomposition::LUDecomposition(const std::vector<std::vector<double>>& A) : n_(A.size()), sign_(1), singular_(false) { if (n_ == 0 || A[0].size() != n_) { throw std::invalid_argument("Matrix must be square and non-empty."); } // 1. 初始化:将A复制到LU_(一维数组),并初始化pivot_ LU_.resize(n_ * n_); for (size_t i = 0; i < n_; ++i) { if (A[i].size() != n_) { throw std::invalid_argument("Matrix must be square."); } std::copy(A[i].begin(), A[i].end(), LU_.begin() + i * n_); } pivot_.resize(n_); for (size_t i = 0; i < n_; ++i) { pivot_[i] = static_cast<int>(i); // 初始化为恒等置换 } const double eps = std::numeric_limits<double>::epsilon() * 10.0; // 奇异阈值 // 2. 主循环:k从0到n-2 for (size_t k = 0; k < n_; ++k) { // --- 部分选主元 --- size_t max_row = k; double max_val = std::abs(LU_[k * n_ + k]); for (size_t i = k + 1; i < n_; ++i) { double val = std::abs(LU_[i * n_ + k]); if (val > max_val) { max_val = val; max_row = i; } } // 如果最大主元太小,认为矩阵奇异 if (max_val < eps) { singular_ = true; // 可以抛出异常或仅做标记。这里我们标记并尝试继续,但结果可能不可靠。 // throw std::runtime_error("Matrix is singular or nearly singular."); } // 交换行 if (max_row != k) { // 交换LU矩阵的第k行和max_row行 for (size_t j = 0; j < n_; ++j) { std::swap(LU_[k * n_ + j], LU_[max_row * n_ + j]); } // 记录交换(交换pivot_中的索引?这里需要仔细处理) // 更常见的做法是维护一个交换记录向量。我们交换pivot_中的值。 std::swap(pivot_[k], pivot_[max_row]); sign_ = -sign_; // 每次行交换,行列式变号 } // --- 消元 --- // 注意:选主元后,LU_[k*n_+k] 就是主元 double pivot = LU_[k * n_ + k]; if (std::abs(pivot) < eps) { // 即使选主元后主元仍为0,跳过该列消元(该列以下元素已为0) continue; } for (size_t i = k + 1; i < n_; ++i) { // 计算乘子,并存储在L的位置(即LU矩阵的(i,k)) LU_[i * n_ + k] /= pivot; double multiplier = LU_[i * n_ + k]; // 更新U的第i行 for (size_t j = k + 1; j < n_; ++j) { LU_[i * n_ + j] -= multiplier * LU_[k * n_ + j]; } } } }注意事项:上面的
pivot_向量记录的是最终的行置换。更常见的实现是,pivot_[i]存储的是第 i 步消元时,与第 i 行交换的行的索引。而在求解Ly = Pb时,需要根据这个记录动态地应用置换。为了清晰,我们采用另一种等价的表示:在分解完成后,pivot_直接存储一个置换向量p,使得A[p[i]][:]是原始矩阵的第i行在分解过程中的新位置。在构造函数中,我们通过交换pivot_的元素来记录交换。这样,在求解时,我们需要先根据pivot_对 b 进行重排:b_permuted[i] = b[pivot_[i]]。
3.3 求解器实现:前向与后向替换
分解完成后,求解 Ax = b 变为:
- 重排b:
b' = P * b(根据pivot_) - 解 Ly = b' (前向替换,因为L是单位下三角)
- 解 Ux = y (后向替换,因为U是上三角)
std::vector<double> LUDecomposition::solve(const std::vector<double>& b) const { if (b.size() != n_) { throw std::invalid_argument("Right-hand side vector size must match matrix dimension."); } if (singular_) { throw std::runtime_error("Cannot solve: matrix is singular."); } // 1. 应用行置换: b_permuted[i] = b[pivot_[i]] std::vector<double> b_permuted(n_); for (size_t i = 0; i < n_; ++i) { b_permuted[i] = b[pivot_[i]]; } // 2. 前向替换:解 Ly = b_permuted std::vector<double> y = forwardSubstitution(b_permuted); // 3. 后向替换:解 Ux = y std::vector<double> x = backwardSubstitution(y); return x; } std::vector<double> LUDecomposition::forwardSubstitution(const std::vector<double>& b) const { std::vector<double> y(n_, 0.0); // Ly = b, L是单位下三角,且乘子存储在LU_的严格下三角部分 for (size_t i = 0; i < n_; ++i) { double sum = 0.0; // L[i][j] for j < i 存储在 LU_[i*n_ + j] for (size_t j = 0; j < i; ++j) { sum += LU_[i * n_ + j] * y[j]; } y[i] = b[i] - sum; // L[i][i] = 1 } return y; } std::vector<double> LUDecomposition::backwardSubstitution(const std::vector<double>& y) const { std::vector<double> x(n_, 0.0); // Ux = y, U是上三角,存储在LU_的上三角部分(包括对角线) for (int i = static_cast<int>(n_) - 1; i >= 0; --i) { double sum = 0.0; // U[i][j] for j > i 存储在 LU_[i*n_ + j] for (size_t j = i + 1; j < n_; ++j) { sum += LU_[i * n_ + j] * x[j]; } if (std::abs(LU_[i * n_ + i]) < std::numeric_limits<double>::epsilon()) { throw std::runtime_error("Back substitution encountered zero diagonal."); } x[i] = (y[i] - sum) / LU_[i * n_ + i]; } return x; }3.4 行列式与逆矩阵计算
利用LU分解,我们可以高效计算行列式和逆矩阵。
- 行列式: det(A) = det(P) * det(L) * det(U)。由于P是置换矩阵,其行列式为 ±1(由行交换次数决定,即我们的
sign_)。L是单位下三角,行列式为1。U是上三角,行列式等于其对角线元素的乘积。因此,det(A) = sign_ * product(U[i][i])。
double LUDecomposition::determinant() const { if (singular_) { return 0.0; } double det = static_cast<double>(sign_); for (size_t i = 0; i < n_; ++i) { det *= LU_[i * n_ + i]; // 乘以U的对角线元素 } return det; }- 逆矩阵: 求A的逆矩阵,即求解矩阵方程 AX = I,其中I是单位矩阵。这等价于对单位矩阵的每一列(即标准基向量e_i)求解 Ax = e_i。我们可以利用已有的LU分解,对每一列执行一次
solve操作。
std::vector<std::vector<double>> LUDecomposition::inverse() const { if (singular_) { throw std::runtime_error("Matrix is singular, cannot compute inverse."); } std::vector<std::vector<double>> inv(n_, std::vector<double>(n_, 0.0)); // 为每一列j求解 A * inv_col_j = e_j for (size_t j = 0; j < n_; ++j) { std::vector<double> e_j(n_, 0.0); e_j[j] = 1.0; std::vector<double> inv_col_j = solve(e_j); // 复用solve函数 for (size_t i = 0; i < n_; ++i) { inv[i][j] = inv_col_j[i]; // 注意:solve返回的是列向量,我们按列填充逆矩阵 } } return inv; }性能提示:求逆矩阵的复杂度是O(n³),与分解本身相同,但常数因子更大(约是分解的3倍)。在实际应用中,应尽量避免显式求逆。如果是为了求解线性方程组,直接使用
solve方法。如果是为了计算表达式如A^{-1}B,也应考虑通过求解矩阵方程AX = B来实现,而不是先求逆再相乘。
4. 测试、验证与性能考量
代码写完不代表工作结束。我们需要验证其正确性、稳定性和效率。
4.1 基础功能测试
编写测试用例是必不可少的。我们可以用已知结果的简单矩阵开始,然后使用随机矩阵,通过残差范数||Ax - b||来验证求解器的精度。
#include <iostream> #include <vector> #include <random> #include "LUDecomposition.h" bool testBasic() { // 测试一个简单的 3x3 矩阵 std::vector<std::vector<double>> A = { {2, -1, 0}, {-1, 2, -1}, {0, -1, 2} }; std::vector<double> b = {1, 0, 1}; // 已知解?这是一个对称正定矩阵,可以用其他方法验证。 // 我们主要验证 Ax - b 是否接近0向量。 LUDecomposition lu(A); std::vector<double> x = lu.solve(b); // 计算残差 double residual = 0.0; for (size_t i = 0; i < 3; ++i) { double sum = 0.0; for (size_t j = 0; j < 3; ++j) { sum += A[i][j] * x[j]; } residual += (sum - b[i]) * (sum - b[i]); } residual = std::sqrt(residual); std::cout << "Basic test residual: " << residual << std::endl; return residual < 1e-10; } bool testRandom(int n) { std::random_device rd; std::mt19937 gen(rd()); std::uniform_real_distribution<> dis(-10.0, 10.0); std::vector<std::vector<double>> A(n, std::vector<double>(n)); std::vector<double> x_true(n); std::vector<double> b(n); // 生成随机矩阵A和真解x_true for (int i = 0; i < n; ++i) { x_true[i] = dis(gen); for (int j = 0; j < n; ++j) { A[i][j] = dis(gen); } } // 确保矩阵非奇异,可以加一个对角占优条件 for (int i = 0; i < n; ++i) { A[i][i] += 10.0 * n; // 增强对角优势 } // 计算 b = A * x_true for (int i = 0; i < n; ++i) { b[i] = 0.0; for (int j = 0; j < n; ++j) { b[i] += A[i][j] * x_true[j]; } } LUDecomposition lu(A); std::vector<double> x_calc = lu.solve(b); // 计算误差 ||x_calc - x_true|| double error = 0.0; for (int i = 0; i < n; ++i) { error += (x_calc[i] - x_true[i]) * (x_calc[i] - x_true[i]); } error = std::sqrt(error); std::cout << "Random test (n=" << n << ") error: " << error << std::endl; // 对于双精度和条件数不太差的矩阵,误差应在1e-12量级或更小 return error < 1e-8; } int main() { bool pass = true; pass &= testBasic(); pass &= testRandom(10); pass &= testRandom(50); pass &= testRandom(100); if (pass) { std::cout << "All tests passed!" << std::endl; } else { std::cout << "Some tests failed." << std::endl; } return 0; }4.2 数值稳定性与条件数
LU分解的精度严重依赖于矩阵的条件数。条件数 cond(A) = ||A|| * ||A^{-1}|| 衡量了矩阵求逆或求解线性方程组对输入误差的敏感度。即使使用部分选主元,对于一个病态矩阵(条件数很大),计算结果也可能只有很少的有效数字。
我们的代码中通过检查主元大小来标记奇异性,但这只是一个简单的启发式方法。更健壮的做法是估计条件数,但这超出了基础实现的范畴。一个实用的建议是:对于重要计算,在求解后总是计算残差||Ax - b||。如果残差远大于机器精度(例如||b|| * eps),就应该警惕结果可能不准确。
4.3 性能分析与优化
我们实现的算法复杂度是 O(n³),对于大的n(比如>1000),性能会成为瓶颈。以下是一些优化方向:
- 内存访问模式:我们当前的内层循环
for (size_t j = k + 1; j < n_; ++j)是按列访问内存(LU_[i * n_ + j]和LU_[k * n_ + j]),这对于C++(行优先存储)来说不是最缓存友好的。更优的方式是交换循环顺序,但会改变算法结构。一个折中是在消元时,对每个乘子multiplier,一次性更新一整行,这符合行优先访问。 - 循环展开与SIMD:现代编译器可以自动进行一定程度的循环展开和向量化(SIMD)。确保编译时开启优化标志(如
-O2或-O3)。对于性能关键部分,可以尝试使用编译器内部函数(intrinsics)或直接调用优化好的BLAS库(如dger用于秩-1更新)。 - 分块算法:为了更好利用CPU缓存,可以将矩阵分块,在块内进行LU分解,这就是LAPACK中
dgetrf例程使用的技术。这能显著提升大规模矩阵分解的性能。 - 并行化:消元过程的外层循环(k循环)是串行的,但内层的行更新(i循环)可以并行化。可以使用OpenMP指令来并行化i循环。
// 一个简单的OpenMP并行化示例(在消元部分) #include <omp.h> ... for (size_t k = 0; k < n_; ++k) { // ... 选主元 ... #pragma omp parallel for for (size_t i = k + 1; i < n_; ++i) { LU_[i * n_ + k] /= pivot; double multiplier = LU_[i * n_ + k]; for (size_t j = k + 1; j < n_; ++j) { LU_[i * n_ + j] -= multiplier * LU_[k * n_ + j]; } } }注意事项:并行化时,选主元步骤必须是串行的。另外,动态调度可能更适合负载不平衡的情况。并行化会引入开销,对于小矩阵(n<500)可能得不偿失。
5. 进阶话题与扩展方向
一个基础的LU分解类已经完成,但要在实际项目中应用,可能还需要考虑以下扩展:
5.1 处理非方阵与秩亏矩阵
我们的实现假设矩阵是方阵且非奇异。现实中的数据可能是超定(方程数多于未知数)或欠定(未知数多于方程数)的。对于超定系统,通常需求最小二乘解,这需要QR分解或SVD。对于欠定系统,需求最小范数解。对于方阵但秩亏的情况,需要更复杂的处理,如使用部分主元QR分解(Column Pivoting QR)或奇异值分解(SVD),它们能提供数值秩和最小二乘解。
5.2 稀疏矩阵支持
工程和科学计算中的矩阵常常是稀疏的(绝大多数元素为零)。对稀疏矩阵进行稠密LU分解会浪费大量内存和计算时间。稀疏LU分解需要:
- 特殊的数据结构:如CSR(Compressed Sparse Row)或CSC格式。
- 符号分解:在数值分解前,根据矩阵的非零模式确定L和U的非零结构,并预先分配内存。
- 更复杂的选主元策略:需要在保持数值稳定性和减少填充元(fill-in,即分解过程中产生的新的非零元)之间权衡。常用的有近似最小度(AMD)或嵌套剖分(Nested Dissection)等重排序算法。 实现一个高效的稀疏LU分解是一个庞大的工程,通常直接使用专业库如SuiteSparse(包含UMFPACK)、SuperLU或MUMPS。
5.3 与现有库的对比与集成
在C++生态中,已经有非常成熟的线性代数库:
- Eigen: 纯头文件库,接口优雅,性能优秀。它的
PartialPivLU类提供了与我们实现类似的功能,但经过深度优化,支持动态大小、表达式模板等。 - Armadillo: 语法类似MATLAB,易用性高。
- LAPACK + C++封装: 如Intel MKL、OpenBLAS,提供工业级强度的
dgetrf(分解)和dgetrs(求解)函数。
在项目中,除非有极特殊的需求(如教学、嵌入式环境或需要绝对控制权),否则直接使用这些成熟库是更明智的选择。我们的实现过程最大的价值在于理解底层原理,以便在出现问题时能够调试,并知道如何正确调用这些库。
5.4 错误处理与接口设计
我们的实现使用了C++异常来报告错误(如奇异矩阵、尺寸不匹配)。在生产环境中,可能需要更细致的错误码枚举。接口设计上,可以考虑:
- 提供
isSingular()方法。 - 提供
conditionNumber()估计(虽然计算昂贵)。 - 提供
refine()方法,利用迭代 refinement 提高解的精度。 - 支持
float和double的模板化。
6. 常见问题与调试技巧实录
在实际编码和调试中,你几乎一定会遇到以下问题:
结果不对,但小矩阵测试又好像是对的?
- 检查选主元:这是最容易出错的地方。确保在交换行时,L矩阵中已计算的部分(前k-1列)也要同步交换。在我们的代码中,由于L的乘子存储在
LU_的严格下三角,交换行k和max_row时,必须交换这两行的全部元素(从0列到n-1列),而不仅仅是第k列之后的元素。我最初的实现就曾只交换了第k列之后的元素,导致结果错误。 - 检查索引:C++是0-based索引,而很多算法描述是1-based。在循环边界上(
< n_还是<= n_-1)极易出错。仔细核对每个循环的起始和终止条件。
- 检查选主元:这是最容易出错的地方。确保在交换行时,L矩阵中已计算的部分(前k-1列)也要同步交换。在我们的代码中,由于L的乘子存储在
求解时程序崩溃或得到NaN/Inf?
- 检查主元是否为零:在消元前,即使选了主元,也可能出现主元为零的情况(矩阵奇异)。我们的代码用
singular_标记,并在solve中抛出异常。你也可以选择在分解时直接抛出异常。 - 检查除数:在后向替换中,除以
U[i][i]前,检查其绝对值是否小于一个极小阈值(如1e-12)。 - 检查输入矩阵:确认输入矩阵是方阵,且没有包含NaN或Inf值。
- 检查主元是否为零:在消元前,即使选了主元,也可能出现主元为零的情况(矩阵奇异)。我们的代码用
性能比Eigen或LAPACK慢几十倍?
- 这是正常的。我们的实现是朴素的O(n³)算法,没有缓存优化、没有循环展开、没有使用SIMD指令。Eigen和LAPACK使用了分块算法、手工优化的汇编内核(如BLAS),性能有数量级差距。不要期望自己的教学实现能达到专业库的性能。它的价值在于可控和可理解。
如何处理复数矩阵?
- LU分解同样适用于复数矩阵。你需要将数据类型从
double改为std::complex<double>。选主元时,比较绝对值应使用std::abs(对于复数,它返回模)。其他算法步骤完全一致。
- LU分解同样适用于复数矩阵。你需要将数据类型从
想支持动态矩阵大小,但不想用
vector<vector<double>>?- 可以使用单个
std::vector<double>并按行优先存储,就像我们做的那样。在构造函数中,你可以接受一个指向一维数组的指针和矩阵尺寸,或者接受一个二维vector但将其展平。提供rows()和cols()接口。Eigen的MatrixXd就是这种设计的典范。
- 可以使用单个
最后,分享一个我自己的调试故事:曾经在一个物理仿真项目中,LU求解器偶尔会给出离奇的结果。最终发现,问题不是出在LU分解本身,而是在构造矩阵A时,由于浮点数累加误差,导致一个理论上应该对称的矩阵出现了极其微小的不对称(~1e-15)。这部分不对称性被LU分解放大,导致了不稳定。解决方案不是修改LU代码,而是在构造矩阵时使用更高精度的累加(如Kahan求和),或者对矩阵进行对称化处理A = (A + A.transpose()) / 2.0。这个故事告诉我们,数值算法的稳定性是一个系统工程,输入数据的质量至关重要。