整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

Javascript 数字精度丢失的问题,如何解决?

Javascript 数字精度丢失的问题,如何解决?

们在处理数据的时候可能会遇到类似0.1+0.2 !=0.3的问题,让我们来分析下原因:

因为 JS 采用 IEEE 754 双精度版本(64位),并且只要采用 IEEE 754 的语言都有该问题(我知道的java也是这样)。我们都知道计算机是通过二进制来存储东西的,0.1和0.2在转换二进制后都是是无限循环的,这样其实没什么问题,但是 JS 采用的浮点数标准却会裁剪掉后面的数字,导致精度丢失 0.1+0.2=0.30000000000000004。

场景复现

一个经典的面试题

0.1 + 0.2===0.3 // false

为什么是false呢?

先看下面这个比喻

比如一个数 1÷3=0.33333333......

3会一直无限循环,数学可以表示,但是计算机要存储,方便下次取出来再使用,但0.333333...... 这个数无限循环,再大的内存它也存不下,所以不能存储一个相对于数学来说的值,只能存储一个近似值,当计算机存储后再取出时就会出现精度丢失问题

比如18466.67*100,按理说他等于1846667吧,可是他等于1846666.9999999998,效果如下


浮点数

“浮点数”是一种表示数字的标准,整数也可以用浮点数的格式来存储

我们也可以理解成,浮点数就是小数

JavaScript中,现在主流的数值类型是Number,而Number采用的是IEEE754规范中64位双精度浮点数编码

这样的存储结构优点是可以归一化处理整数和小数,节省存储空间

对于一个整数,可以很轻易转化成十进制或者二进制。但是对于一个浮点数来说,因为小数点的存在,小数点的位置不是固定的。解决思路就是使用科学计数法,这样小数点位置就固定了

而计算机只能用二进制(0或1)表示,二进制转换为科学记数法的公式如下:

其中,a的值为0或者1,e为小数点移动的位置

举个例子:

27.0转化成二进制为11011.0 ,科学计数法表示为:

前面讲到,javaScript存储方式是双精度浮点数,其长度为8个字节,即64位比特

64位比特又可分为三个部分:

  • 符号位S:第 1 位是正负数符号位(sign),0代表正数,1代表负数
  • 指数位E:中间的 11 位存储指数(exponent),用来表示次方数,可以为正负数。在双精度浮点数中,指数的固定偏移量为1023
  • 尾数位M:最后的 52 位是尾数(mantissa),超出的部分自动进一舍零

如下图所示:

举个例子:

27.5 转换为二进制11011.1

11011.1转换为科学记数法

符号位为1(正数),指数位为4+,1023+4,即1027

因为它是十进制的需要转换为二进制,即 10000000011,小数部分为10111,补够52位即: 1011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000`

所以27.5存储为计算机的二进制标准形式(符号位+指数位+小数部分 (阶数)),既下面所示

0+10000000011+011 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000`


二进制

  • 基数为2
  • 有2个数字,即0和1
  • 满2进1

八进制

  • 基数为8
  • 由8个数字组成,分别是0、1、2、3、4、5、6、7
  • 满8进1

十进制

  • 我们日常生活中所用的都是十进制,也就是满10进1

十六进制

  • 基数为16。
  • 由16个数字符号组成,分别是0、1、2、3、4、5、6、7、8、9、A、B、C、D、E、F
  • 满16进1

在古代中国当时使用的重量单位就是十六进制,16两为1斤,就有了所谓的“半斤八两”

举个例子

比如 十进制

1 2 3 4 5 6 7 8 9 10 11 ...

当要数10时,就要进1位,也就是十位数写1,个位数写0, 即满十进一

二进制

0 1 10 11 10 11 110 111 101 ...

当要数2的时候,就要进1位了,上一位写1,当前位变成0 即满二进一

进制之间怎么转换?

不会的话自行百度吧


问题分析

再回到问题上

0.1 + 0.2===0.3 // false

通过上面的学习,我们知道,在javascript语言中,0.1 和 0.2 都转化成二进制后再进行运算

// 0.1 和 0.2 都转化成二进制后再进行运算
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010=0.0100110011001100110011001100110011001100110011001100111

// 转成十进制正好是 0.30000000000000004

所以输出false

再来一个问题,那么为什么x=0.1得到0.1

主要是存储二进制时小数点的偏移量最大为52位,最多可以表达的位数是2^53=9007199254740992,对应科学计数尾数是 9.007199254740992,这也是 JS 最多能表示的精度

它的长度是 16,所以可以使用 toPrecision(16) 来做精度运算,超过的精度会自动做凑整处理

.10000000000000000555.toPrecision(16)
// 返回 0.1000000000000000,去掉末尾的零后正好为 0.1

但看到的 0.1 实际上并不是 0.1。不信你可用更高的精度试试:

0.1.toPrecision(21)=0.100000000000000005551

如果整数大于 9007199254740992 会出现什么情况呢?

由于指数位最大值是1023,所以最大可以表示的整数是 2^1024 - 1,这就是能表示的最大整数。但你并不能这样计算这个数字,因为从 2^1024 开始就变成了 Infinity

> Math.pow(2, 1023)
8.98846567431158e+307

> Math.pow(2, 1024)
Infinity

那么对于 (2^53, 2^63) 之间的数会出现什么情况呢?

  • (2^53, 2^54) 之间的数会两个选一个,只能精确表示偶数
  • (2^54, 2^55) 之间的数会四个选一个,只能精确表示4个倍数
  • ... 依次跳过更多2的倍数

要想解决大数的问题你可以引用第三方库 bignumber.js,原理是把所有数字当作字符串,重新实现了计算逻辑,缺点是性能比原生差很多

浮点型的存储机制(单精度浮点数,双精度浮点数)

浮点型数据类型主要有:单精度float、双精度double

单精度浮点数(float)

单精度浮点数在内存中占4个字节、有效数字8位、表示范围:-3.40E+38 ~ +3.40E+38

双精度浮点数(double)

双精度浮点数在内存中占8个字节、有效数字16位、表示范围:-1.79E+308 ~ +1.79E+308

浮点型常量 数有两种表示形式:

1. 十进制数形式:由数字和小数点组成,且必须有小数点,如0.123,123.0
2. 科学计数法形式:如:
123e3123E3,其中eE之前必须有数字,且e或E后面的指数必须为整数(当然也包括负整数)

浮点型简单来说就是表示带有小数的数据,而恰恰小数点可以在相应的二进制的不同位置浮动,可能是这样就被定义成浮点型了。~不得不佩服这文化程度,定义个数据名称都这么有深度~

但是!!!

JavaScript 存储小数和其它语言如 Java 和 Python 都不同,JavaScript 中所有数字包括整数和小数都只有一种类型 即 Number类型 它的实现遵循 IEEE 754 标准,IEEE 754 标准的内容都有什么,这个咱不用管,我们只需要记住以下一点:

小结

计算机存储双精度浮点数需要先把十进制数转换为二进制的科学记数法的形式,然后计算机以自己的规则{符号位+(指数位+指数偏移量的二进制)+小数部分}存储二进制的科学记数法

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

解决方案

理论上用有限的空间来存储无限的小数是不可能保证精确的,但我们可以处理一下得到我们期望的结果

当你拿到 1.4000000000000001 这样的数据要展示时,建议使用 toPrecision 凑整并 parseFloat 转成数字后再显示,如下:

parseFloat(1.4000000000000001.toPrecision(12))===1.4  // True

封装成方法就是:

function strip(num, precision=12) {
  return +parseFloat(num.toPrecision(precision));
}

对于运算类操作,如 +-*/,就不能使用 toPrecision 了。正确的做法是把小数转成整数后再运算。以加法为例:

/**
 * 精确加法
 */
function add(num1, num2) {
  const num1Digits=(num1.toString().split('.')[1] || '').length;
  const num2Digits=(num2.toString().split('.')[1] || '').length;
  const baseNum=Math.pow(10, Math.max(num1Digits, num2Digits));
  return (num1 * baseNum + num2 * baseNum) / baseNum;
}

最后还可以使用第三方库,如Math.jsBigDecimal.js

我们可以这样处理:

parseFloat((0.1 + 0.2).toFixed(10))

parseFloat((18466.67*100).toFixed(0))


给大家分享我收集整理的各种学习资料,前端小白交学习流程,入门教程等回答-下面是学习资料参考。

前端学习交流、自学、学习资料等推荐 - 知乎

4位浮点型数据运算

在这周网络授课讲解变量与常量案例章节中,在计算0.1+0.2时,出现的结果与预期不符合,对此问题很多学生较为纠结。为解决这个问题,本节课主要讲授JavaScript数值型数据存储格式及浮点型数据的加运算操作,以解决各位同学的困惑。


问题引入?

例题:请编写JS程序计算0.1+0.2,并通过控制台输出计算结果。

浮点数计算问题

该程序直观判断可知计算结果为0.3,但是通过控制台输出显示的结果却不是0.3,其计算结果为:0.30000000000000004。这一问题也是在浮点类型数据相加操作中代表性问题之一。产生这一问题的原因在于浮点类型数据的存储与运算规则、过程。以下对问题进行解释说明。

浮点数加操作问题

JavaScript数值存储

JavaScript程序设计语言存储数值型数据与其他程序设计语言相比较,没有单独对整数、小数进行精确划分,在实际存储过程中将所有数值按照IEEE754标准使用64位浮点数存储数值。64位浮点类型也称为双精度浮点类型,占用8个字节,共64位。存储结构如下图所示:

64位浮点类型数据结构

在该结构中,64位共分为三组,最高位为符号位用于表示数值的正负,即上图蓝色部分,绿色部分11位用于存储指数值(浮点类型表示类似于科学计数法),剩余52位用于存储小数位即有效数字。例如:0.1 的64位浮点表示值为:

0011111110111001100110011001100110011001100110011001100110011010

10进制小数转换为64位浮点类型

本例所提出的问题需要计算0.1与0.2的和,针对JavaScript脚本语言首先需要将其转换为64位浮点类型。浮点类型数据的表现形式及说明如下:

浮点类型数值形式

其中s符号位占1位,指数E占11位,有效数字占52位。其M值可通过左移右移实现在1~2范围之内。如计算M值为101*2^2可表示为1.01*2^4。对于各部分IEEE754给出了明确的定义,其定义描述如下:

64位浮点数描述

在执行过程中首先需要将十进制小数转换为2进制表示形式,针对题目操作数0.1余0.2,他们对应二进制表示形式如下:

0.2十进制:0.00110011(下划线部分表示无限循环)
0.1十进制: 0.000110011(下划线部分表示无限循环)

两位小数的二进制表示描述如上,如实际存储过程中需要指定长度的话直接用循环部分填充即可。以计算出的二进制为基础可以求出对应的s、M、E分别为多少,其中10进制的0.2求解结果如下:

64位浮点存储求解

以上为基础我们可以求出两位操作数的64位存储相关参数。求解结果描述如下图所示:

64位浮点表示数据

浮点类型数据的加法操作

64位浮点类型数据在进行加法运算时需要按照其运算规则执行运算,其运算规则描述如下:

浮点数的加法操作过程


1.对阶

主要是指将两个操作数的指数部分调整为一样,本例题指数部分分别为-3,-4,按照对阶要求向大的靠拢,需要将-4指数调整为-3。在对阶过程需要对M部分作出对应调整。调整结果描述如下:

对阶处理

经过对阶处理之后所有的指数都变为01111111100。因此我们可以进一步对M部分进行加法操作。

2.M尾数运算

尾数运算部分主要按照二进制数值进行求和运算即可。在计算过程中可能因为进位关系导致数据整体长度发生变化即产生溢出。本例尾数部分运算过程与结果描述如下:

尾数求和

3.规格化处理(右规)

本例运算过程由于加运算产生进位而导致溢出,最终计算结果形式为10.x x x … x。这种形式需要通过对其右规实现规格化处理。尾数每右移一位,阶码相应加 1,最高位补0。

规格化处理

4.舍入处理

在规格化过程中随着右移操作,最右侧位将会被丢掉,因此需要通过舍入处理减少因丢弃导致的精度损失。本例题采用就近舍入方法,最后舍弃位为1,舍弃之后在剩余结果最低位加1,因此最后四位变为0100。操作结果描述如下:

舍弃处理操作

综上所述,本例题最终0.1加0.2的计算结果二进制表示为:

01.0011001100110011001100110011001100110011001100110100*2^-2

问题解决

在获取二进制计算结果之后,为方便观察,我们将该二进制数据恢复为10进制数据,然后进行结果判断,首先去掉指数部分结果如下:

0.010011001100110011001100110011001100110011001100110100

转换结果为:

0.30000000000000004441

如上所示,通过实际手工计算浮点型数据加运算,我们可以清楚了解在JavaScript进行0.1与0.2加运算时出现0.30000000000000004的原因了。如有问题不清楚同学可在评论区留言评论,如发现错误也可留言,大家共同探讨、学习、提高。



本头条号长期关注编程资讯分享;编程课程、素材、代码分享及编程培训。如果您对以上方面有兴趣或代码错误、建议与意见,可以联系作者,共同探讨。更多程序设计相关教程及实例分享,期待大家关注与阅读!系列教程链接如下:

JavaScript基础教程(二)变量、常量与运算符

JavaScript基础教程(一)课程说明

朋友们! 从2024年7月26日起,我们即将开启一段全新的算法学习之旅!
感谢你们的支持,你们的热情是我前进的动力! 学习计划如下,期待与你一起成长:
每日一题:每天一个新挑战;
循序渐进:从易到难,扎实掌握;
系统分类:按数据结构分类,有助于构建知识框架;
丰富题量:100 道精选题,覆盖简单/中等/困难难度。

题目描述

给你一个含 n 个整数的数组 nums ,其中 nums[i] 在区间 [1, n] 内。请你找出所有在 [1, n] 范围内但没有出现在 nums 中的数字,并以数组的形式返回结果。

示例 1:

输入:nums=[4,3,2,7,8,2,3,1]
输出:[5,6]

示例 2:

输入:nums=[1,1]
输出:[2]

提示:

  • n==nums.length
  • 1 <=n <=10^5
  • 1 <=nums[i] <=n

进阶:你能在不使用额外空间且时间复杂度为 O(n) 的情况下解决这个问题吗? 你可以假定返回的数组不算在额外空间内。

代码卡片

题目解析

给定一个长度为 n 的数组 nums,数组中的元素值范围在 [1, n] 之间。题目要求找出 [1, n] 范围内没有出现在 nums 中的所有数字,并以数组形式返回结果。

方法一:使用哈希表

可以通过哈希表来记录 nums 中出现过的数字。然后遍历 [1, n],如果某个数字没有出现在哈希表中,就将其加入到结果数组中。

  • 时间复杂度: O(n),遍历了数组两次,一次是构建哈希表,另一次是查找未出现的数字。
  • 空间复杂度: O(n),使用了额外的哈希表存储出现过的数字。
var findDisappearedNumbers=function(nums) {
    const n=nums.length;
    const numSet=new Set(nums);
    const result=[];

    for (let i=1; i <=n; i++) {
        if (!numSet.has(i)) {
            result.push(i);
        }
    }

    return result;
};

方法二:标记法(不使用额外空间)

如果要在不使用额外空间且时间复杂度为 O(n) 的情况下解决问题。可以通过对原数组进行标记的方法来实现。遍历数组中的每个数字,将数字 nums[i] 对应的位置上的值标记为负数,表示该位置对应的数字已经出现过。最后,再次遍历数组,凡是值为正数的位置,其对应的数字就是缺失的数字。

  • 时间复杂度: O(n),只遍历了数组两次。
  • 空间复杂度: O(1),除了返回值外,未使用额外的空间。
var findDisappearedNumbers=function(nums) {
    for (let i=0; i < nums.length; i++) {
        const index=Math.abs(nums[i]) - 1;
        if (nums[index] > 0) {
            nums[index]=-nums[index];
        }
    }

    const result=[];
    for (let i=0; i < nums.length; i++) {
        if (nums[i] > 0) {
            result.push(i + 1);
        }
    }

    return result;
};

总结

方法二使用了原地修改的模式更优,如果是面试最好都写出来。

最后

如果你有其他思路或方法,欢迎在评论区分享!祝你编码 ? 愉快!