1. 程式人生 > >[轉載]程式碼之謎(五)- 浮點數(誰偷了你的精度?)

[轉載]程式碼之謎(五)- 浮點數(誰偷了你的精度?)

挺有意思的文章,以前也在思考過這個問題,不過沒有達到這個深度。

原文連結:http://hp.dewen.io/?p=2024

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

你可以用最主流的程式語言計算 0.2 + 0.4,如果你使用的是 Chrome、FireFox、IE 8+,可以按 F12 鍵,然後找到 「控制檯」,輸入上面的 表示式 0.2 + 0.4,回車。

然後再用最簡陋的計算器(如果你沒有手持計算器沒關係,手機、電腦都自帶一個計算器,開啟“執行”,輸入 calc,回車) 再計算一下剛才的 算式 0.2 + 0.4。

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

不能表示 VS 不能精確表示

在上一章『浮點數(從驚訝到思考)』中我們講到用浮點數表示  時出現的問題——很多數都 不能表示。(注意 浮點數表示的是數,而不僅僅是小數。)

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

請注意我使用的措辭,區別開 不能表示 和 不能精確表示

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

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

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

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

上一章中我還給出了一種用定點數精確表示小數的方法。事實上,手持計算器、java 中的 BigDecimal、C# 中的貨幣型別、MySQL 中的 NUMERIC 型別就是這麼幹的。你還記得在資料庫中新增欄位時的 SQL 語句是如何寫的嗎?現在明白為什麼我說 再簡陋的計算器也比超級計算器的精度高

 了吧。

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

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

  • 第一步:轉換成二進位制
  • 第二步:用二進位制科學計演算法表示
  • 第三步:表示成 IEEE 754 形式

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

十進位制 VS 二進位制

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

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

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

  1. 如果餘數為 0。yeah!終於結束了,洗洗睡吧
  2. 當除到某一步時,餘數等於 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 表示為二進位制(我假設那些不會進位制轉換的同學已經補習完了):

(1) 0.1 x 2 = 0.2 取整數位 0 得 0.0 
(2) 0.2 x 2 = 0.4 取整數位 0 得 0.00 
(3) 0.4 x 2 = 0.8 取整數位 0 得 0.000 
(4) 0.8 x 2 = 1.6 取整數位 1 得 0.0001 
(5) 0.6 x 2 = 0.2 取整數位 1 得 0.00011 
(6) 0.2 x 2 = 0.4 取整數位 0 得 0.000110 
(7) 0.4 x 2 = 0.8 取整數位 0 得 0.0001100 
(8) 0.8 x 2 = 1.6 取整數位 1 得 0.00011001 
(9) 0.6 x 2 = 1.2 取整數位 1 得 0.000110011 
(n) ... 

我們得到一個無限迴圈的二進位制小數 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 去掉。

請同學們思考一下。

精度在哪兒丟失?

一位熱心網友 獨孤小敗 在 OSC 上回復了我上一篇文章,提出了一個疑問:

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

// 程式碼(a) double d = 0.2 + 0.4; // 結果是 0.6000000000000001 

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

// 程式碼(b) double d = 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。

其實在計算機上 0.2 + 0.4 根本就不等於 0.6 (為什麼?可以檢視本系列『運算子』),因為 0.2 和 0.4 都不能被精確表示。浮點數的精度丟失在每一個表示式,而不僅僅是表示式的求值結果。

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

1.6 + 2.8 = 4.4 

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

先把 1.6 四捨五入為 2 再把 2.8 四捨五入為 3 最後求和 2 + 3 = 5 

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

後記

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

JSON.parse('{"status":1,"id":9986705337161735,"name":"test"}').id; 

把這段程式碼複製到 Chrome 的 Console 中,按回車,詭異的問題出現了 9986705337161735 居然變成了 9986705337161736!原始資料加了 1。

9986705337161735 
9986705337161736 

一開始以為是溢位,換了個更大的數:9986705337161738 發現不會出現這個問題。

但是 9986705337161739 輸出又變成了 9986705337161740!

9986705337161739 
9986705337161740 

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

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

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

解析

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

相關推薦

[轉載]程式碼- 點數精度

挺有意思的文章,以前也在思考過這個問題,不過沒有達到這個深度。 原文連結:http://hp.dewen.io/?p=2024 如果我告訴你,中關村配置最高的電子計算機的計算精度還不如一個便利店賣的手持計算器,你一定會反駁我:「今天寫部落格之前又忘記吃藥了吧」。 你可以用最主流的程式語言計算 0.2

C點數

C語言 浮點數 在 C 語言中,浮點數在內存中的存儲方式為:符號位,指數,位數。float 與 double 類型的數據在計算機內部的表示法是相同的,但由於所占存儲空間的不同,其分別能夠表示的數值範圍和精度不同。如下所示: 下來我們就來講講關於浮點數的轉換:1、將浮點數轉

JVM點數float表示

img 但是 nbsp alt 符號 否則 形式 十進制 浮點數 1. 浮點數的組成:符號位、指數位、尾數位。  1.1 符號位: 占1位,表示正負數; 1.2 指數位: 占8位; 1.3 尾數位: 占23位。 2. 浮點數的表示: 2.1

少說話多寫程式碼Python學習034——其他語句01pass,del

pass語句 pass就是什麼都不做,和C#中的一個分號語句很像。 一般用作佔位符,比如,這種情況,就是什麼都不處理。 name=input('請輸入姓名') if len(name)<3:     print('做什麼') elif len(name)==3: &

少說話多寫程式碼Python學習033——迴圈語句03列表導式

列表導式是利用列表建立新的列表,比如,下面建立一個列表 a=[x*x for x in range(10)] print(a) 輸出 [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] 建立列表過程中也可以增加條件,比如,只要能被2整除的數。 b=[x*

少說話多寫程式碼Python學習032——迴圈語句02如何迭代-排序和反轉

引入兩個函式,排序和反轉,它們作用於序列上,並不修改原序列的值,只是返回排序後或反轉後的值。 下面看看具體的使用, 對序列排序,可以根據指定條件升序和降序排序。具體函式使用可自行查詢。 s=[23,17,31,7,11] sa = sorted(s,reverse=False) print(

少說話多寫程式碼Python學習028——條件語句05斷言

斷言的工作方式可用如下偽程式碼表示, if 條件不滿足   讓程式崩潰 斷言,關鍵為assert,因為如果程式後面會崩潰,不如在錯誤條件開始出現時就讓其崩潰。一般斷言用作程式測試和除錯過程中。 比如, 如下年齡變數,設定必須為0-150之間。 age=10 assert 0&l

少說話多寫程式碼Python學習027——條件語句04同一運算子、字串序列比較、布林運算子

同一性運算 前面看多鏈式賦值,這裡主要說明一下鏈式賦值後,關於值相同物件不用的有趣問題。 看一下比較結果, x=y=["東","西","南","北","中"] z=["東","西","南","北","中"] print(x==y) print(x==z) print(x is y) pri

少說話多寫程式碼Python學習026——條件語句03比較運算子

條件語句中基本的運算子就是比較運算子。常用的運算子如下: ==, <, >, <=, >=, !=, is , is not,  in, not in。 具體用法如下, x=1 y=1 if x==y :     print('x等於y'

少說話多寫程式碼Python學習025——條件語句02if語句

條件語句一般就是使用if語句,或者其等價的運算方式。 我們先看看Python中if語句的用法, name = input('請輸入你的名字:') if name.endswith('樹') :     print('你好,',name)  比如,輸入如下,輸

少說話多寫程式碼Python學習024——條件語句01Python中的布林值

大家都知道條件語句,無非就是判定布林值的真假來做分支。那麼我們看看Python中的布林值, Python中標註的布林值為True和False,我們通過程式碼來看看, a =True print(a) b=False print(b) true = True==1 print(true)

少說話多寫程式碼Python學習017——字典的方法items、pop

  items方法將字典的所有項以列表方式返回,列表中每一項都表示為(鍵,值)對形式,但對字典的項的次序沒有什麼規律。 #items方法 d={'title':'繞口令:喇嘛和啞巴',    'content':'打南邊來了個啞巴,腰裡別了個喇叭;打北邊來了個喇

少說話多寫程式碼Python學習019——字典的方法update、values

update方法 利用一個字典A去更新另一個字典B。A的項會新增到B中,如果存在相同鍵,則A會覆蓋B的這個鍵。 #update 方法 d={     'name':'楊友山',     'blog地址':'https://blog.csdn.net/y

少說話多寫程式碼Python學習018——字典的方法popitem、setdefault

popitem方法 popitem其實和pop方法沒什麼兩樣,雖然解釋說pop是彈出字典的最後一項,popitem彈出的是字典的隨機項。但是字典是一個連結串列結構,哪裡有最後一項和第一項呢?不管怎麼說,我們可以看看popitem的用法。 #popitem d={} d={'詩仙':'李白',

少說話多寫程式碼Python學習031——迴圈語句01如何迭代-索引迭代

序列物件,我們總可以取到其索引,我們可以使用索引遍歷序列的值。比如,我們舉一個替換陣列中的字串的例子。 首先,我們使用自行取得索引的方式, strings=['abc','abx','aby','abu'] for string in strings:     if

少說話多寫程式碼Python學習030——條件語句07如何迭代-並行迭代

迭代就是遍歷一個集合,取所有的值出來。加入有兩個陣列,如何一起把資料取出來。 看下面程式碼, names =['Jim','Hanmeimei','Lilei','Tom'] ages=[13,14,12,15] for i in range(len(names)):   &nbs

少說話多寫程式碼Python學習029——條件語句06迴圈

迴圈語句不多解釋了,不管哪種預言中都是基本的語句,Python中有兩種迴圈,while和for,我們一一看一下用法。 while迴圈 使用格式如下, x=1 while x<=10:     print(x)     x+=1 輸出

少說話多寫程式碼Python學習038——建立函式04函式的使用 二分法查詢

二分法查詢有一個重要前提,就是序列是有序的。在有序的序列中找到一箇中點,然後對比目標元素在中點的哪一側,然後依次這樣查詢,最終找到。邏輯非常簡單。我們主要看在 Python中是如何實現的,直接看程式碼如下, def binarySearch(sequnce,number,lower=0,up

少說話多寫程式碼Python學習037——建立函式03函式的使用 遞迴

前面學會了如何建立函式,至於函式引數列表的使用,函式內部作用域,函式過載等等,Python中的函式與其他語言的函式並無不同,所以這裡不再說明。我們一般學習的第一門程式語言大多是C語言, 學習C語言我們每每學的都是遞迴,二分查詢,氣泡排序以及各種排序等等。那麼我們選一兩樣看看Python中如何實現

少說話多寫程式碼Python學習036——建立函式02函式的註釋

下面我們看看Python中如何給函式增加註釋,以及如何獲取一個函式的基本資訊。 因為Python是解釋執行的語言,增加註釋和檢視函式資訊都需要一些方法實現。 如下,定義了一個將字串生成MD5的函式,並且加了註釋, 然後可以通過__doc__屬性和help函式來獲取函式的資訊。 import