天天看點

代碼之謎(五)- 浮點數(誰偷了你的精度?)

如果我告訴你,中關村配置最高的電子計算機的計算精度還不如一個便利店賣的手持電腦,你一定會反駁我:「今天寫部落格之前又忘記吃藥了吧」。

你可以用最主流的程式設計語言計算 <code>0.2 + 0.4</code>,如果你使用的是 chrome、firefox、ie 8+,可以按 f12 鍵,然後找到 「控制台」,輸入上面的 表達式 <code>0.2 + 0.4</code>,回車。

然後再用最簡陋的電腦(如果你沒有手持電腦沒關系,手機、電腦都自帶一個電腦,打開“運作”,輸入 <code>calc</code>,回車) 再計算一下剛才的 算式 0.2 + 0.4。

怎麼樣?同意我的觀點了吧! 再簡陋的電腦也比超級電腦的精度高,關鍵不在于它的頻率和記憶體,而在于它是如何設計、如何表示、如何計算的。

如果你數學比較好,或者你确信你身體健康,沒有心髒病、高血壓,沒有受過重大精神創傷,那我告訴你, 在浮點數的表示範圍内,有多于 99.999...% 的數在計算機中是 不能表示 的。真的是太令人吃驚,也太令人遺憾了。真相總是很殘忍。

請注意我使用的措辭,差別開 不能表示 和 不能精确表示。

下面我從數量級分析一下,32bit 浮點數的表示範圍是 10 的 38 次方,而表示個數呢,是 10 的 10 次方。能夠被表示的數隻有 1/100000000.... (大概有30個零),這個數多大呢?還記得那個國際象棋和麥子的故事嗎?

為了讓你了解 指數的威力,我再舉個例子:

有一張很大很大的紙,對折 38 次,會有多高呢?一米?一百米?比珠峰還高?再次考驗你心髒承受能力的時刻到了:它不僅僅比珠峰高,其實它已經快到達月球了。

回到原來的話題,還有更殘忍的真相。在剩下的可以表示的不到 0.000...1% 的數中,又有多少不能精确表示呢?這就是我寫這篇部落格的目的。

上一章中我還給出了一種用定點數精确表示小數的方法。事實上,手持電腦、java 中的 bigdecimal、c# 中的貨币類型、mysql 中的 numeric 類型就是這麼幹的。你還記得在資料庫中添加字段時的 sql 語句是如何寫的嗎?現在明白為什麼我說 再簡陋的電腦也比超級電腦的精度高 了吧。

這篇部落格我将為大家講解為什麼很多數 不能精确表示,本篇可能比較燒腦子,我會盡量用最通俗的語言,最貼近現實的例子來講解,不在乎篇幅有多長,關鍵是要給大家講明白。下一篇,你将了解到浮點數如何工作,以及為什麼很多數 不能表示。

熱身 —— 問:要把小數裝入計算機,總共分幾步?你猜對了,3 步。

第一步:轉換成二進制

第二步:用二進制科學計算法表示

第三步:表示成 ieee 754 形式

在上面的第一步和第三步都有可能 丢失精度。

下面我們讨論如何把十進制小數轉換成二進制小數(什麼?你不會?請自覺去面壁)。

考慮我們将 1/7(七分之一) 寫成小數的時候是如何做的?

用 1 除以 7,得到的商就是小數部分,剩下的餘數我們繼續除以 7,一直除到什麼時候結束呢?

有兩種情況:

如果餘數為 0。yeah!終于結束了,洗洗睡吧

當除到某一步時,餘數等于 1… 停!stop!等一下,我發現有什麼地方怪怪的。餘數為 1,餘數如果為 1 的話,再繼續除下去,不就又是 1/7 了嗎?繞了一個大彎,又回來了?對,你猜的很對,它永遠不會結束,它循環了。

注意我上面說的 情況2,我們判斷他循環,并 不是從直覺看感覺它重複了,而是因為 在計算過程中,它又回到了開頭**。為什麼這麼說呢?當你計算一個分數時,它總是連續出現 5,出現了好多次,例如 0.5555555… 你也無法斷定它是無限循環的,比如 一億分之五。

記得高中時,從一本數學課外書學到了手動開平方的方法,于是很興奮的去計算 2 的平方根,發現它的前幾位是 1.414,哇,原來「2的平方根」等于 1.414141…。很多天以後,當我再次看到我的筆記時,隻能苦笑了,「2的平方根」不可能循環啊,它可是一個無理數啊。

你可能不耐煩了,叽哩哇啦說這麼多,有用嗎?當然有用了,以後如果 mm 問你:你會愛我到什麼時候?你可以回答她:我會愛你到 1/7 的盡頭。難道我會把我的表白方式告訴你們嗎? 我對你的愛就像圓周率,無限——卻永不重複。

扯遠了,現在會到主題。你也許會說:我明白了,循環小數不能精确表示,放到計算機中會丢失精度;那麼有限小數可以精确表示吧,比如 0.1。

對于無限小數,不隻是計算機不能精确表示,即使你用别的辦法(省略号除外),比如紙、黑闆、寫字闆…都無法精确表示。什麼?手機?也不能,當然不能了。不,不,ipad也不行,1萬買的也不行,真的,再貴的本子也寫不下。

那麼 0.1 在計算機中可以精确表示嗎?

答案是出人意料的, 不能。

在此之前,先思考個問題:在 0.1 到 0.9 的 9 個小數中,有多少可以用二進制精确表示呢?

我們按照乘以 2 取整數位的方法,把 0.1 表示為二進制(我假設那些不會進制轉換的同學已經補習完了):

我們得到一個無限循環的二進制小數 0.000110011...

我為什麼要把這個計算過程這麼詳細的寫出來呢?就是為了讓你看,多看幾遍,再多看幾遍,繼續看…

還沒看出來,好吧,把眼睛揉一下,我提示你,把第一行去掉,從 (2) 開始看,看到 (6),對比一下 (2) 和 (6)。

然後把前兩行去掉,從 (3) 開始看…

明白了吧,0.2、0.4、0.6、0.8 都不能精确的表示為二進制小數。難以置信,這可是所有的偶數啊!那奇數呢?答案就是:

0.1 到 0.9 的 9 個小數中,隻有 0.5 可以用二進制精确的表示。

如果把 0.0 再算上,那麼就有兩個數可以精确表示,一個奇數 0.5,一個偶數 0.0。為什麼是兩個呢?因為計算機二呗,其實計算機還真夠二的。

世界上有 10 種人,一種是懂二進制的,一種是不懂二進制的。

其實答案很顯然,我再領大家換個角度思考,0.5 就是一半的意思。在十進制中,進制的基數是 10,而 5 正好是 10 的一半。2 的一半是多少?當然是 1 了。是以,十進制的 0.5 就是二進制的 0.1。如果我用八進制呢?不用計算你就應該立刻回答:0.4;轉換成十六進制呢,當然就是 0.8 了。

(0.5)10 = (0.1)2 = (0.4)8 = (0.8)16

如果你還想繼續思考,就又會發現一個有趣的事實,我們稱之為 定理a。我們上面的數,都是小數點後面一位小數,是以,在十進制中,這樣的小數有 10 個(就是 0 到 9);同理,在二進制中,如果我們讓小數點後面有一位小數,應該有多少個呢?當然是 2 個了(0 和 1)。

哇,好像發現了新大陸一樣,很興奮是吧。那我再給你一棒,其實定理a是錯的。再重申一遍 盡信書,則不如無書。我寫部落格的目的 不是把我的思想灌輸到你的腦子裡,你應該有自己的思想,自己的思考方式,當我得出這個結論時,你應該立刻反駁我:“按照你的思路,如果是 16 進制的話,應該可以精确表示所有的 0.1 到 0.9 的數甚至還可以精确表示其它的 6 個數。而事實呢,16 進制可以精确表示的數 和 2 進制可以精确表示的數是一樣的,隻能精确表示 0.5。”

那麼到底怎麼确定一個數能否精确表示呢?還是回到我們熟悉的十進制分數。

1/2、5/9、34/25 哪些可以寫成有限小數?把一個分數化到最簡(分子分母無公約數),如果分母的因式分解隻有 2 和 5,那麼就可以寫成有限小數,否則就是無限循環小數。為什麼是 2 和 5 呢?因為他們是 10 的因子 10 = 2 x 5。

二進制和十六進制呢?他們的因子隻有 2,是以十六進制隻是二進制的一種簡寫形式,它的精度和二進制一樣。

如果一個十進制數可以用二進制精确表示,那麼它的最後一位肯定是 5。

備注:這是個必要條件,而不是充分條件。一位熱心網友設計出了下面的解決精度的方案。我就不解釋了,同學們自己思考一下吧。

我有一個觀點,針對小數精度不夠的問題(例如 0.1),軟體可以人為的在資料最後一位補 5, 也就是 0.15,這樣犧牲一位,但是可以保證資料精度,還原再把那個尾巴 5 去掉。

請同學們思考一下。

在 java 中計算 0.2 + 0.4 得到的結果是

但是當直接輸出 0.6 的時候,确實是 0.6

好像很沖突。很顯然,通過代碼(b)可以知道,在 java 中,可以精确 顯示 0.6,哪怕 0.6 不能被精确表示,但至少能精确把 0.6 顯示出來,這不是和代碼(a)沖突了嗎?

這又是一個 想當然的錯誤,在直覺上認為 0.2 + 0.4 = 0.6 是必然成立的(在數學上确實如此),既然(a)的結果是 0.6,而且 java 可以精确輸出 0.6,那麼代碼(a)的結果應該輸出 0.6。

我們用數學中的概念類比一下,比如四舍五入,我們計算 1.6 + 2.8 保留整數。

四舍五入得到 4。我們用另一種方法

通過兩種運算,我們得到了兩個結果 4 和 5。同理,在我們的浮點數運算中,參與運算的兩個數 0.2 和 0.4 精度已經丢失了,是以他們求和的結果已經不是 0.6 了。

上面一直在讨論小數,整數呢?在部落格園,一位童鞋為下面的代碼抓狂了:

把這段代碼複制到 chrome 的 console 中,按回車,詭異的問題出現了 9986705337161735 居然變成了 9986705337161736!原始資料加了 1。

一開始以為是溢出,換了個更大的數:9986705337161738

發現不會出現這個問題。

但是 9986705337161739 輸出又變成了 9986705337161740!

測試幾次之後發現浏覽器輸出數字的一個規律(justjavac注:其實這個規律是錯誤的):

十位數為偶數,個位數為奇數時會減 1,個位數為奇數時會加1

十位數為奇數,個位數為奇數時會加 1,個位數為奇數時會減1

又多測了幾次,發現根本沒有規律,很混亂!!有時候是加,有時候是減!!

解析:

這顯然不僅僅是丢失精度的問題,欲知後事如何…咳咳…靜待下一篇吧。