js浮点数运算精度问题:0.1+0.2≠0.3
问题概述
在js运算中0.1+0.2运算结果为0.30000000000000004,即0.1+0.2≠0.3
console.log(0.1 + 0.2)
//0.30000000000000004
console.log(0.1 + 0.2 == 0.3)
//false
除了0.1+0.2≠0.3,精度问题会在很多地方出现,例如:
// 加法 =====================
0.2 + 0.4 = 0.6000000000000001
0.7 + 0.1 = 0.7999999999999999
// 减法 =====================
1.5 - 1.2 = 0.30000000000000004
0.3 - 0.2 = 0.09999999999999998
// 乘法 =====================
19.9 * 100 = 1989.9999999999998
0.8 * 3 = 2.4000000000000004
35.41 * 100 = 3540.9999999999995
// 除法 =====================
0.3 / 0.1 = 2.9999999999999996
0.69 / 10 = 0.06899999999999999
// toFixed
1.335.toFixed(2) // 1.33 错误
1.3335.toFixed(3) // 1.333 错误
1.33335.toFixed(4) // 1.3334 正确
1.333335.toFixed(5) // 1.33333 错误
1.3333335.toFixed(6) // 1.333333 错误
分析问题
基础知识
二进制
计算机内部的浮点数数值全部采用二进制进行表示,一个浮点数 float a = 1 不会被存储成1.0,在计算机中没有1.0,它只认0 1编码。
科学记数法
科学记数法是一种记数的方法。日常生活中通常采用十进制科学记数法,把一个数表示成a与10的n次幂相乘的形式(1≤|a|<10,a不为分数形式,n为整数),这种记数法叫做科学记数法。当我们要标记或运算某个较大或较小且位数较多时,用科学记数法免去浪费很多空间和时间。
例如:19971400000000=1.99714×10^13。计算器或电脑表达10的幂是一般是用E或e,也就是1.99714E13=19971400000000。
十进制小数转二进制
十进制小数转二进制,小数部分,乘 2 取整数,若乘之后的小数部分不为 0,继续乘以 2 直到小数部分为 0 ,将取出的整数正向排序。
例如:0.1 转二进制
0.1 * 2 = 0.2 --------------- 取整数 0,小数 0.2
0.2 * 2 = 0.4 --------------- 取整数 0,小数 0.4
0.4 * 2 = 0.8 --------------- 取整数 0,小数 0.8
0.8 * 2 = 1.6 --------------- 取整数 1,小数 0.6
0.6 * 2 = 1.2 --------------- 取整数 1,小数 0.2
0.2 * 2 = 0.4 --------------- 取整数 0,小数 0.4
0.4 * 2 = 0.8 --------------- 取整数 0,小数 0.8
0.8 * 2 = 1.6 --------------- 取整数 1,小数 0.6
0.6 * 2 = 1.2 --------------- 取整数 1,小数 0.2
...
最终 0.1 的二进制表示为 0.000110011… 后面将会 0011 无限循环
同理我们也能得到0.2的二进制表达式
0.2的二进制表示为 0.00110011… 后面 0011 无限循环
既然会无限循环就说明二进制无法精确的保存类似0.1、0.2这样的小数。那这样无限循环也不是办法,又该保存多少位呢?也就有了我们接下来要重点讲解的 IEEE 754 标准。
核心问题
IEEE 754
简介
IEEE 754 是 IEEE 二进制浮点数算术标准的简称,在这之前各家计算机公司的各型号计算机,有着千差万别的浮点数表示方式,这对数据交换、计算机协同工作造成了极大不便,该标准的出现则解决了这一乱象,目前已成为业界通用的浮点数运算标准。
IEEE 754 常用的两种浮点数值的表示方式为:单精确度(32位)、双精确度(64位)。例如, C 语言中的 float 通常是指 IEEE 单精确度,而 double 是指双精确度。
双精确度(64位)
在 JavaScript 中不论小数还是整数只有一种数据类型表示,这就是 Number 类型,即javascript 中所有的数值都是 number 类型,因为它既可以表示浮点数值,也可以表示整数。其遵循 IEEE 754 标准,使用双精度浮点数(double)64 位(8 字节)来存储一个浮点数(所以在 JS 中 1 === 1.0)。
其中能够真正决定数字精度的是尾部,即
64Bits 分为以下 3 个部分:
sign bit(S,符号):用来表示正负号,0 为 正 1 为 负(1 bit)
exponent(E,指数):用来表示次方数(11 bits)
mantissa(M,尾数):用来表示精确度 1 <= M < 2(53 bits)
- 符号 S:
在计算机中一切万物都以二进制表示,为了表示负数通常把最高位当作符号位来表示,这个符号位就表示了正负数,0 表示正数(+),1 表示负数(-)。 - 尾数 M:
IEEE 754 规定,在计算机内部保存 M 时,默认这个数的第一位总是 1,因此可以被舍去,只保存后面部分,这样可以节省 1 位有效数字,对于双精度 64 位浮点数,M 为 52 位,将第一位的 1 舍去,可以保存的有效数字为 52 + 1 = 53 位。 - 指数 E:
E 为一个无符号整数,在双精度浮点数中 E 为 11 位,取值范围为211=2048 ,即表示的范围为 0 ~ 2047。 - 中间值: 由于科学计数法中的 E 是可以出现负数的,IEEE 754 标准规定指数偏移值的固定值为2e-1 -1,以双精度浮点数为例:211-1-1=1023,这个固定值也可以理解为中间值。同理单精度浮点数为 28-1-1=127。
- 正负范围: 双精确度 64 位中间值为 1023,负数为 [0, 1022] 正数为 [1024, 2047]。
双精确度浮点数下二进制数公式 V 最终演变如下所示:
储存
以0.1为例,需要依次进行以下处理:
- 转换为二进制
0.000110011001100110011(0011) // 0011 将会无限循环
- 二进制浮点数的科学计数法表示
0.1 的二进制科学计数法表示如下所示:
1.100110011001100110011(0011无限循环)*2-4 - IEEE 754 存储
3.1 符号位S
由于 0.1 为整数,所以符号位 S = 0
3.2 指数位E
E = -4,实际存储为 -4 + 1023 = 1019,二进制为 1111111011,E 为 11 位,最终为 01111111011
3.3 尾数位M
在 IEEE 754 中,循环位就不能在无限循环下去了,在双精确度 64 位下最多存储的有效整数位数为 52 位,会采用 就近舍入(round to nearest)模式(进一舍零) 进行存储。
11001100110011001100110011001100110011001100110011001 // M 舍去首位的 1,得到如下
1001100110011001100110011001100110011001100110011001 // 0 舍 1 入,得到如下
1001100110011001100110011001100110011001100110011010 // 最终存储
最终结果:
0 01111111011 1001100110011001100110011001100110011001100110011010
此处可以验证结果:
0.2同理,最终推理出:
S E M
0 01111111011 1001100110011001100110011001100110011001100110011010 // 0.1
0 01111111100 1001100110011001100110011001100110011001100110011010 // 0.2
浮点数运算
浮点数运算三步骤:
- 对阶
- 求和
- 规格化
对阶
浮点数加减首先要判断两数的指数位是否相同(小数点位置是否对齐),若两数指数位不同,需要对阶保证指数位相同。
对阶时遵守小阶向大阶看齐原则,尾数向右移位,每移动一位,指数位加 1 直到指数位相同,即完成对阶。
本示例,0.1 的阶码为 -4 小于 0.2 的阶码 -3,故对 0.1 做移码操作。
// 0.1 移动之前
0 01111111011 1001100110011001100110011001100110011001100110011010
// 0.1 右移 1 位之后尾数最高位空出一位,(0 舍 1 入,此处舍去末尾 0)
0 01111111100 100110011001100110011001100110011001100110011001101(0)
// 0.1 右移 1 位完成
0 01111111100 1100110011001100110011001100110011001100110011001101
尾数右移 1 位之后最高位空出来了,如何填补呢?涉及两个概念:
- 逻辑右移:最高位永远补 0
- 算术右移:不改变最高位值,是 1 补 1,是 0 补 0,尾数部分我们是有隐藏掉最高位是 1 的,不明白的再看看上面 3.3 尾数位 有讲解舍去 M 位 1。
尾数求和
两个尾数直接求和
0 01111111100 1100110011001100110011001100110011001100110011001101 // 0.1
+ 0 01111111100 1001100110011001100110011001100110011001100110011010 // 0.2
= 0 01111111100 100110011001100110011001100110011001100110011001100111 // 产生进位,待处理
或者以下方式:
0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
10.0110011001100110011001100110011001100110011001100111
规格化和舍入
由于产生进位,阶码需要 + 1,对应的十进制为 1021,此时阶码为 1021 - 1023(64 位中间值)= -2,此时符号位、指数位如下所示:
S E
= 0 01111111101
尾部进位 2 位,去除最高位默认的 1,因最低位为 1 需进行舍入操作(在二进制中是以 0 结尾的),舍入的方法就是在最低有效位上加 1,若为 0 则直接舍去,若为 1 继续加 1
100110011001100110011001100110011001100110011001100111 // + 1
= 00110011001100110011001100110011001100110011001101000 // 去除最高位默认的 1
= 00110011001100110011001100110011001100110011001101000 // 最后一位 0 舍去
= 0011001100110011001100110011001100110011001100110100 // 尾数最后结果
IEEE 754 中最终存储如下:
0 01111111101 0011001100110011001100110011001100110011001100110100
最高位为 1,得到的二进制数如下所示:
2^-2 * 1.0011001100110011001100110011001100110011001100110100
转换为十进制如下所示:
0.30000000000000004
解决问题
一、toFixed()
使用Math类中的toFixed()方法,保留你需要的小数点位数。
二、Number.EPSILON
Number.EPSILON可以用来设置“能够接受的误差范围”。比如,误差范围设为 2 的-50 次方(即Number.EPSILON * Math.pow(2, 2)),即如果两个浮点数的差小于这个值,我们就认为这两个浮点数相等。
Number.EPSILON=(function(){
//解决兼容性问题(IE10不兼容)
return Number.EPSILON?Number.EPSILON:Math.pow(2,-52);
})();
//上面是一个自调用函数,当JS文件刚加载到内存中,就会去判断并返回一个结果,相比if(!Number.EPSILON){
// Number.EPSILON=Math.pow(2,-52);
//}这种代码更节约性能,也更美观。
function numbersequal(a,b){
return Math.abs(a-b)<Number.EPSILON;
}
//接下来再判断
var a=0.1+0.2, b=0.3;
console.log(numbersequal(a,b)); //这里就为true了
三、转化成整数
最好的方法就是我们想办法规避掉这类小数计算时的精度问题就好了,那么最常用的方法就是将浮点数转化成整数计算,因为整数都是可以精确表示的。规避掉这类小数计算时的精度问题,将浮点数(float)转换为整数(int)类型进行计算,整数都是可以精确的计算出结果。
例如:0.1+0.2 => (0.1*10+0.2*10)/10
总结
推算 0.1 + 0.2 为什么不等于 0.3 这个过程是乏味和有趣并存的,因为它很难理解,但是一旦你掌握了它,能让你更深刻的认识到其中的存储、运算机制,从而理解结果为什么是 0.30000000000000004。
最后做个总结,由于计算机底层存储都是基于二进制的,需要事先由十进制转换为二进制存储与运算,这整个转换过程中,类似于 0.1、0.2 这样的数是无穷尽的,无法用二进制数精确表示。JavaScript 采用的是 IEEE 754 双精确度标准,能够有效存储的位数为 52 位,所以就需要做舍入操作,这无可避免的会引起精度丢失。另外我们在 0.1 与 0.2 相加做对阶、求和、舍入过程中也会产生精度的丢失。