不使用數學函數開方運算的情況下,求解開方運算

1 二分法

          浮點開方也就是給定一個浮點數x,求。這個簡單的問題有很多解,我們從最簡單最容易想到的二分開始講起。利用二分進行開平方的思想很簡單,就是假定中值爲最終解。假定下限爲0,上限爲x,然後求中值;然後比較中值的平方和x的大小,並根據大小修改下限或者上限;重新計算中值,開始新的循環,直到前後兩次中值的距離小於給定的精度爲止。需要注意的一點是,如果x小於1,我們需要將上限置爲1,原因你懂的。代碼如下:

float SqrtByBisection(float n)
{
	float low,up,mid,last; 
	low=0,up=(n<1?1:n); 
	mid=(low+up)/2; 
	do
	{
		if(mid*mid>n)
			up=mid; 
		else 
			low=mid;
		last=mid;
		mid=(up+low)/2; 
	}while(fabsf(mid-last) > eps);

	return mid; 
}
這種方法非常直觀,也是面試過程中經常會問到的問題,不過這裏有一點需要特別注意:在精度判別時不能利用上下限而要利用前後兩次mid值,否則可能會陷入死循環!這是因爲由於精度問題,在循環過程中可能會產生mid值和up或low中的一個相同。這種情況下,後面的計算都不會再改變mid值,因而在達不到精度內時就陷入死循環。但是改爲判斷前後兩次mid值就不會有任何問題(爲啥自己想)。大家可以找一些例子試一下,這可以算是二分法中的一個trick。二分雖然簡單,但是卻有一個非常大的問題:收斂太慢!也即需要循環很多次才能達到精度要求。這也比較容易理解,因爲往往需要迭代3到4次才能獲得一位準確結果。爲了能提升收斂速度,我們需要採用其它的方法。

2 牛頓迭代法

          原理也比較簡單,就是將中值替換爲切線方程的零根作爲最終解。原理可以利用下圖解釋(frommatrix67):

圖一 牛頓迭代法求開方

假設現在要求的值(圖中a=2),我們將其等價轉化爲求函數與x軸大於0的交點。爲了獲得該交點的值,我們先假設一個初始值,在圖一中爲。過的直線與交於一點,過該點做切線交x軸於,則是比好的一個結果。重複上述步驟,過直線與交於一點,過該點做切線交x軸於,則是比更好的一個結果……
很明顯可以看出該方法斜着逼近目標值,收斂速度應該快於二分法。但是如何由獲得呢,我們需要獲得一個遞推公式。看圖中的陰影三角形,豎邊的長度爲,如果我們能求得橫邊的長度l,則很容易得到。因爲三角形的斜邊其實是過的切線,所以我們可以很容易知道該切線的斜率爲,然後利用正切的定義就可以獲得l的長度爲,由此我們得到遞推公式爲:


後面我們需要做的就是利用上面的公式去迭代,直到達到精度要求,代碼如下:

float SqrtByNewton(float x)
{
	float val=x;//初始值
	float last;
	do
	{
		last = val;
		val =(val + x/val) / 2;
	}while(fabsf(val-last) > eps);
	return val;
}


上述代碼進行測試,結果確實比二分法快,對前300萬的所有整數進行開方的時間分別爲1600毫秒和1000毫秒,快的原因主要是迭代次數比二分法更少。雖然牛頓迭代更快,但是還有進一步優化的餘地:首先牛頓迭代的代碼中有兩次除法,而二分法中只有一次,通常除法要比乘法慢個幾倍,因而會導致單次迭代速度的下降,如果能消除除法,速度還能提高不少,後面會介紹沒有除法的算法;其次我們選擇原始值作爲初始估值,這其實不是一個好的估計,這就導致需要迭代多次才能達到精度要求。當然二分法也存在這個問題,但是上下限不容易估計,只能採用最保守的方式。而牛頓迭代則可以任意選擇初始值,所以就存在選擇的問題。

          我們分析一下爲什麼牛頓迭代法可以任意選擇初值(當然必須要大於0)。由公式


我們可以得出幾個結論:

  1. 當i>1時,,這可由均值不等式得到,也即點都在精確值的右側;
  2. 由1推出,可以大於也可以小於,只需要大於0即可,因爲一次迭代之後都會變爲結論1;

所以,牛頓迭代存在一個初值選擇的問題,選擇得好會極大降低迭代的次數,選擇得差效率也可能會低於二分法。我們先給出一個採用新初值的代碼:

float SqrtByNewton(float x)
{
	int temp = (((*(int *)&x)&0xff7fffff)>>1)+(64<<23);
	float val=*(float*)&temp;
	float last;
	do
	{
		last = val;
		val =(val + x/val) / 2;
	}while(fabsf(val-last) > eps);
	return val;
}

對上述代碼重複之前的測試,運行時間由1000毫秒降爲240毫秒,性能提升了接近4倍多!爲啥改用上面複雜的兩句代碼就能使速度提升這麼多呢?這就需要用到我們之前博客介紹的IEEE浮點數表示。我們知道,IEEE浮點標準用的形式來表示一個數,將該數存入float類型之後變爲:

現在需要對這個浮點數進行開方,我們看看各部分都會大致發生什麼變化。指數E肯定會除以2,127保持不變,m需要進行開方。由於指數部分是浮點數的大頭,所以對指數的修改最容易使初始值接近精確值。幸運的是,對指數的開平方我們只需要除以2即可,也即右移一位。但是由於E+127可能是奇數,右移一位會修改指數,我們將先將指數的最低位清零,這就是& 0xff7fffff的目的。然後將該轉換後的整數右移一位,也即將指數除以2,同時尾數也除以2(其實只是尾數的小數部分除以2)。由於右移也會將127除以2,所以我們還需要補償一個64,這就是最後還需要加一個(64<<23)的原因。

          這裏大家可能會有疑問,最後爲什麼加(64<<23)而不是(63<<23),還有能不能不將指數最後一位清零?答案是都可以,但是速度都沒有我上面寫的快。這說明我上面的估計更接近精確值。下面簡單分析一下原因。首先假設e爲偶數,不妨設e=2n,開方之後e則應該變爲n,127保持不變,我們看看上述代碼會變爲啥。e+127是奇數,會清零,這等價於e+126,右移一位變爲n+63,加上補償的64,指數爲n+127,正是所需!再假設e爲奇數,不妨設e=2n+1,開方之後e應該變爲n+1(不精確),127保持不變,我們看看上述代碼會變爲啥。e+127是偶數等於2n+128,右移一位變爲n+64,加上補償的64,指數爲n+1+127,也是所需!這確實說明上述的估計比其他方法更精確一些,因而速度也更快一些。

          雖然優化之後的牛頓迭代算法比二分快了很多,但是速度都還是低於庫函數sqrtf,同樣的測試sqrtf只需要100毫秒,性能是優化之後牛頓迭代算法的3倍!庫函數到底是如何實現的!這說明我們估計的初始值還不是那麼精確。不要着急,我們下面介紹一種比庫函數還要快的算法,其性能又是庫函數的10倍!

3卡馬克算法

       這個算法是99年被人從一個遊戲源碼中扒出來的,作者號稱是遊戲界的大神卡馬克,但是追根溯源,貌似這個算法存在的還要更久遠,原始作者已不可考,暫且稱爲卡馬克算法。啥都不說,先上代碼一睹爲快:

float SqrtByCarmack( float number )
{
	int i;
	float x2, y;
	const float threehalfs = 1.5F;

	x2 = number * 0.5F;
	y  = number;
	i  = * ( int * ) &y;     
	i  = 0x5f375a86 - ( i >> 1 ); 
	y  = * ( float * ) &i;
	y  = y * ( threehalfs - ( x2 * y * y ) ); 
y  = y * ( threehalfs - ( x2 * y * y ) );  	
y  = y * ( threehalfs - ( x2 * y * y ) ); 
	return number*y;
}
掃一眼上面的代碼會有兩個直觀的感覺:這代碼居然沒有循環!這代碼居然沒有除法!第一眼見到該代碼的人都會被震撼!下面對該算法進行解釋,要解釋需要看最原始的版本:

float Q_rsqrt( float number )
{
	long i;
	float x2, y;
	const float threehalfs = 1.5F;

	x2 = number * 0.5F;
	y  = number;
	i  = * ( long * ) &y;            // evil floating point bit level hacking
	i  = 0x5f3759df - ( i >> 1 ); // what the fuck?
	y  = * ( float * ) &i;
	y  = y * ( threehalfs - ( x2 * y * y ) );   // 1st iteration
	//      y  = y * ( threehalfs - ( x2 * y * y ) );   // 2nd iteration, this can be removed

	return y;
}



圖2 牛頓迭代法求開方倒數

最原始的版本不是求開方,而是求開方倒數,也即。爲啥這樣,原因有二。首先,開方倒數在實際應用中比開方更常見,例如在遊戲中經常會執行向量的歸一化操作,而該操作就需要用到開方倒數。另一個原因就是開方倒數的牛頓迭代沒有除法操作,因而會比先前的牛頓迭代開方要快。但是上面的代碼貌似很難看出牛頓迭代的樣子,這是因爲函數變了,由變爲,因而求解公式也需改變,但是遞推公式的推導不變,如圖二所示。按照之前的推導方式我們有:


由這個公式我們就很清楚地明白代碼y  =y*(threehalfs-(x2*y*y));  的含義,這其實就是執行了單次牛頓迭代。爲啥只執行了單次迭代就完事了呢?因爲單次迭代的精度已經達到相當高的程度,代碼也特別註明無需第二次迭代(達到遊戲要求的精度)。圖三給出了對從0.01到10000之間的數進行開方倒數的誤差(from維基百科),可以看出誤差很小,而且隨着數的增大而減小。


圖三 卡馬克算法的誤差

 爲什麼單次迭代就可以達到精度要求呢?根據之前的分析我們可以知道,最根本的原因就是選擇的初值非常接近精確解。而估計初始解的關鍵就是下面這句代碼:

i  = 0x5f3759df - ( i >> 1 );

是由於這句代碼,特別是其中的“magic number”使算法的初始解非常接近精確解。具體的原理又用到前面博客介紹的地址強轉:首先將float類型的數直接進行地址轉換轉成int型(代碼中long在32位機器上等價於int),然後對int型的值進行一個神奇的操作,最後再進行地址轉換轉成float類型就是很精確的初始解。

          在前面的博客中,我們曾經針對float型浮點數和對應的int型整數之間的關係給出一個公式:


其中,表示float型浮點數地址強轉後的int型整數,,x是原始的浮點數(尚未表示成float類型),B=127,是一個無窮小量。化簡一下上述公式我們得到:


有了這個公式我們就可以推導初始解的由來了。要求,我們可以將其等價轉化成,然後代入上面的公式我們就得到:

這個公式就是神奇操作的數學表示,公式中只有是未知量,其它都已知。的值沒有好的求解方法,數學家通過暴力搜索加實驗的方法求得最優值爲0.0450466,此時第一項就對應0x5f3759df。但是後來經過更仔細的實驗,大家發現用0x5f375a86可以獲得更好的精度,所以後來就改用此數。

          算法的最終目的是要對浮點數開平方,而原始的卡馬克算法求的是開方倒數,所以我們最初的代碼返回的結果是原始值乘以開方倒數。該算法性能非常高,而且精度也很高,三次迭代精度就和系統函數一樣,但是速度只有系統函數sqrtf的十分之一不到,相當了得。

4 改進的牛頓迭代

          卡馬克算法也啓發我們能不能對原始的牛頓迭代開方算法進行類似的修改。之前我們已經提供了一種方法去估計初始值,而且獲得了不錯的性能提升,我們希望通過按照卡馬克算法的思路修改初始值的估計來獲得更大的性能提升。要求,我們可以將其等價轉化成,然後再代入上面的公式我們就得到:

新公式和卡馬克公式非常相似,只是係數發生了變化。這也很好理解,本來開方和開方倒數的對數表示只差一個負號。這個公式也只有是未知量,其它都已知。我沒有精力去暴力搜索它的最優值,只能估計幾個:可以選擇等於0,也可以選擇和卡馬克算法中一樣,這樣得到的magic number分別爲0x1fc00000和0x1fbd1e2d。分別用這兩個值去計算,效果差不多,和我們之前的初始值估計相比性能又提升了大約25%,但是還比庫函數sqrtf慢一倍。

          這樣的結果讓人感到沮喪,按理說應該會比卡馬克算法慢,但是依舊差20多倍就不能理解了,難道初始解選擇的還不好?一怒之下,我將循環去掉,也改成只迭代三次,得到的結果和系統函數得到的結果一樣,只是速度上慢了一倍而已!這個結果很令人吃驚,這說明do循環的開銷其實很大。這個結論可以通過在卡馬克算法中也添加do循環得到:如果在卡馬克算法中添加for循環之後,運行速度立刻降了10倍!爲什麼do循環會如此慢呢?一個原因可能是隻需fabsf的原因,其他原因還不詳。

          通過速度慢一倍我們能得到什麼結論呢?原始的牛頓迭代用了兩次除法(加法忽略),而卡馬克算法則用了三次乘法(在返回結果時還有一次),都是迭代三次,它們的性能相差一倍,我們可以推出除法的運行時間大約是乘法的三倍多這個結論啓示我們,優化掉除法是提速的一個重要途徑

          在猜測的值時,我們試驗了兩個很隨意的值,但是結果卻很好,是否會存在更好的呢?答案是肯定的,但是它只有在一次迭代的時候纔會有影響(和原始的卡馬克算法相比),如果迭代三次,則的值將影響不大,在某個區間裏面的值都會得到同樣的結果,運行時間也一樣,因爲結果已經足夠精確。

          如果將我最開始估計初始值的do循環也去掉,則三次迭代精度達不到要求,說明我自己臆想出來的初始值還是太差,初始值估計確實是一門學問。總結一下牛頓迭代和卡馬克算法,我們能得到什麼經驗教訓呢?首先,爲了獲得最好的性能,代碼中儘量不要有循環,這也就是循環展開存在的意義;其次,兩個算法最後的對比完全是除法和乘法的對比,儘量通過數學變換消除代碼中的除法,這也會帶來不少的性能提升;深入瞭解浮點數在計算機中的存儲結構很重要,在不少問題上會給我們帶來很多極致性能的解法。

6 總結

          本博客對浮點開方常用的方法進行了一一介紹,希望能對大家產生些積極的影響。最後,將我的所有實驗結果貼出來,供大家參考。我的CPU型號爲雙核32位酷睿2 T5750,內存2G,測試的內容是對1到300w內的整數進行開方運算,運行時間如下:

後來又在公司的服務器上重測了一遍,直接傷心了。服務器CPU爲24核64位至強E5-2630,內存128G,編譯器爲花錢購買的icc編譯器。測試的內容是對1到1000w內的整數進行開方運算,運行時間如下:

看到這個結果,我只能說系統函數無敵了(沒有算錯)!上面寫的全部作廢,以編譯器實測爲準……

轉自:http://blog.csdn.net/yutianzuijin/article/details/40268445
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章