从0.1加0.2不等于0.3说起,浮点型的精度问题

不知道各位工作中有没有收到过同事或领导这样的警示,不要直接对浮点型数据比较大小,不然会产生一些奇怪的错误。抱着好奇的心态,我对此专门做了研究。我们先看下面几张图:

python:


image.png

js:


image.png

java:


image.png

scala:


image.png

上面几张图可以看出大部分语言都有这种情况,去网上搜索基本会得到这样的回答:由于精度问题,导致了结果不完全精确。但具体是怎么不准确,为何会有精度差异很少有人提起,今天就来彻底剖析一下来龙去脉。

我们分以下几点来说:
一、10进制和2进制的相互转化
1、2进制转10进制
2进制的110.11转换成10进制,计算方法是这样(小数点左边从右向左分别代表2的0,1,2...次方,小数点右边,从左往右数,分别代表2的-1,-2,-3...次方):

1*2^2 + 1*2^1 + 0*2^0 + 1*2^(-1) + 1*2^(-2) = 6.75  (2^x 代表2的x次方)

2、10进制转2进制
10进制的6.75转换成2进制:计算方法是这样的(把10进制的整数和小数部分分开来看,整数部分用除2取余逆序排列法转换二进制,小数部分用乘2取整顺序排列法转2进制):
①、整数部分
6 / 2 = 3……0(此结果代表商为3,余数为0)
3 / 2 = 1……1
1 / 2 = 0……1
将余数逆序排列,所以6的2进制表示为110。
我们来看下原理:其实这个也很好理解,可以把这个数看做是一串带系数的2的n次方的级数,设当前数为x,a[n]为系数,a[n]∈{0,1}
x = a[n]2^n + a[n-1]2^(n-1) + ... + a[0]*2^0
x / 2 的余数相当于a[0]
后面的系数以此类推,这就是除2逆序排列的原理。

②、小数部分
0.75 * 2 = 1 + 0.5 取整数部分1,0.5继续进入后面的循环,直到小数部分为0为止
0.5 * 2 = 1 + 0
于是0.75转换成2进制为0.11

为了后面的铺垫,再举个例子,10进制的0.2转2进制:
0.2 * 2 = 0 + 0.4
0.4 * 2 = 0 + 0.8
0.8 * 2 = 1 + 0.6
0.6 * 2 = 1 + 0.2
0.2 * 2 = 0 + 0.4
。。。
。。

发现没有,按照这个方式计算下去,会进入一个2、4、8、6、2。。。的循环,所以10进制的0.2用二进制表示为0.0011001100110011...
同样,我们看下原理,其实和整数部分的原理差不多,可以把这个数看做是一串带系数的2的n次方的级数,设当前数为x,a[n]为系数,a[n]∈{0,1},

x = a[-1]*2^(-1) + a[-2]*2^(-2) + ... + a[-n]*2^(-n)

x * 2 的整数部分相当于a[-1]
后面的系数以此类推,这就是乘2序排列的原理。

有人可能会有疑问,除去首项,其他所有项乘2再相加也能凑出整数部分啊,下面来做个证明从-2到-n项乘2再求和不可能有整数部分,证明为等比数列前n项和公式(如果这个也不懂,请参考百度错位相减法算等比数列前n项和),把-2项之后的所有项放大到最大,及a[n]均为1,则:
S = a[-2]2^(-2) + ... + a[-n]2(-n) = 2^(-2) + ... + 2^(-n) = 1/4 * (1-(1/2)^(n-1))/(1-1/2) <= 1/2,
当n趋于无穷时,等式可取等号,但是生活中,包括计算机中,都是有限项,无法取到等号,所有S<1/2,2S<1,可知-2至-n项求和再乘2无法得到整数部分。

二、程序是怎么处理浮点型的
1、整型的处理:
我们知道计算机中所有的数据信息都会被处理成2进制的数来表示:
以64整型来举例,共64位0或1,其中第一位表示符号(0为+,1为-),后面63位表示值的大小。
举个例子:
000...011 = 3 (长度为64个,省略的部分均为0)
100...011 = -3 (长度为64个,省略的部分均为0)
于是这样64位0或1,就可以表示整型了。

2、浮点型的处理
先上一张表和一张图:


image.png
image.png

上表分别是单精度和双精度的结构分布,上图则是对双精度的结构展示,下面就用这张图来解释上表的几个名词。
数符:上图的蓝色部分,就是决定数字是正还是负,当第一位是0时为正,是1时为负;
阶码:上图的黄色部分(第2~12位),共11位,为双精度的阶码,2的阶码次幂减去偏移值表示尾值处理后的实际值的小输掉的向左偏移位数(如果是负值,就是向右),好吧,这段听起来不像人话,容我后面举例解释;
尾值:上图的绿色部分(第13~64位),共52位,为双精度的小数部分;
偏移值:就是一个值,单精度就是127,双精度就是1023,先不用管它是怎么来的,他的作用就是让2的阶码次幂减的;
那基本都解释清楚了,那我们看下双精度的浮点型是如何表示的吧。

IEEE 754浮点数标准
这是一种浮点型的表示规范,为什么要有这个规范呢,举个例子:比如10进制的1.5,用2进制表示为1.1,现在我们要用科学计数法表示这个数字,

那我可以写成1.1*(2^0),也可以写成11*(2^(-1)),或者0.11*(2^1)

像上面这样,一个数字可以有多种表示方法,对于人们处理浮点型很不方便,甚至容易造成精度的不统一。为了解决这一问题,人们制定了IEEE 754浮点数标准,此规范规定浮点型都要写成

1.xxxxx*(2^xxxx)

的形式,即第一个数的开头恒定是“1.”,第二个数为2的n(n可以是负数)次方。

那我们看下一个浮点型怎么用64位0,1进行表示吧:
先做一些预设,设数符为a,阶码为b,尾值为c(c为[0,1)区间内的一个小数),偏移值为d,那任意一个浮点型x可表示为

x = (1+c)*(2^(b-d))

解释一下,因为第一项的数字开头恒定为“1.”,所以尾值可能省去表示1这个数字,这样尾值可以多出一位表示小数,用于提高精度。

举个例子:-4.75(2进制表示为-100.11)的64位表示形式
数符:1(因为是负数);
尾值:0.0011(因为开头“1.”恒定);
偏移值:1023(双精度恒定为1023),这个值主要是是为了让阶码正负值都可以表示;
阶码:1025(减去偏移值之后为2);

(-1)*(1+0.0011)*2(1025-1023) = -100.11

看一下64位表示形式
1 10000000001 0011000000...00 省略的均为0。

再举个例子:0.2,前面提到的2进制表示为0.001100110011...,其中循环项0011
数符:0(因为是正数);
尾值:0.10011001011001...1010 (...代表1001的循环项);
偏移值:1023;
阶码:1020(减去阶码之后为-3);
这里单独对尾值做个说明:小数点后共52位,相当于是把0.2的2进制表示0.001100110011...的小数点向右移动了3位,移动到第一个非0数字之后,再取小数点后面的部分,但是这里要注意,因为小数点53位上是1,有一个舍入问题,程序会采取0舍1入的方法进行处理,我们看下49~53位的这几个数字 10011,现在要保留4位,采用0舍1入的原则,最终变成1010。

看一下64位的形式
0 01111111100 10011001...1010 省略的为1001循环项

三、0.1+0.2的计算


image.png

我们看上图,为几个数的浮点型经过位移后的表示形式,因为尾值最多有52个有效数字,加上开头恒定为“1.”,所以每个值一共有53个有效数字(即从左边第一个非0数字开始到结束,一共有53位),图中已用绿色标记,我们看下0.2舍入前后的区别,舍入之前0.2的第54位有效数字为1,所以保留53位时要进1,于是0.2的最后4位就成了1010了,0.1也是同样的道理。

此时我们把0.1和0.2对应位相加,如果等于2就进1,于是就得到了“0.1+0.2舍入前”,然后我们发现,此时有效数字又超过53个了,需要进一步做舍入操作,于是得到“0.1+0.2舍入后”,然后我们用乘2取整法算出0.3的表示,用0.3和0.1+0.2进行对比,发现前面完全一致,只有最后4位存在差异,这也就造成了0.1+0.2不等于0.3的原因了。

幸运的是,python中提供了内置hex()函数,用于进一步验证这个结论,先看下面一张图:


image.png

一点点来解释,hex函数是浮点型的小数点先位移到第一位有效数字之后,再将小数点后面的数字4个4个一组,转换成16进制来表示。
拿0.1的“0x1.999999999999ap-4”来举例:
0x代表是16进制的数字,p-4代表小数点向左移动4位(p+4代表向右移动4),然后主要看下中间的1.999999999999a,我们发现0.1的循环项1001的16进制刚好为9,而0.1的最后4位1010的16进制刚好为a(16进制中a代表10),这更加印证了我们前面的结论是没有错的。

来看下0.1+0.2(“0x1.3333333333334p-2”)与0.3(“0x1.3333333333333p-2”)在最后一位上确实存在差异,一个为4,一个为3,而这刚好是上面我们计算的0100与0011的差异。

四、解决方案
根据以上,我们也就不难理解日常工作中遇到的如下图一样的怪现象了:


image.png

在此提供一种解决方案,就是每次处理浮点型操作的时候,对结果进行符合自己精度要求四舍五入,则可以避免此类问题的发生,如下图操作。


image.png

image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容