Skip to content

一文搞懂 0.1+0.2 浮点数计算和解决方案

1.为什么 0.1+ 0.2 != 0.3?

计算机是通过二进制的方式存储数据的,所以计算机计算 0.1 + 0.2 的时候,实际上是计算的两个数的二进制的和。

在计算机中,浮点数的表示是有限的,而且它们的二进制表示可能无法精确地表示一些十进制分数,这可能导致一些浮点数计算不符合我们预期的精确性。并且0.10.2 在浮点数表示中是近似值,它们的二进制表示并不能精确地表示这些小数。因此,当进行浮点数计算时,可能会出现舍入误差,从而导致类似 0.1 + 0.2 !== 0.3 的情况发生。——> 一句话:浮点数的计算会转化为二进制,但是它们的二进制表示是四舍五入的,所以不精确

所以说 0.1+0.2 不等于 0.3,本质上是因为 0.1 和 0.2 都无法精确表示,那么它俩是怎么表示的呢?

0.1 和 0.2 都无法精确表示的原因与二进制数的特性有关。在十进制中,0.1 和 0.2 都是有限的小数,分别表示为 1/10 和 2/10。然而,在二进制中,它们被转换为无限循环小数

为了更好地理解这一点,我们可以看一下它们的二进制表示:

0.1 的二进制表示约为:0.00011001100110011001100110011001100110011001100110011...

0.2 的二进制表示约为:0.0011001100110011001100110011001100110011001100110011...

在这两种情况下,我们可以看到它们的二进制表示是无限循环的,永远不会终止。然而,计算机的存储是有限的,不能无限精确地表示这些无限循环的二进制小数

当进行 0.1 + 0.2 这样的计算时,实际上是对近似值进行运算,而非精确的 0.1 和 0.2!所以结果当然不准确!

这种舍入误差并不是 JavaScript 特有的问题,而是所有使用 IEEE 754 浮点数标准的编程语言和系统都会面临的挑战。为了避免精度损失,当需要进行精确的小数计算时,建议使用整数运算或采用特定的高精度数值库来处理。

2.IEEE 754 标准浮点数

IEEE 754 浮点数表示法介绍,小数不能精确保存的本质原因?

原因:主要是使用IEEE754 标准的浮点数的存储问题,浮点数小数位数最多52位!

在 IEEE754 标准中常见的浮点数数值表示有:单精准度(32 位)和双精准度(64 位),而 JS 采用的是双精度版本,也就是通过 64 位来表示一个数字;在二进制科学表示法中,双精度浮点数的小数部分最多只能保留 52,再加上.前面的(0 或 1),其实就是保留 53 位有效数字,剩余的需要舍去,遵从“0 舍 1 入”的原则

由于 IEEE 754 浮点数表示法使用有限的二进制位数来表示实数,因此在某些情况下会导致舍入误差和精度损失。这就是为什么在进行浮点数运算时,可能会出现意外的结果。对于关键的高精度计算,通常需要使用其他方法来确保计算的准确性。

  1. 二进制无法精确表示十进制小数:很多十进制小数在二进制中无法精确表示

    • 可以被精确表示的小数通常是二进制的分数,即分母为 2 的幂次的小数。例如,1/2、1/4、1/8 等等都可以在二进制中精确表示。这是因为这些分数可以被精确地表示为有限位的二进制小数。

    • 然而,对于其他分数,尤其是无法精确表示为分母为 2 的幂次的分数,可能会出现精度丢失。例如,0.1(十进制)在二进制中无法被精确表示,因为它是一个无限循环小数:0.000110011001100...。由于计算机中的浮点数通常使用固定数量的位来表示,所以可能会截断或近似这种无限循环的小数,导致精度损失,实际上存储的是一个近似值,而非精确的 0.1。

  2. 浮点数精度:IEEE 754 标准使用一定的位数来表示浮点数,例如,64 位的双精度浮点数中,52 位用于尾数,11 位用于指数,1 位用于符号位。浮点数的表示范围是有限的,当某个数超出了这个范围时,它会被近似为最接近的可表示值,这就引入了舍入误差。

既然 IEEE 754 有一定的弊端(它不能精确表示所有的小数),那么使用 IEEE 754 的原因是什么呢?

IEEE 754 浮点数表示法通过指数和尾数的编码,使得浮点数能够以科学计数法形式表示,从而能够有效地表示极大或极小的实数值。

IEEE 754 浮点数表示法有两种格式:单精度(32 位)和双精度(64 位)。在双精度表示法中,位数分配如下:

  • 1 位符号位,符号位用来表示浮点数的正负,即小数点前面的那一位
  • 11 位指数位
  • 52 位尾数位

通过这种结构,浮点数可以以科学计数法形式表示,即 尾数 * 2^指数。指数允许浮点数覆盖很大范围,而尾数则允许控制精度。

例如,双精度浮点数可以表示像 1.2345 x 10^27 这样的大数,也可以表示像 1.2345 x 10^-27 这样的小数,同时还可以控制小数的精度。(这样实际上 27 只占用了指数的几位二进制位而已,但是却能表达 10^27 这么大的数!)

尽管 IEEE 754 浮点数表示法非常强大,但它也存在精度问题,特别是在处理小数时可能会引起舍入误差。因此,在计算机编程中,需要注意浮点数运算的精度问题,以避免产生不准确的结果。

不论整数和小数都是通过 IEEE 754 标准保存的!

IEEE 754 标准适用于计算机中的浮点数表示,无论是整数还是小数,都会被表示为浮点数的形式。这是因为在计算机中,整数和小数都可以被看作浮点数的特殊情况。

对于整数,它可以被看作指数为 0 的浮点数,而尾数部分包含整数的二进制表示。例如,整数 5 可以表示为 5.0 x 2^0,转化为二进制就是 0.01111111111 101…。

对于小数,它会被表示为指数不为 0 的浮点数,而尾数部分则包含小数的二进制分数表示。例如,0.125 可以表示为 1.0 x 2^-3。

3.如何让 0.1+0.2 和 0.3 相等?

如何实现让 0.1+0.2 和 0.3 相等?也就是说不知道结果是否为 0.3 时,想判断一下是否是 0.3?

1.使用 toFixed()

最简单的方法,四舍五入,只保留小数点后 1 位,注意 toFixed(n)返回值的类型是 string 类型,所以还需要用 parseFloat 转换为小数才可以。

优势 toFixed 会对原始数字进行四舍五入,并将结果格式化为指定的小数位数,确保返回的结果在计算时保持准确性!

js
parseFloat((0.1 + 0.2).toFixed(1)) === 0.3; // true

2.将其转换为整数后在进行运算

运算后再转为对应的小数。

js
(0.1 * 100 + 0.2 * 100) / 100 === 0.3; // true

3.使用 Math.abs()和 Number.EPSILON

可以使用一个很小的误差范围来比较两个浮点数是否接近相等,而不是直接比较它们是否完全相等。这被称为“浮点数比较”或“浮点数近似相等”。

一个直接的解决方法就是设置一个误差范围,通常称为“机器精度”。对 JavaScript 来说,这个值通常为 2-52,在 ES6 中,提供了 Number.EPSILON 属性,而它的值就是 2-52,只要判断 0.1+0.2-0.3 是否小于 Number.EPSILON,如果小于,就可以判断为 0.1 + 0.2 === 0.3。

相加值与目标值之间的误差的绝对值小于机器精度,即可判断为相等。

js
function numberepsilon(arg1, arg2) {
  return Math.abs(arg1 - arg2) < Number.EPSILON; //伊普斯楞
}

console.log(numberepsilon(0.1 + 0.2, 0.3)); // true

4.高精度计算的解决方案

JS 中进行高精度的计算的解决方式:BigNumber 库

也就是说可以精确计算出 0.1+0.2 的结果,也就是 0.3!

JavaScript 中的 BigNumber.js:

BigNumber.js 是一个用于处理高精度数值的 JavaScript 库,它可以处理超出标准浮点数范围的数值,并提供高精度的运算功能。

js
const BigNumber = require("bignumber.js");

const x = new BigNumber("0.1");
const y = new BigNumber("0.2");
const result = x.plus(y);

console.log(result.toString()); // 输出 "0.3"

5.从[0,i]内生成 n 个不重复的随机数

1.最高级的解法:洗牌算法

使用洗牌算法:

如果你希望生成不重复的随机数,你可以使用洗牌算法(Fisher-Yates Shuffle)来随机打乱一个指定范围内的整数,然后取前面的若干个数作为结果。这样就可以确保生成的随机数不重复。

以下是一个生成不重复的随机数的示例:

js
function generateNonRepeatingRandomNumbers(count, maxNumber) {
  const numbers = [];
  // 生成 0 到 maxNumber 之间的所有整数
  for (let i = 0; i <= maxNumber; i++) {
    numbers.push(i);
  }

  // 使用洗牌算法打乱数组顺序 ——> 关键!
  //洗牌算法:遍历每一位,把每一位与除此之外的随机一位进行交换)
  for (let i = numbers.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [numbers[i], numbers[j]] = [numbers[j], numbers[i]];
  }

  // 取前面 count 个数作为结果
  return numbers.slice(0, count);
}

const randomNumbersArray = generateNonRepeatingRandomNumbers(10, 100);
console.log(randomNumbersArray);

2.我自己的解法:for 循环并使用数组记录避免重复:简单

我自己写的从[0,100]内取不重复的 10 个随机数的方法:

这种写法很不好,时间复杂度是 O(n2),并且 toFixed(0)是四舍五入,可能会造成无法输出到 100,不如使用 101 多取一位 + Math.floor 向下取整的方式!

js
const test = () => {
  let result = [];
  for (let i = 0; i < 10; i++) {
    let num = Number((Math.random().toFixed(2) * 100).toFixed(0));
    // console.log(num);
    while (result.includes(num)) {
      //判断是否已经有过这个数字
      num = Number((Math.random().toFixed(2) * 100).toFixed(0));
    }
    result.push(num);
  }
  return result;
};
console.log(test());

3.递归解法:难以理解,但是很重要

递归的思想总结:

1.要明确一个递归方法里面一般都是有两个返回值的,一个是最终我们要的结果(需要明确这个返回值会是什么东西),一个是临界条件也就是递归终止条件的返回值

2.我们要的结果基本上都是通过递归终止的返回值拼接而成的,也可以等于递归终止的返回值!

这里就是我们要的结果等于递归终止的返回值的情况!

3.还有一个重要部分就是递归函数的调用,这个函数的调用有的时候可以和最终结果放在一起,一起 return,这里就是这样!

js
const test = (result = []) => {
  //这里初始条件要给一个空数组,这样也不用在函数外面维护空数组了!或者说就不用写成函数套函数了,不用闭包了!
  if (result.length === 10) {
    return result;
  }
  let num = Number((Math.random().toFixed(2) * 100).toFixed(0));
  // console.log(num);
  //while (result.includes(num)) { //判断是否已经有过这个数字,有的话就循环直到生成一个没有的 (因为我们是递归的,所以不需要,只需要一直递归就可以了)
  //num = Number((Math.random().toFixed(2) * 100).toFixed(0));
  //}
  if (!result.includes(num)) {
    //判断是否已经有过这个数字,有的话就接着递归
    result.push(num);
  }
  return test(result);
};
console.log(test());

4.生成[0,i]内的一个随机整数的通用方法:Math.floor(Math.random() * (i + 1))

核心:生成[0,i]内的一个随机整数的方法:Math.floor(Math.random() * (i + 1)) 是一个常用的形式,它的解释:

Math.floor(Math.random() * (i + 1))是一个常见的用于生成介于 [0, i] 范围内随机整数的方法

让我们来逐步解释这个表达式:

  1. Math.random(): Math.random() 是 JavaScript 中的一个内置函数,它返回一个 0(包括)到 1(不包括)之间的随机小数。也就是说,它返回一个范围为 [0, 1) 的随机数。
  2. Math.random() * (i + 1): 这一步是将随机小数值乘以 (i + 1),其中 i 是当前循环迭代的次数,也就是当前数组的索引。因为 Math.random() 返回的是 [0, 1) 的随机小数,所以乘以 (i + 1) 之后,得到的结果就是一个介于 [0, i+1) 的随机数。
  3. Math.floor(Math.random() * (i + 1)): Math.floor() 是 JavaScript 中的一个内置函数,它将传入的数值向下取整,即去掉小数部分。这样,Math.floor(Math.random() * (i + 1)) 就得到了一个介于 [0, i] 的随机整数。

5.toFixed(0) Math.floor() 的对比?

  • toFixed(0) 适用于处理带有小数的数字,并将其四舍五入到最接近的整数,返回一个字符串表示该整数
  • Math.floor() 适用于将任意数值向下取整,返回一个整数

因此,如果你需要处理带有小数的数字并四舍五入到最接近的整数,你可以使用 toFixed(0)。如果你只是需要将一个数字向下取整并得到整数部分,你可以使用 Math.floor()

6.为什么toFixed()方法会将数字转换为字符串类型?

toFixed() 方法返回字符串的原因是为了确保精度和保留小数位数的准确性。

JavaScript 中的数字类型(Number)使用 IEEE 754 浮点数表示法来存储和处理数字,这种表示法对于大多数情况是有效的,但对于某些特定的数字和计算,可能会导致精度损失或舍入错误。

toFixed() 方法的设计目的是提供一种简单的方式来处理精确小数位数,并避免由于浮点数计算带来的精度问题。当你使用 toFixed() 来指定保留的小数位数时,它会对原始数字进行四舍五入,并将结果格式化为指定的小数位数,确保返回的结果在计算时保持准确性。

返回字符串而不是数字的好处在于,字符串的表示形式不受 JavaScript 中数字类型的精度限制。这样可以确保 toFixed() 方法返回的结果在任何计算或显示中都能够保持准确(否则 0.3 保存的时候还是不准确的,至少字符串我们可以确保这次输出准确),而不会出现精度丢失或舍入错误。

如果需要将 toFixed() 方法返回的字符串转换回数字类型,你可以使用 Number() 函数或 parseFloat() 函数来实现

6.难度升级:从[min,max]内生成不重复的 n 个随机数

1.公式:牢记

js
Math.floor(Math.random() * (max - min + 1)) + min;

解释:

  • Math.random() * (max - min + 1): 这部分计算一个介于 0 和 max - min + 1 之间的随机浮点数。这个数在范围内的每个整数都有相同的概率。
  • Math.floor(Math.random() * (max - min + 1)): 这部分将上述计算得到的随机浮点数向下取整,得到一个介于 0 和 max - min 之间的整数。
  • Math.floor(Math.random() * (max - min + 1)) + min: 最后,将上述计算得到的整数加上 min,得到一个介于 minmax 之间的随机整数。——> 相当于生成的区间范围整体向右平移 min 个单位!

理解:最大范围先减一下 min,然后整体平移 min 即可,然后就保证了最小最大范围都准确!