两个数相等,是什么意思
「浮点数不能用 == 比较,要留一个 epsilon」——这句话每个人都听过,然后每个人都写下了 Math.abs(a-b) < 1e-9,然后就再没想过这件事。这一章要说明这个写法在两个方向上都会失效,而且失效得很彻底:在大数上它永远判不等(连相邻的两个 double 都判不等),在小数上它会把相差 400% 的两个数判成相等。
0.1 + 0.2 算出来是 0.30000000000000004,而 0.3 是 0.3。这是全世界最有名的浮点数例子。
问:在数轴上,这两个数中间隔着多少个 double?
第二问:0.1 + 0.1 + 0.1 + … + 0.1(十个)等于 1.0 吗?
答案:它们是邻居
0.3 精确值 = 0.29999999999999998⟨8897769753748434595763683319091796875⟩ 0.1 + 0.2 精确值 = 0.3000000000000000⟨444089209850062616169452667236328125⟩ 真值 3/10 落在这两个 double 中间。 0.3 是离它最近的那个(所以 0.3 存进去就是它)。 0.1+0.2 恰好被推到了另一边那一格。 两者相隔:1 格。中间没有任何 double。
这件事值得停一下,因为它把整个例子的调性都改了。「0.1+0.2 ≠ 0.3」这个著名的失败,其实是可能犯的最小的错误。它不是「浮点数很不准」的证据,它是「浮点数准到只能差一格」的证据。
第二问的答案更有意思:sum([0.1] * 10) === 1.0 是 true,一格都不差。十次抹零的方向有上有下,最后正好回到刻度上。而 0.1 + 0.2 这两次就没这个运气。
所以「什么时候相等」这件事,本质上没有规律可讲——它取决于每一步被抹到了哪一边。这就是为什么需要一个比较策略。
三种比较,三种失效
| 写法 | 在哪里对 | 在哪里失效 |
|---|---|---|
a === b | 整数(< 2⁵³)、常量、位模式相同 | 任何经过运算的小数 |
|a−b| < ε绝对容差 | 你写 ε 时心里想的那个量级 | 大数:永远不等。小数:全都相等。 |
|a−b| < ε·|b|相对容差 | 绝大多数场合 | b 接近 0 时。还有:a 和 b 谁做分母?不对称 |
ulps(a,b) < N隔几格 | 直接量「差了几步舍入」,量级无关 | 跨越 0 时(+0 和 −0 之间的距离没有意义) |
把这几对数放进去试:
| a | b | |a−b| | 隔几格 | 绝对容差 1e-9 判 |
|---|---|---|---|---|
| 0.1+0.2 | 0.3 | 5.55 × 10⁻¹⁷ | 1 | 相等 ✓ |
| 10¹⁰ | 10¹⁰+10⁻⁶ | 1.907 × 10⁻⁶ | 1 | 不等 ✗(它们是邻居) |
| 10⁻¹² | 5 × 10⁻¹² | 4 × 10⁻¹² | 1.02 × 10¹⁶ | 相等 ✗(差了 400%) |
| 1 | 1+eps | 2.22 × 10⁻¹⁶ | 1 | 相等 ✓ |
| 10⁻³²⁰ | 2 × 10⁻³²⁰ | 10⁻³²⁰ | 2024 | 相等(次正规区) |
中间两行就是绝对容差的两个失效方向。它们不是边角情况——只要你的程序处理的量跨越多个数量级(价格、距离、时间、损失值),就一定会同时踩到两头。
「隔几格」怎么算
IEEE 754 有一个非常漂亮的设计:把正 double 的位模式当成 64 位整数来读,顺序和数值顺序完全一致。于是「隔几格」就是两个整数相减。
const buf = new ArrayBuffer(8);
const f = new Float64Array(buf), u = new BigUint64Array(buf);
function ord(x) { // 把位模式映射成单调整数
f[0] = x;
const b = u[0];
return (b & 0x8000000000000000n) // 负数:翻转成对称的负整数
? 0x8000000000000000n - (b & 0x7fffffffffffffffn)
: b;
}
function ulpsBetween(a, b) {
const d = ord(a) - ord(b);
return d < 0n ? -d : d;
}
ulpsBetween(0.1 + 0.2, 0.3) // 1n
ulpsBetween(1e10, 1e10 + 1e-6) // 1n
用它写断言,判据非常直观:「允许差 4 次舍入」就写 ulpsBetween(a,b) <= 4n。这个数字和量级无关,写一次就到处能用。
实践里的推荐写法是「相对容差 + 一个小的绝对容差兜底」,两者取或:
相等 ⇔ |a − b| ≤ max( rel_tol × max(|a|, |b|), abs_tol )
└────────┬────────┘ └───┬───┘
管一般情况,量级无关 只管 0 附近
这正是 Python math.isclose 的公式(PEP 485)。两个参数各有各的职责,缺一个就会在一头失效:
- 只有
abs_tol⇒ 上面那张表里中间两行的灾难。 - 只有
rel_tol⇒ 和 0 比较时永远不等(math.isclose(0, 1e-300)返回False)。
abs_tol 该填多少?填「小于这个数我就当它是 0」的那个值。这是个业务问题,不是数值问题——钱是 0.005 元,像素是 0.5,概率可能是 1e-12。
标准库的默认值,看清楚再用
| 函数 | 默认参数 | 要注意的 |
|---|---|---|
math.isclose(a,b) | rel_tol=1e-9, abs_tol=0.0 | 对称(用 max(|a|,|b|));和 0 比永远 False |
numpy.isclose(a,b) | rtol=1e-5, atol=1e-8 | 不对称(公式是 |a−b| ≤ atol + rtol·|b|);atol=1e-8 会把 10⁻¹² 和 5×10⁻¹² 判成相等 |
assertEquals(a,b,δ)(JUnit) | 要你自己填 δ | 纯绝对容差,两头都会失效 |
toBeCloseTo(a,n)(Jest) | n=2,即 |a−b| < 0.005 | 纯绝对容差,且默认非常松 |
numpy.isclose 那一行是真实世界里最常见的陷阱:它的默认 atol=1e-8 意味着,任何小于 10⁻⁸ 的两个数,在它眼里都相等。算概率、算梯度、算小残差时,这个默认值会让断言变成一句空话。np.testing.assert_allclose 的默认 atol 是 0,行为更合理——写测试时用后者。
epsilon 这个词在比较的语境里被用得最乱。它至少指三样完全不同的东西:
- 机器 epsilon(第 2 章):
2⁻⁵² = 2.22×10⁻¹⁶。硬件属性,固定值。 - 你写在断言里的容差:
1e-9之类。这是业务判断,跟机器无关。 - 算法里的收敛阈值:
while (err > eps)。这是第 14 章的话题。
三者常被同一个变量名 eps 装着,然后互相污染——最典型的错误是把机器 epsilon 直接当容差用:Math.abs(a-b) < Number.EPSILON。这个判据只在 1.0 附近有意义,在 1000 附近它比一格还小(永远不等),在 0.001 附近它宽得离谱。
什么时候 == 是对的
不要走到另一个极端。== 在这些场合完全正确,而且是唯一正确的写法:
- 整数在 2⁵³ 以内。
3.0 === 3.0,Math.floor(x) === 0——精确,没有任何疑问。 - 和自己比、和刚存进去的常量比。没经过运算就没抹过零。
- 哨兵值。
x === Infinity、x !== x(这是标准的 NaN 检测,因为NaN !== NaN)。 - 循环终止在整数计数上。
for (let i = 0; i !== n; i++)安全;for (let x = 0; x !== 1; x += 0.1)是死循环(因为累加十次不一定正好到 1——虽然上面看到0.1×10恰好等于 1,0.1×3就不等于 0.3 了)。
另外两个位层的坑,顺手记下:
-0.0 === 0.0是true,但它们的位模式不同(0x8000000000000000vs0x0)。Object.is(-0, 0)是false,1/-0是-Infinity。排序、去重、当 Map 的键时要小心。NaN !== NaN,所以[NaN].includes(NaN)是true(用 SameValueZero)而[NaN].indexOf(NaN)是-1(用 ===)。同一个数组,两个方法给出相反的答案。
把「隔几格」这个工具装进手边,它比任何容差都好用:
import math, struct
def ulps(a, b):
o = lambda x: (lambda u: u if u >= 0 else -0x8000000000000000 - u)(
struct.unpack('<q', struct.pack('<d', x))[0])
return abs(o(a) - o(b))
pairs = [(0.1 + 0.2, 0.3), (1e10, 1e10 + 1e-6), (1e-12, 5e-12),
(1.0, 1.0 + 2**-52), (0.1 * 3, 0.3), (sum([0.1] * 10), 1.0)]
for a, b in pairs:
print(f'{a!r:>24} vs {b!r:<22} 隔 {ulps(a,b):>18} 格 '
f'|Δ|<1e-9? {abs(a-b) < 1e-9}')
import numpy as np
print('math.isclose(1e-12, 5e-12) =', math.isclose(1e-12, 5e-12)) # False ✓
print('np.isclose (1e-12, 5e-12) =', np.isclose(1e-12, 5e-12)) # True ✗
print('math.isclose(0, 1e-300) =', math.isclose(0, 1e-300)) # False
会打出:
0.30000000000000004 vs 0.3 隔 1 格 |Δ|<1e-9? True
10000000000.0 vs 10000000000.000002 隔 1 格 |Δ|<1e-9? False
1e-12 vs 5e-12 隔 10245139294026372 格 |Δ|<1e-9? True
1.0 vs 1.0000000000000002 隔 1 格 |Δ|<1e-9? True
0.30000000000000004 vs 0.3 隔 1 格 |Δ|<1e-9? True
1.0 vs 1.0 隔 0 格 |Δ|<1e-9? True
第二行和第三行并排看:同一个判据,把邻居判成不等,把差 400% 的判成相等。
python3 cmp.py
在线:python.org/shell(把 numpy 那两行删掉)。JS 版见上面的 ulpsBetween。
单元测试。这是绝对容差最大的产地。assertEquals(expected, actual, 1e-9) 在测「归一化后的概率」时太松(1e-9 能盖住一堆真 bug),在测「总资产」时太紧(永远失败)。换成相对容差,然后为「接近 0」的情况单独写一个绝对下限。
几何判断。「这个点在不在这条线上」「这三点共不共线」——计算几何里所有的判定都要面对这个问题,而且更严重:不同的判定之间必须一致(如果 A 在线上、B 在线上,那 AB 中点也该在线上),而容差比较不保证这一点。这就是为什么严肃的几何库(CGAL)用精确谓词:先用浮点快速算,不确定时退回精确有理数算术。这是「快速路径 + 精确兜底」这个模式的经典应用。
图形学的 z-fighting。两个共面的三角形在深度缓冲里的值差不到一个 ulp,于是渲染顺序随视角闪烁。修法不是调容差,是换表示——反向深度缓冲(reverse-Z)把精度花在近处,正好利用了第 2 章那个「刻度不均匀」的性质。
对账。「这两笔金额算不算一致」是个业务问题,不是数值问题——答案通常是「差额小于一分钱」,也就是一个明确的绝对容差 0.005 元。这是绝对容差正确的少数场合之一,因为钱本来就有一个天然的最小单位。第 17 章会说,更好的做法是干脆别用浮点。
「浮点数不能用 == 比,要用 abs(a-b) < 1e-9。」
前半句在很多场合也是错的(整数、常量、哨兵值都该直接用 ==),后半句在两个方向上都会失效——这一章的全部内容。
这条建议之所以流传这么广,是因为它在教学例子上永远有效:教材里的数字都在 1 附近,1e-9 在那个量级上恰好合适。而真实程序里的数横跨十几个数量级。
正确的版本要多说两句,但也就两句:用相对容差处理一般情况,用一个业务上有意义的绝对容差兜住 0 附近。或者直接用「隔几格」——它天生就是量级无关的,而且能告诉你一个更有用的信息:这个差距相当于几次舍入。差 2 格是正常运算误差,差 10¹⁶ 格是算错了。
正确答案是 B:隔 1 格,它们是紧挨着的邻居。第二问:sum([0.1]*10) === 1.0 是 true,一格都不差。
=== 返回 false。但这个直觉抓到了一件对的事:它们之间的差距是可能存在的最小差距。
C 「大约 2000 格」——2024 这个数出现在另一处:1e-320 和 2e-320 之间隔 2024 格。那是次正规区,刻度是等距的,所以「相差一倍」也只隔两千格。同样是「差一倍」,在正规区隔 2⁵² 格,在次正规区隔两千格——这就是次正规数的代价:它们的相对精度很差。
D 「大约 10¹⁶ 格」——这个思路是「差 4×10⁻¹⁷,而 double 的精度是 10⁻¹⁶,所以……」,把两个不同量级的量做了除法。10¹⁶ 这个数在这一章里确实出现了,但它是 1e-12 和 5e-12 之间的距离(1.02×10¹⁶ 格)。换句话说:一对看起来「几乎相等」的数隔 1 格,一对看起来「都很小」的数隔一万万亿格。「看起来接近」和「隔得近」是两回事。
比较这件事,按场景选写法:
Math.abs(a - b) < 1e-9 —— 只在一个量级上正确
一般情况:Math.abs(a-b) <= Math.max(1e-9 * Math.max(Math.abs(a), Math.abs(b)), absTol)
写测试:用 math.isclose / np.testing.assert_allclose(rtol=…, atol=0),别用 np.isclose 的默认参数
调试数值算法:直接看隔几格。差 1–4 格是正常舍入,差 10⁶ 格说明有相消,差 10¹⁵ 格说明算错了
钱、像素、格子:别比浮点,比整数(第 17 章)
几何谓词:浮点快速判断 + 不确定时退回精确算术,而不是调容差
最后一条通则,也是这一章最该带走的:「相等」在浮点世界里不是一个数学概念,是一个你必须为自己的场景定义的业务概念。写下容差的那一刻,你是在声明「多小的差别我不在乎」——这句话没有通用答案。
这一章的一句话
绝对容差是一把固定长度的尺子,而浮点数轴的刻度横跨六百个数量级;要么用相对的尺子,要么直接数隔了几格。
卷 III 到此结束——四章,四次改写,每一次都是「同一个数学,换一条计算路径」。
卷 IV 换一个模式:不再一步算完,改成一步步逼近。这带来一个新的好处和一个新的麻烦。好处是牛顿法这样的算法能让正确位数每一步翻倍——从 0.5 位到 1.2 位到 2.8 位到 5.8 位到 11.9 位,五步撞上 double 的天花板,而二分法要走 52 步。麻烦是:既然是逼近,就得决定什么时候停——而停机判据是这本书里最容易写错的东西。