深入理解計算機系統(2.8)---浮點數的舍入,Java中的舍入例子以及浮點數運算(重要)
https://www.cnblogs.com/zuoxiaolong/p/computer12.html
前言
上一章我們簡單介紹了IEEE浮點標準,本次我們主要講解一下浮點運算舍入的問題,以及簡單的介紹浮點數的運算。
之前我們已經提到過,有很多小數是二進位制浮點數無法準確表示的,因此就難免會遇到舍入的問題。這一點其實在我們平時的計算當中會經常出現,就比如之前我們提到過的0.3,它就是無法用浮點小數準確表示的。
為此LZ專門寫了一個小程式,使用Java語言打印出了0.3的二進位制表示,是這樣的一個數字,0 01111101 00110011001100110011010。我們來簡單算一下,這個數值大約是多少。它的階碼在偏置之後的值為-2,它的尾數位在加1之後為1 + 1/8 + 1/16 + 1/128 + 1/256 = 1.19921875。後面還有有效位,不過我們只大概計算一下,就不算那麼精確了,最終算出來的值為0.2998046875。(LZ用計算器算的,0.0)
可以看出,這個值離0.3已經非常接近了,而且我們還省略了一小部分有效小數位,但是不管怎麼說,二進位制無法像十進位制小數一樣,準確的表示0.3這個數值。因此舍入這一部分是浮點數無法逃脫的內容。
浮點數舍入
在我們平時日常使用的十進位制當中,我們一般對一個無理數或者有位數限制的有理數進行舍入時,大部分時候會採取四捨五入的方式,這算是一種比較符合我們期望的舍入方式。
不過針對浮點數來說,我們的舍入方式會更豐富一些。一共有四種方式,分別是向偶數舍入、向零舍入、向上舍入以及向下舍入。
這四種舍入方式都不難理解,其中向偶數舍入就是向最靠近的偶數舍入,比如將1.5舍入為2,將0.1舍入為0。而向零舍入則是向靠近零的值舍入,比如將1.5舍入為1,將0.1舍入為0。對於向上舍入來說,則是往大了(也就是向正無窮大)舍入的意思,比如將1.5舍入為2,將-1.5舍入為-1。而向下舍入則與向上舍入相反,是向較小的值(也就是向負無窮大)舍入的意思。
這裡需要提一下的是,除了向偶數舍入以外,其它三種方式都會有明確的邊界。這裡的含義是指這三種方式舍入後的值x'與舍入之前的值x會有一個明確的大小關係,比如對於向上舍入來說,則一定有x <= x'。對於向零舍入來說,則一定有|x| >= |x'|。
對於向偶數舍入來講,它最大的作用是在統計時使用。向偶數舍入可以讓我們在統計時,將舍入產生的誤差平均,從而儘可能的抵消。而其它三種方式在這方面都是有一定缺陷的,向上和向下舍入很明顯,會造成值的偏大或偏小。而對於向零舍入來講,如果全是正數的時候則會造成結果偏小,全是負數的時候則會造成結果偏大。
通常情況下我們採取的舍入規則是在原來的值是舍入值的中間值時,採取向偶數舍入,在二進位制中,偶數我們認為是末尾為0的數。而倘若不是這種情況的話,則一般會有選擇性的使用向上和向下舍入,但總是會向最接近的值舍入。其實這正是IEEE採取的預設的舍入方式,因為這種舍入方式總是企圖向最近的值的舍入。
比如對於10.10011這個值來講,當舍入到個位數時,會採取向上舍入,因此此時的值為11。當舍入到小數點後1位時,會採取向下舍入,因此此時的值為10.1。當舍入到小數點後4位時,由於此時為10.10011舍入值的中間值,因此採用向偶數舍入,此時舍入後的值為10.1010。
Java當中的浮點數舍入
之前我們講解了一堆舍入的方式,最終我們給出一個結論,就是IEEE標準預設的舍入方式,是企圖向最近的值舍入(Round to the Nearest Value)。
上面我們已經詳細的解釋了IEEE標準中預設的舍入方式(黑色加粗的那部分解釋),但是估計還是會有不少猿友比較迷糊,書中也沒有給出具體的例子,因此這裡LZ以Java語言為例,我們直接寫程式來看一下,看看Java當中的舍入方式是否是按照我們所說的進行的。
在各位看這個測試程式之前,LZ需要再給各位再解釋一下中間值的概念。中間值就是指的,比如1.1(二進位制)這個數字,假設要舍入到個位,那麼它就是一箇中間值,因為它處於1(二進位制)和10(二進位制)的中間,在這個時候將會採用向偶數舍入的方式。
下面便是LZ寫的測試程式,其中那些具體的浮點數值是使用二進位制小數的演算法計算出來的,各位猿友不必在意,如果你不嫌麻煩,也可以自己手算一下。我們主要看的是最終的舍入情況。
public class Main{ public static void main(String[] args){ System.out.println("舍入前: 10.10011111111111111111101"); System.out.print("舍入後:"); printFloatBinaryString(2.62499964237213134765625f); System.out.println(); System.out.println("舍入前: 10.10011111111111111111111"); System.out.print("舍入後:"); printFloatBinaryString(2.62499988079071044921875f); System.out.println(); System.out.println("舍入前: 10.10011111111111111111101011"); System.out.print("舍入後:"); printFloatBinaryString(2.62499968707561492919921875f); System.out.println(); System.out.println("舍入前: 10.10011111111111111111100011"); System.out.print("舍入後:"); printFloatBinaryString(2.62499956786632537841796875f); System.out.println(); System.out.println("舍入前: -10.10011111111111111111101"); System.out.print("舍入後:"); printFloatBinaryString(-2.62499964237213134765625f); System.out.println(); System.out.println("舍入前: -10.10011111111111111111111"); System.out.print("舍入後:"); printFloatBinaryString(-2.62499988079071044921875f); System.out.println(); System.out.println("舍入前: -10.10011111111111111111101011"); System.out.print("舍入後:"); printFloatBinaryString(-2.62499968707561492919921875f); System.out.println(); System.out.println("舍入前: -10.10011111111111111111100011"); System.out.print("舍入後:"); printFloatBinaryString(-2.62499956786632537841796875f); System.out.println(); } public static void printFloatBinaryString(Float f){ char[] binaryChars = getBinaryChars(f); for (int i = 0; i < binaryChars.length; i++) { System.out.print(binaryChars[i]); if (i == 0 || i == 8) { System.out.print(" "); } } System.out.println(); } public static char[] getBinaryChars(Float f){ char[] result = new char[32]; char[] binaryChars = Integer.toBinaryString(Float.floatToIntBits(f)).toCharArray(); if (binaryChars.length < result.length) { System.arraycopy(binaryChars, 0, result, result.length - binaryChars.length, binaryChars.length); for (int i = 0; i < result.length - binaryChars.length; i++) { result[i] = '0'; } }else { result = binaryChars; } return result; } }
上面是測試程式,其實程式中看不出什麼,就是一堆輸出語句。如果各位猿友有興趣,也可以簡單看一下程式的實現。不過我們主要還是看結果,下面是程式結果。
上面一共有8次舍入,前4次是正數,後4次是負數。可以看出對於正負數來講,舍入後的位表示是一樣的,只是最高位的符號位不同而已,因此這裡LZ就不再分析下面4個負數的舍入方式了,我們主要來看前4次舍入。
第1次和第2次對於末尾01和11的舍入,由於是中間值,因此全部採取的向偶數舍入的方式,保證最低位為0。第3次由於比中間值大,而數值又是正數,因此採用向上舍入的方式。第4次則比中間值小,數值也同樣是正數,因此採用向下舍入的方式。
由此可以看出,Java正是採用的我們所描述的方式進行舍入操作的,也就是總是企圖朝最近的數值舍入。相對於其它語言,由於LZ主修Java,例子篇幅也比較長,因此這裡就不寫其他語言的例子了,有興趣的猿友可以嘗試寫一下C/C++或者C#的例子來看一下,看是否是採用的同樣的舍入方式。
浮點數運算
在IEEE標準中,制定了關於浮點數的運算規則,就是我們將把兩個浮點數運算後的精確結果的舍入值,作為我們最終的運算結果。正是因為有了這一個特殊點,就會造成浮點數當中,很多運算不滿足我們平時熟知的一些運算特性。
比如加法的結合律,也就是a + b + c = a + (b + c),這是很普通的加法運算的特性,但是浮點數是不滿這一特性的,比如說下面這一段小程式。
public static void main(String[] args){ System.out.println(1f + 10000000000f - 10000000000f); System.out.println(1f + (10000000000f - 10000000000f)); }
這一段程式會依次輸出0.0和1.0,正是因為舍入而造成的這一誤差。在第一個輸出語句中,計算1f+10000000000f時,會將1這個有效數值舍入掉,而導致最終結果為0.0。而在第二個輸出語句中10000000000f-10000000000f將先得到結果0.0,因此最終的結果為1.0。
相應的,浮點數運算對乘法也不滿足結合律,也就是 a * b * c != a * (b * c),同時也不滿足分配律,即 a * (b + c) != a * b + a * c。
浮點數失去了很多運算方面的特性,因此也導致很多優化手段無法進行,比如我們試圖優化下面這樣一段程式。
/* 優化前 */ float x = a + b + c; float y = b + c + d; /* 優化後 */ float t = b + c; float x = a + t; float y = t + d;
對於優化前的程式碼來講,進行了4次浮點運算,而優化後則是3次。然而這種優化是編譯器無法進行的,因為可能會引入誤差,比如就像前面的小例子中的結果0和1一樣。編譯器在此時一般是不敢進行優化的,試想一下,如果是銀行系統的匯款或者收款等功能,如果編譯器進行優化的話,很可能一不小心就把別人的錢給優化掉了。
文章小結
2.X系列主要講解了二進位制的位表示方式、無符號以及補碼編碼以及二進位制整數和浮點數的表示方式和運算。這一章是2.X的最後一章,下一章我們將進入組合語言3.X的世界,那裡我們可以看到程式是如何使用暫存器和儲存器的、如何表示C語言中的指標、組合語言如何實現程式的流程控制等等一系列內容。相對來講,3.X的內容會比2.X的內容有意思很多,因此希望各位猿友不要錯過。
版權宣告
作者:zuoxiaolong(左瀟龍)
出處:部落格園左瀟龍的技術部落格--http://www.cnblogs.com/zuoxiaolong
您的支援是對博主最大的鼓勵,感謝您的認真閱讀。
本文版權歸作者所有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利。