標籤:
一、浮點計算中發生精度丟失
大概很多有編程經驗的朋友都對這個問題不陌生了:無論你使用的是什麼程式設計語言,在使用浮點型資料進行精確計算時,你都有可能遇到計算結果出錯的情況。來看下面的例子。
// 這是一個利用浮點型資料進行精確計算時結果出錯的例子,使用Java編寫,有所省略。
double a = (1.2 - 0.4) / 0.1;
System.out.println(a);
如果你認為這個程式的輸出結果是“8”的話,那你就錯了。實際上,程式的輸出結果是“7.999999999999999”。好,問題來了。到底是哪裡出了錯?
浮點型資料進行精確計算時,該類問題並不少見。我們姑且稱其為“精度丟失”吧。大家可以試著改一下上面的程式,你會發現一些有趣的現象:
1、如果你直接使用一個數字代替括弧裡的運算式,如“0.8 / 0.1”或者“1.1 /0.1”,那麼似乎,注意只是似乎不會出現問題;
2、我們可能會做第二個測試,就是對比“0.8 / 1.1”和“(1.2 - 0.4) / 1.1”的結果,沒錯,我就是這樣做的。那麼你會發現,前者的結果是“0.7272727272727273”(四捨五入後的結果),而後者的結果是 “0.7272727272727272”(沒有進行四捨五入)。可以推測,經過一次計算後,精度丟失了;
3、很好,我覺得我們已經很接近真相了,但是接下來的第三個測試或許會讓你泄氣,我是這樣做的:對比“(2.4 - 0.1) / 0.1”、“(1.2 - 0.1) / 0.1”以及“(1.1 - 0.1) / 0.1”的結果,第一個是“22.999999999999996”,第二個是“10.999999999999998”,第三個是“10.0”。似乎完 全推翻了我們的想法;
4、你可能還不死心,因為在上面的測試裡,第三個運算式括弧中的結果實在太詭異了,正好是“1.0”。那我們再來對比一下“(2.4 - 0.2) / 0.1”和“(2.4 - 0.3) / 0.1”,前者結果是“21.999999999999996”,後者結果是“21.0”。恭喜你,做到這裡,你終於可以放棄這個無聊的測試了。
最後,我們還可以來推翻一下我們第一個測試的假設:當使用“2.3 / 0.1”時,結果為“22.999999999999996”,出現精度丟失。也就是說,所謂“經過一次計算後,精度丟失”的假設是不成立的。
二、為何會出現精度丟失
那麼為什麼會出現精度丟失呢?在查閱了一些資料以後,我稍微有了一些頭緒,下面是本人的愚見,僅供參考。
首先得從電腦本身去討論這個問題。我們知道,電腦並不能識別除了位元據以外的任何資料。無論我們使用何種程式設計語言,在何種編譯環境下工作,都要先 把來源程式翻譯成二進位的機器碼後才能被電腦識別。以上面提到的情況為例,我們來源程式裡的2.4是十進位的,電腦不能直接識別,要先編譯成二進位。但問 題來了,2.4的二進位表示並非是精確的2.4,反而最為接近的二進位表示是2.3999999999999999。原因在於浮點數由兩部分組成:指數和 尾數,這點如果知道怎樣進行浮點數的二進位與十進位轉換,應該是不難理解的。如果在這個轉換的過程中,浮點數參與了計算,那麼轉換的過程就會變得不可預 知,並且變得無法復原。我們有理由相信,就是在這個過程中,發生了精度的丟失。而至於為什麼有些浮點計算會得到準確的結果,應該也是碰巧那個計算的二進位與 十進位之間能夠準確轉換。而當輸出單個浮點型資料的時候,可以正確輸出,如
double d = 2.4;
System.out.println(d);
輸出的是2.4,而不是2.3999999999999999。也就是說,不進行浮點計算的時候,在十進位裡浮點數能正確顯示。這更印證了我以上的想法,即如果浮點數參與了計算,那麼浮點數二進位與十進位間的轉換過程就會變得不可預知,並且變得無法復原。
事實上,浮點數並不適合用於精確計算,而適合進行科學計算。這裡有一個小知識:既然float和double型用來表示帶有小數點的數,那為什麼我們不稱 它們為“小數”或者“實數”,要叫浮點數呢?因為這些數都以科學計數法的形式儲存。當一個數如50.534,轉換成科學計數法的形式為5.053e1,它 的小數點移動到了一個新的位置(即浮動了)。可見,浮點數本來就是用於科學計算的,用來進行精確計算實在太不合適了。
三、如何使用浮點數進行精確計算
那麼能夠使用浮點數進行精確計算嗎?直接計算當然是不行啦,但是我們當然也可以通過一些方法和技巧來解決這個問題。由於浮點數計算的結果跟正確結果非常接近,你很可能想到使用四捨五入來處理結果,以得到正確的答案。這是個不錯的思路。
那麼如何?四捨五入呢?你可能會想到Math類中的round方法,但是有個問題,round方法不能設定保留幾位小數,如果我們要保留兩位小數,我們只能像這樣實現:
public double round(double value){
return Math.round(value*100)/100.0;
}
如果這能得到正確的結果也就算了,大不了我們再想方法改進。但是非常不幸,上面的代碼並不能正常工作,如果給這個方法傳入4.015,它將返回4.01而不是4.02。
java.text.DecimalFormat也不能解決這個問題,來看下面的例子:
System.out.println(new java.text.DecimalFormat("0.00").format(4.025));
它的輸出是4.02,而非4.03。
難道沒有解決方案了嗎?當然有的。在《Effective Java》這本書中就給出了一個解決方案。該書中也指出,float和double只能用來做科學計算或者是工程計算,在商業計算等精確計算中,我們要用java.math.BigDecimal。
BigDecimal類一個有4個方法,我們只關心對我們解決浮點型資料進行精確計算有用的方法,即
BigDecimal(double value) // 將double型資料轉換成BigDecimal型資料
思路很簡單,我們先通過BigDecimal(double value)方法,將double型資料轉換成BigDecimal資料,然後就可以正常進行精確計算了。等計算完畢後,我們可以對結果做一些處理,比如 對除不盡的結果可以進行四捨五入。最後,再把結果由BigDecimal型資料轉換回double型資料。
這個思路很正確,但是如果你仔細看看API裡關於BigDecimal的詳細說明,你就會知道,如果需要精確計算,我們不能直接用double,而非要用 String來構造BigDecimal不可!所以,我們又開始關心BigDecimal類的另一個方法,即能夠協助我們正確完成精確計算的 BigDecimal(String value)方法。
// BigDecimal(String value)能夠將String型資料轉換成BigDecimal型資料
那麼問題來了,想像一下吧,如果我們要做一個浮點型資料的加法運算,需要先將兩個浮點數轉為String型資料,然後用 BigDecimal(String value)構造成BigDecimal,之後要在其中一個上調用add方法,傳入另一個作為參數,然後把運算的結果(BigDecimal)再轉換為浮 點數。如果每次做浮點型資料的計算都要如此,你能夠忍受這麼煩瑣的過程嗎?至少我不能。所以最好的辦法,就是寫一個類,在類中完成這些繁瑣的轉換過程。這 樣,在我們需要進行浮點型資料計算的時候,只要調用這個類就可以了。網上已經有高手為我們提供了一個工具類Arith來完成這些轉換操作。它提供以下靜態 方法,可以完成浮點型資料的加減乘除運算和對其結果進行四捨五入的操作:
public static double add(double v1,double v2)
public static double sub(double v1,double v2)
public static double mul(double v1,double v2)
public static double div(double v1,double v2)
public static double div(double v1,double v2,int scale)
public static double round(double v,int scale)
下面會附上Arith的原始碼,大家只要把它編譯儲存好,要進行浮點數計算的時候,在你的來源程式中匯入Arith類就可以使用以上靜態方法來進行浮點數的精確計算了。
附錄:Arith原始碼
import java.math.BigDecimal;
/**
* 由於Java的簡單類型不能夠精確的對浮點數進行運算,這個工具類提供精
* 確的浮點數運算,包括加減乘除和四捨五入。
*/
public class Arith{
//預設除法運算精度
private static final int DEF_DIV_SCALE = 10;
//這個類不能執行個體化
private Arith(){
}
/**
* 提供精確的加法運算。
* @param v1 被加數
* @param v2 加數
* @return 兩個參數的和
*/
public static double add(double v1,double v2){
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.add(b2).doubleValue();
}
/**
* 提供精確的減法運算。
* @param v1 被減數
* @param v2 減數
* @return 兩個參數的差
*/
public static double sub(double v1,double v2){
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.subtract(b2).doubleValue();
}
/**
* 提供精確的乘法運算。
* @param v1 被乘數
* @param v2 乘數
* @return 兩個參數的積
*/
public static double mul(double v1,double v2){
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.multiply(b2).doubleValue();
}
/**
* 提供(相對)精確的除法運算,當發生除不盡的情況時,精確到
* 小數點以後10位,以後的數字四捨五入。
* @param v1 被除數
* @param v2 除數
* @return 兩個參數的商
*/
public static double div(double v1,double v2){
return div(v1,v2,DEF_DIV_SCALE);
}
/**
* 提供(相對)精確的除法運算。當發生除不盡的情況時,由scale參數指
* 定精度,以後的數字四捨五入。
* @param v1 被除數
* @param v2 除數
* @param scale 表示表示需要精確到小數點以後幾位。
* @return 兩個參數的商
*/
public static double div(double v1,double v2,int scale){
if(scale<0){
throw new IllegalArgumentException(
"The scale must be a positive integer or zero");
}
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.divide(b2,scale,BigDecimal.ROUND_HALF_UP).doubleValue();
}
/**
* 提供精確的小數位四捨五入處理。
* @param v 需要四捨五入的數字
* @param scale 小數點後保留幾位
* @return 四捨五入後的結果
*/
public static double round(double v,int scale){
if(scale<0){
throw new IllegalArgumentException(
"The scale must be a positive integer or zero");
}
BigDecimal b = new BigDecimal(Double.toString(v));
BigDecimal one = new BigDecimal("1");
return b.divide(one,scale,BigDecimal.ROUND_HALF_UP).doubleValue();
}
};
Java中浮點型資料Float和Double進行精確計算的問題