← 所有文章

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

这个模型让表示误差变得容易观察。先记住计算经过了哪些步骤:

  1. 0.1 先被舍入一次;
  2. 0.2 也先被舍入一次;
  3. 加法器算的是这两个“替身”,不是数学世界里的原值;
  4. 计算结果还可能需要再次舍入。

不过,还差一个重要问题:如果把数学上的 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。

现在我们既知道误差来自哪里,也知道为什么恰好是这个结果。

数字写进计算机之前,必须先选择一种表示方法;选择了表示方法,也就选择了它的范围、精度与代价。

— 本文完 —

有不同的想法?欢迎在下方交流,或写封邮件。

继续翻阅其他笔记 ↗

交流与讨论