4th

浮点数运算的精度损失

接上一条

上次留了一个问题,就是为什么在编程语言中用 float 类型的 0.3 + 0.6,结果会是 0.899999…,而不是 0.9。

要想弄明白这个问题,必须先搞清楚两件事:

  1. 十进制小数如何转换成二进制浮点数

  2. 浮点数如何做加法运算

十进制小数 → 二进制浮点数

首先说一下, 十进制小数如何转换成二进制浮点数。十进制小数要想转换成二进制,要把小数部分乘以 2,乘积记做 r,如果 r > 1,则记下 1,令 r=(r1)×2r = (r - 1) \times 2 ​,继续循环操作;如果 r < 1,则记下 0,令 ​ r=r×2r=r\times 2,继续循环操作;如果 r = 1,则记下 1,计算结束。计算过程的伪代码如下:

 r = n * 2  // n 是十进制的小数
 s = "0."   // s 是最终结果,用字符串表示
 while r != 1:
     if r > 1:
         s += '1'
         r = (r - 1) * 2
     else if r < 1:
         s += '0'
         r *= 2
 s += '1'

当我们尝试把十进制的 0.3 转换成二进制时,你会发现,我们得到的是 0.0100110011…,这里的「0011」会无限循环下去,如果用上面这段伪代码去计算 0.3 的二进制,它会陷入死循环,永远无法结束!按照上次讲的 IEEE-754 标准,0.3 应该表示成 ​ (1)0×1.00110011×22(-1)^0\times 1.00110011\dots \times 2^{-2} ,如果在计算机中存储为 32 位的浮点数,即 float,就是这样:

虽然小数点部分的「0011」是无限循环的,但是计算机的位数是有限的,以 float 为例,小数点后的有效数位长度只有 23,超过 23 位的部分都被「截」掉了,所以计算机是无法精确表示 0.3 的。虽然 64 位浮点数 double 的有效数位更长,但是对于小数点后无限循环的二进制小数来说,double 同样无法精确表示,顶多就是精度比 float 更高而已。

如果你仔细观察,会发现在上图中的有效数位 f 中,最右边的三位是 010。如果按照 00110011… 这样循环下去,取前 23 位,最后得到的数应该是 00110011001100110011001,那么最右边三位应该是 001 才对啊,怎么是 010 呢?这其实涉及到了浮点数的舍入问题。

上面这张图展示了 0.3 的有效数位,上面那行(即白色背景)数字是实际存储在计算机中的,也就是舍入后的,下面那行(绿色背景 + 橙色背景)是舍入前的,其中橙色背景的是需要舍入的部分。默认的舍入规则是舍入到最接近,一样接近的情况下偶数优先。比如上面那张图中,直接舍掉的话结果是 00110011001100110011001,与直接舍掉相比,舍入前的数字显然更接近 00110011001100110011010(不要忘了我们讨论的只是小数部分,前面还有一个默认的「1.」),所以我们最终得到的有效数位 f 就是 00110011001100110011010。

还有一种情况,就是舍入前的有效位数不是无限循环的,比如这样:

这种情况下,只有最右边的 1 需要舍入,而 0.001100110011001100110011 与 0.00110011001100110011001 和 0.00110011001100110011010 的距离是一样的,都相差 0.000000000000000000000001,这时就按照偶数优先的原则,让 1 进位,得到 0.00110011001100110011010。

IEEE 标准总共列出了 4 种不同的方法:

  • 舍入到最接近:舍入到最接近,在一样接近的情况下偶数优先(Ties To Even,这是默认的舍入方式):会将结果舍入为最接近且可以表示的值,但是当存在两个数一样接近的时候,则取其中的偶数(在二进制中是以 0 结尾的)。

  • 朝 +∞ 方向舍入:会将结果朝正无限大的方向舍入。

  • 朝 -∞ 方向舍入:会将结果朝负无限大的方向舍入。

  • 朝 0 方向舍入:会将结果朝 0 的方向舍入。

同理,0.6 的 float 表示形式为:

浮点数如何做加法运算

浮点数做加法运算遵循一个原则,那就是先对齐,再计算

「对齐」指的是对齐指数位 e。两个浮点数相加时,如果 e 不一样,就要先把 e 对齐再做加法运算。如何对齐呢?对齐的原则就是把 e 都统一成其中较大的一个。

比如 0.5 与 0.125 相加,这两个数表示成浮点数分别是 (1)0×1.0×21(-1)^0\times 1.0 \times 2^{-1} ​ 和 (1)0×1.0×23(-1)^0\times 1.0 \times 2^{-3} ​,由于 0.5 的指数大于 0.125 的指数,所以要把 0.125 的指数统一成和 0.5 一样的 -1,指数增大,那么有效数位要右移,因为 f 前面默认有个 1,所以右移之后,0.125 的有效数位 变成了 01,然后将 0.5 的有效数位与 0.125 的有效数位相加,结果还是 01,最终表示成浮点数就是 ​ (1)0×1.01×21(-1)^0\times 1.01 \times 2^{-1}

如果有效数位相加的过程中得到的结果大于 1 了,那么要让有效数位进行右移,同时指数增加相应的值,右移了几位,指数就加几。右移时必然会有丢失的有效数,如果这些有效数都是 0 还好,不会有影响,如果丢失的有效数中包含 1,就要按照前面讲的舍入规则进行舍入,从而造成了精度损失。

如果两个相加的单精度浮点数的指数位相差超过了了 23,也就是差不多 1600 万倍,那么相加的结果就等于较大的那个数。你可以在 Java 中用 float 类型的 2000 万加 1,循环 1000 万次,看看结果是多少。

现在我们来看一下为什么 0.3 + 0.6 = 0.8999999999999999。

开头那张图我是用 Python3 算的,Python3 默认是使用 64 位的浮点数,所以 0.3 和 0.6 的浮点数表示形式如下:

0.3 :

0.6:

注意,这个时候其实计算机中存的已经不是精确的 0.3 和 0.6 了,然后按照我们刚才讲的浮点数加法运算的规则,先对齐,再计算,得到的结果是:

把这个结果换算成十进制,就是 0.8999999999999999。

如何避免浮点数运算带来的精度损失也是一个比较有意思的话题,大家感兴趣的话可以搜一下 Kahan Summation 算法。

参考资料:

  1. 《计算机组成与设计:硬件 / 软件接口》3.5.1 节

微信扫一扫上方二维码,关注我的公众号:王虾片

Last updated

Was this helpful?