(五)浮點數(shù)(誰偷了你的精度?)

2018-02-24 16:09 更新

光棍節(jié)加長版

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

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

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

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

不能表示 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 步。

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

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

十進制 VS 二進制

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

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

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

  1. 如果余數(shù)為 0。yeah!終于結束了,洗洗睡吧

  2. 當除到某一步時,余數(shù)等于 1… 停!stop!等一下,我發(fā)現(xiàn)有什么地方怪怪的。余數(shù)為 1,余數(shù)如果為 1 的話,再繼續(xù)除下去,不就又是 1/7 了嗎?繞了一個大彎,又回來了?對,你猜的很對,它永遠不會結束,它循環(huán)了。

注意我上面說的 情況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)補習完了):

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

我們得到一個無限循環(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。 為什么是兩個呢?因為計算機二唄,其實計算機還真夠二的。

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

其實答案很顯然,我再領大家換個角度思考,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?!?/p>

那么到底怎么確定一個數(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)友設計出了下面的解決精度的方案。我就不解釋了,同學們自己思考一下吧。

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

請同學們思考一下。

精度在哪兒丟失?

一位熱心網(wǎng)友?獨孤小敗?在 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 是必然成立的(在數(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ù)。

1.6 + 2.8 = 4.4 

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

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

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

后記

上面一直在討論小數(shù),整數(shù)呢?在博客園,一位童鞋為下面的代碼抓狂了:

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

把這段代碼復制到 Chrome 的 Console 中,按回車, 詭異的問題出現(xiàn)了 9986705337161735 居然變成了 9986705337161736!原始數(shù)據(jù)加了 1。

9986705337161735
9986705337161736

一開始以為是溢出,換了個更大的數(shù):9986705337161738 發(fā)現(xiàn)不會出現(xiàn)這個問題。

但是 9986705337161739 輸出又變成了 9986705337161740!

9986705337161739
9986705337161740

測試幾次之后發(fā)現(xiàn)瀏覽器輸出數(shù)字的一個規(guī)律(justjavac注:其實這個規(guī)律是錯誤的):

  1. 十位數(shù)為偶數(shù),個位數(shù)為奇數(shù)時會減 1,個位數(shù)為奇數(shù)時會加1
  2. 十位數(shù)為奇數(shù),個位數(shù)為奇數(shù)時會加 1,個位數(shù)為奇數(shù)時會減1

又多測了幾次,發(fā)現(xiàn)根本沒有規(guī)律,很混亂??!有時候是加,有時候是減??!

解析

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

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號