拆解那個著名的結果
到這裡你已經知道,機器並不儲存整條實數線。它儲存的是一個有限的 浮點數 集合,每個值由符號、尾數與指數組成,依 IEEE 754 標準 以二進位打包。前兩篇已鋪好這個基礎,現在我們用它來破解計算界最惡名昭彰的一行程式。當你輸入 `0.1 + 0.2` 看到 `0.30000000000000004` 時,其實什麼都沒出錯。每一步都精確地照標準執行——那一點點殘餘,正是一個無法用二進位寫出的小數所留下的可見足跡。
根本原因在於 0.1 並不是有限位的二進位分數。在二進位中,十分之一是 0.0001100110011001100...無限循環,就像在十進位裡 1/3 是 0.3333...無限循環一樣。有限寬度的尾數裝不下無窮尾巴,於是機器保留前 53 個有效位元、其餘四捨五入。儲存下來的值並不是 0.1,而是最接近 0.1 的可表示數,相差約 5.55e-18 的一絲。0.2 也是如此,大多數看似無害的小數都是如此。
一個清楚的看法是:在雙精度中,0.1 被存成 0.1000000000000000055511151231257827021181583404541015625。這才是電腦握有的真相——而不是你輸入的那個小數。顯示程式通常會把尾巴藏起來、印出「0.1」,所以這份意外只有在好幾個捨入誤差恰好像 0.1 + 0.2 那樣疊加起來時才浮現。
逐步走過這個加法
回想前一篇的捨入運算模型:一個運算的計算結果,是其精確結果在目前 捨入模式 下捨入到最近的浮點數。每個基本運算的相對誤差都不超過 單位捨入 u,雙精度下約為 1.11e-16。所以 `0.1 + 0.2` 一共闖過三次捨入關卡——輸入存入時各一次,加總本身一次。
- 存入 0.1:捨入到最近的雙精度數。結果略大於 0.1,約多 5.55e-18。
- 存入 0.2:捨入到最近的雙精度數。結果略小於 0.2,約少 1.11e-17。
- 把兩個已儲存的值精確相加。它們的真實和落在兩個可表示數之間,本身必須再被捨入。
- 離這個和最近的雙精度數恰好是 0.30000000000000004,而不是離 0.3 最近的那個雙精度數——間隙就是對不齊。最後這個錯位就是整齣戲的全部。
值得細品的微妙轉折在這裡:離 0.3 最近的雙精度數,和你把已儲存的 0.1 與 0.2 相加得到的雙精度數,是兩個相差一格的不同可表示數。沒有哪個值該怪罪,也沒有哪個運算出了錯。各步誤差都低於 u,卻沒有互相抵消——它們把和輕輕推過了中點,落入下一個可用的格子。這正是為何 `0.1 + 0.2 == 0.3` 在一個又一個語言裡都回傳 false。
永遠不要用相等去測試浮點數
第一個實用教訓很直白:對浮點結果來說,`==` 是錯的工具。兩個數學上完全相同的計算,可能產生相鄰的兩個雙精度數,因此精確相等的測試十分脆弱。應該改問兩個值是否「接近」,並用一個尊重數值大小的容差。寫 `if x == 0.3` 很脆弱、就算 x「應該」是 0.3 也常常為假;用 `abs(x - 0.3) <= tol * max(1.0, abs(x))` 搭配一個小的 `tol` 才是穩健的替代。把容差設成每次運算幾倍 單位捨入 的量級,是個合理的起點。
為什麼加法不滿足結合律
0.1 + 0.2 的故事是一個更深真相的特例:浮點加法不滿足結合律。在精確算術中 (a + b) + c 永遠等於 a + (b + c);在浮點裡兩種括號分法卻可能給出不同的雙精度數,因為每個「+」都會把自己的中間結果捨入。試試 a = 1e16、b = 1.0、c = -1e16。那麼 (a + b) + c 會得到 0.0:把 1.0 加到 1e16 會直接捨回 1e16,於是這個小項在兩個大數相消之前就被吞掉了。但 a + (b + c) 會得到 1.0:兩個巨人先相消,留下完整的 1.0。
這裡有兩種不同的效應在運作。第一是吸收:把一個極小的數加到一個極大的數上時,那個小數落到了該量級相鄰雙精度數之間的間隙以下,就此消失。第二是 災難性相消:當兩個幾乎相等的大數相減時,前面相同的位數彼此抵消而消失,把低位的捨入雜訊提升到了答案的首位。順序決定哪種效應先發作,於是加總的順序就改變了結果。
這不是只對刻意設計的輸入才出現的奇談。把一長串數字加起來——試算表的一欄、一百萬個殘差、一個緩慢收斂級數的各項——每做一次加法就累積一個捨入誤差。最壞情況下,n 項的總誤差會像 n 乘以 u 那樣成長,重排這串數字確實會改變答案的最後幾位。誠實的結論是:多個浮點數並沒有單一「正確」的和;只有你的演算法算出的那個和,而好的演算法會讓它貼近精確值。
把加總做得更好:從天真到 Kahan
如果順序有影響、天真加總又可能損失精度,我們能不能加得更聰明?可以。最便宜的招數是排序後從絕對值最小的開始加,讓微小的項先在彼此之間累積,再去碰巨人——這能減輕吸收,但無法根治。真正的解法是 Kahan 加總法,又稱補償式加總。它額外帶一個執行變數,捕捉每次加法中丟失的低位位元,再把這份補償回饋到下一步。結果就像是用高得多的精度做加法,代價只是每項多幾個浮點運算。
function kahan_sum(x[1..n]):
s = 0.0 # running sum
c = 0.0 # running compensation (lost low bits)
for i = 1 to n:
y = x[i] - c # add back what we lost last time
t = s + y # this rounding may drop low bits of y
c = (t - s) - y # recover exactly those dropped bits
s = t
return sc = (t - s) - y 這一行看起來必定是零——代數上 t 等於 s + y,整串就是零。在浮點裡它並不是零,而這正是重點。因為 t = s + y 已被捨入,(t - s) 取回了 y 真正進入和裡的那一部分,再減掉 y,剩下的恰好是被丟掉的位元。Kahan 加總法把最壞情況誤差從像 n*u 成長,壓到大約一個小常數乘以 u,幾乎與你加多少項無關——這是圍繞捨入真實行為來設計演算法的漂亮範例。
這真正教會你的事
退一步看,0.1 + 0.2 的謎題就不再是怪癖,而成為這整條學習階梯往後的信條。浮點不是壞掉的實數算術;它是自成一格、定義明確、在有限數集上的精確算術,每一步都帶捨入。你算出的每個數值答案都是近似的。有意思的問題從來不是「它精確嗎?」,而是「誤差多大,我能否替它定界?」這種量化誤差、而非否認誤差的心態,正是讓一個能用的科學計算,有別於一個看似合理卻錯誤的計算。
下一篇會把相消這條線索推得更遠:我們會看到課本上的二次公式如何在完全平凡的輸入上幾乎丟光所有位數,並把它改寫成不會如此的穩定形式。而當你日後想對一個龐大資料集求高精度的總和時,你會毫不猶豫地伸手去拿 補償式加總——因為你現在不只是知道 0.1 + 0.2 會出狀況,而是確切明白為什麼,以及該怎麼辦。