floating - point

03. 浮点数

0. 本章先解决什么问题

浮点数是很多初学者第一次遇到“计算机不按数学直觉来”的地方:

0.1 + 0.2

结果可能不是精确的:

0.30000000000000004

这不是某个语言坏了,也不是 CPU 不会算加法,而是因为:

浮点数是二进制近似表示。

本章要解决:

  • 浮点数为什么存在?
  • IEEE 754 大致如何表示浮点数?
  • 为什么很多十进制小数无法被二进制精确表示?
  • 为什么大数加小数,小数可能消失?
  • NaN、Infinity、-0 是什么?
  • 为什么金额、计数、精确比较不能随便用浮点?
  • 遇到浮点误差时如何判断是正常误差还是 bug?

浮点表示与舍入

这张图把浮点数的工程取舍画出来:sign 决定正负,exponent 决定数量级,fraction/significand 决定有效数字。浮点能表示很大范围,但每个数量级下的精度有限,表示和计算都可能舍入。

这张图怎么读

读浮点图时,把浮点数看成:

符号 + 数量级 + 有效数字

它的强项是范围,弱项是精度有限。图里最重要的是两条边界:

边界结果
有效数字不够舍入,低位信息丢失
指数范围不够上溢、下溢、无穷或接近 0

所以浮点问题不要只看显示出来的小数,要问:

这个十进制小数能不能被二进制有限表示?
这次计算发生了几次舍入?
当前数量级下,相邻可表示数之间间隔多大?

1. 浮点数解决什么问题

整数不能表示小数,也不适合表示极大或极小的量。

例如:

0.000000000123
123000000000000000000

如果完全用定长整数直接表示,会很不方便。

浮点数使用类似科学计数法的方式。

十进制科学计数法:


123.45 = 1.2345 * 10

二进制浮点数类似:


value = sign * significand * 2

也就是:

部分作用
sign正负
significand有效数字
exponent数量级

浮点数用有限位数换取很大的表示范围,但代价是精度有限。

2. IEEE 754 的基本结构

常见浮点格式遵循 IEEE 754。

以 32 bit 单精度为例:

部分位数作用
sign1符号位
exponent8指数,控制数量级
fraction23尾数部分,控制有效数字

64 bit 双精度:

部分位数
sign1
exponent11
fraction52

双精度比单精度:

  • 范围更大。
  • 有效数字更多。
  • 占用空间更大。
  • 运算和带宽成本也可能更高。

不要把浮点理解成“小数点后固定多少位”。它不是定点数,而是:

有效数字 + 指数

小数点会随着指数移动,所以叫 floating point。

3. 为什么十进制小数会不精确

十进制里,1/3 无法有限表示:

0.333333…

二进制也一样。有些十进制有限小数,在二进制里会变成无限循环。

例如 0.1:

十进制 0.1
-> 二进制无法用有限位精确表示
-> 只能存一个最接近的近似值

所以:

0.1 + 0.2

实际是:

近似的 0.1 + 近似的 0.2

结果也只能是近似。

这就是浮点误差的第一性来源:

表示时已经近似。
计算后还要舍入。

手推:为什么 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
0.6 * 2 = 1.2 -> 1,剩 0.2
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1

可以看到余数开始循环,所以二进制表示也会循环:

0.1(10) = 0.0001100110011…(2)

有限位浮点只能截断或舍入这个无限循环。于是存进去的 0.1 已经不是数学上的精确 0.1,而是最接近它的可表示数。

这就是为什么浮点误差不是“加法错了”,而是输入表示就已经进入近似世界。

4. 舍入:每次计算都可能丢一点

浮点数的有效数字位数有限。计算结果如果需要更多位,就必须舍入。

例如:

代码块PLAINTEXT · 2 行收起展开
真实结果: 1.234567890123456789
可保存:   1.234567890123456

后面的信息丢掉了。

一次误差可能很小,但连续计算会累积:

读入近似值
-> 加法舍入
-> 乘法舍入
-> 除法舍入
-> 多次迭代
-> 误差扩大

所以数值计算不仅看公式,还看:

  • 运算顺序。
  • 舍入方式。
  • 数据规模。
  • 误差传播。
  • 是否发生灾难性消去。

5. 大数吃小数

浮点数有效数字有限,所以大数和小数相加时,小数可能被吞掉。

例子直觉:

10000000000000000 + 1

如果当前浮点格式已经无法在这个数量级上分辨“+1”,结果可能仍然是原来的大数。

原因是:

指数决定数量级。
尾数位数决定这个数量级下能分辨多细。

数量级越大,相邻可表示浮点数之间的间隔越大。

所以浮点数不是“所有位置都一样精细”。它在不同数量级下的精度不同。

手推:ULP 间隔如何让大数附近的 +1 消失

ULP 可以理解成某个数量级附近两个相邻可表示浮点数之间的距离。为了简化,假设一个浮点格式只有 4 位有效二进制数字。

1.xxxx * 2^0 附近,4 位有效数字能表示:


1.000 * 2

1.001 * 2

1.010 * 2

相邻间隔是:

0.001 * 2^0 = 1/8

到了 1.xxxx * 2^10 附近,相邻间隔变成:

0.001 * 2^10 = 128

这意味着在这个数量级上,比 128 小得多的变化可能无法改变表示结果。

真实格式有效位更多,但规律一样:

数值越大
-> 指数越大
-> 相邻可表示数间隔越大
-> 小增量越容易被舍入吞掉

所以大数附近的 +1 可能没有任何效果;这不是加法失效,而是当前格式在该数量级下分辨率不够。

6. 浮点比较为什么不能直接依赖相等

如果计算涉及近似和舍入,直接比较:

a + b == c

可能不可靠。

更常见的比较方式是误差范围:

abs((a + b) - c) < eps

这里 eps 是可接受误差。

eps 也不能随便选。要根据:

  • 数值规模。
  • 业务容忍度。
  • 运算步骤数量。
  • 绝对误差还是相对误差。

小数值适合绝对误差:

abs(x - y) < 1e-9

大数值更要考虑相对误差:

abs(x - y) / max(abs(x), abs(y)) < threshold

7. 金额为什么不要用二进制浮点

金额通常需要十进制精确性。

如果用二进制浮点表示:

0.1 元

可能已经是近似值。

金额计算常用思路:

方式说明
整数最小单位用分、厘等整数单位保存
十进制定点固定小数位,按十进制规则处理
高精度十进制用十进制精确表示和舍入规则

关键是:

金额需要明确舍入规则和精度边界。

例如:

  • 什么时候四舍五入?
  • 保留几位小数?
  • 税费如何计算?
  • 多次分摊后的余数怎么处理?

这些都不能交给二进制浮点近似自动决定。

8. NaN、Infinity 和 -0

浮点数除了普通数,还有特殊值。

含义
NaNNot a Number,不是一个普通数
+Infinity正无穷
-Infinity负无穷
-0带负号的零

NaN 常见来源:

0 / 0
sqrt(负数)
无效浮点操作

NaN 的一个重要性质:

NaN 不等于任何值,包括它自己。

所以判断 NaN 要使用专门检测方式,而不是普通相等比较。

边界条件:NaN 会让普通比较失去全序直觉

普通数字比较有一种直觉:

x < y
x == y
x > y

三者通常总有一个成立。但 NaN 打破了这个直觉:

NaN < 1 -> false
NaN == 1 -> false
NaN > 1 -> false
NaN == NaN -> false

这会影响排序、最大最小值、阈值过滤和断言。

例如一个“找最小值”的流程:

代码块PLAINTEXT · 4 行收起展开
min = first
for x in values:
  if x < min:
   min = x

如果 first 是 NaN,后面所有正常数和 NaN 比较都可能无法把 min 替换掉,最终最小值变成 NaN。反过来,如果 NaN 出现在后面,它可能被悄悄跳过。

更稳的做法是先定义特殊值策略:

策略适用场景
输入阶段拒绝 NaN数据必须是普通数
NaN 单独计数并报警数据采集可能异常
排序时把 NaN 放最后展示或统计需要稳定顺序
计算前过滤 NaN聚合指标允许忽略坏样本
NaN 一旦出现就传播失败科学计算需要暴露非法输入

浮点比较不是只有 eps 问题,还要先处理特殊值是否进入了比较路径。

Infinity 常见来源:

正数 / 0
超出浮点可表示范围

-0 看起来奇怪,但在某些数值计算、符号方向、除法结果里有意义。

这些特殊值会传播。如果系统没有处理,后续结果可能一路变成 NaN。

9. 上溢、下溢和非规格化数

浮点也有范围限制。

上溢:

结果太大
-> 超出可表示范围
-> 可能变成 Infinity

下溢:

结果太小,接近 0
-> 无法正常表示
-> 精度降低,甚至变成 0

为了让接近 0 的数更平滑地表示,IEEE 754 还有非规格化数。你不用一开始记所有编码细节,但要知道:

浮点数在极大和极小区域都会出现边界行为。

10. 运算顺序会影响结果

数学上:

(a + b) + c = a + (b + c)

但浮点计算里,结合律可能不成立。

原因是每一步都会舍入。

示意:

(大数 + 小数) + 另一个小数

可能先把小数吞掉。

而:

小数 + 另一个小数
再加大数

可能保留更多小数影响。

这对统计、仿真、机器学习、图形计算、科学计算都重要。

边界条件:相减比相加更容易放大误差

如果两个很接近的浮点数相减:

1.0000001 - 1.0000000

真实差值很小,前面的有效数字大量抵消,剩下的低位误差会变得显眼。这叫灾难性消去。

运算形态风险
大数 + 小数小数可能被吞
近似相等的数相减有效数字被抵消,误差相对放大
大量小数累加舍入误差逐步累积
除以接近 0 的数结果极大,误差被放大

所以数值计算里,“代数等价”的公式不一定“数值稳定”等价。一个公式在纸上很好,在有限精度机器上可能很脆。

11. 联系实际:遇到浮点问题怎么排查

看到浮点结果异常时,先问:

  1. 输入是否已经是近似值?
  2. 是否用浮点表示了需要十进制精确的业务量?
  3. 是否直接用相等比较?
  4. 运算是否有大量累加、相减、乘除?
  5. 是否存在大数加小数?
  6. 是否出现 NaN 或 Infinity?
  7. 是否需要固定舍入规则?
  8. 是否能改成整数最小单位或十进制定点?

不要看到 0.30000000000000004 就以为程序坏了。先判断它是浮点正常误差,还是业务不应该使用浮点。

排障卡:浮点异常先分四类

看到浮点结果“不像数学”,先不要急着修公式。按这四类判断:

类型典型现象方向
表示误差0.1 + 0.2 不是精确 0.3二进制无法有限表示某些十进制小数
舍入累积多次加减乘除后误差变大调整运算顺序、减少重复舍入
精度吞噬大数加小数,小数消失分组累加、提高精度、缩放数据
特殊值传播出现 NaNInfinity查非法操作、除零、上溢、输入异常

如果业务要求十进制精确,例如金额、库存、计数、协议字段,不要依赖二进制浮点近似。先问:

能否用整数最小单位?
能否用十进制定点?
舍入规则是否明确?
比较是否使用了合理误差范围?

浮点不是错,它只是解决“巨大范围近似数值”的结构,不适合所有小数问题。

12. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 浮点数和整数的根本区别是什么?
  2. IEEE 754 为什么要分 sign、exponent、fraction?
  3. 为什么十进制小数 0.1 在二进制里可能不精确?
  4. 为什么浮点比较不能随便使用相等?
  5. 为什么大数加小数时,小数可能消失?
  6. NaN、Infinity、-0 分别意味着什么?
  7. 为什么金额和精确计数不适合用二进制浮点?
  8. 遇到浮点误差时,如何判断是表示问题、舍入问题、运算顺序问题还是业务建模问题?

浮点数不是“错误的小数”,它是一种用有限位数近似表示巨大范围数值的工程折中。理解这个折中,才能安全使用它。

延伸阅读