Lehem 的时间胶囊

浮点计算精度损失原因

|

介绍

3.0 - 2.99 竟然不等于 0.01

我们将一个浮点数减去另一个浮点数:
PHP代码

$a = 3.0;
$b = 2.99;
var_dump($a - $b);
#=> 打印的不是 0.01, 而是 float(0.0099999999999998)

在我们的数学知识体系中, 结果是是0.01, 但是在这里结果会是float(0.0099999999999998)
同样类似的情况会在很多语言里面出现:

Ruby代码

a = 6.6
b = 1.3
p a + b
#=> outputs 7.8999999999999995

我们可以发现浮点运算不是百分百正确的

通常来说, float或者double已经能满足一般的小数计算需求, 但是在我们设计所有与金融财务价格等相关的应用的时候, 必然会接触到小数的加减乘除运算, 所以必然会出现误差

为什么使用floatdouble会出现精度损失

大部分的CPU是用IEEE二进制浮点数算术标准(IEEE 754)来计算浮点数的
有常用的, 单精确度浮点数float, 双精确度浮点数double
采用二进制科学计数法, 分为三个部分, 符号位(sign), 指数部分(exponent)和有效部分(fraction, mantissa)
其中float占用32位, 符号位, 指数部分, 有效部分各占1位, 8位, 23位 double占用64位, 符号位, 指数部分, 有效部分各占1位, 11位, 52位

IEEE 754浮点数的三个域

计算机二进制角度计算 6.6 + 1.3 的过程

1.转换整数部分 6 转换为二进制 也就是 110, 1 转换为 1

2.转换小数部分 0.6 转换为二进制

这里给出快速将十进制小数转换为二进制的方法:将小数乘以2, 取整数部分作为二进制的值, 然后再将小数乘以2, 再取整数部分, 以此往复循环 根据上表, 将整数部分这列纵向取值, 可以得到小数的二进制的值

乘2结果 小数部分 整数部分 数位
0.6 0.6 0 个位
1.2 0.2 1 小数十分位
0.4 0.4 0 小数百分位
0.8 0.8 0 小数千分位
1.6 0.6 1 -
1.2 0.2 1 -
0.4 0.4 0 -
0.8 0.8 0 -
1.6 0.6 1 -
1.2 0.2 1 -
0.4 0.4 0 -
0.8 0.8 0 -
1.6 0.6 1 -
1.2 0.2 1 -
0.6 -->   0.10011001100110011001...  // 是个无限循环的小数
6.6 --> 110.10011001100110011001...

3.规约化 我们通过规约化将小数转为规约形式, 类似我们用的科学计数法, 就是保证小数点前面有一个有效数字, 在二进制里面, 就是保证整数位是一个 1

110.10011001100110011001 --> 1.1010011001100110011001 * 2^2  //因为是2的2次方, 规约化后的幂即为指数值, 为2

4.指数偏移值 是指浮点数中指数部分的值, 它的值为规约形式的指数值加上某个固定的值, float的固定值为127, 计算方法是2^e-1 其中的e为存储指数部分的比特位数, 前面提到的float为8位, double为11位
在这里, 因为是 2 的 2 次方, 偏移值就是127+2=129, 转换为二进制就是10000001

5.拼接 6.6为正数, 符号位为0, 指数部分为偏移值的二进制10000001, 有效部分为规约形式的小数部分, 为什么只取小数部分? 因为整数肯定是1, 去掉了不会产生误差
我们去取小数的前23位即10100110011001100110011, 最后拼接到一起即01000000110100110011001100110011
同理, 我们可以计算出1.3的浮点数为00111111101001100110011001100110
PS: 可以使用这个工具验证是否转换正确IEEE 754 Converter

0 + 10000001 +   1.10100110011001100110011
0 + 10000001 + (1.)10100110011001100110011  // 只要小数部分
0 + 10000001 +     10100110011001100110011
--> 01000000110100110011001100110011

6.求和 因为1.36.6小, 我们对1.3进行移位直到两个数的偏移值相同, 然后与6.6相加

     0 01111111 01001100110011001100110  // 1.3
->   0 10000000 10100110011001100110011  // 1.3  这里前面补位补了1, 就是步骤5被舍弃的部分
->   0 10000001 01010011001100110011001  // 1.3

+    0 10000001 10100110011001100110011  // 6.6
---------------------------------------
     0 10000001 11111001100110011001100  // 结果

转为十进制

0 10000001 11111001100110011001100 --> 1.11111001100110011001100 * 2^2
2^2 * (2^0 + 2^-1 + 2^-2 + 2^-3 + 2^-4 + 2^-5 + 2^-8 +...+ 2^-21) = 7.899999618530273

至此完成 6.6 + 1.3 的过程, 加减乘除方法类似, 有兴趣可以自己计算看看

精度损失细节探究

我们不难发现, 上面第 5 步, 数据会在存储的时候, 就已经开始出现误差, 运算的时候, 也就是第 6 步, 也会出现误差 我们可以得出以下结论:

  1. 浮点数精度有限, 只能展示有限的信息
  2. 浮点数在每次计算的时候, 都会出现误差, 如果运算次数越多, 误差也会越来越大

浮点数使用注意

  1. 不要用 == 来判断两个浮点数是否相等
  2. 可以尝试使用两个浮点数差的绝对值来判断是否相等
    if ( abs( a - b ) < 0.0000001 ) 
    

出现精度损失的几个通用解决办法

使用decimal

Ruby 的 BigDecimal 库就是来处理这个问题 有很多类似的讨论:
Ruby 由小数的精度问题引出设计问题

直接使用整型变量

也有些也可以直接使用整型, 理论上和decimal差不多
为什么要金额使用分为单位保存成整型在数据库中?

Comments