0.1 + 0.2 为什么不等于 0.3:从手算到二进制编码
计算机连 0.1 + 0.2 都算不对?本文从乘 2 取整开始,亲手制造一次浮点误差,再用 Python struct.pack 拍下数字在内存里的真实模样。
本文目录
0.1 + 0.2 听起来像是一道幼儿园算术题,但把它扔给 Python,事情突然变得诡异起来:
>>> 0.1 + 0.2
0.30000000000000004
好家伙,计算机天天号称每秒能算几百亿次,结果连 0.1 + 0.2 都能翻车?
其实计算机没有算错。真正的问题是:当你输入 0.1 的那一刻,它拿到的就已经不是精确的 0.1 了。
这次我们把“浮点数有误差”拆开算一遍:先把 0.1 拆成二进制,再用只有 8 位小数的模型观察表示误差,最后算清真实浮点加法为什么没有选中 0.3。
最后,我们再化身内存侦探,用 Python 的 struct.pack 给数字拍一张“X 光片”,看看 0.3 和 0.30000000000000004 到底差在哪一个比特。
一、 手算 0.1:无限循环的开始
整数转二进制,大家可能听过“除 2 取余”。
小数正好反过来:不断乘 2,每次拿走整数部分。
1. 把 0.1 扔进乘 2 机器 (Decimal to Binary)
我们拿纸笔算:
| 计算 | 拿走的整数 | 剩下的小数 |
|---|---|---|
| 0.1 × 2 = 0.2 | 0 | 0.2 |
| 0.2 × 2 = 0.4 | 0 | 0.4 |
| 0.4 × 2 = 0.8 | 0 | 0.8 |
| 0.8 × 2 = 1.6 | 1 | 0.6 |
| 0.6 × 2 = 1.2 | 1 | 0.2 |
把中间那列从上往下抄下来:
0.00011...
注意最后一行:余数又变回了 0.2。
这意味着从第二行开始,整个过程会像卡住的唱片一样不断循环:
0.1 = 0.0001100110011001100110011...₂
└─0011─┘ 无限循环
第一处真相出现了:十进制的 0.1,在二进制世界里是个无限循环小数。
2. 那 0.2 呢?
0.2 刚好等于 0.1 × 2。
二进制乘 2 不需要重新列竖式,只要把小数点向右挪一格:
0.1 = 0.00011001100110011...₂
0.2 = 0.00110011001100110...₂
它们身后都拖着一条永远写不完的 0011 尾巴。
这不是二进制“不够高级”。十进制同样写不完 1 ÷ 3,我们只能写成:
0.3333333333333333...
不同进位制,只是各自害怕的分数不同。
💡 程序员的顿悟:
0.1在十进制里看起来干净利落,是因为我们的计数系统以 10 为底。计算机以 2 为底,约分后分母是 2 的幂的分数,才能写成有限二进制小数,例如
0.5、0.25和0.125。0.1的分母里藏着一个 5,进了二进制世界就只能无限循环。
二、 袖珍计算机:先看有限位表示的误差
真实的 Python 浮点数有 53 位有效精度。要在纸上抄 53 个 0 和 1,不仅不酷,还很容易把自己数瞎。
所以我们先造一台小数点后固定保留 8 位的袖珍计算机。这是定点模型,用来观察舍入;真实浮点数的小数点位置可以变化,下一节再走到那里。
它的规矩很简单:
- 小数点后只有 8 个座位;
- 坐不下的数字必须丢掉;
- 选择距离最近的刻度;恰好居中时,选择最低保留位为 0 的那个。
1. 先把 0.1 塞进去
0.1 的真实二进制是:
0.00011001100110011...
机器只有 8 个座位:
保留:0.00011001
丢掉: 100110011...
被丢掉的部分以 1 开头,而且后面还有非零位,说明超过了半格,需要进位:
0.00011001
+ 1
──────────────
0.00011010
所以袖珍计算机里的 0.1 实际上是:
0.00011010₂
把它翻译回十进制也能手算。小数点后的位权依次是:
1/2 1/4 1/8 1/16 1/32 1/64 1/128 1/256
0.00011010 里,第 4、5、7 位是 1:
1/16 + 1/32 + 1/128
= 16/256 + 8/256 + 2/256
= 26/256
= 0.1015625
我们明明输入的是 0.1,机器拿到的却是 0.1015625。
第一笔误差,已经悄悄混进来了。
2. 再把 0.2 塞进去
0.2 的二进制是:
0.0011001100110011...
保留 8 位:
保留:0.00110011
丢掉: 00110011...
被丢掉的第一位是 0,这次不用进位。
0.2 ≈ 0.00110011₂
翻译回十进制:
1/8 + 1/16 + 1/128 + 1/256
= 32/256 + 16/256 + 2/256 + 1/256
= 51/256
= 0.19921875
这次结果比真正的 0.2 小了一点。
3. 见证奇迹:开始二进制加法
现在两个数都已经住进内存。接下来没有任何魔法,就是从右往左做普通加法:
0.00011010
+ 0.00110011
────────────
0.01001101
把结果换回十进制:
1/4 + 1/32 + 1/64 + 1/256
= 64/256 + 8/256 + 4/256 + 1/256
= 77/256
= 0.30078125
Bingo!
我们亲手制造出了一个“不等于 0.3”的答案:
0.1 + 0.2 ≈ 0.30078125
这个模型让表示误差变得容易观察。先记住计算经过了哪些步骤:
0.1先被舍入一次;0.2也先被舍入一次;- 加法器算的是这两个“替身”,不是数学世界里的原值;
- 计算结果还可能需要再次舍入。
不过,还差一个重要问题:如果把数学上的 0.3 也放进这台定点机器,最近的刻度同样是 77/256。因此在这个模型里,存下来的 0.1 + 0.2 与存下来的 0.3 相等。
这个实验解释了“表示值不等于数学原值”,还没有解释 Python 中两个表达式为什么不相等。接下来必须换成真实的浮点规则。
三、真实的 binary64:为什么偏偏向上舍入?
在通常的 Python 运行环境中,float 使用 IEEE 754 binary64,分成 1 位符号、11 位指数和 52 位小数部分。
对于规格化数,开头的二进制 1 不必显式存储,因此有 53 位有效精度。零、非规格化数、无穷大和 NaN 有另外的编码规则,不能都套用这个省略开头 1 的解释。Python 浮点数说明
0.1 = 1.1001100110011…₂ × 2⁻⁴
0.2 = 1.1001100110011…₂ × 2⁻³
指数负责移动小数点,有效数字负责精度。这次不是统一保留“小数点后 8 位”,而是保留有限的有效数字,所以数值范围不同,相邻刻度间距也可能不同。
1. 先拿到机器真正保存的两个数
不用手抄 53 个比特,Python 可以直接给出精确的整数比:
print((0.1).as_integer_ratio())
print((0.2).as_integer_ratio())
输出:
(3602879701896397, 36028797018963968)
(3602879701896397, 18014398509481984)
两个分母分别是 2^55 和 2^54。统一分母,就得到了对阶后的精确加法:
存储的 0.1 = 3602879701896397 / 2^55
存储的 0.2 = 7205759403792794 / 2^55
相加 = 10808639105689191 / 2^55
= 5404319552844595.5 / 2^54
最后这个 .5 很关键。加法的精确结果落在两个可表示刻度的正中间。
2. 两个候选刻度是什么?
在 [0.25, 0.5) 这个范围内,相邻 binary64 数的间距是 2^-54。我们可以把它们写成“整数格数除以 2^54”:
下方刻度:5404319552844595 / 2^54
精确和: 5404319552844595.5 / 2^54
上方刻度:5404319552844596 / 2^54
采用通常的“舍入到最近值,恰好居中时取偶数”规则时,需要看有效数字末位:下方格数是奇数,上方格数是偶数,所以选上方。
这不是“有误差,所以随便偏了一点”,而是由输入表示和舍入规则一起决定的。
3. 为什么直接输入 0.3 又选下面?
数学上的 0.3 在同一把尺子上位于:
0.3 × 2^54 = 5404319552844595.2
它距离下方整数格更近,因此直接输入 0.3 时选下方;而已经舍入过的 0.1 与 0.2 相加,精确和是 .5 格,最终选上方。
两条路径不同,结果相差一个刻度。现在,标题里的“不相等”才算真正解释完。
4. 用精确分数复核这一步
from fractions import Fraction
exact_sum = Fraction(0.1) + Fraction(0.2)
lower = Fraction(0.3)
upper = Fraction(0.1 + 0.2)
print(exact_sum == (lower + upper) / 2)
print(upper - lower == Fraction(1, 2**54))
print(Fraction('0.3') * 2**54)
输出:
True
True
27021597764222976/5
最后一个分数就是上面的 5404319552844595.2。Fraction(0.1) 保留的是 float 已经存下来的值,Fraction('0.1') 才直接表达精确的十分之一。
四、给两个结果拍一张字节照片
现在用 struct.pack 把数字按指定格式序列化。>d 表示大端序的 8 字节双精度数,方便从高位往低位阅读;它不代表本机内存一定采用大端布局。
import struct
for value in [0.1, 0.2, 0.3, 0.1 + 0.2]:
print(f'{value!r:<20} {struct.pack(">d", value).hex()}')
输出:
0.1 3fb999999999999a
0.2 3fc999999999999a
0.3 3fd3333333333333
0.30000000000000004 3fd3333333333334
最后一个十六进制数字从 3 变成了 4,编码数值相差 1,因此它们是这个范围里相邻的两个浮点数。不过 0011 变成 0100 实际翻转了三个比特,“相差一个刻度”不等于“只改了一个比特”。
完整的十进制值可以这样观察:
print(format(0.3, '.55f'))
print(format(0.1 + 0.2, '.55f'))
输出:
0.2999999999999999888977697537484345957636833190917968750
0.3000000000000000444089209850062616169452667236328125000
Python 平时显示 0.3 或 0.30000000000000004,是在寻找能重新读回同一个 float 的简短十进制表示。这两个短字符串都不是对应浮点数的完整十进制展开。
也可以使用 float.hex() 直接观察十六进制有效数字和二进制指数:
print((0.3).hex())
print((0.1 + 0.2).hex())
得到 0x1.3333333333333p-2 和 0x1.3333333333334p-2,其中 p-2 表示乘以 2^-2。
五、工程里该怎样比较和计算?
1. 精确相等和足够接近,是不同问题
浮点数可以用 ==,但你要确定自己比较的是浮点值精确相等,还是允许某种误差。== 也并非逐位比较:例如正零和负零的编码不同,却相等;NaN 则有特殊的比较行为。
如果要判断测量或计算结果是否足够接近,可以明确容差:
import math
print(math.isclose(0.1 + 0.2, 0.3))
print(math.isclose(1e-15, 0.0, rel_tol=1e-9, abs_tol=1e-12))
两行都是 True。接近零时,绝对容差尤其重要。1e-12 只是演示值,真实阈值应来自数据单位和误差要求,不能从示例直接照搬。
2. 需要十进制语义时,先选择表示方法
如果以分为最小单位处理金额,可以用整数保存分;如果需要十进制精度和舍入规则,可以使用 Decimal:
from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2'))
print(Decimal(0.1))
第一行得到 0.3。第二行会忠实接收已经被 float 近似过的值,得到一长串十进制数字。字符串输入与 float 输入,起点不同。
Decimal 也不是无限精确的实数系统:除法、上下文精度和舍入仍需要设计。整数定点数同样需要考虑最小单位、乘除法舍入与范围限制。
3. round 主要解决哪一步?
如果只是显示两位小数,可以格式化输出。如果中间计算需要特定的精度规则,则应在计算模型里安排舍入,不能指望最后显示成 0.30 就证明整个计算过程精确。
浮点数用有限空间覆盖很大的数值范围,又能得到硬件的高效支持。它很适合大量数值计算,只是使用时需要知道刻度在哪里。
六、附录:同样的字节,还能表示什么?
以下内容不参与前面的浮点推导,但能帮助理解“字节需要解释规则”。
| 规则 | 示例 | 它表达的值 |
|---|---|---|
| 8 位无符号整数 | D6 |
214 |
| 8 位补码整数 | D6 |
-42 |
| BCD | 59 |
十进制 59 |
| 普通无符号整数 | 59 |
十进制 89 |
| 约定单位为分的整数 | 30 | 0.30 元 |
补码中的 -42 可以从 00101010 取反加一得到 11010110;BCD 则把每个十进制数字分别放进 4 位中。
字节序是另一层规则。16 位整数 1000 的十六进制是 03e8:
import struct
print(struct.pack('>H', 1000).hex())
print(struct.pack('<H', 1000).hex())
结果分别是 03e8 和 e803。数字的类型与数值不变,字节排列顺序变了。文件和网络协议应明确指定字节序及宽度;更完整的例子可以继续看二进制文件格式设计。
再看 0.1 + 0.2
输入的两个十进制小数先各自变成 binary64;两个近似值的精确和落在相邻刻度的中点;舍入规则选择偶数有效数字的那个刻度;Python 再把它显示成 0.30000000000000004。
现在我们既知道误差来自哪里,也知道为什么恰好是这个结果。
数字写进计算机之前,必须先选择一种表示方法;选择了表示方法,也就选择了它的范围、精度与代价。
交流与讨论