前端必知:JS大数运算和浮点数精度的那些事儿

126 阅读10分钟

前言

大家好,欢迎来到大厂面试系列,本文给大家介绍一下JS中大数运算和浮点数运算如何实现,并且详细讲解其中的知识点,包含了许多大厂喜欢追问的一些细节问题,请接着往下看吧!


大数运算

在JS中,当我们对一个非常大的数进行运算操作时(如下),会发现出现了各种各样的问题,那么,这是由什么导致的呢?

// 加法错误
console.log(9007199254740992 + 1); // 9007199254740992(应为 9007199254740993)

// 乘法错误
console.log(9007199254740992 * 2); // 18014398509481984(可能不准确)

// 比较错误
console.log(9007199254740992 === 9007199254740993); // true(错误)

安全范围

其实,JS的安全整数范围是−2^53+1到2^53−1,这个数值我们也称为Number.MAX_SAFE_INTEGER

我们可以通过JS查看其具体的值为9007199254740991

image.png

同时,你会发现,你尝试使用一个超出安全整数范围的数,编辑器会提示我们

image.png

而上面的各种运算错误以及比较错误就是由于9007199254740992超过了9007199254740991这个安全范围中的最大值。


如何处理大数运算呢?

那么,我们知道了不能用大于安全范围的值直接去计算,那么在实际业务中,倘若我们不得不对超出安全范围的值去计算,那么该怎么办呢?

手动模拟运算

实际上,我们会去手动模拟运算,比如计算两个大数num1和num2的和,我们可以通过以下方式模拟加法运算的程序

  1. 将num1和num2转化为字符串str1和str2

  2. 用两个指针指向str1和str2的最后一位

  3. 模拟加法运算:从后往前遍历两数代表的字符串,相加当前位的和,并且记录是否需要进位

function addStrings(num1, num2) {
    let i = num1.length - 1; // 指针指向 num1 的末尾
    let j = num2.length - 1; // 指针指向 num2 的末尾
    let carry = 0; // 进位标志
    let result = ''; // 存储结果
    
    // 从右到左逐位相加
    while (i >= 0 || j >= 0 || carry > 0) {
        // 获取当前位的数字,若指针越界则用 0 代替
        const digit1 = i >= 0 ? parseInt(num1[i--]) : 0;
        const digit2 = j >= 0 ? parseInt(num2[j--]) : 0;
        
        // 计算当前位的和(包括进位)
        const sum = digit1 + digit2 + carry;
        
        // 当前位的值
        result = (sum % 10) + result
        
        // 更新进位
        carry = Math.floor(sum / 10);
    }
    
    return result
}

// 测试用例
console.log(addStrings("123", "456"));     // "579"
console.log(addStrings("999", "1"));       // "1000"

BigInt

我们能看见,上面的代码确实可行,在ES6之前,JS就是这样实现两个大数相加,很麻烦对不对,学过Python的大佬可能更深有体会,因为Python可以自动处理两个大数相加,在ES6之前,对于上面的这一大段代码,Python其实一行就能搞定。

但其实JS在被设计出来的目的是为了实现页面的展示效果和交互,它的目的是为了“表现”,而Python这种语言的目的是为了实现计算,所以我们的JS根本没有设计像Python中直接进行大数运算的这种功能。

但是,随着时代的推进,人们发现JavaScript在各类大型项目的应用越来越广泛,它各种“残缺”的功能也逐渐被人诟病,于是ES6之后,JavaScript就进行了一次大更新,推出了很多类似于块级作用域、箭头函数这样的各式各样的新特性,同时也推出了能帮我们快速实现大数运算的BigInt。

BigInt是一种简单的数据类型,能够表示安全范围外的数字。

1.BigInt的声明方式

BigInt有两种声明方式

//两种声明方式
const bigNum = 123456789012345678901234567890123456789n;
const theNum = BigInt("123456789012345678901234567890123456789"); //传参需要用字符串的方式 

要注意,我们使用函数进行声明的时候,不能使用new BigInt哦,因为BigInt是原始类型(Primitive),而不是对象,而且在传参时需要传入字符串。

2.BigInt相加

有了两个BigInt数据类型的数据,咱们就可以放心对大数进行相加啦!

const BigNum1 = 500000000000000000000000000000n

const bigNum2 = BigInt("500000000000000000000000000000")

console.log(BigNum1,bigNum2,BigNum1 + bigNum2);

//500000000000000000000000000000n
//500000000000000000000000000000n
//1000000000000000000000000000000n

注意哦,相加的时候不能拿BigInt和普通的数字进行相加,只能用BigInt和BigInt相加。

image.png

3.BigInt的原理

其实BigInt实现的原理有点类似于我们上面的模拟加法运算。

BigInt 通常基于 数组或链表 存储每一位数字。(例如,12345678901234567890n 可能存储为 [1,2,3,...,0] 这样的数组。)

而底层的相加是模拟我们的加法运算进行相加,从最低位开始相加,如果某一位的和>=10,则进位,直到最高位。感兴趣的大佬可以去看一下源码,这里就简单介绍了。


拓展:Number.MAX_VALUEInfinity

前面我们提到了,最大的安全数是MAX_SAFE_INTEGER(2^53-1),在JS中,对超出这个值的数进行计算会产生各种各样的计算问题。

Number.MAX_VALUE

Number.MAX_VALUE指的是JavaScript 中能表示的最大有限数值,约为1.7976931348623157e+308,注意:要将JS中的MAX_SAFE_INTEGERNumber.MAX_VALUE进行区分开来,前者指的是最大安全整数,超出这个整数进行计算会精度丢失,后者指的是最大有限数值。

image.png

Infinity

Infinity在JS中代表无穷大,是一个全局属性,它没有具体值,我们在某些算法题内可以使用它,因为任何有限值都小于Infinity,所以当求最小值的题目时,可以把它拿来当初始值用。


它们三者的区别如下

特性Number.MAX_SAFE_INTEGERNumber.MAX_VALUEInfinity
定义最大安全整数(精确表示的整数上限)最大有限数值(浮点数上限)数学上的无穷大
具体值9007199254740991(2⁵³ - 1)≈1.7976931348623157e+308Infinity(无具体值)
用途确保整数运算不丢失精度表示浮点数的最大值表示超出数值范围的无限概念
超出范围的结果超出后可能丢失精度(如 +1 不准确)超出后变为 Infinity任何有限数 < Infinity


小数运算:

0.1+0.2 是否等于 0.3 ?

大家请看如下场景

image.png

我们会发现,在JS中,0.1+0.2居然不是等于0.3,这到底是为什么呢,让我们一起来探讨一下这道有名的前端面试题吧!!


IEEE 754

在JS以及大多数编程语言中,都是采用IEE 754双精度浮点数(64位存储数字),其存储方式为:

  • 1 位 符号位(0 正数,1 负数)
  • 11 位 指数位(决定数值范围)
  • 52 位 尾数位(决定数值精度)

image.png


二进制运算

一般十进制小数要先转换成二进制,比如0.625转换成0.101,但并不是所有小数都能转换成二进制的。0.1就不能直接用二进制表示,它的二进制是0.000110011001100…,这是一个无限循环小数。

所以结合我们上面的IEEE 754,0.1在用二进制表示时,会在52位时截断,由于IEE 754的尾数为只有52位,所以最终用一个52位的近似值来表示0.1,同样0.2也是,它们都会以一个二进制的近似值来表示。


所以上面0.1+0.2实际上是:

  1. 0.1 的二进制近似值:
0.0001100110011001100110011001100110011001100110011001101 (52位截断)
  1. 0.2 的二进制近似值:
0.001100110011001100110011001100110011001100110011001101 (52位截断)
  1. 两者相加后
0.0100110011001100110011001100110011001100110011001100111

4.相加结果转换成十进制

0.30000000000000004

所以我们才会看到0.1 + 0.2 === 0.30000000000000004,这都是由于浮点数的精度丢失问题


如何避免浮点数精度问题?

我们知道了为什么会0.1 + 0.2 !== 0.3 后,那么怎么做能得到我们想要的结果让0.1 + 0.2等于0.3呢,很可惜的是,这个历史遗留的问题至今没有得到彻底解决,我们只能通过间接的方式来使得0.1+0.2等于0.3。

第一种方式: 最简单的实现方式就是(0.1* 10 + 0.2 *10)/10==0.3,几乎所有语言都能使用这种方式来进行解决这个题。

image.png

第二种方式 使用 toFixed() 四舍五入(适用于显示)

const sum = 0.1 + 0.2; // 0.30000000000000004
const fixedSum = parseFloat(sum.toFixed(10)); // 0.3(保留10位小数并转回数字)
console.log(fixedSum === 0.3); // true

第三种方式 有些时候,我们需要控制一定的误差,也能达到我们的效果

function isEqual(a, b) {
  return Math.abs(a - b) < Number.EPSILON;
}
console.log(isEqual(0.1 + 0.2, 0.3)); // true

Number.EPSILON 是 JavaScript 最小精度值(约 2.22e-16),用于判断两个浮点数是否“足够接近”。 但是这种方式可能不适合一些需要很精确计算的业务,所以我们只需了解即可。



总结

JavaScript中的数值运算看似简单,却隐藏着许多细节问题,比如大数运算的精度丢失和浮点数计算的误差。本文从Number.MAX_SAFE_INTEGER的安全范围出发,探讨了大数运算的解决方案(如BigInt和手动模拟运算),并深入解析了经典的0.1 + 0.2 ≠ 0.3问题背后的IEEE 754浮点数存储机制。最后,提供了几种避免精度问题的方法,帮助你在实际开发中规避这些“坑”。无论是面试还是实际项目,掌握这些细节都能让你更加游刃有余!



🌇结尾

感谢你看到最后,最后再说两点~
①如果你持有不同的看法,欢迎你在文章下方进行留言、评论。
②如果对你有帮助,或者你认可的话,欢迎给个小点赞,支持一下~
我是3Katrina,一个热爱编程的大三学生

(文章内容仅供学习参考,如有侵权,非常抱歉,请立即联系作者删除。)

作者:3Katrina
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。