1. 程式人生 > >揭祕 0.1 + 0.2 != 0.3

揭祕 0.1 + 0.2 != 0.3

“0.1 + 0.2 = ?”,這道題如果給小學生,他會立馬告訴你答案是 0.3,但是交給一些程式去計算,結果就不是那麼簡單了。

math

事實上,不僅僅是 JS,在其他採用 IEEE754 浮點數標準的語言中,0.1 + 0.2 都不會等於 0.3,但是 0.2 + 0.3 卻等於 0.5,這是為何?想必這類問題也困擾著不少程式設計師。

IEEE754 浮點數的演算

我們知道,科學計數法中 30000 可以寫成 3x104,以 10 為底數 4 為指數的科學計數法。在 IEEE754 標準中是比較類似的,只不過它是二進位制數,底數也為 2。

IEEE 754 中最常用的浮點數值表示法是:單精確度(32位)和雙精確度(64位),JavaScript 採用的是後者。舉個例子,十進位制數 150,使用雙精度浮點數表示法,表示如下:

// D 表示十進位制,B 表示二進位制
150D = 2^8 * 0.10010110B // 後面省略了 46 個 0

可以通過短除法計算:

   150   餘數位
÷    2
---------------
    75     0   
÷    2
---------------
    37     1
÷    2
---------------
    18     1
÷    2
---------------
     9     0
÷    2
---------------
     4     1
÷    2
---------------
     2     0
÷    
2 --------------- 1 0 ÷ 2 --------------- 0 1

上面是整數的表示法,而小數的表示法採用的是乘二取整,如 0.1,它的二進位制表示為:最後一個餘數為高位值,於是拿到 150 對應的二進位制數位 10010110,也就等於 2^8 * 0.10010110

// (0011) 表示迴圈
0.1D = 2^-3 * 0.110011(0011)

其演算方法如下:

    0.1   整數位
×     2
---------------
    0.2     0 
×     2
---------------
    0.4     0   * ↓
×     
2 --------------- 0.8 0 × 2 --------------- 1.6 1 × 2 --------------- 1.2 1 × 2 --------------- 0.4 0 * ↑ (0011迴圈)

如果一個數既包含整數部分,又包含小數部分,其表示法的計算,需要分拆為整數和小數兩部分,然後相加得到結果。與整數不同的是,第一個計算得到的整數位為最高位,故 0.1 對應的二進位制數為 0.000110011(0011),也就等於 2^-3 0.1100110011(0011)

IEEE754 浮點數精度丟失

IEEE754 浮點數表示法的資料格式如下圖:

// 下圖採用大端表示,高位在左,低位在右。

sign  exponent         fraction
+---+----------+---------------------+
| 1 |   2~12   |         13~64       |
+---+----------+---------------------+
  • 從上面小數的乘二取整演算中可以看到,有些小數對應的二進位制數是無法寫全的,比如 0.1,而 fraction 尾數部分有要求,只允許 52 位,超過部分進一舍零。符號位:高位第 1 位,如圖 sign 部分
  • 指數位:高位第 2~12 位,如圖 exponent 部分
  • 尾數位:剩下的 fraction 部分

那麼,我們就可以得到:

0.1D 
= 2^-4 * 1.10011(0011)B
= 2^-4 * 1.10011(0011 repeat 12 times)0011B // ← 最後一位為 1,進 1
= 2^-4 * 1.10011(0011 repeat 12 times)010B

揭祕 0.1 + 0.2

根據上面我們瞭解到的知識,我們可以很容易算出這些值:

0.1D = 2^-4 * 1.1001100110011001100110011001100110011001100110011010B
0.2D = 2^-3 * 1.1001100110011001100110011001100110011001100110011010B
0.3D = 2^-2 * 1.0011001100110011001100110011001100110011001100110011B

0.1 + 0.2 時,先將兩者指數統一為 -3,故 0.1 小數點向左移一位,於是:

   0.1100110011001100110011001100110011001100110011001101B
+  1.1001100110011001100110011001100110011001100110011010B
------------------------------------------------------------
= 10.0110011001100110011001100110011001100110011001100111B

得到的二進位制數為:

10.0110011001100110011001100110011001100110011001100111B

小數點往左移一位使得整數部分為 1,此時尾數部分為 53 位,進一舍零,於是得到最後的值是:

2^-2 * 1.0011001100110011001100110011001100110011001100110100

這個值轉化成真值,結果為:0.30000000000000004。那麼 0.1 + 0.2 = 0.30000000000000004 的推演到這裡就結束了。

相關驗證

畢竟咱們手動計算可能存在筆誤,可以通過一個叫做 double-bits 的 npm 進行推演,我寫了一個小 demo,感興趣的可以玩耍下:

const db = require('double-bits');
const pad = require('pad');

// [lo, hi] where lo is a 32 bit integer and hi is a 20 bit integer.
const base2Str = (n) => {
  const f = db.fraction(n);
  const s = db.sign(n) ? '-' : '';
  const e = `2^${db.exponent(n) + 1}`;
  const t = `0.${pad(f[1].toString(2), 20, '0')}${pad(f[0].toString(2), 32, '0')}`;
  return `${s}${e} * ${t}`;
};

console.log(base2Str(0.1).toString(2));
console.log(base2Str(0.2).toString(2));
console.log(base2Str(0.3).toString(2));
console.log(base2Str(1.2).toString(2));

上面輸出結果為:

2^-3 * 0.11001100110011001100110011001100110011001100110011010
2^-2 * 0.11001100110011001100110011001100110011001100110011010
2^-1 * 0.10011001100110011001111001100110011001100110011001100
2^1 * 0.10011001100110011001111001100110011001100110011001100

最後

為了按照計算機的思維,IEEE754 的標準來計算 0.1 + 0.2,又重新複習了一遍大學計算機基礎的知識,原碼、反碼、補碼,以及除二取餘、乘二取整計演算法,最後能夠推演出來,也算是一個勝利吧~

更多閱讀

題圖:math by Roman Mager

筆耕不輟,歡迎關注微信公眾號小鬍子哥(barretlee_com),分享生活,分享技術,我在那裡等你。