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
如果完全用定长整数直接表示,会很不方便。
浮点数使用类似科学计数法的方式。
十进制科学计数法:
二进制浮点数类似:
value = sign * significand * 2
也就是:
| 部分 | 作用 |
|---|---|
| sign | 正负 |
| significand | 有效数字 |
| exponent | 数量级 |
浮点数用有限位数换取很大的表示范围,但代价是精度有限。
2. IEEE 754 的基本结构
常见浮点格式遵循 IEEE 754。
以 32 bit 单精度为例:
| 部分 | 位数 | 作用 |
|---|---|---|
| sign | 1 | 符号位 |
| exponent | 8 | 指数,控制数量级 |
| fraction | 23 | 尾数部分,控制有效数字 |
64 bit 双精度:
| 部分 | 位数 |
|---|---|
| sign | 1 |
| exponent | 11 |
| fraction | 52 |
双精度比单精度:
- 范围更大。
- 有效数字更多。
- 占用空间更大。
- 运算和带宽成本也可能更高。
不要把浮点理解成“小数点后固定多少位”。它不是定点数,而是:
有效数字 + 指数
小数点会随着指数移动,所以叫 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. 舍入:每次计算都可能丢一点
浮点数的有效数字位数有限。计算结果如果需要更多位,就必须舍入。
例如:
代码块收起展开
真实结果: 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
浮点数除了普通数,还有特殊值。
| 值 | 含义 |
|---|---|
| NaN | Not 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
这会影响排序、最大最小值、阈值过滤和断言。
例如一个“找最小值”的流程:
代码块收起展开
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. 联系实际:遇到浮点问题怎么排查
看到浮点结果异常时,先问:
- 输入是否已经是近似值?
- 是否用浮点表示了需要十进制精确的业务量?
- 是否直接用相等比较?
- 运算是否有大量累加、相减、乘除?
- 是否存在大数加小数?
- 是否出现 NaN 或 Infinity?
- 是否需要固定舍入规则?
- 是否能改成整数最小单位或十进制定点?
不要看到 0.30000000000000004 就以为程序坏了。先判断它是浮点正常误差,还是业务不应该使用浮点。
排障卡:浮点异常先分四类
看到浮点结果“不像数学”,先不要急着修公式。按这四类判断:
| 类型 | 典型现象 | 方向 |
|---|---|---|
| 表示误差 | 0.1 + 0.2 不是精确 0.3 | 二进制无法有限表示某些十进制小数 |
| 舍入累积 | 多次加减乘除后误差变大 | 调整运算顺序、减少重复舍入 |
| 精度吞噬 | 大数加小数,小数消失 | 分组累加、提高精度、缩放数据 |
| 特殊值传播 | 出现 NaN、Infinity | 查非法操作、除零、上溢、输入异常 |
如果业务要求十进制精确,例如金额、库存、计数、协议字段,不要依赖二进制浮点近似。先问:
能否用整数最小单位?
能否用十进制定点?
舍入规则是否明确?
比较是否使用了合理误差范围?
浮点不是错,它只是解决“巨大范围近似数值”的结构,不适合所有小数问题。
12. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 浮点数和整数的根本区别是什么?
- IEEE 754 为什么要分 sign、exponent、fraction?
- 为什么十进制小数
0.1在二进制里可能不精确? - 为什么浮点比较不能随便使用相等?
- 为什么大数加小数时,小数可能消失?
- NaN、Infinity、-0 分别意味着什么?
- 为什么金额和精确计数不适合用二进制浮点?
- 遇到浮点误差时,如何判断是表示问题、舍入问题、运算顺序问题还是业务建模问题?
浮点数不是“错误的小数”,它是一种用有限位数近似表示巨大范围数值的工程折中。理解这个折中,才能安全使用它。