haizlin/fe-interview

[js] 第23天 0.1 + 0.2、0.1 + 0.3和0.1 * 0.2分别等于多少?并解释下为什么?

Opened this issue · 11 comments

第23天 0.1 + 0.2、0.1 + 0.3和0.1 * 0.2分别等于多少?并解释下为什么?

JS中采用的IEEE 754的双精度标准,计算机内部存储数据的编码的时候,导致精度变化。不是所有浮点数都有舍入误差。二进制能精确地表示位数有限且分母是2的倍数的小数(这种情况的小数像乘法0.1*0.2的出五十分之一就不能精确)。

用一句话概括就是:

EcmaScrpt规范定义Number的类型遵循了IEEE754-2008中的64位浮点数规则定义的小数后的有效位数至多为52位导致计算出现精度丢失问题!

这个问题也算是经常遇到的面试题之一了,楼上说的对,简单来说就是js中采用IEEE754的双精度标准,因为精度不足导致的问题,比如二进制表示0.1时这这样表示1001100110011...(0011无线循环),那么这些循环的数字被js裁剪后,就会出现精度丢失的问题,也就造成了0.1不再是 0.1 了,而是变成了 0.100000000000000002
我们可以来测试一下:

0.100000000000000002 === 0.1//true

那么同样的,0.2 在二进制也是无限循环的,被裁剪后也失去了精度变成了 0.200000000000000002:

0.200000000000000002 === 0.2 // true

由此我们可以得出:

0.1 + 0.2 === 0.30000000000000004//true

所以自然0.1+0.2!=0.3
那么如何解决这个问题;使用原生最简单的方法:


parseFloat((0.1+0.2).toFixed(10)) === 0.3//true

参考:
深度剖析0.1 +0.2===0.30000000000000004的原因:https://www.jianshu.com/p/d6b81e4e25e3

0.30000000000000004
0.4
0.020000000000000004

EcmaScrpt规范定义Number的类型遵循了IEEE754-2008中的64位浮点数规则定义的小数后的有效位数至多为52位导致计算出现精度丢失问题!

一般使用(0.1 + 0.2 - 0.3 )< Number.EPSILON来解决

先说结果:

image-20200731152004358

之所以会出现0.1 + 0.2 != 0.3这种问题,原因在于我们现实世界中使用十进制来表示数字,但是计算机中只能使用二进制来表示数字,小数也是用二进制来表示。JavaScript存储二进制数据也是有限度的,正如在现实中我们无法写下一个无限循环的小数一样,只能写个近似数。

说的再简单些:

我们可以把计算机转换二进制存储的过程类比成下面的问题:

image-20200731152004358

当我们把1除以3得到的0.333结果再进行相加,永远加不到1

JavaScript存储数字的标准

JavaScript采用了IEEE754标准来规定数字。在IEEE754标准中,又分为以下几种标准:

  • 单精度
  • 双精度(64位)
  • 延伸单精度
  • 延伸双精度

Javascript中采用的是双精度标准来表示数字,64位的意思就是由0或者1组成这64位,从而表示出一个二进制的数字。

在这64位数字中,并不是01随意地排列组合,IEEE754标准把这64位分成了三个部分

image-20200731122803946

可能现在还不能理解为何要这样划分,接着往下看。

计算机如何存储数字

在研究计算机是如何存储数字前,我们先来回顾一下科学计数法:

image-20200731123300874

对于一个非常大的数字来说,我们可以通过科学计数法来表示:例如666000可以被表示为6.66 x 10^5,这样我们就可以只存储一个有效数字6.66,然后记住它的指数位上的数字5,通过这两个简单的数字来表示一个非常大的数字。

计算机也采用这种方式来存储数字,不过存储的是二进制的。例如:

image-20200731143025293

进制转换

toString()方法实现进行转换

这里由于篇幅限制,不具体讲解进制转换的问题。

既然我们说在计算机中是通过二进制来表示数字的,我们先把0.10.2转换成二进制来看一下。如何在JavaScript中进行进制的转换呢?

答案是:toString()方法

不要只认为toString()方法是将一个值转换成字符串的,通过向该方法中传入基数参数,toString()可以输出以二进制八进制十六进制,乃至其他任意有效进制格式表示的字符串值

image-20200731121235195

手动将十进制小数转换为二进制

直接用toString()方法得到出的好像并不是一个无限循环的二进制数,那为什么图中标明了‘0110’循环呢?我们手动计算一下,应该就知道了。

十进制小数转换成二进制小数采用"乘2取整,顺序排列"法。

具体做法是:

  • 用2乘十进制小数,可以得到积,将积的整数部分取出;
  • 再用2乘余下的小数 部分,又得到一个积,再将积的整数部分取出;
  • 如此进行,直到积中的小数部分为零,或者达到所要求的精度为止;
  • 然后把取出的整数部分按顺序排列起来,先取的整数作为二进制小数的高位有效位,后取的整数作为低位有效位。

十进制0.1

0.1 * 2 = 0.2,整数部分是0

0.2 * 2 = 0.4,整数部分是0

0.4 * 2 = 0.8,整数部分是0

0.8 * 2 = 1.6,整数部分是1

0.6 * 2 = 1.2,整数部分是1

0.2 * 2 = 0.4,整数部分是0

...

十进制0.1→二进制0.000110011→二进制科学记数法:1.10011 * 2-4

从上面的计算过程,可以发现,整数部分从0.4那里开始循环,得到的值永远都是一个小数,结果是一个无限循环的数。因此,只能取一个近似数来表示。(这样的话,就会存在精度丢失了)

十进制0.2

0.2 * 2 = 0.4,整数部分是0

0.4 * 2 = 0.8,整数部分是0

0.8 * 2 = 1.6,整数部分是1

0.6 * 2 = 1.2,整数部分是1

0.2 * 2 = 0.4,整数部分是0

0.4 * 2 = 0.8,整数部分是0

0.8 * 2 = 1.6,整数部分是1

0.6 * 2 = 1.2,整数部分是1

0.2 * 2 = 0.4,整数部分是0

...

十进制0.2→二进制0.001100110→二进制科学记数法:1.10011 * 2-3

计算十进制小数0.2也是如此,会发现无限循环,因此只能取一个近似数来代替(同样会发生精度丢失)

因为这两个十进制的小数转换成二进制的小数后,是一个无限循环的数,因此用IEEE754标准来表示数字的话肯定会出现后续的位置无法存储的问题。

因此指数位只有11位,有效数只有52位。有效数部分只能存储52个数字,这样就迫使计算机取一个近似的数字。那么0.10.2相加以后再转换成十进制就已经不再是纯正的0.3了。

image-20200731154413062

解决方法

image-20200731114240040

幸运的是0.10.2得出的这个近似0.3的数不后面很多个0以后才出现4这个数字,因此有多种方法,可以将结果“修正”为正确答案

toFixed()方法

我们可以使用toFixed()方法将相加的结果保留指定位置的小数,例如,这里保留了5位小数。toFixed()方法的结果是一个字符串,可以利用parseInt()或者parseFloat()方法将字符串转换为数值。这里由于最终结果应该是一个小数,因此使用parseFloat()方法。

image-20200731155049649
个人能力有限,如有错误,敬请指正!
补充两篇文章:
揭秘 0.1 + 0.2 != 0.3
为什么「0.1+0.2!=0.3」,而「0.1+0.3==0.4

JS中采用的IEEE 754的双精度标准,计算机内部存储数据的编码的时候,导致精度变化。不是所有浮点数都有舍入误差。二进制能精确地表示位数有限且分母是2的倍数的小数(这种情况的小数像乘法0.1*0.2的出五十分之一就不能精确)。

(0.2 * 1e20 + 0.3 * 1e20)/1e20

0.30000000000000004
0.4
0.020000000000000004
EcmaScrpt规范定义Number的类型遵循了IEEE754-2008中的64位浮点数规则定义的小数后的有效位数至多为52位,这导致计算出现精度丢失问题。
计算机内部存储数据的编码的时候,0.1在计算机内部根本就不是精确的0.1,而是一个有舍入误差的0.1。当代码被编译或解释后,0.1已经被四舍五入成一个与之很接近的计算机内部数字。其它小数同理.

mark js采用的双精度标准,遵循了IEEE754-2008中的64位浮点数规则定义的小数后的有效位数至多为52位,这导致计算出现精度丢失问题。

除了含含糊糊的精度损失,你能给出更有营养的解释么?让我们看看到底是为什么!

首先,让我们举一个整数的例子,比如:

十进制「13」:1*(10�^1) + 3(10^0) = 10 + 3 = 13
二进制「1101」:1*(2^3) + 1*(2^2) + 0*(2^1) + 1*(2^0) = 8 + 4 + 0 + 1 = 13
接着,让我们再举一个小数的例子,比如:

十进制「0.625」:6*(10^-1) + 2*(10^-2) + 5*(10^-3) = 0.625
二进制「0.101」:1*(2^-1) + 0*(2^-2) + 1*(2^-3) = 5/8 = 0.625
最重要的一点是你要明白计算机是如何表示小数的:比如二进制的「0.1111111」,无非就是十进制的「1/2 + 1/4 + 1/8 + 1/16 + 1/32 + 1/64 + 1/128」,不过细心的你可能已经发现问题了,计算机这种处理小数的方式存在精度损失的,比如一个十进制的「0.1」,换算成分数的话就是十进制的「1/10」,对比前面的结果,你会发现计算机没办法精确表示它,只能近似等于二进制的「0.00011」,也就是十进制的「1/16 + 1/32 = 3/32」,当然二进制小数点后可以多取几位,可惜结果是只能无限趋近,但永远不可能等于。

下面看看为什么「0.1 + 0.2 != 0.3」,而「0.1 + 0.3 == 0.4」。既然存在精度损失,那么「0.1 + 0.2 != 0.3」也说得过去,我们推算一下为什么「0.1 + 0.3 == 0.4」:

十进制的「0.1」近似等于二进制「0.00011」
十进制的「0.3」近似等于二进制「0.01001」
十进制的「0.4」近似等于二进制「0.01100」
于是,十进制的「0.1 + 0.3」也就是二进制的「0.00011 + 0.01001」:

链接 https://cloud.tencent.com/developer/article/1918183