官术网_书友最值得收藏!

2.4 浮點數的精度問題

很多人在項目中會用double類型替代float類型來提高精度,他們錯誤地認為double類型可以解決精度問題。我正好相反,在編程中極少使用double類型,由于浮點數在大多數項目中并沒有使用到特別高的精度,所以float類型基本都夠用。其實,即使使用double類型也同樣有精度問題,因為浮點數本身就很容易導致不一致的問題。

我們不妨比較float與double,來看看它們有什么不同。float和double所占用的位數不同會導致精度不同,float是32位占用4字節,而double是64位占用8字節,因此,它們在計算時也會引起計算效率的不同。

在實際工作中,我們很多時候想通過使用double替換float來解決精度問題,最后基本都會以失敗告終。因此,我們要認清精度這個問題的根源,才能真正解決問題。我們先來看浮點數在內存中到底是如何存儲的。

計算機只能識別0和1,不管是整數還是小數,在計算機中都以二進制方式存儲在內存中。那么浮點數是以怎樣的方式來存儲的呢?根據IEEE 754標準,任意一個二進制浮點數F均可表示為

F=(-1^s)×(1.M)×(2^e)

從上式可以看出,它被分為3個部分:符號部分即s部分、尾數部分即M部分、階碼部分即e部分。s為符號位0或1;M為尾數,是指具體的小數,用二進制表示,它完整地表示為1.xxx中1后面的尾數部分,也是因此才稱它為尾數;e是比例因子的指數,是浮點數的指數。

圖2-2所示的是32位和64位的浮點數存儲結構,即float和double的存儲結構。不論是32位浮點數還是64位浮點數,它們都由s、M、e三部分組成,并使用相同的公式來計算得到最終值。

圖2-2 32位和64位的浮點數存儲結構

其中e的階碼采用隱含方式,即采用移碼方法來表示正負指數。移碼方法對兩個指數大小的比較和對階操作比較方便,因為階碼的值大時,其指向的數值也是大的,這樣更容易計算和辨認。移碼(又叫增碼)是符號位取反的補碼,例如,float的8位階碼,應將指數e加上一個固定的偏移值127(01 111 111),即e加上127才是存儲在二進制中的數據。

尾數M則更簡單,它只表示1后面的小數部分,而且是二進制直譯的那種,然后再根據階碼來平移小數點,最后根據小數點的左右部分分別得出整數部分和小數部分的數據。

以9.625為例,將9.625(10)轉換為二進制數1001.101(2),也可以表達為1.001 101×(2^3),因此,M尾數部分為00 110 100 000 000 000 000 000,去掉了小數點左側的1,并用0在右側補齊。

其中,表示9.625的1001.101的小數部分為什么是“101”,因為整數部分采用“除2取余法”得到從十進制數轉換到二進制數的數字,而小數部分則采用“乘2取整法”得到二進制數。因此這里的小數部分0.625乘以2為1.25取整得到1,繼續0.25乘以2為0.5取整得到0,再繼續0.5乘以2為1取整為1,后面都是0不再計算,因此得到0.101這個小數點后面的二進制數。

再以198 903.19為例,先形象地轉成二進制的數值,整數部分采用“除2取余法”,小數部分采用“乘2取整法”,并把整數和小數部分用點號隔開得到:

110 000 100 011 110 111.001 100 001 010 001 1(截取16位小數)

以1為首,平移小數點后得到1.100 001 000 111 101 110 011 000 010 100 011×(2^17)(平移17位)即

198 903.19(10)=(-1^0)×1.100 001 000 111 101 110 011 000 010 100 011×(2^17)

從結果可以看出,小數部分0.19轉為二進制數后,小數位數超過16位(已經手算到小數點后32位都還沒算完,其實這個位數是無窮盡的),因此這里導致浮點數有諸多精度的問題,很多時候它無法準確地表示數字,甚至非常不準確。

浮點數的精度問題不只是小數點的精度問題,隨著數值越來越大,即使是整數,也會出現相同的問題,因為浮點數本身是一個由1.M×(2^e)公式形式得到的數字。當數字放大時,M的尾數的存儲位數沒有變化,能表達的位數有限,自然越來越難以準確表達,特別是數字的末尾部分越來越難以準確表達時。

人們總是覺得精度問題好像很難碰到,其實并非如此,實際開發中常常碰到而且確實很頭疼,如果沒有這部分的知識結構,則很容易在查看問題時陷入無盡的疑問。下面看看哪些情況會碰到這類問題。

(1)數值比較不相等

我們在編寫程序時經常會遇到閾值觸發某邏輯的情景,比如某個變量,需要從0開始加,每次加某個小于0.01的數,加到剛好0.23時做某事,到0.34時做另外一件事,到0.56時再做另外一件事。

在這種情況下,精確定位就會遇到麻煩。因為浮點數在加減乘除時無法準確定位到某個值,所以就會出現要么比0.23小,要么比0.23大,但永遠不會出現剛剛與0.23相等的情況,這時我們不得不放棄“==”(等于號)而選擇“>”(大于號)或者“<”(小于號)來解決這種問題的出現。

如果一定要用等于來做比較,則需要有一個微小的浮動區間,即ABS(X-Y)<0.000 01時認為X和Y是相等的。

(2)數值計算不確定

比如,x=1f,y=2f,z=(1f /5555f)×11 110f,如果x/y<=0.5f時做某事,那么理論上說x/z也能通過這個if,因為在我們看來,z就等于2,和y是一樣的,但實際上未必是這樣的。浮點數在計算時由于位數的限制,無法得到精確的數值而是一個被截斷的數值,因此z的計算結果可能是0.499 999 999 999 1,當x/z時,結果有可能大于0.5。

這讓我們很頭疼,在實際編碼中,經常會遇到這樣的情況,在外圈的if判斷成立,理論上同樣的結果只是公式不同,它們在內圈的if判斷卻可能不成立,使得程序出現異常行為,因為看起來應該是得到同樣的數值,但結果卻不一樣。

(3)不同設備的計算結果不同

不同平臺上的浮點數計算也有所偏差,由于設備上CPU寄存器和操作系統架構不同,因此會導致相同的公式在不同的設備上計算出來的結果略有偏差。

面對這些精度上的問題該怎么辦?下面就來看看有哪些解決方案。

1)簡單方法。

由一臺機器決定計算結果,只計算一次,且認定這個值為準確值,把這個值傳遞給其他設備或模塊,只用這個變量結果進行判斷,也省去了多次計算浪費CPU內存空間。

不進行多次計算也就排除了因結果不同導致的問題。

看似相等的計算得到的結果卻有可能不同,這使問題變得更復雜,比如上面所說的1f/2f的結果,使用1f/(1f/5555f×11 110f)來表示得到的結果不一樣將導致問題變得不可控。因此不如只使用一次計算,不再進行多次計算,認定這次結果的數值為準確數值,只將這個浮點數值當作判斷的標準。

我在編程時也常用這種方法,這種方法簡單實用,特別是在網絡同步方面,使用某客戶端的計算結果或由服務器決定計算結果,能很好地解決浮點數計算不一致的問題。

2)改用int或long類型來替代浮點數。

浮點數和整數的計算方式都是一樣的,只是小數點部分不同而已。我們完全可以通過把浮點數乘以10的冪次得到更準確的整數,也就是把自己需要的精度用整數表示。比如保留3位精度,所有浮點數都乘以10 000(因為第4位不是很準確),1.5變成15 000的整數,9.9變成99 000的整數存儲。

這樣,整數15 000乘以99 000得到的結果與整數30 000除以2再乘以99 000得到的結果是完全相等的。

再舉例,原來2.5/3.1×5.1與0.8064×5.1都只是約等于4.1126,用整數替代,2500/31×51與80×51,等于4080,把4080看成4.08,雖然精度出現問題,但是前兩者結果不一致,而后兩者結果完全相同,使用整數來代替小數,使得一致性得到了保證。

如果你覺得用整數做計算的精度問題比較大,則可以再擴大數值到10的冪次,擴大后如果是250 000/31×51,就等于411 290,是不是發現精度提高了?但問題又來了,若通過乘以10的冪次來提高精度,當浮點數值比較大時,就會超出整數的最大上限2^32-1或者2^64-1。

如果你覺得精度可以接受,并且數值計算的范圍肯定會被確定在32位或64位整數范圍內,則可以用int和long類型的方式來代替浮點數。

3)用定點數保持一致性并縮小精度問題。

浮點數在計算機中是用V=(-1)^s×(1.M)×2^(e)公式表示的,也就是說,浮點數的表達其實是模糊的,它用了另一個數乘以2的冪次來表示當前的數。

定點數則不同,它把整數部分和小數部分拆分開來,都用整數的形式表示,這樣計算和表達都使用整數的方式。由于整數的計算是確定的,因此就不會存在誤差,缺點是由于拆分了整數和小數,兩個部分都要占用空間,所以受到存儲位數的限制,占用字節多了就會使用64位的long類型整數結構來存儲定點數,這會導致計算的范圍相對縮小。

與浮點數不同,使用定點數做計算能保證在各設備上計算結果的一致性。C#有一種叫decimal的整數類型,它并非基礎類型,是基礎類型的補充類型,是C#額外構造出來的一種類型,可以認為是構造了一個類作為數字實例并重載了操作符,它擁有更高的精度,卻比float范圍小。它的內部實現就是定點數的實現方式,我們完全可以把它看成定點數。

C#的decimal類型數值有幾個特點需要我們重點關注,它占用128位的存儲空間,即一個decimal變量占用16字節,相當于4個int型整數大小,或2個long型長整數大小,比double型還要大1倍。它的數值范圍在±1.0×10^28到±7.9×10^28之間,這么大的占用空間卻比float的取值范圍還小。decimal精度比較大,精度范圍為28個有效位,另外任何與它一起計算的數值都必須先轉化為decimal類型,否則就會編譯報錯,數值不會隱式地自動轉換成decimal。

看起來好用的decimal卻不是大部分游戲開發者的首選。使用C#自帶的decimal定點數存在諸多問題。最大問題在于無法與浮點數隨意互相轉換,因此在計算上需要進行一定的封裝,要么提前對float處理,要么在decimal的基礎上封裝一層外殼(類)以適應所有數值的計算。精度過大導致CPU計算消耗量大,128位的變量、28位的精度范圍,計算起來有比較大的負荷,如果大量用于程序內的邏輯計算,則CPU就會不堪重負。內存也是如此,大量使用會使得堆棧內存直線飆升,這也間接增大了CPU的消耗。因此它只適用于財務和金融領域的軟件,對于游戲和其他普通應用來說不太合適,其根源是不需要這么高的精度,浪費了諸多設備資源。

實際上,大部分項目都會自己實現定點數,具體實現如前面所說的那樣:把整數和小數拆開來存儲,用兩個int整數分別表示整數部分和小數部分,或者用long長整型存儲(前32位存儲整數,后32位存儲浮點數),long型存儲會更好,它便于存儲和計算。這樣,無論是整數部分還是小數部分,都用整數表示,并封裝在類中。因此我們需要重載(override)所有的基本計算和比較符號,包括+、-、*、/、==、!=、>、<、>=、<=,這些符號都需要重載,重載范圍包括float(浮點數)、double(雙精度)、int(整數)、long(長整數)等。除了以上這些,為了能更好地融合定點數與外部數據的邏輯計算,還需要為此編寫額外的定點庫,包括定點數坐標類、定點數Quaternion類等來擴展定點數。

這看起來比較困難,其實并不復雜,只要靜下心來編寫,就會發現不是難事。將定點數與其他類型數字的加減乘除做運算符重載,如果涉及更多的數學運算,則再建立一個定點數數學庫,存放一些數學運算的函數,再用編寫好的定點數類去寫些用于擴展的邏輯類,僅此而已,都是只要花點時間就能搞定的事,Github上也有很多定點數開源的代碼,可以下載下來參考,或者把它從頭到尾看一遍,將它改成適合自己項目的工具庫。

4)用字符串代替浮點數。

如果想要精確度非常高,定點數和浮點數無法滿足要求,那么就可以用字符串代替浮點數來計算。但它的缺點是CPU和內存的消耗特別大,只能做少量高精度的計算。

我在大學里做算法競賽題目時,就遇到過這種檢驗程序員的邏輯能力和考慮問題全面性的題目,題目很簡單,A×B或A-B或A+B或A/B輸出結果,精度要求在小數點后100位。我們把中小學算術的筆算方式寫入程序里,把字符串轉化為整數,并用整數計算當前位置,接著用字符串形式存儲數字,這樣的計算方式完全不需要擔心越界問題,還能自由控制精度。

缺點是很消耗CPU和內存,比如對于123 456.789 123 45×456 789.234 567 8這種類型的計算,使用字符串代替浮點數,一次的計算量相當于計算好幾萬次的普通浮點數。所以,如果程序中對精度要求很高,且計算的次數不多,這種方式可以放在考慮范圍內。

主站蜘蛛池模板: 运城市| 碌曲县| 神农架林区| 望奎县| 会东县| 西乌| 霍林郭勒市| 明溪县| 山丹县| 石景山区| 澄迈县| 定州市| 江陵县| 绍兴市| 祁门县| 四子王旗| 长海县| 阿荣旗| 香格里拉县| 武胜县| 阿拉善右旗| 泾川县| 镇江市| 驻马店市| 娄烦县| 天长市| 万山特区| 招远市| 商南县| 永福县| 天祝| 五莲县| 龙江县| 田阳县| 武穴市| 大同市| 平度市| 乐东| 雷山县| 盐山县| 隆昌县|