卷 I · 栅栏CH 03深度 3/20

减法不产生误差

数值计算里最有名的坑叫「灾难性相消」,它的名字造成了一个持久的误解:好像减法是一种危险操作,会把误差搞出来。事实正好相反——两个相近的 double 相减,结果精确到一个 ulp 都不差,这是可以证明的定理。可就是这个最诚实的运算,能在一步之内让八位有效数字消失。

Sterbenz 引理相消一步丢 8 位

▷ 猜一下差多少

要算 √(x+1) − √x,在 x = 100 000 000(一亿)处。这是个再普通不过的表达式,中学生都会。照着写:

Math.sqrt(x + 1) - Math.sqrt(x)

问:这样算出来的答案,有几位有效数字是对的?(double 一共能给你 15 到 16 位)

A 全对。这里只有一次减法和两次开方,都是硬件直接支持的运算,各自误差不超过半个 ulp B 对 15 位。丢掉最后一两位很正常 C 只对 8 位。一半的有效数字没了 D 一位都不对

顺便想一下:如果把 x 换成一万亿(10¹²),情况会变好还是变坏?

先证明减法是无辜的

有一条很短的定理,叫 Sterbenz 引理

◆ Sterbenz 引理

如果两个浮点数 ab 满足 b/2 ≤ a ≤ 2b(也就是它们相差不到一倍),那么 a − b 可以被精确表示,减法的结果一个 ulp 都不会被抹。

为什么?想想上一章那把尺子。ab 相差不到一倍,说明它们最多跨了一个段,两者所在位置的刻度间距最多差一倍。它们的差是刻度间距的整数倍,而这个差比 a 本身小,落在更靠近原点、刻度更的地方。既然差值是粗刻度的整数倍,它当然也落在细刻度上——结果一定在格子上,不需要抹。

所以:两个相近的数相减,是浮点运算里最精确的一种操作。这不是修辞,是定理。

那八位有效数字是怎么没的?

误差不是被创造的,是被提拔的

x = 1e8 那一步拆开看。√(x+1)√x 各自都要先存成 double,各自都被抹过一次:

√(100000001) = 10000.0000500000⟨00555883…⟩    ← 存进 double,误差约 1e−12
√(100000000) = 10000                          ← 正好,精确

相减(Sterbenz:这一步精确,一格都不抹):
             = 0.0000500000⟨00555883…⟩

正确答案是   = 0.00004999999987⟨5000…⟩
                          ↑ 从这一位开始就不对了

看清楚发生了什么。√(x+1) 那个数的绝对误差大约是 10⁻¹²——相对它自己(一万)来说是 10⁻¹⁶,非常小,完全合格。

然后减法把前面那四位「10000.」全部消掉了。剩下的结果只有 5×10⁻⁵ 那么大,可是那个 10⁻¹² 的绝对误差原封不动地留了下来。它相对于新结果的比例,一下子从 10⁻¹⁶ 变成了 2×10⁻⁸

相减前:绝对误差 1e−12,相对误差 1e−16   ← 合格
相减后:绝对误差 1e−12(一点没变),相对误差 2e−8   ← 放大了一亿倍

# 减法一个新误差都没造。它只是把结果缩小了一亿倍,
# 于是原本藏在小数点后面很远的那个误差,被顶到了前排。

这就是全书第一个「放大」的例子,也是整个卷 II 的引子

丢几位可以算出来

这件事有一个很好记的定量版本:

◆ 主线

计算 a − b 时,如果 b/a 接近 1,你丢掉的有效位数约等于 −log₂|1 − b/a|

换成十进制的说法:a 和 b 前面有几位相同,就丢几位。相同得越多,丢得越多。

a = 1.0000000012345678
b = 1.0000000000000000
     └────┬───┘
      前 10 位一模一样

a − b = 1.2345677813385691e−9
        └───┬───┘
         只剩 8 位可信,后面全是 a 存进来时被抹掉的那点东西

回到开头的题:√(1e8+1)√(1e8) 前面有 8 位相同(10000.00),所以丢 8 位。答案是 C

x 换成 10¹²?那两个开方值前面相同的位数更多——实测只剩 5.1 位可信x 越大越糟,这一点也很反直觉:大多数人会觉得数越大越「稳当」。

另一个经典:(1 − cos x) / x²

这个式子在物理和图形里到处都是(它的极限是 1/2)。看它在 x 变小时的表现:

x直接照写 (1-Math.cos(x))/(x*x)改写后
10⁻³0.499999958325503260.4999999583333347
10⁻⁵0.50000004137018540.4999999999958333
10⁻⁷0.49960036108132050.4999999999999996
10⁻⁸00.5
10⁻⁹00.5

x 小到 10⁻⁸ 时,cos x 已经是 0.99999999999999999…,它存进 double 之后就是 11 − 1 = 0。整个式子返回 0,而正确答案是 0.5。

注意这里的可怕之处:没有报错,没有 NaN,没有 Infinity。它返回了一个格式完全正确、类型完全正确、看起来无比正常的 0。这类失败是这本书里最值得警惕的一种,它不会在测试里跳出来。

改写的方法是三角恒等式 1 − cos x = 2sin²(x/2)

// 这么写:两个几乎相等的数相减
(1 - Math.cos(x)) / (x * x)

// 改成:没有减法了
2 * Math.sin(x / 2) ** 2 / (x * x)

右边那个式子里,sin(x/2) 本身就是个小数,直接算就好,不需要从 1 里减出来。同一个数学量,换一种代数写法,减法就消失了。这是卷 III 的全部内容。

✎ 术语正名

灾难性相消(catastrophic cancellation)这个词里的「灾难性」,指的不是减法这个动作,而是后果——你从此再也不知道自己手上还剩几位是真的。

与之相对的叫良性相消(benign cancellation):如果参与相减的两个数本身就是精确的(比如都是小整数,或者都是刚读进来的原始数据),那么相消之后什么也不会发生,结果照样精确。

所以判据不是「有没有相减」,而是:被减的这两个数,自己身上带着误差吗?带,就危险;不带,就没事。2.0 - 1.0 永远精确,Math.sqrt(2)**2 - 2 就不是。

⌨ 自己跑一遍

相消最好的体感来自「把丢掉的位数打出来」。下面这段会打出一张表,每行是一个 x,最后一列是还剩几位可信

import math
print(f"{'x':>8}  {'直接相减':>24}  {'改写后':>24}  {'剩几位'}")
for k in range(2, 15):
    x = 10.0 ** k
    naive = math.sqrt(x + 1) - math.sqrt(x)
    good  = 1 / (math.sqrt(x + 1) + math.sqrt(x))
    rel   = abs(naive - good) / good
    left  = 16 if rel == 0 else max(0.0, -math.log10(rel))
    print(f"1e{k:<6}  {naive:>24.17g}  {good:>24.17g}  {left:.1f}")

会打出(节选):

1e2        0.049875621120889946    0.049875621120890272    14.2
1e6      0.00049999987504634191  0.00049999987500006253    10.0
1e8      5.0000000555883162e-05  4.9999999874999996e-05  ★  7.9
1e12     5.0000380724668503e-07  4.9999999999987493e-07  ★  5.1
1e14      5.029141902923584e-08  4.9999999999999872e-08  ★  2.2
1e16                          0  5.0000000000000001e-09  ★  0.0

每往右走一个数量级,就掉一位左右。到 10¹⁶ 直接归零——那时 √(x+1)√x 存进 double 之后已经是同一个数了。

python3 -c "$(pbpaste)" / 或者存成 cancel.py 再 python3 cancel.py

在线跑:python.org/shell。JS 版把 math. 换成 Math. 就行,结果逐位相同。

▸ 在现实里

GPS。卫星到接收机的距离是两万公里量级,而定位精度要做到米级——直接相减两个两万公里的数,米级的信息落在第 8 位上。所以差分 GPS 和 RTK 的做法从来不是「算得更准」,而是先减掉一个共同的基准,让剩下的量本身就是小数。这和上面 1 − cos x 的改写是同一个动作。

财务的损益。「本期利润 = 本期收入 − 本期成本」,当利润率只有 1% 时,你在用两个 100 倍大的数去算一个小数——丢两位。第 8 章会把这件事量化到一分钱。

物理模拟里的能量。检查「能量守恒」时算的是 E_now − E_start,两个几乎相等的大数。所以这类检查一定要用相对量,而且要清楚自己的判据本身能有几位可信。

回归模型的显著性。统计里 Var = E[x²] − (E[x])² 是同一个陷阱,第 10 章会看到它能把方差算成负数。

✗ 这个直觉是错的

「精度不够就上更高精度。用 long doubleDecimal/128 位,相消问题就解决了。」

高精度推迟问题,但不解决问题。相消丢掉的位数只跟「两个数前面有多少位相同」有关,跟你有多少位无关。从 53 位换到 113 位,你还是丢 8 位——只不过是从 113 位里丢,剩得多一点。

而且这个办法有个上限:如果两个量在数学上就相等(比如 x → 0 时的 1 − cos x),任何有限精度都会在某个 x 处归零,只是那个 x 更小一点。

真正解决它的是换一种写法——把减法从式子里拿掉。这不花一分钱,而且效果是无限的:改写后的 2sin²(x/2)/x² 在任意小的 x 上都给 0.5。

◇ 对账

正确答案是 C:只对 8 位。直接算给出 0.00005000000055588316,正确答案是 0.000049999999874999996,相对误差 1.362 × 10⁻⁸x 换成 10¹² 会更糟——丢 12 位。

A 「每个运算误差不超过半个 ulp,所以三步下来还是很准」——每一句都对,结论错。半个 ulp 说的是相对误差,相对于那一步的结果。减法把结果缩小了一亿倍,之前那些「相对很小」的误差,相对新结果就不小了。逐步的相对误差小,不蕴含最终的相对误差小——这是整本书里最容易犯的推理错误。 B 「丢一两位很正常」——这是把这件事当成了普通的累积舍入。可这里只有三次运算,累积舍入最多丢一位。丢掉的那七位不是累出来的,是一步之内被顶上来的。相消和累积是两种完全不同的机制,第 4 章会专门讲后者。 D 「一位都不对」——在 x = 1e8 时太悲观了,还剩 8 位。但如果 x 到 10¹⁶,D 就成了正确答案:那时 √(x+1)√x 存进 double 后完全相同,结果精确地等于 0猜 D 的直觉方向是对的,只是提前了八个数量级。
◈ 换一种写法

相消的标准解法是有理化——分子分母同乘共轭式,把减法搬到分母上(分母上是相,不会相消):

Math.sqrt(x+1) - Math.sqrt(x)  在 x=1e8 处只剩 8 位 1 / (Math.sqrt(x+1) + Math.sqrt(x))  在任何 x 上都有 16 位

推导只有一行:(√(x+1) − √x) × (√(x+1) + √x) = (x+1) − x = 1,所以原式等于 1 / (√(x+1) + √x)数学上完全等价,数值上差了八个数量级。

同一招的其他形态:1 − cos x → 2sin²(x/2)log(1+x) → log1p(x)eˣ − 1 → expm1(x)。标准库里之所以有 log1pexpm1 这两个看起来多余的函数,原因就是这一章。

这一章的一句话

减法是浮点里最诚实的运算,它一个误差都不制造;它只是把结果缩小,于是把早就存在的误差顶到了前排——所以看到「两个相近的数相减」,要问的不是这一步准不准,而是这两个数各自是从哪里来的。

下一章看另一种放大方式:不靠相消,纯靠次数。累加一千万个 0.1,得到的不是 1 000 000,而是 999 999.999838975374586880207061767578125——差了 1 383 191 个 ulp。但这里有个反直觉的地方:这个误差不是按次数线性长的,它按 √n 长。而爱国者导弹那个误差偏偏就是线性长的。区别在哪?