光棍節(jié)加長版 轉:http:///codepuzzle/2012/11/11/codepuzzle-float-who-stole-your-accuracy.html 代碼之謎(五)- 浮點數(shù)(誰偷了你的精度?)如果我告訴你,中關村配置最高的電子計算機的計算精度還不如一個便利店賣的手持計算器,你一定會反駁我:「今天寫博客之前又忘記吃藥了吧」。 你可以用最主流的編程語言計算 然后再用最簡陋的計算器(如果你沒有手持計算器沒關系,手機、電腦都自帶一個計算器,打開“運行”,輸入 怎么樣?同意我的觀點了吧! 再簡陋的計算器也比超級計算器的精度高,關鍵不在于它的頻率和內存,而在于它是如何設計、如何表示、如何計算的。 不能表示 VS 不能精確表示在上一章『浮點數(shù)(從驚訝到思考)』中我們講到用浮點數(shù)表示 數(shù) 時出現(xiàn)的問題——很多數(shù)都 不能表示。(注意 浮點數(shù)表示的是數(shù),而不僅僅是小數(shù)。) 如果你數(shù)學比較好,或者你確信你身體健康,沒有心臟病、高血壓,沒有受過重大精神創(chuàng)傷,那我告訴你, 在浮點數(shù)的表示范圍內,有多于 99.999...% 的數(shù)在計算機中是 不能表示 的。 真的是太令人吃驚,也太令人遺憾了。 真相總是很殘忍。 請注意我使用的措辭,區(qū)別開 不能表示 和 不能精確表示。 下面我從數(shù)量級分析一下,32bit 浮點數(shù)的表示范圍是 10 的 38 次方,而表示個數(shù)呢,是 10 的 10 次方。 能夠被表示的數(shù)只有 1/100000000.... (大概有30個零),這個數(shù)多大呢?還記得那個國際象棋和麥子的故事嗎? 為了讓你了解 指數(shù)的威力,我再舉個例子: 有一張很大很大的紙,對折 38 次,會有多高呢? 一米?一百米?比珠峰還高?再次考驗你心臟承受能力的時刻到了:它不僅僅比珠峰高,其實它已經(jīng)快到達月球了。 回到原來的話題,還有更殘忍的真相。 在剩下的可以表示的不到 0.000...1% 的數(shù)中,又有多少不能精確表示呢?這就是我寫這篇博客的目的。 上一章中我還給出了一種用定點數(shù)精確表示小數(shù)的方法。 事實上,手持計算器、java 中的 BigDecimal、C# 中的貨幣類型、MySQL 中的 NUMERIC 類型就是這么干的。 你還記得在數(shù)據(jù)庫中添加字段時的 SQL 語句是如何寫的嗎?現(xiàn)在明白為什么我說 再簡陋的計算器也比超級計算器的精度高 了吧。 這篇博客我將為大家講解為什么很多數(shù) 不能精確表示,本篇可能比較燒腦子,我會盡量用最通俗的語言,最貼近現(xiàn)實的例子來講解,不在乎篇幅有多長,關鍵是要給大家講明白。下一篇,你將了解到浮點數(shù)如何工作,以及為什么很多數(shù) 不能表示。 熱身 —— 問:要把小數(shù)裝入計算機,總共分幾步?你猜對了,3 步。
在上面的第一步和第三步都有可能 丟失精度。 十進制 VS 二進制下面我們討論如何把十進制小數(shù)轉換成二進制小數(shù)(什么?你不會?請自覺去面壁)。 考慮我們將 1/7(七分之一) 寫成小數(shù)的時候是如何做的? 用 1 除以 7,得到的商就是小數(shù)部分,剩下的余數(shù)我們繼續(xù)除以 7,一直除到什么時候結束呢? 有兩種情況:
注意我上面說的 情況2,我們判斷他循環(huán),并 不是從直觀看感覺它重復了,而是因為 在計算過程中,它又回到了開頭**。為什么這么說呢?當你計算一個分數(shù)時,它總是連續(xù)出現(xiàn) 5,出現(xiàn)了好多次,例如 0.5555555… 你也無法斷定它是無限循環(huán)的,比如 一億分之五。 記得高中時,從一本數(shù)學課外書學到了手動開平方的方法,于是很興奮的去計算 2 的平方根,發(fā)現(xiàn)它的前幾位是 1.414,哇,原來「2的平方根」等于 1.414141…。很多天以后,當我再次看到我的筆記時,只能苦笑了,「2的平方根」不可能循環(huán)啊,它可是一個無理數(shù)啊。 你可能不耐煩了,嘰哩哇啦說這么多,有用嗎?當然有用了,以后如果 MM 問你:你會愛我到什么時候?你可以回答她:我會愛你到 1/7 的盡頭。難道我會把我的表白方式告訴你們嗎? 我對你的愛就像圓周率,無限——卻永不重復。 扯遠了,現(xiàn)在會到主題。 你也許會說:我明白了,循環(huán)小數(shù)不能精確表示,放到計算機中會丟失精度; 那么有限小數(shù)可以精確表示吧,比如 0.1。 對于無限小數(shù),不只是計算機不能精確表示,即使你用別的辦法(省略號除外),比如紙、黑板、寫字板…都無法精確表示。什么?手機?也不能,當然不能了。不,不,iPad也不行,1萬買的也不行,真的,再貴的本子也寫不下。 哪些數(shù)能精確表示?那么 0.1 在計算機中可以精確表示嗎? 答案是出人意料的, 不能。 在此之前,先思考個問題: 在 0.1 到 0.9 的 9 個小數(shù)中,有多少可以用二進制精確表示呢? 我們按照乘以 2 取整數(shù)位的方法,把 0.1 表示為二進制(我假設那些不會進制轉換的同學已經(jīng)補習完了):
我們得到一個無限循環(huán)的二進制小數(shù) 0.000110011... 我為什么要把這個計算過程這么詳細的寫出來呢?就是為了讓你看,多看幾遍,再多看幾遍,繼續(xù)看… 還沒看出來,好吧,把眼睛揉一下,我提示你,把第一行去掉,從 (2) 開始看,看到 (6),對比一下 (2) 和 (6)。 然后把前兩行去掉,從 (3) 開始看… 明白了吧,0.2、0.4、0.6、0.8 都不能精確的表示為二進制小數(shù)。 難以置信,這可是所有的偶數(shù)啊!那奇數(shù)呢? 答案就是: 0.1 到 0.9 的 9 個小數(shù)中,只有 0.5 可以用二進制精確的表示。 如果把 0.0 再算上,那么就有兩個數(shù)可以精確表示,一個奇數(shù) 0.5,一個偶數(shù) 0.0。 為什么是兩個呢?因為計算機二唄,其實計算機還真夠二的。
其實答案很顯然,我再領大家換個角度思考,0.5 就是一半的意思。 在十進制中,進制的基數(shù)是 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 如果你還想繼續(xù)思考,就又會發(fā)現(xiàn)一個有趣的事實,我們稱之為 定理A。 我們上面的數(shù),都是小數(shù)點后面一位小數(shù),因此,在十進制中,這樣的小數(shù)有 10 個(就是 0 到 9); 同理,在二進制中,如果我們讓小數(shù)點后面有一位小數(shù),應該有多少個呢?當然是 2 個了(0 和 1)。 哇,好像發(fā)現(xiàn)了新大陸一樣,很興奮是吧。那我再給你一棒,其實定理A是錯的。再重申一遍 盡信書,則不如無書。我寫博客的目的 不是把我的思想灌輸?shù)侥愕哪X子里,你應該有自己的思想,自己的思考方式,當我得出這個結論時,你應該立刻反駁我:“按照你的思路,如果是 16 進制的話,應該可以精確表示所有的 0.1 到 0.9 的數(shù)甚至還可以精確表示其它的 6 個數(shù)。而事實呢,16 進制可以精確表示的數(shù) 和 2 進制可以精確表示的數(shù)是一樣的,只能精確表示 0.5。” 那么到底怎么確定一個數(shù)能否精確表示呢?還是回到我們熟悉的十進制分數(shù)。 1/2、5/9、34/25 哪些可以寫成有限小數(shù)?把一個分數(shù)化到最簡(分子分母無公約數(shù)),如果分母的因式分解只有 2 和 5,那么就可以寫成有限小數(shù),否則就是無限循環(huán)小數(shù)。為什么是 2 和 5 呢?因為他們是 10 的因子 10 = 2 x 5。 二進制和十六進制呢?他們的因子只有 2,所以十六進制只是二進制的一種簡寫形式,它的精度和二進制一樣。 如果一個十進制數(shù)可以用二進制精確表示,那么它的最后一位肯定是 5。 備注:這是個必要條件,而不是充分條件。一位熱心網(wǎng)友設計出了下面的解決精度的方案。我就不解釋了,同學們自己思考一下吧。
請同學們思考一下。 精度在哪兒丟失?一位熱心網(wǎng)友 獨孤小敗 在 OSC 上回復了我上一篇文章,提出了一個疑問: 在 java 中計算 0.2 + 0.4 得到的結果是
但是當直接輸出 0.6 的時候,確實是 0.6
好像很矛盾。很顯然,通過代碼(b)可以知道,在 java 中,可以精確 顯示 0.6,哪怕 0.6 不能被精確表示,但至少能精確把 0.6 顯示出來,這不是和代碼(a)矛盾了嗎? 這又是一個 想當然的錯誤,在直觀上認為 0.2 + 0.4 = 0.6 是必然成立的(在數(shù)學上確實如此),既然(a)的結果是 0.6,而且 java 可以精確輸出 0.6,那么代碼(a)的結果應該輸出 0.6。 其實在計算機上 0.2 + 0.4 根本就不等于 0.6 (為什么?可以查看本系列『運算符』),因為 0.2 和 0.4 都不能被精確表示。 浮點數(shù)的精度丟失在每一個表達式,而不僅僅是表達式的求值結果。 我們用數(shù)學中的概念類比一下,比如四舍五入,我們計算 1.6 + 2.8 保留整數(shù)。
四舍五入得到 4。我們用另一種方法
通過兩種運算,我們得到了兩個結果 4 和 5。同理,在我們的浮點數(shù)運算中,參與運算的兩個數(shù) 0.2 和 0.4 精度已經(jīng)丟失了,所以他們求和的結果已經(jīng)不是 0.6 了。 后記上面一直在討論小數(shù),整數(shù)呢?在博客園,一位童鞋為下面的代碼抓狂了:
把這段代碼復制到 Chrome 的 Console 中,按回車, 詭異的問題出現(xiàn)了 9986705337161735 居然變成了 9986705337161736!原始數(shù)據(jù)加了 1。
一開始以為是溢出,換了個更大的數(shù):9986705337161738 發(fā)現(xiàn)不會出現(xiàn)這個問題。 但是 9986705337161739 輸出又變成了 9986705337161740!
測試幾次之后發(fā)現(xiàn)瀏覽器輸出數(shù)字的一個規(guī)律(justjavac注:其實這個規(guī)律是錯誤的):
又多測了幾次,發(fā)現(xiàn)根本沒有規(guī)律,很混亂?。∮袝r候是加,有時候是減!! 解析: 這顯然不僅僅是丟失精度的問題,欲知后事如何…咳咳…靜待下一篇吧。 某網(wǎng)友回復 |
|
我覺得作者寫的很好,不過有些問題說的不夠清晰。
這些誤差都是在數(shù)制轉換中產(chǎn)生的,而電腦在實際的浮點運算中并不存在數(shù)制轉換,所以只要以16進制方式輸出和輸入就不存在誤差。
如果要以小數(shù)方式表示一個分數(shù),任何數(shù)制都可能產(chǎn)生無限循環(huán)小數(shù)。
二進制和十進制的區(qū)別在于底數(shù)為2而不是10,所以不能精確表示分母中含有5因子的分數(shù),而十進制依然無法表示分母存在7,13,等等其他因子的最簡分數(shù)。
所以如果真的要避免無限循環(huán)小數(shù),唯一的辦法是使用分數(shù)表示法。
而且實際上數(shù)制轉換中產(chǎn)生的這些誤差都非常微小。
如您的例子 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1=0.8999999999999999
實際上誤差只有0.0000000000000001
絕大多數(shù)場合這樣的誤差微不足道。
9986705337161735這個數(shù)已經(jīng)超過了32位整型數(shù)的上限,如果不出意料,最終是以浮點數(shù)的形式存在的,而把誤差和第一個例子的0.0000000000000001進行對比,很容易明白這一切的原因:那就是這根本不是一個整數(shù),而是一個浮點數(shù),并且在數(shù)制轉換中出現(xiàn)了誤差。
javascript作為一個弱類型的腳本語言雖然很不錯,但是對于沒有學習過經(jīng)典的c,pascal等強制類型語言的新手來說,類型自動轉換絕對是一個很棘手的問題。縱然js少有顯式的類型轉換,但是如果對類型轉換如果沒有深刻的了解顯然會導致很多問題,作者文章中的許多問題就來源于此。(超出整型范圍后的隱式轉換,包括任何含有小數(shù)點的數(shù),以及超過int32范圍的數(shù))
事實上對于9986705337161735這種超長的id,如果加上引號,用字符串表示,或者進行一定的處理,轉為數(shù)組儲存,都是毫無問題的。
計算機中對于精度要求很高的計算,必然需要運用到高精度庫,浮點運算的作用原本就不在于精度。事實上浮點數(shù)被廣泛使用的原因在于日常使用中,這種程度的精度已經(jīng)足夠了,比如圓周率通常運算中也只以3.1415926計算。