在网上看到一个问题
然后看到这篇关于浮点数的文章,希望大家看了之后有所启发
想一下,为什么第一个打印的和预设值不同,但是第二个是相同的?
如图:
尾数部分是如何转变成二进制的?
前言
很多人在初学写程式时都会遇到所谓的浮点误差,如果你到目前都还没被浮点误差雷过,那只能说你真的很幸运XD
以下图Python 的例子来说 0.1 + 0.2
并不等于0.3
,8.7 / 10
也不等于0.87
,而是0.869999…
,真的超怪der 🤔
但这绝对不是什么神bug,也不是Python 设计得不好,而是浮点数在做运算时必然的结果,所以即便是到了Node.js 或其他语言也都是一样
电脑如何储存一个整数(Integer)
在讲为什么会有浮点误差之前,先来谈谈电脑是怎么用0 跟1 来表示一个 整数,大家应该都知道二进制这个东西:像 101
代表2² + 2⁰ 也就是5、1010
代表2³ + 2¹ 也就是10
如果是一个unsigned 的32 bit 整数,代表他有32 个位置可以放0 或1,所以最小值就是 0000...0000
也就是0,而最大值 1111...1111
代表2³¹ + 2³⁰ + … + 2¹ + 2⁰ 也就是4294967295
从排列组合的角度来想,因为每一个bit 都可以是0 或1,整个变数值有2³² 种可能性,所以可以 精确的 表达出0 到2³²-1 中任一个值,不会有任何误差
浮点数(Floating Point)
虽然从0 到2³²-1 之间有很多很多个整数,但数量终究是 有限 的,就是2³² 个那么多而已;但浮点数就大大的不同了,大家可以这样想:在1 到10 这个区间中只有十个整数,但却有 无限多个 浮点数,譬如说5.1、5.11、5.111 等等,再怎么数都数不完
但因为在32 bit 的空间中就只有2³² 种可能性,为了把所有浮点数都塞在这个32 bit 的空间里面,许多CPU 厂商发明了各种浮点数的表示方式,但若各家CPU 的格式都不一样也很麻烦,所以最后是以IEEE发布的IEEE 754作为通用的浮点数运算标准,后来的CPU 也都遵循这个标准进行设计
IEEE 754
IEEE 754 里面定义了很多东西,其中包括单精度(32 bit)、双精度(64 bit)跟特殊值(无穷大、NaN)的表示方式等等
正规化
以8.5 这个符点数来说,如果要变成IEEE 754 格式的话必须先做正规化:把8.5 拆成8 + 0.5 也就是2³ + 1/2¹,接着写成二进位变成1000.1,最后再写成1.0001 x 2³,跟十进位的科学记号满像的
单精度浮点数
在IEEE 754 中32 bit 浮点数被拆成三个部分,分别是sign、exponent 跟fraction,加起来总共是32 个bit
sign:最左侧的1 bit 代表正负号,正数的话sign 就为0,反之则是 1
exponent:中间的8 bit 代表正规化后的次方数,采用的是 超127格式,也就是3 还要加上127 = 130
fraction:最右侧的23 bit 放的是小数部分,以1.0001 来说就是去掉1. 之后的0001
所以如果把8.5 表示成32 bit 格式的话就会是这样:
这图我画超久的,请大家仔细看XD
什么情况下会不准呢?
刚刚8.5 的例子可以完全表示为2³+ 1/2¹,是因为8 跟0.5 刚好都是2 的次方数,所以完全不需要牺牲任何精准度
但如果是8.9 的话因为没办法换成2 的次方数相加,所以最后会被迫表示成1.0001110011… x 2³,而且还会产生大概0.0000003 的误差,好奇结果的话可以到IEEE-754 Floating Point Converter网站上玩玩看
双精度浮点数
上面讲的单精度浮点数只用了32 bit 来表示,为了让误差更小,IEEE 754 也定义了如何用64 bit 来表示浮点数,跟32 bit 比起来fraction 部分大了超过两倍,从23 bit 变成52 bit,所以精准度自然提高许多
以刚刚不太准的8.9 为例,用64 bit 表示的话虽然可以变得更准,但因为8.9 无法完全写成2 的次方数相加,到了小数下16 位还是出现误差,不过跟原本的误差0.0000003 比起来已经小了很多
类似的情况还有像Python 中的 1.0
跟 0.999...999
是相等的、123
跟 122.999...999
也是相等的,因为他们之间的差距已经小到无法放在fraction 里面,所以就二进制的格式看来他们每一个bit 都一样
解决方法
既然无法避免浮点误差,那就只好跟他共处了(打不过就加入?),这边提供两个比较常见的处理方法
设定最大允许误差ε (epsilon)
在某些语言里面会提供所谓的epsilon,用来让你判断是不是在浮点误差的允许范围内,以Python 来说epsilon 的值大概是2.2e-16
所以你可以把 0.1 + 0.2 == 0.3
改写成0.1 + 0.2 — 0.3 <= epsilon
,这样就能避免浮点误差在运算过程中作怪,也就可以正确比较出0.1 加0.2 是不是等于0.3
当然如果系统没提供的话你也可以自己定义一个epsilon,设定在2 的-15 次方左右
完全使用十进位进行计算
之所以会有浮点误差,是因为十进制转二进制的过程中没办法把所有的小数部分都塞进fraction,既然转换可能会有误差,那干脆就不要转了,直接用十进制来做计算!!
在Python 里面有一个module 叫做decimal,它可以帮你用十进位来进行计算,就像你自己用纸笔计算0.1 + 0.2 绝对不会出错、也不会有任何误差(其他语言也有类似的模组)
自从我用了Decimal 之后不只bug 不见了,连考试也都考一百分了呢!
虽然用十进位进行计算可以完全躲掉浮点误差,但因为Decimal 的十进位计算是模拟出来的,在最底层的CPU 电路中还是用二进位在进行计算,所以跑起来会比原生的浮点运算慢非常多,所以也不建议全部的浮点运算都用Decimal 来做
总结
回归到这篇文章的主题:「为什么浮点误差是无法避免的?」,相信大家都已经知道了
至于你说知道IEEE 754 的浮点数格式有什么用吗?好像也没什么特别的用处XD,只是觉得能从浮点数的格式来探究误差的成因很有趣而已,感觉离真相又近了一点点
而且说不定哪天会有人问我「为什么浮点运算会产生误差而整数不会」,那时我就可以有自信的讲解给他听,而不是跟他说「反正浮点运算就是会有误差,背起来就对了」
后记
这是我第一次写这种几乎是纯原理的文章,不管你是觉得很有趣,还是觉得太理论了不知道学这要干嘛,都欢迎你在下方留言跟我说,或是透过拍手表达你的意见,这样我也比较能知道你们喜欢什么类型的文章,谢谢大家~
参考资料
https://zh.wikipedia.org/wiki/IEEE_754
https://www.h-schmidt.net/FloatConverter/IEEE754.html
https://zh.wikipedia.org/wiki/%E6%B5%AE%E7%82%B9%E6%95%B0
文章转自:https://medium.com/starbugs/see-why-floating-point-error-can-not-be-avoided-from-ieee-754-809720b32175