全面總結 JS 中浮點數運算問題

雲影sky發表於2019-10-19

經常會碰到一個問題,"為什麼 0.1 + 0.2 !== 0.3? ",我找了很多資料,儘可能全面地分析原因和解決辦法。

文章可能有點枯燥,囧。

這裡先給出判斷方法

Math.abs(0.1+0.2-0.3) <= Number.EPSILON
複製程式碼

IEEE 754 64 位浮點型別

IEEE 754

IEEE 754 規定了四種表示浮點數值的方式:單精確度(32位)、雙精確度(64位)、延伸單精確度(43位元以上,很少使用)與延伸雙精確度(79位元以上,通常以80位實現)。

該標準的全稱為IEEE二進位制浮點數算術標準(ANSI/IEEE Std 754-1985),又稱IEC 60559:1989,微處理器系統的二進位制浮點數算術(本來的編號是IEC 559:1989)。

單精度浮點數

單精度浮點數格式是一種資料型別,在計算機儲存器中佔用 4 個位元(32 bits),利用“浮點”(浮動小數點)的方法,可以表示一個範圍很大的數值。

在 IEEE 754-2008 的定義中,32-bit base 2 格式被正式稱為 binary32 格式。這種格式在 IEEE 754-1985 被定義為 single,即單精度。需要注意的是,在更早的一些計算機系統中,也存在著其他 4 位元組的浮點數格式。

定義

第 1 位表示正負,中間 8 位表示指數,後 23 位儲存有效數位(有效數位是 24 位)。

中間八位共可表示 28=256 個數,指數可以是二補碼;或 0 到 255,0 到 126 代表-127 到-1,127 代表零,128-255 代表 1-128。

有效數位最左手邊的 1 並不會儲存,因為它一定存在(二進位制的第一個有效數字必定是 1)。換言之,有效數位是 24 位,實際儲存 23 位。

圖片

雙精度浮點數

雙精度浮點數(double)是計算機使用的一種資料型別。比起單精度浮點數,雙精度浮點數(double)使用 64 位(8 位元組) 來儲存一個浮點數。 它可以表示十進位制的 15 或 16 位有效數字,其可以表示的數字的絕對值範圍大約是 [2.23e-308,1.79e308]

定義

和單精度類似,第 1 位表示正負,後 11 位為指數位,最後 52 位表示精確度(有效位數是 53 位)。

圖片

Number in JavaScript

Number.EPSILON

Number.EPSILON === 2.220446049250313e-16,表示 1 與 Number 可表示的大於 1 的最小的浮點數之間的差值。其接近於 2**-52

Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON 可以判斷 0.1 + 0.20.3 的大小。

Number.MAX_SAFE_INTEGER

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

因為 Javascript 的數字儲存使用了 IEEE 754 中規定的雙精度浮點數資料型別,而這一資料型別能夠安全儲存 -(2**53 - 1)2**53 - 1 之間的數值(包含邊界值)。

這裡安全儲存的意思是指能夠準確區分兩個不相同的值,例如 Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 將得到 true 的結果

Number.MAX_VALUE

Number.MAX_VALUE 屬性表示在 JavaScript 裡所能表示的最大數值。

MAX_VALUE 屬性值接近於 1.79e308,也就是雙精度浮點型能表示的最大數字。大於 MAX_VALUE 的值代表 Infinity

看個例子

圖片.png

Number.MIN_SAFE_INTEGER

代表在 JavaScript 中最小的安全的 integer 型數字 -(2**53 - 1)9007199254740991.

Number.MIN_VALUE

Number.MIN_VALUE 屬性表示在 JavaScript 中所能表示的最小的正值。

MIN_VALUE 屬性是 JavaScript 裡最接近 0 的正值,而不是最小的負值。

MIN_VALUE 的值約為 5e-324。小於 MIN_VALUE ("underflow values") 的值將會轉換為 0。

注意下,用 Math.abs(0.1 + 0.2 - 0.3) < Number.MIN_VALUE 將會返回 false

Number.isSafeInteger()

Number.isSafeInteger() 方法用來判斷傳入的引數值是否是一個“安全整數”(safe integer)。

比如,2**53 - 1 是一個安全整數,它能被精確表示,在任何 IEEE-754 舍入模式(rounding mode)下,沒有其他整數舍入結果為該整數。作為對比,2**53 就不是一個安全整數,它能夠使用 IEEE-754 表示,但是 2**53 + 1 不能使用 IEEE-754 直接表示,在就近舍入(round-to-nearest)和向零舍入中,會被舍入為 2**53

圖片.png

0.1、0.2、0.3 分別是怎麼表示的

這個地方比較複雜,涉及到二進位制小數無法表示時自動截斷,在 JS 中測試時發現,截斷的精度有時是 52 位,有時是 53 位。在 0.1 + 0.2 中截斷精度是 52 位,在 0.1 + 0.5 中截斷精度是 53 位。

(0.1).toString(2) === "0.000 110011001100110011001100110011001100110011001100110 1“

(0.2).toString(2) === "0.00 1100110011001100110011001100110011001100110011001101"

(0.30000000000000004).toString(2) === "0.0100110011001100110011001100110011001100110011001101"

(0.3).toString(2) === "0.010011001100110011001100110011001100110011001100110011"

我們看看 0.1 是如何被表示成這麼一大串數字的。

0.1 * 2 = 0.2  -> 0
0.2 * 2 = 0.4  -> 0
0.4 * 2 = 0.8  -> 0
0.8 * 2 = 1.6  -> 1
0.6 * 2 = 1.2  -> 1
0.2 * 2 = 0.4  -> 0
0.4 * 2 = 0.8  -> 0 ... 一直迴圈,無法達到 1
複製程式碼

所以最終 0.1 用二進位制表示是 0.0001 1001 1001 1001 ...,但是我們看上面 (0.1).toString() 最後的六位 001101,正常迴圈應該是 001100,所以截斷之後,0.1 二進位制表示的值變大了!!!。0.2 轉換為二進位制表示截斷之後也變大了。

圖片

通過對比 0.1、0.2 及它們的和的二進位制表示,可以發現字串的長度變化了,但是精確度卻沒有變化,也就是從 1 開始到最後的字串長度都是 52。

0.1 + 0.2 本來應該是長度在為 57,但是由於無法表示這樣一個數,重新從 1 開始的數字開始計數,會截斷最後的三個數字 (最後精確度為 52 或者 53 )

我們再來看一個例子, 0.1 + 0.5 === 0.6 為 true,實際不能這麼比較,極其容易出錯。

(0.1).toString(2) === "0.0001100110011001100110011001100110011001100110011001101“,字串長度為 57,精度為 52。

(0.5).toString(2) === "0.1"

(0.6).toString(2) === "0.10011001100110011001100110011001100110011001100110011",這個字串長度為 55,精度為 53.

0.1 + 0.5 的原本結果為 0.1001100110011001100110011001100110011001100110011001101,這個數字無法用二進位制表示,因為從第一個 1 開始往後的總長度為 55,大於 53,所以截斷之後變成了 0.10011001100110011001100110011001100110011001100110011,這個結果和 0.6 的二進位制表示正好相等!!!所以有 0.1 + 0.5 === 0.6

小數什麼時候精度為 52 位,什麼時候為 53 位

0.1、0.2、0.3 分別是怎麼表示的 這一節中,我們看到 0.1+0.2 結果的精確度是 52 位,而 0.1+0.5 的精確度是 53 位的,結合之前講的雙精度浮點數的表示方法,不免有個疑惑,精確度不應該都是用 53 位的嗎?

我們進一步看看,0.1~0.9 這幾個小數的二進位制表示

0.1 -> "0.0001100110011001100110011001100110011001100110011001101" 精度 52 位
0.2 -> "0.001100110011001100110011001100110011001100110011001101"  精度 52 位
0.3 -> "0.010011001100110011001100110011001100110011001100110011"  精度 53 位
0.4 -> "0.01100110011001100110011001100110011001100110011001101"   精度 52 位
0.5 -> "0.1"
0.6 -> "0.10011001100110011001100110011001100110011001100110011"   精度 53 位
0.7 -> "0.1011001100110011001100110011001100110011001100110011"    精度 52 位
0.8 -> "0.1100110011001100110011001100110011001100110011001101"    精度 52 位
0.9 -> "0.11100110011001100110011001100110011001100110011001101"   精度 53 位
複製程式碼

說實話,沒有從這幾個數字中獲得什麼規律!!!0.7、0.9 的精確度位數和預想的不一樣。。

歡迎各位留言討論這一部分~

如何解決小數運算不準確的問題

小數運算不準是因為要計算的數字小數部分無法用二進位制精確表示所導致,我們可以把小數轉化成整數運算之後再變回小數來解決!

以下解決辦法來自 number-precision

/**
 * 精確加法
 */
function plus(num1: number, num2: number, ...others: number[]): number {
  if (others.length > 0) {
    // 遞迴
    return plus(plus(num1, num2), others[0], ...others.slice(1));
  }
  // digitLength 是獲取小數的點後面的字元個數
  // 下面是計算讓 num1、num2 都為整數時的最小倍數
  const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
  // 讓 num1、num2 都變成整數,然後運算,然後再變回小數
  return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
}
複製程式碼

計算過程類似於

0.11 + 0.345

0.11 -> digitLength(0.11) -> 2
0.345 -> digitLength(0.345) -> 3

故 baseNum = 3

0.11 * 10**3 = 110
0.345 * 10**3 = 345

110 + 345 = 455

455 / baseNum= 0.455
複製程式碼

image

手動寫安全的加減乘數

計算的關鍵就在於把小數轉換成可準確表示的整數,下面的程式碼只是大概的功能實現,如果要直接使用,可以用 github.com/nefe/number…


更新: 由於小數 * 整數也可能導致計算不準確,所以之前的程式碼存在誤差。修復過後不允許直接對小數引數做運算,必須轉換成整數運算然後再轉換回來


digitLength 獲取到數字的小數部分的位數,這是變為整數的關鍵。

// 相容多種型別的表示
// 1.11 或者 1.11e-30 或者 1e-30
export function digitLength(num: number): number {
  // 1.11 -> eSplit: ['1.11']
  // 1.11e-30 -> eSplit: ["1.11", "-30"]
  const eSplit = num.toString().split(/[eE]/)
  // 右邊的 `|| ''` 為了防止 1e-30 -> eSplit: ["1", "-30"] 這種
  // 左邊 1.11 有兩個小數,右邊 e 後面有 -30,所以是 2 - (-30) 為 32
  const len = (eSplit[0].split('.')[1] || '').length - Number(eSplit[1] || 0)
  return len > 0 ? len : 0
}
複製程式碼

baseNum 計算出讓 num1num2 都為整數的最小 10 的倍數

export function baseNum(num1: number, num2: number): number {
  return Math.pow(10, Math.max(digitLength(num1), digitLength(num2)))
}
複製程式碼

strip 對錯誤的數進行修正

/**
 * 把錯誤的資料轉正
 * strip(0.09999999999999998)=0.1
 */
+export function strip(num: number, precision = 12): number {
+  // (0.09999999999999998).toPrecision(12) => "0.100000000000"
+  // parseFloat("0.100000000000") => 0.1
+  return +parseFloat(num.toPrecision(precision))
+}
複製程式碼

float2Fixed 把傳入的數變為整數

+export function float2Fixed(num: number) {
+  // 1.23456 => 123456
+  if (num.toString().indexOf('e') === -1) {
+    return Number(num.toString().replace('.', ''))
+  }
+  // 1.1e-30
+  const dLen = digitLength(num)
+  // 這個地方需要輔助矯正,num * Math.pow(10, dLen) 小數和整數相乘仍然可能會出現不準的情況
+  return dLen > 0 ? strip(num * Math.pow(10, dLen)) : num
+}
複製程式碼

乘法計算

export function times(num1: number, num2: number): number {
   const bn = digitLength(num1) + digitLength(num2)
-  const intNum1 = num1 * Math.pow(10, digitLength(num1))
-  const intNum2 = num2 * Math.pow(10, digitLength(num2))
+  const intNum1 = float2Fixed(num1)
+  const intNum2 = float2Fixed(num2)
   return (intNum1 * intNum2) / Math.pow(10, bn)
}
複製程式碼

加法計算

export function plus(num1: number, num2: number): number {
   const bn = baseNum(num1, num2)
-  return (num1 * bn + num2 * bn) / bn
+  // fix:不能使用 num1 * bn,小數與整數相乘可能不準確,需要精確乘 times
+  return (times(num1, bn) + times(num2, bn)) / bn
}
複製程式碼

減法計算

export function minus(num1: number, num2: number): number {
   const bn = baseNum(num1, num2)
-  return (num1 * bn - num2 * bn) / bn
+  return (times(num1, bn) - times(num2, bn)) / bn
}
複製程式碼

除法計算

export function divide(num1: number, num2: number): number {
   const bn = baseNum(num1, num2)
-  const intNum1 = num1 * bn
-  const intNum2 = num2 * bn
+  const intNum1 = times(num1, bn)
+  const intNum2 = times(num2, bn)
   // 要檢查擴大後的數字是否超過了安全邊界
   return intNum1 / intNum2


+  // 避免把數字擴的太大的寫法
+  // const num1Changed = float2Fixed(num1)
+  // const num2Changed = float2Fixed(num2)
+  // return times(num1Changed / num2Changed, strip(Math.pow(10, digitLength(num2) - digitLength(num1))))
}
複製程式碼

這四種運算的原理都是先放大數字,使之能夠精確表示,計算之後再縮小數字,得到實際值。

測試結果

import { plus, minus, divide, times } from './index'

test('javascript/number-precision-operation', () => {
  expect(plus(0.1, 0.2)).toBe(0.3) // 0.30000000000000004
  expect(plus(0.1, 0.7)).toBe(0.8) // 0.7999999999999999
  expect(minus(1, 0.9)).toBe(0.1) // 0.09999999999999998
  expect(divide(0.1, 0.3)).toBe(0.3333333333333333) // 0.33333333333333337
  expect(times(0.1, 0.1)).toBe(0.01) // 0.010000000000000002
})
複製程式碼

參考

歡迎在本文下面評論或者在 GitHub issue 中參與討論 github.com/lxfriday/gi…


歡迎大家關注我的掘金和公眾號,演算法、TypeScript、React 及其生態原始碼定期講解

全面總結 JS 中浮點數運算問題

歡迎進群討論~~

全面總結 JS 中浮點數運算問題
|

相關文章