【js奇妙說】如何跟非電腦從業者解釋,為什麼浮點數計算0.1+0.2不等於0.3?

壹 ❀ 引

0.1+0.2不等於0.3,即便你不知道原理,但也應該聽聞過這個問題,包括部落客本人也曾在面試中被問到過此問題。很遺憾,當時只知道一句精度丟失,但是什麼原因造成的精度丟失卻不太清楚。而我在查閱資料的過程中發現,大部分文章都是假定了你有一定電腦基礎,對於非此專業的人來說,可能文章讀起來就顯得晦澀難懂。那麼本文就會站在此問題的角度,從二進位計算說起說起,用基礎數學通俗易懂的去解釋究竟是什麼原因造成了電腦中浮點數計算的精度丟失,本文開始。

貳 ❀ 從二進位說起

與我們人的計算思維不同,計算運算採用二進位而非十進位,畢竟人可以用十根手指表示十個數字。而對於早期電腦而言,第一代電子管數字機(1946年)在硬體方面,邏輯元件採用的都是真空電子管,而使用電子管表示十種狀態過於複雜,所以當時的電子電腦只有兩種狀態,即開和關,因此電子管的兩種狀態也奠定了電腦採用二進位來表示數字和數據。

十進位非常好理解,比如一個基礎的十進位計算:

9 + 2 = 11 // 逢十進一,剩餘1個1,所以等同於10 + 1,因此是11

與十進位的逢十進一不同,二進位只有01兩個數字,它遵循逢二進一,比如:

1 + 1 = 10 //1+1等於2,逢二進一,因此為10,這裡讀作一零,而不是十

除了加法,二進位一樣存在加減乘除的操作,比如:

// 加法
0 + 0 = 0; 0 + 1 = 1; 1 + 0 = 1; 1 + 1 = 10;
// 減法
0 - 0 = 0; 1 - 0 = 1; 1 - 1 = 0; 0 - 1 = 1;
// 乘法
0 * 0 = 0; 1 * 0 = 0; 0 * 1 = 0; 1* 1 = 1;
// 除法
0 / 1 = 0; 1 / 1 = 1;

那麼到這裡,我們了解了二進位的基本計算規則。而回到文章開頭的問題,0.1+0.2的操作對於電腦而言,它一定是將十進位的數字轉成二進位之後做的計算,所以要想知道精度如何丟失,我們肯定得先知道十進位數字如何轉變成二進位,我們接著聊。

叄 ❀ 十進位如何轉二進位

十進位數如何轉二進位數,我們可以先知曉一個規則,考慮到十進位數字存在浮點數,我們可以總結為:

整數部分除以2,一直算到結果為0為止,逆序取余;小數部分乘以2,一直算到結果為1為止,順序取整。

什麼意思呢?我們來以5.625為例,將其拆分成整數部分5,以及小數部分0.625並分別套用上面的公式:

//整數/2    取余
5 / 2 = 2   1
2 / 2 = 1   0
1 / 2 = 0   1
// 逆序取余(從下往上),因此是101

//小數乘以1        取整
0.625 * 2 = 1.25  1
0.25 * 2 = 0.5    0
0.5 * 2 = 1       1
// 順序取整(從上往下),因此也是101
// 綜合起來,轉二進位為 101.101

因此5.625轉二進位結果為101.101

OK,我們再來試著轉換0.10.2為二進位,先看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
0.8 * 2 = 1.6  1
0.6 * 2 = 1.2  1
0.2 * 2 = 0.4  0 // 開始循環
0.4 * 2 = 0.8  0
//0.000110011001100110011001100110011001100110011001100...

經過轉換我們發現,0.1轉二進位會陷入0.2 0.4 0.8 0.6這四個數字的循環,所以最終的結果是一個無限的0.0 0011 0011 0011...的結構。

接著看0.2的二進位轉換:

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
0.8 * 2 = 1.6  1
0.6 * 2 = 1.2  1
0.2 * 2 = 0.4  0 // 繼續循環
0.4 * 2 = 0.8  0
// 0.0011001100110011001100110011001100110011001100110011001...

好傢夥,0.2更直接,直接陷入0.2 0.4 0.8 0.6這四個數字的計算循環,因此它轉成二進位也是一個無限的0.0011 0011 0011...類型結構的數字。

叄 ❀ 二進位的指數形式

我們知道,電腦的存儲空間一定是有限的,即便數字的佔用空間再小,它也沒辦法存儲一個無限大的數,那電腦是怎麼做的呢?這裡就得引入二進位的指數以及浮點數IEEE 745標準兩個概念,我們先說二進位的指數。

十進位的指數很好理解,比如數字1000用指數表示為1 * 10^3,其中10為底數,3為指數,翻譯過來就是1 * (10 * 10 * 10),而這個過程其實可以理解成將小數點往左移動了3位;同理,那自然也有也有將小數點往右移,讓指數為負數的情況,比如:

1000  1*10^3
0.001 1*10^-3

而二進位的指數與十進位並無區別,只是將指數從10變成了2,一樣如果小數點往左移動N位,那麼就是2^n,反之往右移動那就是2*-n,看兩個簡單的例子:

// 這裡都是二進位的數字
1010  1.010 * 2^11 // 底數為2,指數為3
0.001 1 * 2^-11    // 底數為2,指數為-3

這裡有同學可能就要說了,不是移動3位嗎,怎麼指數是11,前面已經說了,二進位中只存在數字0和1,數字3轉成二進位不就是11了,大家只要心裡清楚這裡是3即可。

那麼說了這麼多,指數有什麼價值呢?前面也說了電腦記憶體有限,在有限的空間去盡量描述無限大或者無限小的數字是很有必要的,那麼大家可以想想數字10000和數字1*10^4誰更節省空間,以及數字9999999.99999*10^999999在同等空間下,誰能描述更大的數字,很顯然指數更勝一籌,那麼到這裡我們解釋了指數的意義以及二進位指數的描述方式。

肆 ❀ 浮點數的IEEE 754標準

在解釋完指數,我們了解到指數能描述和存儲更大的數字,但即便再大電腦也沒辦法使用指數後就能存一個無限長的數字,比如上文十進位0.1轉成二進位之後的結果。因此有了指數還不夠,電腦還是得對數字做取捨,怎麼取捨呢?這就得介紹浮點數的IEEE 754標準了,標準如下:

其中符號位佔一位,表示這個數字是正數或者負數,如果是正數,符號位是0,反之是負數,那麼符號位就是1。

指數部分11位,前面已經解釋過指數,比如1*10^11,這裡的指數代表的就是11

尾數部分佔52位,比如0.11001100,這裡的尾數部分指的就是11001100這一部分。

其實說完尾數,大家應該就知道0.1以及0.2轉成二進位的無限小數已經得按尾數的佔位規則進行取捨了,這裡我們再附上轉換之後的二進位數:

// 0.1的二進位
0.000110011001100110011001100110011001100110011001100110011001100110011...
// 0.2的二進位
0.00110011001100110011001100110011001100110011001100110011001100110011...

然後我們再將其轉換成二進位指數形式:

// 0.1 二進位指數形式,往右移動4位,指數為-4
1.10011001100110011001100110011001100110011001100110011001100110011... // 指數為-4
// 0.2 二進位指數形式,往右移動3位,指數為-3
1.10011001100110011001100110011001100110011001100110011001100110011... // 指數為-3

前文說了,尾數部分只能是52位,因此我們得做取捨,與十進位四捨五入不同,二進位遵循零舍一入的規則,開始轉換:

// 0.1 IEE 754
// 53位是1,進一位
1.10011001100110011001100110011001100110011001100110011
// 52位變成了2,逢二進一
1.1001100110011001100110011001100110011001100110011002
// 最終結果
1.1001100110011001100110011001100110011001100110011010 // 指數為-4

上述轉換中,因為53位是1,遵循零舍一入,導致52位變成了2,而二進位逢二進一,因此結尾變成了10。

同理我們也對0.2的二進位指數也做尾數取捨:

// 0.1 IEE 754
// 53位也是1
1.10011001100110011001100110011001100110011001100110011
// 零舍一入後再逢二進一
1.1001100110011001100110011001100110011001100110011010 // 指數為-3

轉換完成之後我們需要對兩個數求和,但因為指數不同不能直接計算,因此我們將0.1的指數也變成-3

// 0.1 指數為-3
0.1100110011001100110011001100110011001100110011001101

由於尾數只能有52位,小數點往右移動了一位,因此我們得再捨棄一位,正好最後一位是0,直接捨棄,所以有了上面的結果。

最後我們對如下兩個指數相同的數進行求和:

// 0.1 指數為-3
0.1100110011001100110011001100110011001100110011001101
// 0.2 指數為-3
1.1001100110011001100110011001100110011001100110011010
// 指數為-3的和
10.0110011001100110011001100110011001100110011001100111
// 指數為0的和,尾數只能是52位,再次取捨
0.0100110011001100110011001100110011001100110011001101

求和同樣是相同位進行加法計算,遵循逢二進一,這裡直接給出結果後,再得出指數為0的結果,由於尾數只能是52位,所以我們再次取捨。

在拿到結果後,我們得將二進位再還原成十進位,轉換規則為:

位權展開求和,以小數點為起始位,小數點每往左移動n位,當前位結果為當前數字 * 2^n,小數點往右移動一位,當前位結果為當前數字 * 2^-n

由於上述數字整數部分是0,我們不做考慮,那麼最終結果應該為:

0*2^-1 + 1*2^-2 + 0*2^-3 + 0*2^-4 + .... + 1*2^-52 

這裡我們通過程式來計算這個過程:

const s = '0100110011001100110011001100110011001100110011001101';
let ans = 0;
for (let i = 0; i < s.length; i++) {
  ans += (+s[i]) * Math.pow(2, -(i + 1));
};
console.log(ans); // 0.30000000000000004

如上,我們最終轉換的結果為0.30000000000000004,這與控制台輸出結果完全一致:

那麼到這裡,我們解釋了為什麼0.1+0.2不等於0.3,其本質原因是0.1 0.2在轉二進位時因為是無限長小數,為符合IEEE 754標準進行長度取捨以及零舍一入所造成的精度丟失。

伍 ❀ 如何判斷0.1+0.2等於0.3

我們可以借用Number.EPSILON來做比較,Number.EPSILON表示1與Number可表示的大於1的最小浮點數之間的差值,比如:

console.log( Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON);// true

同理,0.1+0.7其實也不等於0.8,我們一樣可以用這種方式做對比:

console.log( Math.abs(0.1 + 0.7 - 0.8) <= Number.EPSILON);

陸 ❀ 總

那麼到這裡,我們解釋了0.10.2不等於0.3的本質原因,除此之外,我們也了解二進位與十進位相互轉換的規則,以及IEEE 754對於浮點數計算造成的影響。而事實上,並不是所有的浮點數計算都有精度丟失,比如0.5 + 0.5等於1;1/3結果是0.33333...,而當1/3+1/3+1/3時,結果並不是0.999999..而是整數1。當然,在實際開發中當遇到浮點數計算時,我們往往可以將乘以1000或者更大的數之後再進行計算後再還原,儘可能保證其精度的準確性,那麼關於0.10.2求和的故事就說到這裡了,本文結束。

Tags: