最近那誰出的程序媛計划,先不說這個事情是好是壞,這個程序做的是好是壞(壞)...
只是最近微博上,尤其是非CST專業的人,居然有很多人認為入門學編程不需要學習算法....
連程序媛計划的發起人都在微博中說 “不需要數學和英文基礎”
而其支持者的態度也是 “入門時不需要學習算法,甚至工作中用到算法的也不多,用到了再學就行”
不得不說這真是一副培訓班的忽悠口吻....
入門為什么要學算法?
並不是說不學算法就沒法學編程,而是說接觸了算法之后,會幫助更好的去思考編程的邏輯,而這個邏輯的培養正是新手學習編程的初期最需要的東西。
(至於工作后不需要算法這個事情就懶得吐槽了,大概是我們對程序員有不同的理解吧)
舉個栗子:
計算一個數的平方根,編寫一個函數,input一個數字,output這個數字的平方根的近似值
1. 用最接近人類的方法實現:
如果是沒有數學基礎的人,其想法一般是什么呢?
先取一個小的數字 a,計算 a的平方,如果a的平方小於input數字,則把a加大,
然后循環,直到大於或等於input數字了,則這時的a就是input數字的平方根的近似值
此時還沒有考慮精度問題,函數完全沒有效率,也會出現很多錯誤(精度不足導致的死循環問題)
x = 25 epsilon = 0.01 step = epsilon**2 numGuesses = 0 ans = 0.0 while (abs(ans**2 - x)) >= epsilon and ans <= x: ans += step numGuesses += 1 print('numGuesses = ' + str(numGuesses)) if abs(ans**2-x) >= epsilon: print('Failed on square root of ' + str(x)) else: print(str(ans) + ' is close to the square root of ' + str(x))
在這個方法中,僅僅是求x=25的近似平方根,就會執行49990次,而且精度很低
numGuesses = 49990
4.999 is close to the square root of 25
2. 用稍微clever的方法實現
如果現在你已經有一些“聰明”的想法,或者知道有個算法叫二分法,那么便可以寫出聰明的方法
注意,現在你已經不是 “完全不需要算法的狀態了”
二分法簡單的圖示
# 二分法計算平方根 x = 12345 epsilon = 0.01 numGuesses = 0 low = 0.0 high = x ans = (high + low)/2.0 while abs(ans**2 - x) >= epsilon: numGuesses += 1 if ans**2 < x: low = ans else: high = ans ans = (high + low)/2.0 print('numGuesses = ' + str(numGuesses)) print(str(ans) + ' is close to square root of ' + str(x))
同樣計算25的平方根,只需要計算13次,而且精度更高
numGuesses = 13
5.00030517578 is close to square root of 25
計算12345五位數的平方根,也沒有增加多少,只需要26次
numGuesses = 26
111.108076461 is close to square root of 12345
3. 用數學的方法實現
這個時候就開始需要數學了,從這里開始,也變得越來越復雜,效率也變得越來越高
3.1 牛頓-拉夫遜 (Newton & Raphson) 算法
最基礎的平方根算法之一
其數學原理為:
用公式表示為:
# Newton-Raphson for square root epsilon = 0.01 y = 12345.0 guess = y/2.0 numGuesses = 0 while abs(guess*guess - y) >= epsilon: numGuesses += 1 guess -= ((guess**2) - y)/(2*guess) # print(guess) print('numGuesses = ' + str(numGuesses)) print('Square root of ' + str(y) + ' is about ' + str(guess))
此時,計算12345五位數的平方根,僅需要9次
numGuesses = 9 Square root of 12345.0 is about 111.108057705
你以為這就結束了?
遠遠不呢,3.1的方法中,有一個明顯的缺陷就是初始值的選擇,雖然使用了牛頓法去收斂,但初始值仍然是 y/2.0
那么,如果我們能夠一開始就選擇一個非常近似的值,就能夠大大減少計算的次數,提高運算效率了
3.2 基於泰勒公式的級數逼近(泰勒級數+牛頓法)
微積分中的泰勒級數可以表示為:
符號a
表示某個常量,記號f'、f''
和f'''
表示函數f
的一階、二階和三階導數,以此類推
這個公式稱為泰勒公式,基於這個公式,我們平方根公式的展開式為:
根據該公式我們可以在一定精度內逼近真實值。
在泰勒級數展開中,平方根函數的公式當且僅當參數值位於一個有效范圍內時才有效,在該范圍內計算趨於收斂。
該范圍即是收斂半徑,當我們對平方根函數用a=1
進行計算時,泰勒級數公式希望x
處於范圍:$0<x<2$之間。
如果x
在收斂半徑之外,則展開式中的項會越來越大,泰勒級數離答案也就越來越遠。
為了解決該問題,我們可以考慮當待開平方數大於4時以4去除它,最后將得到的數乘以相同次數的2即可。
C參考實現(Github上的方法,原鏈接似乎失效了)
public double TSqrt() { //設置修正系數 double correction = 1; //因為要對原值進行縮小,因此設置臨時值 double tempValue = value; while (tempValue >= 2) { tempValue = tempValue / 4; correction *= 2; } return this.TSqrtIteration(tempValue) * correction; } private double TSqrtIteration(double value) { double sum = 0, coffe = 1, factorial = 1, xpower = 1, term = 1; int i = 0; while (Math.abs(term) > 0.000001) { sum += term; coffe *= (0.5 - i); factorial *= (i + 1); xpower *= (value - 1); term = coffe * xpower / factorial; i++; } return sum; }
再次,你以為這就結束了?
圖樣圖奶衣服
3.3 平方根倒數速算法(卡馬克快速平方根算法)(What the FUCK方法)
這個方法實在可怕,完全不明白
其通俗的解釋為:
首先接收一個32位帶符浮點數,然后將之作為一個32位整數看待,以將其向右進行一次邏輯移位的方式將之取半。
用十六進制“魔術數字” 0x5f3759df 減之,如此即可得對輸入的浮點數的平方根倒數的首次近似值;
而后重新將其作為浮點數,以牛頓法計算,求出更精確的近似值。
在計算浮點數的平方根倒數的同一精度的近似值時,此算法比直接使用浮點數除法要快四倍。
在wiki上的詳細解釋,認輸.jpg:
https://en.wikipedia.org/wiki/Fast_inverse_square_root
float InvSqrt (float x) { float xhalf = 0.5f*x; int i = *(int*)&x; i = 0x5f3759df - (i >> 1); // 計算第一個近似根 x = *(float*)&i; x = x*(1.5f - xhalf*x*x); // 牛頓迭代法 return x; }
對沒錯只有這么點兒,而且那個 0x5f3759df 是什么玩意兒?
而且它通過某種方法算出了一個與真根非常接近的近似根,因此它只需要使用一次迭代過程就獲得了較滿意的解。
以下引用鏈接中對此方法的解釋:
http://www.cnblogs.com/vagerent/archive/2007/06/25/794695.html
IEEE標准下,float類型的數據在32位系統上是這樣表示的(大體來說就是這樣,但省略了很多細節,有興趣可以GOOGLE):
bits:31 30 ... 0 31:符號位 30-23:共8位,保存指數(E) 22-0:共23位,保存尾數(M)
所以,32位的浮點數用十進制實數表示就是:M*2^E。
開根然后倒數就是:M^(-1/2)*2^(-E/2)。
現在就十分清晰了。語句i> >1其工作就是將指數除以2,實現2^(E/2)的部分。
而前面用一個常數減去它,目的就是得到M^(1/2)同時反轉所有指數的符號。
至於那個0x5f3759df,呃,我只能說,的確是一個超級的Magic Number。
那個Magic Number是可以推導出來的,但我並不打算在這里討論,因為實在太繁瑣了。
簡單來說,其原理如下:因為IEEE的浮點數中,尾數M省略了最前面的1,所以實際的尾數是1+M。
如果你在大學上數學課沒有打瞌睡的話,那么當你看到(1+M)^(-1/2)這樣的形式時,應該會馬上聯想的到它的泰勒級數展開,而該展開式的第一項就是常數。
下面給出簡單的推導過程:
對於實數R>0,假設其在IEEE的浮點表示中, 指數為E,尾數為M,則: R^(-1/2) = (1+M)^(-1/2) * 2^(-E/2) 將(1+M)^(-1/2)按泰勒級數展開,取第一項,得: 原式 = (1-M/2) * 2^(-E/2) = 2^(-E/2) - (M/2) * 2^(-E/2) 如果不考慮指數的符號的話, (M/2)*2^(E/2)正是(R>>1), 而在IEEE表示中,指數的符號只需簡單地加上一個偏移即可, 而式子的前半部分剛好是個常數,所以原式可以轉化為: 原式 = C - (M/2)*2^(E/2) = C - (R>>1),其中C為常數 所以只需要解方程: R^(-1/2) = (1+M)^(-1/2) * 2^(-E/2) = C - (R>>1) 求出令到相對誤差最小的C值就可以了
最后,引用鏈接中的code,對3.1-3.3三種算法進行效率對比
https://segmentfault.com/a/1190000006122223

@Test public void testBabylonian() { for (int i = 0; i < 10000; i++) { Assert.assertEquals(2.166795861438391, squareRoots.Babylonian(), 0.000001); } } @Test public void testTSqrt() { for (int i = 0; i < 10000; i++) { Assert.assertEquals(2.166795861438391, squareRoots.TSqrt(), 0.000001); } } @Test public void testFastInverseSquareRoot() { for (int i = 0; i < 10000; i++) { Assert.assertEquals(2.1667948388864198, squareRoots.FastInverseSquareRoot(), 0.000001); } }

@Test public void benchMark() { //巴比倫算法計時器 long babylonianTimer = 0; //級數逼近算法計時器 long tSqrtTimer = 0; //平方根倒數速算法計時器 long fastInverseSquareRootTimer = 0; //隨機數生成器 Random r = new Random(); for (int i = 0; i < 100000; i++) { double value = r.nextDouble() * 1000; SquareRoots squareRoots = new SquareRoots(value); long start, stop; start = System.currentTimeMillis(); squareRoots.Babylonian(); babylonianTimer += (System.currentTimeMillis() - start); start = System.currentTimeMillis(); squareRoots.TSqrt(); tSqrtTimer += (System.currentTimeMillis() - start); start = System.currentTimeMillis(); squareRoots.FastInverseSquareRoot(); fastInverseSquareRootTimer += (System.currentTimeMillis() - start); } System.out.println("巴比倫算法:" + babylonianTimer); System.out.println("級數逼近算法:" + tSqrtTimer); System.out.println("平方根倒數速算法:" + fastInverseSquareRootTimer); }
最后的結論是:
結果為: 方法3.1: 巴比倫(即牛頓-拉夫遜 NR)算法:17 方法3.2: 泰勒級數逼近算法:34 方法3.3: 平方根倒數速算法:7
最最后,附贈一個關於魔法數字 0x5f3759df 的鏈接(看不懂)
http://blog.jobbole.com/105295/
怎么樣?編程很好玩吧?(不
也許我們工作中大部分的程序員用不到如此級別的算法,但是說 “算法完全用不到” 這是不可能的,即使是最最不需要數學的二分法,也是算法了。
而且,在學習編程的時候,研究這些 “聰明” 的方法,或者僅僅是觀看它們,也會享受到編程的樂趣,和邏輯的美。
這對提高興趣有極大的幫助,總不能一輩子做一個苦逼的編程初學者吧。
希望本文能夠為初學者提供一個正確學習編程的思路:“ 編程為什么需要研究算法?”
對了,如果你問為什么一個計算平方根,要搞得如此變態?反正電腦的計算速度很強不是嗎?
其實方法3.3 最早於計算機圖形學的硬件與軟件領域有所應用,如SGI和3dfx就曾在產品中應用此算法。
在 3D 圖形中,你使用平面法線,長度為 1 的三坐標向量,來表示光線和反射。
你會使用很多平面法線,計算它們時需要對向量進行標准化。
而如何標准化一個向量呢?每個坐標分別除以向量的長度,因此,每個坐標需乘上
計算平方和加法相對開銷很小,但計算平方根和除法,就需要很大的開銷了。
想想3D圖形中,用到這個計算的地方會有多少?當這個開銷被放大到非常大時,還使用笨辦法,電腦的計算能力還扛得住嗎?