如果你写过代码,大概率遇到过这个问题:明明 0.1 + 0.2 应该等于 0.3,可计算机告诉你答案是 0.30000000000000004。这不是 bug,是计算机的算数本身就长这样。
计算机怎么存小数?
计算机只认识 0 和 1。整数好办——转成二进制就行,比如 5 = 101₂。
但小数不行。你不能在二进制里加个小数点就完事——因为你存的空间是固定的。计算机科学家发明了一种办法:用科学计数法存数字。
任何数字都可以表示为:
$$数值 = (-1)^{符号位} × 尾数 × 2^{指数}$$
这叫「浮点数」——因为小数点可以左右浮动,就像科学计数法里小数点能移来移去一样。这个标准叫 IEEE 754,1985 年制定,到今天所有 CPU 都在用它。
一个 64 位的双精度浮点数被分成三段:1 位符号位、11 位指数、52 位尾数。加起来 64 位——这就是你平常写代码用的 double 或者 float64。
精度为什么丢了?
问题出在进制转换上。
在十进制里,1/10 = 0.1,一个干净利落的小数。但在二进制里,1/10 是一个无限循环小数——你永远写不完它,就像 1/3 = 0.3333… 永远写不完一样。
计算机只有 52 位来存尾数,所以它必须在某处截断。截断就会产生误差。
0.1 在二进制里是:0.000110011001100110011001100…(无限循环)
截断后存进去的,其实是一个「接近 0.1 但不是 0.1」的数。
同样的,0.2 也是无限循环的。两个近似值加在一起,偏差累积,得到了 0.30000000000000004 而不是 0.3。
这个误差能杀人
大多数时候误差小到可以忽略——小数点后面第 16 位才出问题,不影响日常使用。
但关键时刻会要命。
1991 年海湾战争,美国爱国者导弹的拦截系统使用浮点数进行时间计算。系统把一个以 0.1 秒为单位的时间值转成秒数,用了一个 24 位的浮点数来存。连续运行 100 小时后,累积误差达到 0.34 秒。
听起来不长,但导弹以超音速飞行。0.34 秒的偏差导致雷达算错了飞毛腿导弹的位置——拦截弹打到了错误的位置。28 名美军士兵因此丧生。
一个工程师也知道的问题,因为觉得「误差够小」,导致了 28 条人命。
怎么办?
主要有几种办法:
- 不用等号比较——用容差(epsilon)判断两个浮点数是否足够接近,而不是直接 ==
- 金融计算用定点数——银行计算 0.01 元加减必须精确,所以用专门的小数类型(Python 的 decimal,Java 的 BigDecimal)
- 永远别用浮点数做循环计数——比如 for (let i = 0; i != 1; i += 0.1) ——这个循环可能永远停不下来
- 高精度场景用专门库——科学计算可以用 GMP、MPFR 等任意精度库
本质是什么?
浮点数不是 bug,是一个工程取舍。
现实世界是连续的、无限的。而计算机只能用有限的 64 位去表示它。你等于在问:能不能用一张 A4 纸画下整个宇宙的地图——画得下,但每个城市只有半个像素那么大。
了解这个原理之后,下次在代码里碰到浮点数问题,你至少知道:不是电脑有问题,是这个世界太复杂,而比特太少了。