# 灾难性抵消 · 两个相近的数相减

> 累积误差是慢性病，灾难性抵消是急性发作：两个相近的数一相减，有效数字当场湮灭，相对误差瞬间放大千万倍。这一章用求根公式的经典翻车现场演示它，并教你怎么重排公式把它绕开。

- 作者：David（道雾轩）
- 专栏：数值计算的艺术（https://daiw.org/manual/numerical-computing.md）
- 最后更新：2026-07-14
- 原文：https://daiw.org/manual/numerical-computing/cancellation
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 灾难性抵消 · 两个相近的数相减

上一章的累积误差是「慢性病」——攒很多次才出问题。这一章的**灾难性抵消（catastrophic cancellation）** 是「急性发作」：**一步就能让结果错得面目全非。** 它是数值计算的头号杀手，却又极其隐蔽。

## 症状：相近的数相减，有效数字湮灭

问题出在**两个非常接近的数相减**。假设你有两个数，各自准确到 16 位有效数字，但它们前 15 位都相同：

$$
\begin{aligned}
a &= 1.2345678901234\,\mathbf{5}\ldots \\
b &= 1.2345678901234\,\mathbf{2}\ldots \\
a - b &= 0.0000000000000\,\mathbf{3}\ldots
\end{aligned}
$$

相减后，前 15 位那些相同的、可信的数字**全抵消没了**，只剩最后一位——而那一位恰恰是**精度最差、最不可信**的。原本 16 位有效数字，一减只剩 1 位，**相对误差瞬间放大了 10¹⁵ 倍**。数字没变多，可信度崩了。

## 翻车现场：求根公式

最经典的例子是初中就会背的求根公式 $x = \dfrac{-b \pm \sqrt{b^2-4ac}}{2a}$。照着写：

```python
import math
def roots_naive(a, b, c):
    d = math.sqrt(b*b - 4*a*c)
    return ((-b + d) / (2*a), (-b - d) / (2*a))

print(roots_naive(1, 1e8, 1))
# (-7.450580596923828e-09, -100000000.0)
```

两个根，大的那个 $-10^8$ 没问题；可**小的那个根应该是 $-10^{-8}$，算出来却是 $-7.45\times10^{-9}$——错了约 25%！** 为什么？看小根那一支：$-b+\sqrt{b^2-4ac}$，当 $b=10^8$ 时，$\sqrt{b^2-4ac}\approx 10^8$，于是 $-10^8 + 10^8$——**两个近乎相等的大数相减**，灾难性抵消，精度全丢。

## 解药：重排公式，绕开减法

抵消不是算不出，是**公式写错了**。绕开它的思路：**别让两个相近的数相减。** 对求根公式，诀窍是用韦达定理 $x_1 x_2 = \dfrac{c}{a}$——先用「不抵消」的那一支算出一个根，另一个根用乘除关系反推：

```python
def roots_stable(a, b, c):
    d = math.sqrt(b*b - 4*a*c)
    q = -(b + math.copysign(d, b)) / 2   # 让 b 和 √ 同号相加,绝不抵消
    return (q / a, c / q)                # 另一个根由 x1·x2 = c/a 反推

print(roots_stable(1, 1e8, 1))
# (-100000000.0, -1e-08)                  ← 两个根都对了
```

关键在 `math.copysign(d, b)`：让 $b$ 和 $\sqrt{\cdot}$ **取相同符号再相加**——同号相加只会越加越大，**永远不会抵消**。先稳稳算出一个根 $q$，另一个根用 $c/q$ 得到，躲开了那个危险的减法。同样的输入，换个写法，小根从错 25% 变成精确。**公式没变、数学等价，数值行为天差地别——这就是数值计算的「艺术」。**

## 它藏在很多常见计算里

<Callout type="warn">
  灾难性抵消防不胜防，几个高频「案发地点」记一下：

  - **方差的朴素公式** $\frac{1}{n}\sum x_i^2 - \bar{x}^2$：当数据均值大、方差小时，两个大数相减，方差算出来可能是**负的**（！）。改用两遍算法（先求均值再求偏差平方和）或 Welford 在线算法。
  - **$1 - \cos x$（x 很小时）**：$\cos x \approx 1$，相减抵消。改用恒等式 $1-\cos x = 2\sin^2(x/2)$。
  - **$\sqrt{x+1} - \sqrt{x}$（x 很大时）**：两个相近的根号相减。改成有理化后的 $\dfrac{1}{\sqrt{x+1}+\sqrt{x}}$（变成相加，不抵消）。
  - **数值微分** $\frac{f(x+h)-f(x)}{h}$：$h$ 很小时 $f(x+h)\approx f(x)$，抵消——这是[数值微分那章](https://daiw.org/manual/numerical-computing/differentiation)「天生不稳」的根源。

  **规律：凡是「两个相近的量相减」，就亮红灯。** 看到就想：能不能用恒等式、有理化、或换个等价公式，把这个减法消掉？
</Callout>

## 小结与下一章

灾难性抵消是「两个相近的数相减，有效数字当场湮灭，相对误差爆炸」。求根公式是最经典的翻车现场：朴素写法小根错 25%，用韦达定理 + `copysign` 绕开减法即精确。它藏在方差、$1-\cos x$、$\sqrt{x+1}-\sqrt x$、数值微分等无数地方——**看到「相近的数相减」就警觉**，重排公式绕开它。

抵消是「算法」层面的误差放大——换个写法能救。但还有一种放大是**问题本身自带**的，再好的算法也救不了。区分这两者，是误差分析的最后一块拼图。👉 [条件数与稳定性：是问题病态，还是算法不稳](https://daiw.org/manual/numerical-computing/conditioning)。

## 参考文献

- [1] David Goldberg.「What Every Computer Scientist Should Know About Floating-Point Arithmetic.」1991. —— 抵消与求根公式案例。
- [2] Nick Higham.《Accuracy and Stability of Numerical Algorithms》, §1.7 —— 抵消的系统分析。
- [3] Welford.「Note on a method for calculating corrected sums of squares.」1962. —— 稳定方差算法。
