JavaScript 浮点数精度问题

309 阅读12分钟

b0fced9bf8864e88bb35b437b72f0c14.jpg

图片来源 bz.zzzmh.cn/index

前言

我们知道在 JavaScript0.1 + 0.2 !== 0.3 ,究其原因,是因为浮点数精度问题导致。以前自己对这个问题也只是一知半解,最近想对这问题有个全方位的认识,所以想写下这篇文章记录一下。

ECMAScript 的数字类型

ECMAScript 中的 Number 类型使用 双精度 IEEE 754 64位浮点 标准来表示整数浮点数值。所谓 IEEE754 标准,全称 IEEE二进制浮点数算术标准(ANSI/IEEE Std 754-1985),又称 IEC 60559:1989,微处理器系统的二进制浮点数算术

IEEE 754 规定了四种表示浮点数值的方式:单精确度(32位)双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。

840566444-5b9b6af6179a0.png

浮点数转二进制

例如 0.75 用二进制表示:

0.75 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4 + ...

因为是二进制,所以这里的系数 a、b、c、d 的值不是 0 就是 1

两边同时乘以 2 :

1 + 0.5 = a * 2^0 + b * 2^-1 + c * 2^-2 + d * 2^-3... (所以 a = 1)

剩下的:

0.5 = b * 2^-1 + c * 2^-2 + d * 2^-3...

再同时乘以 2:

1 + 0 = b * 2^0 + c * 2^-2 + d * 2^-3... (所以 b = 1)

所以 0.75 用二进制表示就是 0.ab,也就是 0.11

0.1 用二进制表示:

0.1 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4 + e * 2^-5 ...

0 + 0.2 = a * 2^0 + b * 2^-1 + c * 2^-2 + ...   (a = 0)(取出整数部分00 + 0.4 = b * 2^0 + c * 2^-1 + d * 2^-2 + ...   (b = 0)(取出整数部分00 + 0.8 = c * 2^0 + d * 2^-1 + e * 2^-2 + ...   (c = 0)(取出整数部分00 + 0.6 = d * 2^0 + e * 2^-1 + f * 2^-2 + ...   (d = 1)(取出整数部分10 + 0.2 = e * 2^0 + f * 2^-1 + g * 2^-2 + ...   (e = 1)(取出整数部分10 + 0.4 = f * 2^0 + g * 2^-1 + h * 2^-2 + ...   (f = 0)(取出整数部分00 + 0.8 = g * 2^0 + h * 2^-1 + i * 2^-2 + ...   (g = 0)(取出整数部分00 + 0.6 = h * 2^0 + i * 2^-1 + j * 2^-2 + ...   (h = 1)(取出整数部分10 + 0.2 = i * 2^0 + j * 2^-1 + k * 2^-2 + ...   (i = 1)(取出整数部分1)
....

可以看出 0.1 用二进制表示就是 0.00011001100110011……((0011)循环

IEEE754 浮点数存储

我们以 64位双精度浮点数 为例:

v2-8479dec5d2bdeaedb098b08dd34d5ea9_r.png

如上图所示,这 64 个二进制位的内存编号从高到低 (从63到0), 共包含如下几个部分:

sign: 符号位, 即图中蓝色的方块;

biased exponent: 偏移后的指数位, 即图中绿色的方块;

fraction: 尾数位, 即图中红色的方块;

符号位: sign

符号位: 占据最高位(第63位)这一位, 用于表示这个浮点数是正数还是负数, 为0表示正数, 为1表示负数。

对于十进制数20.5, 存储在内存中时, 符号位应为0, 因为这是个正数

偏移后的指数位: biased exponent

指数位占据第 52 位到第 62 位这 11位, 也就是上图的绿色部分,用于表示以 2 位底的指数。

尾数位:fraction

尾数位占据剩余的51位到 0 位这 52 位, 用于存储尾数。

以 0.1 的二进制 0.00011001100110011…… 这个数来说,用二进制可以表示为:

1.1001100110011…… * 2^-4

其中 sign 就是 0,exponent 就是 2^-4,fraction 就是 1.1001100110011…, 有时候尾数会不够填满尾数位(即图中的红色格子),尾数不够52位 此时, 需要在 低位补零

浮点数 (Value) 通用表达式

V = (-1)^S * (1 + Fraction) * 2^E;

(-1)^S 表示符号位,当 S = 0,V 为正数;当 S = 1,V 为负数。

(1 + Fraction),所有的浮点数都可以表示为 1.xxxx * 2^xxx 的形式,前面的一定是 1.xxx,所以没必要存储这个 1 了,像 0.1 这样直接存 1 后面的 1001100110011…… 好了,可以省略前面的 1 和小数点,等读取的时候再把第一位1加上去。所以52位有效数字实际可以存储53位

2^E,例如 1020.75,对应二进制数就是 1111111100.11,对应二进制科学计数法就是 1 * 1.11111110011 * 2^9,E 的值就是 9,而如果是 0.1 ,对应二进制是 1 * 1.1001100110011…… * 2^-4, E 的值就是 -4,也就是说,E 既可能是负数,又可能是正数,那我们该怎么储存这个 E 呢 ?

在 64 位浮点数中,指数位有 11 位,它能表示的数字是从 0 到 2 的 11 次方,也就是 2048。 但是指数有正有负,所以我们需要把 2048 这个数字从中间劈开,一半表示正数一半表示负数,所以就是 -1024 到 +1023中间还有个0),只能表示 -1024 到 1023 这 2048 个数字。

那么怎么记录负数呢?一种作法是把高位置 1,这样我们只要看到高位是 1 的就知道是负数了,所谓高位置 1 就是说把 0 到 2047 这么多个数字劈成两半,从 0 到 1023 表示正数,从 1024 到 2047 表示负数。但是这种作法会带来一个问题:当你比较两个数的时候,比如 1300 和 30,谁更大?机器会觉得 1300 更大,但实际上 1300 是个负数,它应该比 30 小才对。所以为了解决这个麻烦,人们发明了另外一种方法:把所有数字都给它加上 1024 ,这样-1024 加上 1024 就变成了0,而 1023 加上 1024 变成了 2047,这样的话,再比较大小,就不存在负数比正数大的情况了。

我们要得到原来的数字你只要再把指数减去 1024 就得到了原来的数字。比如说你读到 0,那么减去1024,就得到了负指数-1024,读到 2047,减去1024,就得到了 1023 。

那为什么 双精度 64 位指数偏移是 1023,不是 1024 ?因为人们为了特殊用处,不允许使用 0 和 2048 这两个数字表示指数,少了 2 个数字,所以采用 1023 了。

所以呢,真到实际存储的时候,我们并不会直接存储 E,而是会存储 E + bias,当用 11 位的时候,这个 bias 就是 1023。如果要存储一个浮点数,我们存 S 和 Fraction 和 E + bias 这三个值就好了

比如 0.1 ,对应二进制是 1 * 1.1001100110011…… * 2^-4, Sign 是 0,E + bias 是 -4 + 1023 = 1019,1019 用二进制表示是 01111111011,Fraction 是 1001100110011……

对应 64 位的完整表示就是:

0 01111111011 1001100110011001100110011001100110011001100110011010

同理, 0.2 表示的完整表示是:

0 01111111100 1001100110011001100110011001100110011001100110011010

IEEE 754 计算网站 IEEE 754 Calculator

所以当 0.1 存下来的时候,就已经发生了精度丢失,当我们用浮点数进行运算的时候,使用的其实是精度丢失后的数。

为了加深理解, 我们再反向推导 0.2 一遍: 符号位是 0: 所以这是个正数;尾数是: 1001100110011001100110011001100110011001100110011010,去掉后面的补零, 再加上隐藏的整数部分1.  得到完整的尾数(含隐藏的整数部分)为: 1.100110011001100110011001100110011001100110011001101,偏移后的指数位为: 01111111100, 转换为十进制为 1020 , 减去偏移量1023 , 得到真正的指数是 -3。所以, 最后得到的浮点数 = 尾数(含隐藏的整数部分) * 以2为底的指数次幂=二进制的: 1.100110011001100110011001100110011001100110011001101 * 2^-3== 把小数点向右移动4位=二进制 0.001100110011001100110011001100110011001100110011001101=十进制 0.2。(进制转换网站

浮点数的运算

关于浮点数的运算,一般由以下五个步骤完成:对阶、尾数运算、规格化、舍入处理、溢出判断。我们来简单看一下 0.1 和 0.2 的计算。

首先是对阶,所谓对阶,就是把阶码调整为相同,比如 0.1 是 1.1001100110011…… * 2^-4,阶码是 -4,而 0.2 就是 1.10011001100110...* 2^-3,阶码是 -3,两个阶码不同,所以先调整为相同的阶码再进行计算,调整原则是小阶对大阶,也就是 0.1 的 -4 调整为 -3,对应变成 0.11001100110011…… * 2^-3

接下来是尾数计算:

  0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
————————————————————————————————————————————————————————
 10.0110011001100110011001100110011001100110011001100111

我们得到结果为 10.0110011001100110011001100110011001100110011001100111 * 2^-3。将这个结果处理一下,即结果规格化,变成 1.0011001100110011001100110011001100110011001100110011(1) * 2^-2,括号里的 1 意思是说计算后这个 1 超出了范围,所以要被舍弃了。再然后是舍入,四舍五入对应到二进制中,就是 0 舍 1 入,因为我们要把括号里的 1 丢了,所以这里会进一,结果变成 1.0011001100110011001100110011001100110011001100110100 * 2^-2

所以最终的结果存成 64 位就是:

0 01111111101 0011001100110011001100110011001100110011001100110100

将它转换为10进制数就得到 0.30000000000000004440892098500626,因为两次存储时的精度丢失加上一次运算时的精度丢失,最终导致了 0.1 + 0.2 !== 0.3。

toPrecision 方法

toPrecision() 方法以指定的精度返回该数值对象的字符串表示。

// 以指定的精度返回该数值对象的字符串表示
(0.1 + 0.2).toPrecision(21)
=> "0.300000000000000044409"
(0.3).toPrecision(21)
=> "0.299999999999999988898"

(0.1).toPrecision(16)
=> '0.1000000000000000'

(0.1).toPrecision(21)
=> '0.100000000000000005551'

MAX_SAFE_INTEGER

Number.MAX_SAFE_INTEGER 常量表示在 JavaScript 中最大的安全整数(maxinum safe integer)(2^ 53 - 1)。

Number.MAX_SAFE_INTEGER
=> 9007199254740991

最大的安全整数为什么是 2^ 53 - 1? “安全”意思是说能够 one-by-one 表示的整数,也就是说在(-2^53, 2^53)范围内,双精度数表示和整数是一对一的,反过来说,在这个范围以内,所有的整数都有唯一的浮点数表示,这叫做安全整数。最大的安全整数必然是 Fraction 全部是 1 的浮点数,Fraction 全为1,即为 1.11…1(小数部分共有52位1),该浮点数换算为二进制后记为111…1(共53位)。该二进制数换算为十进制数字即为2^52+2^51+…+2^1+2^0 一个等比数列求和的计算~结果为2^ 53 - 1

最大安全数的二进制 const MAX_SAFE_NUM_BINARY = (2^53-1).toString(2);
 => 11111111111111111111111111111111111111111111111111111 
 
// 科学计数法表示
const numE = 1.1111111111111111111111111111111111111111111111111111e52
// 省去整数位,刚好是52位1
=> "1111111111111111111111111111111111111111111111111111"

根据IEEE754标准,双精度浮点数中,尾数最多只有 52 位,当 52 位都为 1 时已经是能表示的最大数值。

(Math.pow(2,53)-1).toString(2)
=> '11111111111111111111111111111111111111111111111111111'

Math.pow(2,53).toString(2)
=> '100000000000000000000000000000000000000000000000000000'

我们试着给2^53 - 1 加 1:

(2^53-1) + 1 
 521 + 510 和一位 1
 => "1111111111111111111111111111111111111111111111111111" + "0000000000000000000000000000000000000000000000000001" // 注意结果有53位,显然是要进位的 
 => "10000000000000000000000000000000000000000000000000000" // 把原有的整数加上进位会得到这个结果 
 => "10.0000000000000000000000000000000000000000000000000000e52" // 整数保留一位后得到新的科学计数法结果,注意尾数有53位0,显然不符合规范
 => "1.00000000000000000000000000000000000000000000000000000e53" // 由于尾数最多只能用52位表示,我们需要舍去最后一个0 // 结果是:整数位一位1,52位尾数均为0,指数是53 
 => "1.0000000000000000000000000000000000000000000000000000e53" // 虽然2^53次方仍然可以正确标识出来,但是从这里开始已经出现了精度丢失

Number.EPSILON

Number.EPSILON 属性表示 1 与Number可表示的大于 1 的最小的浮点数之间的差值。

x = 0.2;
y = 0.3;
z = 0.1;
equal = (Math.abs(x - y + z) < Number.EPSILON);

引入一个这么小的量,目的在于为浮点数计算设置一个误差范围,如果误差能够小于Number.EPSILON,我们就可以认为结果是可靠的。

为什么 x=0.1 能得到 0.1?

存储二进制时小数点的偏移量最大为 52 位,最多可表示的十进制为 9007199254740992,对应科学计数尾数是 9.007199254740992,这也是 JS 最多能表示的精度。它的长度是 16,所以可以使用 toPrecision(16) 来做精度运算,js自动做了这一部分处理,超过的精度会自动做凑整处理。于是就有

0.10000000000000000555.toPrecision(16) //0.1000000000000000 去掉末尾的零后正好为0.1

但你看到的 0.1 实际上并不是 0.1


0.1.toPrecision(21)=0.100000000000000005551

大数相加

9999999999999999 == 9999999999999999 +1 ===true ?,16位和17位数竟然相等,大整数的精度丢失和浮点数本质上是一样的。要想解决大数的问题你可以引用第三方库 bignumber.js,原理是把所有数字当作字符串,重新实现了计算逻辑,缺点是性能比原生的差很多。

leetcode大数相加

var addStrings = function(num1, num2) {
    let i = num1.length - 1, j = num2.length - 1, add = 0;
    const ans = [];
    while (i >= 0 || j >= 0 || add != 0) {
        const x = i >= 0 ? num1.charAt(i) - '0' : 0;
        const y = j >= 0 ? num2.charAt(j) - '0' : 0;
        const result = x + y + add;
        ans.push(result % 10);
        add = Math.floor(result / 10);
        i -= 1;
        j -= 1;
    }
    return ans.reverse().join('');
};

这里 num1.charAt(i) - '0'是转为数字类型。两个数字字符运算,两个字符会提升为对应 ASCII 码值,而 ASCII 码里面 '0' 对应为 48,'2'对应 50,所以 '2' - '0' = 50 - 48 = 2, '0' - '0' = 48 - 48 = 0,这样的话,字符数字转为对应的数字类型的最快方法就是减去对应的字符'0'。

参考文献

JavaScript 深入之浮点数精度