上一章我們簡單介紹了IEEE浮點(diǎn)標(biāo)準(zhǔn),本次我們主要講解一下浮點(diǎn)運(yùn)算舍入的問題,以及簡單的介紹浮點(diǎn)數(shù)的運(yùn)算。
之前我們已經(jīng)提到過,有很多小數(shù)是二進(jìn)制浮點(diǎn)數(shù)無法準(zhǔn)確表示的,因此就難免會遇到舍入的問題。這一點(diǎn)其實(shí)在我們平時的計(jì)算當(dāng)中會經(jīng)常出現(xiàn),就比如之前我們提到過的0.3,它就是無法用浮點(diǎn)小數(shù)準(zhǔn)確表示的。
為此LZ專門寫了一個小程序,使用Java語言打印出了0.3的二進(jìn)制表示,是這樣的一個數(shù)字,0 01111101 00110011001100110011010。我們來簡單算一下,這個數(shù)值大約是多少。它的階碼在偏置之后的值為-2,它的尾數(shù)位在加1之后為1 + 1/8 + 1/16 + 1/128 + 1/256 = 1.19921875。后面還有有效位,不過我們只大概計(jì)算一下,就不算那么精確了,最終算出來的值為0.2998046875。(LZ用計(jì)算器算的,0.0)
可以看出,這個值離0.3已經(jīng)非常接近了,而且我們還省略了一小部分有效小數(shù)位,但是不管怎么說,二進(jìn)制無法像十進(jìn)制小數(shù)一樣,準(zhǔn)確的表示0.3這個數(shù)值。因此舍入這一部分是浮點(diǎn)數(shù)無法逃脫的內(nèi)容。
浮點(diǎn)數(shù)舍入
在我們平時日常使用的十進(jìn)制當(dāng)中,我們一般對一個無理數(shù)或者有位數(shù)限制的有理數(shù)進(jìn)行舍入時,大部分時候會采取四舍五入的方式,這算是一種比較符合我們期望的舍入方式。
不過針對浮點(diǎn)數(shù)來說,我們的舍入方式會更豐富一些。一共有四種方式,分別是向偶數(shù)舍入、向零舍入、向上舍入以及向下舍入。
這四種舍入方式都不難理解,其中向偶數(shù)舍入就是向最靠近的偶數(shù)舍入,比如將1.5舍入為2,將0.1舍入為0。而向零舍入則是向靠近零的值舍入,比如將1.5舍入為1,將0.1舍入為0。對于向上舍入來說,則是往大了(也就是向正無窮大)舍入的意思,比如將1.5舍入為2,將-1.5舍入為-1。而向下舍入則與向上舍入相反,是向較小的值(也就是向負(fù)無窮大)舍入的意思。
這里需要提一下的是,除了向偶數(shù)舍入以外,其它三種方式都會有明確的邊界。這里的含義是指這三種方式舍入后的值x'與舍入之前的值x會有一個明確的大小關(guān)系,比如對于向上舍入來說,則一定有x <= x'。對于向零舍入來說,則一定有|x| >= |x'|。
對于向偶數(shù)舍入來講,它最大的作用是在統(tǒng)計(jì)時使用。向偶數(shù)舍入可以讓我們在統(tǒng)計(jì)時,將舍入產(chǎn)生的誤差平均,從而盡可能的抵消。而其它三種方式在這方面都是有一定缺陷的,向上和向下舍入很明顯,會造成值的偏大或偏小。而對于向零舍入來講,如果全是正數(shù)的時候則會造成結(jié)果偏小,全是負(fù)數(shù)的時候則會造成結(jié)果偏大。
通常情況下我們采取的舍入規(guī)則是在原來的值是舍入值的中間值時,采取向偶數(shù)舍入,在二進(jìn)制中,偶數(shù)我們認(rèn)為是末尾為0的數(shù)。而倘若不是這種情況的話,則一般會有選擇性的使用向上和向下舍入,但總是會向最接近的值舍入。其實(shí)這正是IEEE采取的默認(rèn)的舍入方式,因?yàn)檫@種舍入方式總是企圖向最近的值的舍入。
比如對于10.10011這個值來講,當(dāng)舍入到個位數(shù)時,會采取向上舍入,因此此時的值為11。當(dāng)舍入到小數(shù)點(diǎn)后1位時,會采取向下舍入,因此此時的值為10.1。當(dāng)舍入到小數(shù)點(diǎn)后4位時,由于此時為10.10011舍入值的中間值,因此采用向偶數(shù)舍入,此時舍入后的值為10.1010?! ?/p>
Java當(dāng)中的浮點(diǎn)數(shù)舍入
之前我們講解了一堆舍入的方式,最終我們給出一個結(jié)論,就是IEEE標(biāo)準(zhǔn)默認(rèn)的舍入方式,是企圖向最近的值舍入(Round to the Nearest Value)。
上面我們已經(jīng)詳細(xì)的解釋了IEEE標(biāo)準(zhǔn)中默認(rèn)的舍入方式(黑色加粗的那部分解釋),但是估計(jì)還是會有不少猿友比較迷糊,書中也沒有給出具體的例子,因此這里L(fēng)Z以Java語言為例,我們直接寫程序來看一下,看看Java當(dāng)中的舍入方式是否是按照我們所說的進(jìn)行的。
在各位看這個測試程序之前,LZ需要再給各位再解釋一下中間值的概念。中間值就是指的,比如1.1(二進(jìn)制)這個數(shù)字,假設(shè)要舍入到個位,那么它就是一個中間值,因?yàn)樗幱?(二進(jìn)制)和10(二進(jìn)制)的中間,在這個時候?qū)捎孟蚺紨?shù)舍入的方式。
下面便是LZ寫的測試程序,其中那些具體的浮點(diǎn)數(shù)值是使用二進(jìn)制小數(shù)的算法計(jì)算出來的,各位猿友不必在意,如果你不嫌麻煩,也可以自己手算一下。我們主要看的是最終的舍入情況。
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;
}
}
上面是測試程序,其實(shí)程序中看不出什么,就是一堆輸出語句。如果各位猿友有興趣,也可以簡單看一下程序的實(shí)現(xiàn)。不過我們主要還是看結(jié)果。
上面一共有8次舍入,前4次是正數(shù),后4次是負(fù)數(shù)??梢钥闯鰧τ谡?fù)數(shù)來講,舍入后的位表示是一樣的,只是最高位的符號位不同而已,因此這里L(fēng)Z就不再分析下面4個負(fù)數(shù)的舍入方式了,我們主要來看前4次舍入。
第1次和第2次對于末尾01和11的舍入,由于是中間值,因此全部采取的向偶數(shù)舍入的方式,保證最低位為0。第3次由于比中間值大,而數(shù)值又是正數(shù),因此采用向上舍入的方式。第4次則比中間值小,數(shù)值也同樣是正數(shù),因此采用向下舍入的方式。
由此可以看出,Java正是采用的我們所描述的方式進(jìn)行舍入操作的,也就是總是企圖朝最近的數(shù)值舍入。相對于其它語言,由于LZ主修Java,例子篇幅也比較長,因此這里就不寫其他語言的例子了,有興趣的猿友可以嘗試寫一下C/C++或者C#的例子來看一下,看是否是采用的同樣的舍入方式。
浮點(diǎn)數(shù)運(yùn)算
在IEEE標(biāo)準(zhǔn)中,制定了關(guān)于浮點(diǎn)數(shù)的運(yùn)算規(guī)則,就是我們將把兩個浮點(diǎn)數(shù)運(yùn)算后的精確結(jié)果的舍入值,作為我們最終的運(yùn)算結(jié)果。正是因?yàn)橛辛诉@一個特殊點(diǎn),就會造成浮點(diǎn)數(shù)當(dāng)中,很多運(yùn)算不滿足我們平時熟知的一些運(yùn)算特性。
比如加法的結(jié)合律,也就是a + b + c = a + (b + c),這是很普通的加法運(yùn)算的特性,但是浮點(diǎn)數(shù)是不滿這一特性的,比如說下面這一段小程序。
public static void main(String[] args){
System.out.println(1f + 10000000000f - 10000000000f);
System.out.println(1f + (10000000000f - 10000000000f));
}
這一段程序會依次輸出0.0和1.0,正是因?yàn)樯崛攵斐傻倪@一誤差。在第一個輸出語句中,計(jì)算1f+10000000000f時,會將1這個有效數(shù)值舍入掉,而導(dǎo)致最終結(jié)果為0.0。而在第二個輸出語句中10000000000f-10000000000f將先得到結(jié)果0.0,因此最終的結(jié)果為1.0。
相應(yīng)的,浮點(diǎn)數(shù)運(yùn)算對乘法也不滿足結(jié)合律,也就是 a * b * c != a * (b * c),同時也不滿足分配律,即 a * (b + c) != a * b + a * c。
浮點(diǎn)數(shù)失去了很多運(yùn)算方面的特性,因此也導(dǎo)致很多優(yōu)化手段無法進(jìn)行,比如我們試圖優(yōu)化下面這樣一段程序。
/* 優(yōu)化前 */
float x = a + b + c;
float y = b + c + d;
/* 優(yōu)化后 */
float t = b + c;
float x = a + t;
float y = t + d;
對于優(yōu)化前的代碼來講,進(jìn)行了4次浮點(diǎn)運(yùn)算,而優(yōu)化后則是3次。然而這種優(yōu)化是編譯器無法進(jìn)行的,因?yàn)榭赡軙胝`差,比如就像前面的小例子中的結(jié)果0和1一樣。編譯器在此時一般是不敢進(jìn)行優(yōu)化的,試想一下,如果是銀行系統(tǒng)的匯款或者收款等功能,如果編譯器進(jìn)行優(yōu)化的話,很可能一不小心就把別人的錢給優(yōu)化掉了。
文章小結(jié)
2.X系列主要講解了二進(jìn)制的位表示方式、無符號以及補(bǔ)碼編碼以及二進(jìn)制整數(shù)和浮點(diǎn)數(shù)的表示方式和運(yùn)算。這一章是2.X的最后一章,下一章我們將進(jìn)入匯編語言3.X的世界,那里我們可以看到程序是如何使用寄存器和存儲器的、如何表示C語言中的指針、匯編語言如何實(shí)現(xiàn)程序的流程控制等等一系列內(nèi)容。相對來講,3.X的內(nèi)容會比2.X的內(nèi)容有意思很多,因此希望各位猿友不要錯過。