JS中的精度问题

82 阅读6分钟

背景

使用js时遇到的奇怪问题

console.log(0.1 + 0.2);   // 输出 0.30000000000000004
console.log(0.3 - 0.1);  // 输出 0.19999999999999998
console.log(0.5 - 0.4);  // 输出 0.09999999999999998
console.log(9007199254740991 + 1);  // 输出 9007199254740992
console.log(9007199254740991 + 2);  // 输出 9007199254740992
1.35.toFixed(1) // 1.4 正确
1.335.toFixed(2) // 1.33  错误

上面这些问题都与js使用的数字表示方式有关。

javascript采用的是 双精度(64位)浮点数,这在许多情况下会导致精度丢失,特别是对于小数运算。

双精度浮点数

一个双精度浮点数使用64位来表示,分为三个部分:

  1. 符号位(Sign bit):1位

  2. 指数(Exponent):11位

  3. 尾数(Mantissa)或称为有效数(Significand):52位

结构示意图

structural_diagram.webp

  • S:符号位,决定数值是正还是负。0表示正,1表示负。

  • Exponent:指数部分,用于表示浮点数的范围。指数采用偏移表示法储存。

  • Mantissa:尾数部分,实际储存有效数的精度。

具体表示方法

符号位

符号位是最左边的1位:

  • 0 表示正数
  • 1 表示负数

指数部分

指数部分使用11位表示,采用偏移量表示法来存储。通过对实际的指数值添加一个固定的偏移量(bias)来得到存储在浮点数表示中的指数值。这样做的目的是简化浮点数比较和计算的硬件实现。

对于双精度浮点数,偏移量是1023。指数范围[-1023,1024]

2^(k-1)-1
2^10-1 = 1023 (中间值)
11111111111
01111111111

例如,存储的指数是1024,那么实际指数就是:

1024 - 1023 == 10000000000 - 01111111111 == 1

偏移量的作用

偏移量是为了在表示指数时能够覆盖到负数指数和正数指数的范围,同时保持浮点数的精度和有效性。通过将指数的实际值与偏移量结合,可以更好地表示极小和极大的数值,同时保持浮点数的顺序和比较操作的简便性。

为什么选择使用偏移量而不使用符号位呢?

偏移量方便进行数值的比较和排序操作。

将指数部分变为符号类型可能会引入额外的复杂性和不必要的计算开销。符号类型的表示方式可能会增加对数值范围的限制,同时可能导致数值比较和排序时的不便。

因此,通过偏移量来表示指数部分是一种更为简洁和有效的设计选择,能够更好地满足双精度浮点数的表示需求。

尾数部分

尾数部分的52位实际上表示一个1.xxx...的格式(隐含了一个1)。二进制最高位一定为1。

浮点数表示公式

综合以上部分,一个浮点数可以表示为:

(−1) ^{Sign} ×2 ^{ (Exponent−1023)}×1.xxx

整数范围

尾数部分可以表示的最大值是 53 位 1(含隐含位):1.111...1111(52 个 1)。

[-2^{53}+1,2^{53}-1]

[-9007199254740991,9007199254740991]

双精度浮点数表示整数时,尾数部分有效位的数量决定了指数的最大值和最小值。

由于双精度浮点数的尾数部分有52位,可以准确表示指数在52位范围内的整数值1023 + 52。

示例

二进制双精度浮点数
0.10.0001100110011...0 01111111011 1001100110011001100110011001100110011001100110011010
0.20.001100110011...0 01111111100 1001100110011001100110011001100110011001100110011010

像0.1和0.2用二进制表示,是个无限循环小数,存储时会导致一些精度损失和舍入错误,这会导致误差累积。

0.1.toPrecision(30)
'0.100000000000000005551115123126'
0.2.toPrecision(30)
'0.200000000000000011102230246252'
为什么 x=0.1 能得到 0.1?

JS的表示问题:js自动做处理,超过的精度会自动做凑整处理。

也可以自己处理:使用 toPrecision 凑整并 parseFloat 转成数字后再显示。

function strip(num, precision = 12) {
  return parseFloat(num.toPrecision(precision));
}
strip(1.4000000000000001) == 1.4
tofixed()对于小数最后一位为5时进位不正确问题

根本原因还是计算机里浮点数精度丢失的问题

1.35.toFixed(1) // 1.4 正确
1.335.toFixed(2) // 1.33  错误

1.35.toPrecision(20)
// '1.3500000000000000888'
1.335.toPrecision(20)
// '1.3349999999999999645'

总结

因为JS存储时有位数限制(64位),并且某些十进制的浮点数在转换为二进制数时会出现无限循环,会造成二进制的舍入操作(0舍1入),当再转换为十进制时就造成了计算误差。

解决方案

浮点数精度问题的本质在于计算机使用二进制表示浮点数时,无法精确表示某些十进制小数。完全避免这个问题是不可能的,但可以通过一些方法减轻这些问题的影响。

小数问题

整数运算

通过使用整数来避免浮点数精度问题。例如,在处理货币时,可以以最小单位(如分或厘)存储数值,而不是使用小数。

如果先扩大再缩小:将浮点数转换为整数进行运算,然后再转换回浮点数。还会部分存在精度问题。

let a = 0.1 * 100;
let b = 0.2 * 100;
console.log((a + b) / 100);  // 输出 0.3

35.41 * 100 == 3540.9999999999995 // true
// 即使扩大再缩小 还是会有丢失精度的问题
(35.41*100*100)/100 == 3541 //false  

容忍度比较

比较浮点数时使用一个容忍度(epsilon)。

ES6在Number对象上新增了一个极小的常量——Number.EPSILON;引入一个这么小的量,目的在于为浮点数计算设置一个误差范围,如果误差能够小于Number.EPSILON,我们就可以认为结果是可靠的。

Number.EPSILON
// 2.220446049250313e-16

function withinErrorMargin (left, right) {
    return Math.abs(left - right) < Number.EPSILON
}
withinErrorMargin(0.1+0.2, 0.3)

大数问题

BigInt

对于需要处理大整数的情况,JavaScript引入了BigInt,可以安全地表示和操作任意大的整数,但不支持小数。参数为Number和String。

let bigInt1 = BigInt("9007199254740991");
let bigInt2 = BigInt("9007199254740992");
console.log(bigInt1 + bigInt2);  // 输出 18014398509481983n

字符串

直接使用字符串处理。

一位一位进行运算。精度高、效率低。

第三方库

Math.js、BigDecimal.js、big.js

数字相关优化

数字分隔符:提升大数字可读性(如1_000_000)。

BigInt:支持超大整数运算(后缀n)。