0%

小数的精度问题

想象一下我们十进制里的分数 1/3。你想把它写成小数,结果是 0.333333...,它是一个无限循环小数,你永远也写不完。无论你写多少个 3,它都只是一个近似值,永远不等于 1/3

计算机内存是有限的,它没办法存储无限的小数位。所以,它只能在某个位置进行截断或舍入。这就导致了计算机存储的 0.1,其实并不是精确的 0.1,而是一个非常非常接近它的近似值,比如 0.10000000000000000555...

这个微小的误差,就是所有问题的“万恶之源”。

1. IEEE 754

为了统一标准,计算机界制定了 IEEE 754 规范,它定义了两种最常见的小数存储方式:

  1. 单精度浮点数 (float): 用 32 位 (4 字节) 存储。
  2. 双精度浮点数 (double): 用 64 位 (8 字节) 存储,精度更高,也是大多数现代编程语言(如 Python、JavaScript)默认的小数类型。

你可以把它理解成二进制版本的“科学记数法”。一个浮点数被拆成三个部分存储:

  • 符号位 (Sign): 1 位,表示正数或负数。
  • 指数位 (Exponent): 表示数值的大小范围,类似科学记数法里的 10 的多少次方。
  • 尾数位 (Mantissa/Fraction): 表示数值的精度,也就是那个被截断的二进制小数。

double 比 float 拥有更多的指数位和尾数位,所以它能表示的数值范围更大,精度也更高。但请记住,即便是精度更高的 double,它也只是一个更精确的“近似值”而已。

1.1 原理

要理解它的原理,我们先从熟悉的东西入手:十进制的科学记数法。

一个数字,比如 123.45,我们可以写成 1.2345 × 10^2
一个数字,比如 -0.00678,我们可以写成 -6.78 × 10^-3

无论多大或多小的数,我们都可以用 “符号” (正/负) + “有效数字” (Mantissa) + “指数” (Exponent) 的形式来表示。

IEEE 754 的核心思想完全一样,只不过它用的是二进制。

它规定,一个浮点数(以 32 位的 float 为例)在内存中必须以如下格式存储:

[符号位 S (1位)] [指数位 E (8位)] [尾数位 F (23位)]

S | EEEEEEEE | FFFFFFFFFFFFFFFFFFFFFFF
1位 | 8位 | 23位

这三部分共同构成了二进制的科学记数法,其值为:

Value = (-1)^S × (1.F) × 2^(E - Bias)

1.2 乘 2 取整法

计算机它不认识“0.75”这么复杂的数字,它只认识两个最简单的暗号:“1”(代表“有”)和“0”(代表“没有”)。
0.75 瓶果汁倒进了魔法机器里,变成了 1.5 瓶——也就是满满的 1 瓶,还多出来一个小小的半瓶(0.5 瓶)。
奇奇把剩下的那 0.5 瓶果汁,又倒进了魔法机器里。正好是满满的 1 瓶!

我们每次都把剩下的果汁拿去翻倍,变成暗号“1”或“0”。直到果汁用完为止。是不是很简单呀?

1.3 将十进制 9.625 编码为 32 位浮点数

我们用一个具体的例子 9.625 走一遍完整的流程,你就全明白了。

第 1 步:转换为二进制

  • 整数部分:9 → 1001
  • 小数部分 0.625:使用“乘 2 取整法”
    • 0.625 × 2 = 1.25 → 取整数 1
    • 0.25 × 2 = 0.5 → 取整数 0
    • 0.5 × 2 = 1.0 → 取整数 1
    • 所以,0.625 → 0.101
  • 合在一起,9.625 的二进制就是 1001.101

第 2 步:规格化 (Normalize)
把这个二进制数写成科学记数法 1.xxxx... × 2^n 的形式。
1001.101 → 1.001101 × 2^3 (小数点向左移动了 3 位)
现在我们得到了两个关键信息:

  • 真正的尾数部分是 001101 (小数点后面的部分)
  • 真正的指数是 3

第 3 步:拆解到三个部分 (S, E, F)

  1. 符号位 (S)
    9.625 是正数,所以 S = 0。

  2. 尾数位 (F - Fraction)
    这里有一个天才般的设计:隐藏的整数 1 (Implicit Leading Bit)。
    因为任何规格化的二进制数,第一位永远是 1 (1.xxxx...),所以根本没必要存储它!这样就可以省出 1 位来提高精度。
    所以,我们只存储小数点后面的部分:001101
    32 位 float 的尾数位有 23 位,所以我们要在后面补 0 把它填满。
    F = 00110100000000000000000 (共 23 位)

  3. 指数位 (E - Exponent)
    这里有另一个巧妙的设计:指数偏移 (Exponent Bias)。
    指数可以是正数(比如 2^3),也可以是负数(比如 2^-5)。如果直接存储,就需要一个额外的符号位来表示指数的正负,这很麻烦。
    设计者决定,将存储的指数 E 设计为一个无符号整数。通过加上一个“偏移量”,把所有可能的指数都映射到正数范围。

    • 对于 32 位 float,指數有 8 位(范围 0-255),偏移量 Bias = 127。
    • 存储的指数 E = 真实指数 + Bias
      在我们的例子里,真实指数是 3
      所以,E = 3 + 127 = 130
      现在把 130 转换为 8 位二进制:10000010
      E = 10000010。

第 4 步:组合起来
现在,把 S, E, F 按顺序拼接在一起:

S | E | F
0 | 10000010 | 00110100000000000000000

这就是 9.625 在你电脑内存里真实的 32 位二进制样子!

1.4 为什么 0.1 无法精确表示?

现在你已经掌握了原理,我们再回头看 0.1 的问题就一目了然了。
我们对 0.1 做“乘 2 取整法”:

  • 0.1 × 2 = 0.2 → 0
  • 0.2 × 2 = 0.4 → 0
  • 0.4 × 2 = 0.8 → 0
  • 0.8 × 2 = 1.6 → 1
  • 0.6 × 2 = 1.2 → 1
  • 0.2 × 2 = 0.4 → 0 (开始循环了…)

你会发现 0.1 的二进制是 0.0001100110011...,其中 0011 无限循环。
而尾数位 F 只有 23 位(double 也只有 52 位),是有限的。计算机只能在某个位置进行截断,这就导致存储的值只是 0.1 的一个非常接近的近似值。这个微小的误差,就是万恶之源。

1.5 比较的陷阱

1
2
3
4
5
6
7
8
9
10
# 一个经典的例子
a = 0.1
b = 0.2
c = 0.3

print(a + b)
# 输出: 0.30000000000000004

print(a + b == c)
# 输出: False

2. 运算过程

2.1 为什么这么存

  1. “好坏开关”格子 (符号 S):
    这个最简单啦!就像玩具的开关一样,1 代表这是个“淘气包”玩具(负数),0 代表这是个“乖宝宝”玩具(正数)。用一个最小的位置,就决定了数字的“性格”,是不是很省地方?

  2. “放大镜/缩小镜”格子 (指数 E):
    这个格子最神奇!它告诉电脑,我们的数字到底有多“大”或者多“小”。

    • 如果这里放一个很大的数,就等于给我们的数字装上了一个 超级放大镜,能看到像宇宙里星星那么远的距离!
    • 如果这里放一个很小的数,就等于装上了一个 超级缩小镜,能看到像小细菌那么微小的东西!
      好处: 只用一个小小的盒子,我们就能表示出特别特别大的数,和特别特别小的数。这就是它的“范围广”!
  3. “积木形状”格子 (尾数 F):
    这个格子装着数字最具体的“样子”,就像一盒精密的积木。它决定了我们的数字有多“精确”。比如 1.2345 就比 1.2 要精确,因为它描述得更详细,用的“积木”更多。
    好处: 它保证了我们描述数字的“细节”程度,这就是它的“精度高”!

它把一个数最关键的三个信息——正负(性格)、大小(尺寸)、细节(样子)——分门别类地放好。这样做让我们能用有限的空间(比如 32 个小格子),既能表示天文数字,又能表示微观粒子,还保证了足够的精确度。非常高效!

2.2 计算时怎么处理

好了,现在我们知道怎么存了。那电脑要计算 9.625 + 0.5 的时候,它在做什么呢?

想象一下,有两只小猴子,“大壮”和“小巧”,它们要把自己的身高加在一起。

  • 大壮 (代表 9.625): 它的身高暗号是 1.001101 × 2³。它的“放大镜”度数是 3。
  • 小巧 (代表 0.5): 它的身高暗号是 1.0 × 2⁻¹。它的“放大镜”度数是 -1。

电脑看到这两个暗号,它可不会傻乎乎地直接加哦。它会说:“等一下!你们俩用的‘放大镜’度数不一样,没法直接加!得先统一标准!”

第 1 步:对齐放大镜(专业术语叫“对阶”)

电脑会遵守一个规则:听度数大的那个!

大壮的放大镜度数是 3,小巧的是 -1。所以,小巧必须换成和大壮一样的 3 度放大镜。

可是,随便换放大镜,身高不就变了吗?对!所以为了保持身高不变,当小巧把放大镜度数从 -1 调到 3(相当于放大了 2⁴=16 倍)时,它自己的身高数字就必须缩小 16 倍来补偿。

原来的身高数字 1.0,小数点就要向左移动 4 位,变成 0.0001

现在,小巧的身高暗号变成了:0.0001 × 2³

你看,现在大壮和小巧的放大镜度数都是 3 了!它们终于站在了“同一起跑线”上!

第 2 步:加积木(专业术语叫“尾数相加”)

既然放大镜一样了,电脑就可以放心地把它们的“积木”(身高数字)加起来了。

1
2
3
4
5
  1.001101  (大壮的身高)
+ 0.000100 (小巧的身高,后面补0对齐)
-----------
1.010001

所以,加起来之后,新的身高是 1.010001,用的还是那个 3 度的放大镜。

新的身高暗号就是:1.010001 × 2³

第 3 步:整理结果(专业术语叫“规格化”)

电脑会检查一下这个新暗号是不是标准格式(1.xxxx... 的样子)。
1.010001 × 2³ 已经是标准格式了,太棒了!电脑不用再做什么了。

如果刚才加出来的结果是 10.1... 或者 0.1...,电脑就需要挪动一下小数点,同时调整放大镜的度数,把它变回 1.xxxx... 的标准样子。

最后,电脑把这个最终的暗号 1.010001 × 2³ 转换回十进制,就是 10.125。计算完成!

3. 提问问题

3.1 为什么命名 IEEE 754

这个名字其实很简单,它是一个“组织名”+“标准编号”。
IEEE: 全称是 Institute of Electrical and Electronics Engineers(电气和电子工程师协会)。
754: 就是这个协会内部给“浮点数算术标准”分配的编号。

3.2 是不是只有无理数不精确

并不仅仅是无理数才存不精确!0.1 都不精确。

  1. 你的积木盒(十进制): 你的盒子里有长度是 10 厘米、1 厘米、0.1 厘米、0.01 厘米 的积木块。
  2. 电脑的积木盒(二进制): 电脑的盒子里,积木块的长度很特别,它们是 8 厘米、4 厘米、2 厘米、1 厘米,还有更小的 0.5 厘米(一半)、0.25 厘米(一半的一半)、0.125 厘米(一半的一半的一半)…… 它的所有小积木块都是前面一块的“一半长”。

搭一个 0.1 厘米的木条,这个数字我们看着是不是超级简单?就是“十分之一”嘛。

  • 你来搭: 你打开自己的积木盒,拿出一块 0.1 厘米 的积木,一步搞定!太简单了!
  • 电脑来搭: 电脑打开它的积木盒,挠了挠头,麻烦来了……
    • 它想搭 0.1 厘米。
    • 它最大的小数积木是 0.5,太长了,不能用。
    • 0.25 也太长了,不能用。
    • 0.125 也太长了……
    • 好,它找到了一个能用的:0.0625 (也就是 1/16)。
    • 0.1 - 0.0625 = 0.0375。还差一点点!
    • 它又在盒子里翻呀翻,找到了 0.03125 (也就是 1/32)。
    • 0.0375 - 0.03125 = 0.00625。唉呀,还差一丁点儿!
    • 它只好继续找更小的积木来拼……
      你会发现一个神奇的事情:电脑用它的“一半、一半的一半”积木,永远也无法【刚刚好】拼出 0.1 厘米!

3.3 Decimal 的原理

Decimal 类型的核心原理是:它在内部把一个小数拆成两部分来存储。

  1. 一个巨大的整数(The Digits / Significand): 也就是把小数点去掉后的所有数字。
  2. 一个“小数点位置”指令(The Position / Exponent): 告诉电脑,小数点应该从右往左数几位。

让我们来看看 Decimal 是如何给数字拍“快照”的:

  • 对于数字 123.45

    • Decimal 不会去想 “100 + 20 + 3 + 0.4 + 0.05”
    • 它会拍一张快照,然后把信息存成一个“魔法配方”:
      • 主要材料: 大整数 12345
      • 烹饪指令: “把小数点从最右边往左移动 2 位。”
  • 对于我们头疼的 0.1

    • 普通 float 类型:(开始手忙脚乱地找 1/16, 1/32… 积木来拼凑)
    • Decimal 类型:(淡定地拿出相机“咔嚓”一声)
      • 主要材料: 整数 1
      • 烹饪指令: “把小数点从最右边往左移动 1 位。”
  • 对于 0.2

    • Decimal 类型:(又“咔嚓”一声)
      • 主要材料: 整数 2
      • 烹饪指令: “把小数点从最右边往左移动 1 位。”

现在,神奇的事情发生了!当我们计算 0.1 + 0.2 时:

Decimal 类型的内部计算机会读取这两个“魔法配方”,它会做我们人类在纸上做算术时一模一样的事情:

  1. “嗯,两个配方的‘小数点位置’指令都是移动 1 位,太好了,对齐了!”
  2. “那我只要把‘主要材料’加起来就行了:1 + 2 = 3。”
  3. “最后,再应用那个‘烹饪指令’:把小数点从 3 的右边往左移动 1 位。”
  4. 最终结果:0.3

看到了吗?整个过程,全都是整数运算!它压根儿就没碰过那些二进制小数积木,所以根本没有机会产生 0.30000000000000004 这种奇怪的误差。它从头到尾处理的都是我们人类最熟悉的十进制数字,只是换了一种方式记录而已。

4. 总结

4.1 黄金法则

在编程世界里,我们有一个黄金法则:

  • 用 Float / Double: 当你处理科学计算、图形、游戏、机器学习等领域,对速度要求极高,且能容忍极微小误差时。比如一个游戏角色的坐标是 1.3333333 还是 1.3333334,肉眼根本看不出来。
  • 用 Decimal: 当你处理金钱、财务、账单等任何跟钱有关的计算时。因为在这些领域,精度是绝对的第一位,一分钱都不能差!

4.1 注意的点

凡是涉及“钱”的计算,绝对不要用浮点数!

  1. 整数运算法:将金额单位转换成最小单位(如“分”),然后用整数进行计算。比如,12.99 元存储为 1299 分。所有计算都在整数上进行,只在最终展示给用户时才除以 100 变回“元”。这是业界最常用、最稳妥的方案。
  2. 使用 Decimal 类型:几乎所有主流语言都提供了专门用于高精度十进制运算的库或类型。Decimal 类型的原理是模拟十进制的运算,速度会比浮点数慢,但可以保证结果的精确性。
  3. 不要用 == 直接比较两个浮点数。
可以加首页作者微信,咨询相关问题!