又見浮點數精度問題


 

 

今天看到一篇文章: http://younglab.blog.51cto.com/416652/241886,大概是說在使用Javascript進行下面的浮點數計算時出現了問題:
 
        obj.style.opacity =  (parseInt(obj.style.opacity *100) + 1)/100;
 
obj.style.opacity是一個浮點數,范圍從0~1,初始值為0。這句代碼每隔一小段時間執行一次,從而讓目標由透明慢慢變為不透明(淡入效果)。
 
問題是,起初obj.style.opacity還能夠按照預期的每次以0.01逐步增加,但增加到0.29時就一直保持不變了。
 
作者只是記錄了這個問題,沒有寫出為什么。讀完這篇博客后我的第一感覺是:
 
         這又是一個由於浮點數精度所引發的問題。
 
下面讓我們來寫一個小程序重現一下這個問題:
 
double opacity = 0;
for (int i = 0; i < 100; i++) {
     opacity = ((int) (opacity * 100 + 1)) / 100.0;

     System.out.println("opacity=" + opacity);
}

 

程序是用Java寫的,共執行100次循環,采用了與那篇文章中相同的計算方法。正常情況下opacity會由0逐步增大到1。
 
程序輸出如下:
 

opacity=0.01
opacity=0.02
opacity=0.03
opacity=0.04
opacity=0.05
opacity=0.06
(中間省略……)
opacity=0.27
opacity=0.28
opacity=0.29
opacity=0.29
opacity=0.29
……后面一直為0.29

 
可以發現,當opacity達到0.29后便不再增加了。由於Java和JS使用的是相同的浮點數格式,所以采用Java和JS結果都是相同的。
 
這里有一個細節需要注意:在這段程序中,除數必須寫成100.0。這是由於在Java中有整數除法和浮點數除法兩種不同的運算,如果寫成100,那么被除數和除數將都是整數,Java就會按照整數除法來計算,就會導致每次計算的結果都是0(因為每次計算的結果都小於1,因此取整后就變為了0)。JS里沒有這個問題,因為JS沒有整數除法,所有除法都會當成浮點數除法來對待。
 

深入分析

現在我把上面那個程序做一點修改:

 

double opacity = 0;
for (int i = 0; i < 100; i++) {
     opacity = ((int) (opacity * 100 + 1)) / 100.0;

     System.out.println("opacity=" + new BigDecimal(opacity));
     System.out.println("opacity*100=" + new BigDecimal(opacity * 100));
     System.out.println("----------------------------");
}
 
因為Java在將浮點數轉換為字符串時會做一些處理,讓結果看起來更“美觀”一些,但這樣會讓我們無法看清楚程序運行的真實情況。
 
在這個程序中我借助BigDecimal來顯示浮點數在內存中的真正的樣子。BigDecimal有一個以double數字為參數的構造方法,該方法會完整拷貝此double參數在內存中的位模式,它的toString( )方法也會嚴格按照實際的值進行轉換,而不會為了“美觀”而做任何處理。因此我們可以利用這種方法來看清一個double的“真面目”。
 
程序輸出如下:
 
opacity=0.01000000000000000020816681711721685132943093776702880859375
opacity*100=1
----------------------------
opacity=0.0200000000000000004163336342344337026588618755340576171875
opacity*100=2
----------------------------
opacity=0.0299999999999999988897769753748434595763683319091796875
opacity*100=3
 
(中間省略……)
 
opacity=0.270000000000000017763568394002504646778106689453125
opacity*100=27
----------------------------
opacity=0.2800000000000000266453525910037569701671600341796875
opacity*100=28.000000000000003552713678800500929355621337890625
----------------------------
opacity=0.289999999999999980015985556747182272374629974365234375
opacity*100=28.999999999999996447286321199499070644378662109375
 
……后面一直重復相同的內容
 
可以發現,當opacity的值為0.29時,實際上在內存中的准確值是0.2899999……,所以乘以100變成28.99999……,這比29要稍微小那么一點點。但就是少了這一點點,當強制轉換為整數后的結果卻是28而不是期望的29。而這正是導致這個問題的原因所在。
 
從這個程序的運行結果中我們還可以觀察到以下幾個現象:
 
1. 每個中間結果例如0.01、0.02……等等,都無法用double類型精確表示
 
2. 即使本身無法精確表示,但在0.28之前,opacity*100的結果卻都是精確的
 
3. 在無法精確表示的數中,有些比真實值略大,而有些卻比真實值略小。如果是前者,當截斷小數位轉成整型時得到的結果是“正確”的;但如果是后者則會得到錯誤的結果。例如0.28*100轉成整型為28,而0.29*100轉成整型不是29而是28。

如何改正 

經過前面的分析,現在我們已經弄明白了問題產生的原因,那么該如何修正它呢?
 
之前的代碼之所以無法正確運行,其根本原因在於一個double類型的數字強制轉換為整型時會發生截斷,這會導致小數部分全部丟失,然而計算的中間結果中有一些要比期望的整數值略小,截斷小數位以后得到的是比期望值小1的值。
 
因此我們可以從以下兩個方面着手修正此問題:一是從代碼中去除強制轉換操作;或者,保證截斷之前的中間結果一定是比期望值略大的。
 

方法1. 去除強制轉換

程序的目的是讓opacity的值每次增加0.01,那么就只需要每次加上0.01就好了,完全不需要繞圈子。如下:

 

double opacity = 0;
while (opacity < 1) {
     opacity += 0.01;
     System.out.println("opacity=" + opacity);
}
 
這個程序簡單、直接,而且沒有任何問題。我個人推薦這個方法。該程序輸出如下:
 

opacity=0.01
opacity=0.02
opacity=0.03
opacity=0.04
opacity=0.05
opacity=0.060000000000000005

(中間省略……)

opacity=0.9800000000000006
opacity=0.9900000000000007
opacity=1.0000000000000007

 

方法2. 保證截斷之前的中間結果略大於期望值

既然原程序的問題發生在截斷時,那么只要保證截斷發生之前,中間結果的值略大於期望值,就能保證程序的正確性。例如如果要讓截斷后的結果為29,只要保證截斷前的值在[29, 30)這個范圍內即可。
 
如何做到這一點呢?
 
由於我們可以肯定在這個問題中,opacity*100的結果是非常接近我們所期望的整數的,只是由於double類型的精度限制而比期望的整數略大或略小而已,其誤差一定非常非常小。
 
所以我們可以修改這句代碼:
 
        opacity = ((int) (opacity * 100 + 1)) / 100.0;
 
不是給opacity * 100加上1,而是加一個更大一些的數,例如1.5,變為:
 
        opacity = ((int) (opacity * 100 + 1.5)) / 100.0;
 
如果我們期望的值是29,那么修改后的中間結果一定是在29.5附近,這樣就能保證截斷后的值一定是29了。程序如下:
 
double opacity = 0;
for (int i = 0; i < 100; i++) {
     opacity = ((int) (opacity * 100 + 1.5)) / 100.0;

     System.out.println("opacity=" + opacity);
}

 

輸出為:
 

opacity=0.01
opacity=0.02
opacity=0.03
opacity=0.04
opacity=0.05
opacity=0.06

(中間省略……)

opacity=0.96
opacity=0.97
opacity=0.98
opacity=0.99
opacity=1.0

 
可以看到結果是正確的。
 

總結

只要稍有經驗的程序員都知道浮點數不能直接進行相等比較,但是像這篇文章中所碰到的問題可能並不那么常見,因此有時不容易意識到發生了問題。
 
每個程序員都應該知道計算機中是采用近似值來保存浮點數的,當進行浮點數相關的計算時,需要時刻提防由於精度問題所導致的誤差,並注意避免那些會影響到結果正確性的誤差(所謂正確性,就是誤差超出了所允許的最大范圍)。
 
 
 

附:

 
下面這個網頁列舉了歷史上的一些由於計算問題引起的軟件災難,其中一例是1996年歐洲航天局的Ariane 5火箭發射失敗事件,該火箭發射后僅40秒即發生爆炸,導致發射基地的2名法國士兵死亡,並導致歷時近10年、耗資達70億美元的航天計划嚴重受挫。事后調查報告顯示問題的原因出在火箭的慣性參考系的軟件系統中,其中有一個地方是將水平方位的64位浮點數轉換為一個16位的整數,當浮點數的值超過32767時,轉換就會失敗(即轉換的結果是錯誤的),從而導致了悲劇的發生。
 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM