做仿真项目时,我被一组冷却系统的热平衡方程卡了将近一周。方程组里有流量、壁面换热量、出口温度三个未知数,彼此耦合,还夹着指数项和对数项,想用高斯消元直接“解出来”根本不现实。后来换用非线性方程组的牛顿迭代法,配合 MATLAB 手写迭代程序,几十行代码就把问题解决了。这篇文章我就把从一元牛顿法推广到多元方程组的推导、完整可跑的 MATLAB 程序、收敛性分析和实测踩坑记录都整理出来,适合正在学数值分析、或者需要在 MATLAB 里快速求解非线性方程组的同学参考。代码可以直接抄走,但更重要的是理解每一步背后的原因,否则一旦结果不对,你连从哪里开始查都不知道。
1. 为什么非线性方程组没法随便“消元”
先泼一盆冷水:凡是打算靠高斯消元把非线性方程组硬“解”出来的想法,趁早放下。线性方程组为什么好解?因为加减乘除在有限步内能把未知数一个一个消掉,整个过程不产生新的未知数结构。但非线性方程组完全不一样,我前面说的热平衡模型就是典型例子:方程右边有指数项,温度藏在对数项里,流量之间互相乘积,你消掉一个变量,另一个变量的高次项立刻长出来,越消越乱,根本走不到头。
这不是个别现象。电路仿真里的二极管方程叠加在节点电压上,化学平衡计算里各组分的浓度关系,流体管网里压力和流量的非线性映射——只要模型靠近真实世界,非线性方程组就躲不开。这类问题的特点是:变量互相纠缠,函数整体可能不是单调的,甚至解有几个都不一定说得清。所以工程和科研里几乎没有人追求解析解,而是转去逼近数值解。
逼近的基本思路不复杂:非线性函数在很小的局部范围内可以看成直的,那我就用一条条“切线段”去贴合原曲线,每走一小步重新贴合一次,反复迭代直到落进真解附近。这正是牛顿法的核心哲学,把一个非线性问题拆成一串线性问题来解。注意“一串”这个词意味着牛顿法天然是迭代法,它不像高斯消元那样一次性结束。
既然要迭代,写程序前就必须先想清楚两件事:迭代到什么程度算收敛,迭代到上限还不收敛怎么办。这两个问题不算清楚,后面程序写得再漂亮也是白搭。我的经验是不管多么自信,迭代上限一定得写,容差一定得给,这是所有数值算法的底线。
2. 多元牛顿法的数学推导:从一元切线到雅可比矩阵
2.1 一元牛顿法回顾
单变量情形大家多少都有印象。要求解 f(x) = 0,取一个初始点 x₀,把 f 在 x₀ 处做一阶泰勒展开:
f(x) ≈ f(x₀) + f′(x₀)(x − x₀)
令右边等于 0,解出新的近似点:
x₁ = x₀ − f(x₀) / f′(x₀)
几何上就是过 (x₀, f(x₀)) 作曲线切线,切线与 x 轴的交点就是 x₁。然后以 x₁ 为新的起点重复,直到 f(xₖ) 足够小。为什么一阶展开就够了而不是非要展开到二阶?因为只要初值离真解足够近,高阶项的贡献远小于线性项,忽略掉之后产生的误差会在迭代中自己修正回来,这就是“以线性化误差换迭代收敛”的典型思路。
2.2 多元情形的推广
现在有 n 个方程、n 个未知数,写成一个向量方程:
F(x) = 0,其中 x = (x₁, x₂, …, xₙ)ᵀ,F = (F₁, F₂, …, Fₙ)ᵀ
把每一个 Fᵢ 都在 xₖ 处做一阶泰勒展开,忽略高阶项后整理成向量形式:
F(x) ≈ F(xₖ) + J(xₖ)(x − xₖ)
这里的 J 就是雅可比矩阵,它的第 i 行第 j 列是 ∂Fᵢ/∂xⱼ,也就是第 i 个函数对第 j 个变量的偏导数在 xₖ 处的取值。多元函数的“切线”就是由这一组偏导数张成的超平面,J 的每一行描述了一个方程在当前点的局部坡度方向。
令 F(x) = 0,就得到一个关于修正量 Δx 的线性方程组:
J(xₖ)Δx = −F(xₖ)
解出 Δx 后更新:
xₖ₊₁ = xₖ + Δx
这就是多元牛顿法的全部核心。每一步的核心工作量是解一个线性方程组,这是整个算法的主成本。和一元牛顿法一样,这里的 J 对应导数,Δx 对应 −f/f′。
2.3 手推一个迭代步
为了不让推导停在抽象层面,用我文章里反复出现的一个例子:
F₁ = x₁² + x₂² − 4 F₂ = x₁² − x₂² − 1
这个系统有四个解析根:x₁ = ±√2.5,x₂ = ±√1.5。雅可比矩阵是:
J = [2x₁, 2x₂; 2x₁, −2x₂]
从初值 x₀ = (1.5, 1.0) 出发:
F(x₀) = (−0.75, 0.25)ᵀ J(x₀) = [3, 2; 3, −2]
解线性方程组 [3 2; 3 −2]Δx = (0.75, −0.25)ᵀ,得到 Δx = (0.08333, 0.25)ᵀ,所以 x₁ = (1.58333, 1.25)。算一下这一点的残差:
F(x₁) ≈ (0.06944, −0.05556)ᵀ
残差从 0.75 一步掉到 0.069,降了一个数量级还多。再迭代一轮,残差降到 6.9e−4,第三轮直接到 6.8e−8,第四轮就触到 1e−15 量级的机器精度。这个下降速度就是所谓的“二次收敛”:误差大致按平方比例缩小,肉眼可见地往里冲。我下面把完整迭代过程列出来,你会发现从第二步开始,修正量就已经小到小数点后两位了,这种收敛速度是一般不动点迭代完全没法比的。
| 迭代步 | x₁ | x₂ | 残差 ‖F‖∞ |
|---|---|---|---|
| 0 | 1.500000 | 1.000000 | 7.50e−1 |
| 1 | 1.583333 | 1.250000 | 6.94e−2 |
| 2 | 1.581140 | 1.225000 | 6.89e−4 |
| 3 | 1.581139 | 1.224745 | 6.78e−8 |
| 4 | 1.581139 | 1.224745 | 6.67e−16 |
3. MATLAB 实现:可以直接抄的完整程序
3.1 主函数 NewtonSys
先把核心程序放上来。代码不长,但每个细节都值得说两句。
function [x, iter, hist] = NewtonSys(F, J, x0, tol, maxit) % [x, iter, hist] = NewtonSys(F, J, x0, tol, maxit) % 用牛顿迭代法求解非线性方程组 F(x) = 0 % 输入: % F - 函数句柄,输入列向量 x,返回列向量 F(x) % J - 函数句柄,输入列向量 x,返回雅可比矩阵 J(x) % x0 - 初始迭代点(列向量) % tol - 收敛容差,按残差无穷范数判断 % maxit - 最大迭代次数,防止死循环 % 输出: % x - 最终近似解 % iter - 实际迭代步数 % hist - 每一步的残差记录 x = x0(:); hist = zeros(maxit, 1); iter = 0; f = F(x); hist(1) = norm(f, inf); while norm(f, inf) > tol && iter < maxit A = J(x); dx = -A \ f; % 用左除解线性系统,不要用 inv(A)*f x = x + dx; iter = iter + 1; f = F(x); hist(iter + 1) = norm(f, inf); if iter <= 5 || mod(iter, 10) == 0 fprintf('iter %2d: x = [%s], ||F|| = %.3e\n', ... iter, num2str(x(:)'), norm(f, inf)); end end hist = hist(1:iter + 1); if norm(f, inf) > tol warning('NewtonSys:NoConvergence', ... '迭代 %d 步后残差仍为 %.2e,可能未收敛', iter, norm(f, inf)); end end终止条件这里我默认用残差无穷范数 ‖F(xₖ)‖∞ < tol。另一种常见做法是看迭代步长 ‖Δx‖,两者侧重不同:残差小说明当前点确实接近零点,步长小说明点基本不动了。但点不动不保证残差小,平坦区域里很容易出现“假收敛”。所以工程上最稳妥的做法是两者结合,判断收敛时同时看残差和步长。我自己写更严肃的程序时,会在主判断用残差之外,额外记录最近两轮的 Δx,如果连续两轮步长极小但残差没有下降,就直接报警返回,不继续空转。
3.2 数值雅可比函数
雅可比矩阵有两种来源:解析求导和数值差分。解析雅可比精度高,但要求你先手搓偏导,工程场景里函数往往嵌套了子程序,手求很容易错。我的习惯是先用数值差分把整套流程跑通,确认逻辑无误后再考虑换解析雅可比。
function Jac = NumJacob(F, x, h) % 用中心差分近似函数 F 在点 x 处的雅可比矩阵 if nargin < 3 h = 1e-6; end n = length(x); F0 = F(x); Jac = zeros(n, n); for j = 1:n xp = x; xp(j) = xp(j) + h; xm = x; xm(j) = xm(j) - h; Jac(:, j) = (F(xp) - F(xm)) / (2*h); end end这里用中心差分而不是前向差分,是因为中心差分的截断误差是 O(h²),前向差分只有 O(h)。代价是多算一次函数值,但换来精度提升是划算的。h 的选取有个经验区间:太小会在减法时被舍入误差吃掉,太大又引入大的截断误差,我一般取 1e−6 到 1e−8。如果函数本身计算有噪声,把 h 稍微调大反而更稳。
3.3 调用示例与验证
用第 2 节的例子完整跑一遍:
F = @(x) [x(1)^2 + x(2)^2 - 4; x(1)^2 - x(2)^2 - 1]; J = @(x) [2*x(1), 2*x(2); 2*x(1), -2*x(2)]; x0 = [1.5; 1.0]; [x, iter, hist] = NewtonSys(F, J, x0, 1e-8, 50); fprintf('解为 x = [%.10f, %.10f],迭代 %d 步\n', x(1), x(2), iter); % 用数值雅可比交叉验证解析雅可比 Jnum = NumJacob(F, x, 1e-7); disp(Jnum - J(x)); % 应该接近零矩阵运行后你会看到残差序列按二次收敛的节奏飞快下降,四步左右就停在机器精度附近,解为 x = (1.5811388, 1.2247449)。那句解析雅可比和数值雅可比的对比输出,如果差别在 1e−6 量级以下,说明你手写的 J 没写错;如果差别很大,回头检查偏导公式,这是我很喜欢用的一个自检手段。
4. 收敛性与初始值选择:为什么有时候就是算不出来
4.1 收敛定理到底说了什么
数值分析教材里关于牛顿法的基本定理是这么说的:若 F(x*) = 0,雅可比 J(x*) 可逆,且 F 在真解 x* 附近足够光滑,那么当初始点 x₀ 充分靠近 x* 时,牛顿迭代收敛,且收敛阶至少为 2。这里的“收敛阶至少为 2”,意味着误差大致按平方递减:从 1e−3 到 1e−6 再到 1e−12,三两个来回直接到底。这也是为什么牛顿法一旦进入状态,快得让人愉悦。
但请注意定理里的两个前提。第一,J(x*) 必须可逆。如果真解处雅可比奇异,牛顿法要么收敛极慢,要么干脆发散。第二,也是最致命的,“x₀ 充分靠近 x*”。这个“充分靠近”没有任何量化保证,它意味着牛顿定理本质上是局部收敛定理,初值的好坏直接决定命运。现实中大量翻车都不是算法错了,而是初值落在了收敛域之外。
定理还隐含一个容易忽略的点:每一步迭代中的 J(xₖ) 也得是可逆的,哪怕最终的 J(x*) 可逆也不行。迭代中途雅可比完全可能变成病态矩阵,这种情况我在下一节会专门讲。
4.2 同一系统、不同初值的真实表现
还是那个方程组,我换了几组初值实测,结果差异非常大:
| 初始点 | 初始残差 | 收敛状态 | 备注 |
|---|---|---|---|
| (1.5, 1.0) | 0.75 | 4 轮收敛到 1e−8 | 典型顺利路径,二次收敛特征明显 |
| (1.0, 0.5) | 2.75 | 约 7 轮收敛 | 前两轮残差先升后降,过程有些颠簸 |
| (0.1, 0.1) | 3.98 | 发散 | 雅可比接近奇异,修正量爆炸 |
| (−1.5, 1.5) | 1.00 | 收敛到 (−√2.5, √1.5) | 同一方程的另一根 |
最值得分析的是 (0.1, 0.1) 这一组。此时雅可比 J = [0.2, 0.2; 0.2, −0.2] 的行列式接近零,解出来的 Δx 各分量被放得极大,一步就把迭代点甩到 (12.55, 7.55) 附近,新一轮函数值大得离谱,再冲更远,直接失控。这不是代码 bug,是牛顿法的本性:它不像二分法那样有上下界,一旦出了收敛域,修正量可能大到让你怀疑人生。
从 (1.0, 0.5) 这一组还能观察到有意思的现象:前两轮残差不降反升。这说明牛顿迭代在收敛过程中允许“绕路”,它不是单调逼近的。所以看到残差暂时变大不用慌,只要后面能掉头回来就行;但如果连续五六轮都在涨,那基本就是出去了,该干预就干预。
4.3 工程里初值到底怎么选
既然牛顿法对初值这么敏感,实际问题里初值从哪来?我常用的策略有四个。
第一,用更稳但更慢的算法先粗定位。一维问题用二分或扫点,多维问题可以用下一节讲的阻尼牛顿,先把解圈进一个不算离谱的邻域,再用纯牛顿加速。
第二,画图辅助。二维问题直接把两条隐式曲线 F₁ = 0 和 F₂ = 0 画出来,肉眼就能看出根在哪个象限、有多少个根。三维以上没有直观图,但可以固定一部分变量做切片扫描。
第三,从物理量级出发。工程模型里的未知数通常有天然的范围约束,温度不会超过热源温度,流量不会超过泵的额定值。结合量级取初值,往往已经足够靠近收敛域。我那个热平衡模型就是把初值设在环境温度附近,一次就过了。
第四,用同伦延续法。把问题从“好解的版本”连续过渡到“真正的问题”,比如把方程组里的某个参数从 0 慢慢推到目标值,每一步用上一步的解作为初值。这招我在一个相平衡算例里救过场,代价是代码量多几十行,但稳定性提升显著。
4.4 阻尼牛顿:牺牲速度换存活率
如果初值实在没把握,还有个中间方案:给修正量加一个步长因子 λ,令 xₖ₊₁ = xₖ + λΔx,λ 在 (0, 1] 之间。每步先全量试探,如果发现残差反而变大,就把 λ 减半重来,保证每步残差不升。这就是阻尼牛顿法,核心逻辑加在循环里就行。
lambda = 1; x_new = x + lambda * dx; while norm(F(x_new), inf) > norm(f, inf) && lambda > 1e-4 lambda = lambda / 2; x_new = x + lambda * dx; end x = x_new;阻尼牛顿在初值较差时存活率大幅提升,代价是每步可能要额外算几次 F,收敛阶也掉到接近线性。我的用法很明确:正式跑之前先用阻尼牛顿探路,把解的粗略位置确定下来,然后切回纯牛顿拿到二次收敛的速度。两种模式切换只需要一个开关量,代码上非常轻。
5. 实测踩坑记录:奇异雅可比、振荡发散与死循环
牛顿法程序好写,难的是出了情况能快速定位。这里把我踩过的三个坑完整复盘一下,都是真实项目里遇到过的事。
5.1 坑一:雅可比矩阵接近奇异
有一次跑两相流压力方程,某轮迭代点落在函数形态的“鞍部”,偏导数几乎为零,雅可比矩阵行列式小到离谱。这时候用A \ f解出来的 Δx 元素达到几十上百,一步把解带到完全离谱的区域,下一轮函数值直接溢出,输出 NaN。排查方法很简单:在循环里用cond(A)看条件数,大于 1e12 基本就是病态了。遇到这种情况,要么退回阻尼牛顿把步长掐住,要么给迭代点加一个微小扰动跳出鞍部,要么干脆换初值重来。另外程序里一定要判断新解出的 Δx 是否包含 NaN 或 Inf,一旦出现立刻终止并提示,不要带着 NaN 继续循环,不然后面所有结果都是垃圾。
5.2 坑二:在多组解之间振荡
发散不一定是冲远了,也可能是周期性振荡。我调试另一个方程组时发现残差序列在 0.8 和 3.2 之间跳来跳去,每两轮一个循环,永远降不下去。原因大概率是方程在某个区域里存在多个根,初值恰好落在两个根的收敛域交界处,牛顿迭代在无法察觉的边界上来回横跳,无法选边。
识别振荡有个技巧:把每步残差打印出来看是否存在周期性规律。如果呈现重复序列,换初值比任何参数调优都有效。也可以故意在 x 上加一个微小的随机扰动,看它能不能跳出这个循环,但这属于碰运气,不推荐依赖。
5.3 坑三:无限循环与假收敛
初写牛顿程序的人最容易漏掉迭代上限。雷区在于:某些场景下残差下降极慢但始终不为零,比如迭代逼近某个退化点,残差每轮只能下降几个量级,程序能在那儿磨上百万步。所以 maxit 一定不能省。50 次对于牛顿法已经非常充裕,超过 50 次还没收敛的话,问题基本不在迭代次数,而在初值或函数性态。
假收敛则是另一类事故:残差停在某个非零常数,迭代点却几乎不动。常见原因是用户给的函数句柄写错了,比如变量下标引用错误。最有效的防御是收敛后自检:再算一次 F(x_star),看残差是不是真的小于容差,同时换一组初值重跑,对比解是否一致。这两步加起来就几行代码,但能挡住大量莫名其妙的结果。
5.4 调试流程清单
把上面所有经验整理成一套排查顺序,我每次新问题都按这个走:
- 打印每步的 x、‖F‖、cond(J);
- 出现 NaN/Inf 立刻停止,检查函数句柄和初值;
- 残差持续不降时,怀疑初值不在收敛域,重新定位初值;
- 残差呈周期性跳动时,换初值或加扰动;
- 收敛后自检 F(x_star) 的真实残差;
- 换一组物理上合理的初值重跑,确认是否得到同一个根。
这 6 步走完,九成以上的牛顿法问题能定位到根因,剩下的是函数本身病态程度太高的硬骨头,那就不只是换算法能解决的了。
6. 从牛顿法到拟牛顿法:降低雅可比计算成本
6.1 雅可比的计算开销可能比你想象的大
牛顿法每步要组装雅可比矩阵并解一个线性系统,n 元问题一步就是 O(n²) 到 O(n³) 的复杂度。工程场景里函数本身可能很贵,比如每算一次 F 要跑一个内部迭代循环,这时候数值差分求雅可比的代价会被放大,整体循环慢到让人怀疑人生。我在一个化学平衡算例里就吃过这个亏:函数求值本身就是重的,雅可比差分额外带来几十倍开销,原来在教科书里“几轮收敛”的算法,实际跑起来却像在等蜗牛爬。
6.2 Broyden 秩一修正的思路
拟牛顿法的核心思路是:不重新计算完整的雅可比,而是每步用一个低秩修正去更新已有近似。最主流的是 Broyden 方法。设 Bₖ 是第 k 步对雅可比的近似,迭代格式为:
Bₖsₖ = −F(xₖ) xₖ₊₁ = xₖ + sₖ yₖ = F(xₖ₊₁) − F(xₖ) Bₖ₊₁ = Bₖ + (yₖ − Bₖsₖ)sₖᵀ / (sₖᵀsₖ)
它把牛顿法每轮“计算 n 次函数值求雅可比”的成本,降成了“只多算一次 F(xₖ₊₁) 加一次线性系统求解”。代价是收敛阶从二次掉到超线性(约为 1.618 阶),但在每轮成本大幅下降的前提下,总耗时往往更划算。你可以把 Broyden 理解为一个在“重用旧信息”和“保持修正方向合理”之间找平衡的方法。
6.3 到底怎么选:几种方案横向对比
| 方案 | 每轮函数求值次数 | 收敛阶 | 鲁棒性 | 适用场景 |
|---|---|---|---|---|
| 解析牛顿法 | 1 次 F + 1 次解析 J | 2 | 一般 | 函数简单、雅可比易求 |
| 数值差分牛顿法 | 2n+1 次 F | 2 | 一般 | 万能兜底、小规模问题 |
| 阻尼牛顿法 | 1 + 回溯次数 次 F | 接近线性 | 较好 | 初值差、函数病态 |
| Broyden 拟牛顿 | 2 次 F | 约 1.618 | 尚可 | 函数求值昂贵、规模略大 |
我的工程建议是:当 n ≤ 10 且函数求值便宜时,直接上数值差分牛顿法,代码简单、可读性强,出了问题也好解释;当函数求值昂贵或 n 超过 10 时,优先考虑 Broyden 类拟牛顿;当初值把握不足时,先用阻尼牛顿探路,后切纯牛顿。这套组合在大多数仿真和数据分析场景里都够用。
6.4 更大规模怎么办
如果未知数继续涨到几十上百,牛顿法每步对完整雅可比做 LU 分解的 O(n³) 成本就扛不住了。这时候思路要转向:用稀疏雅可比配合稀疏线性求解器,或者转向无导数优化类方法,再或者用 Krylov 子空间思想去近似求解每一步的线性系统。普通科研和工程模型到这个规模之前,前面几节讲的方法已经足够。真踩到大规模场景,建议直接读数值线性代数的专门章节,那已经是另一个层次的内容,不是一篇文章能讲完的了。
7. 写在最后:三个教训和一个建议
最后说点实际应用里的个人体会。牛顿法是一匹性格刚烈的马:驯服了它,收敛速度之快让人上瘾;没驯服,它一脚把你踢进 NaN 的深渊。我踩过不少坑之后,总结成三个铁律:第一,迭代上限永远要写,这不是防御性编程,这是保命;第二,循环里一定要打印残差,哪怕只看一眼,也能让你在发散前及时动手;第三,给初值留个心眼,先用笨办法探路,再让牛顿法冲锋。
如果你只是要一个结果,也可以先直接调 MATLAB 自带的 fsolve 把解算出来,再用我给的 NewtonSys 去复现对比,把它当成一个理解算法内部机制的解剖工具。搞懂了每一步在做什么,你才能真正掌控它,而不是靠运气算出个数字。这大概就是数值方法最让人着迷的地方:所有失败都有迹可循,所有成功都可以复现。