[雜談] 編程為什么要學算法 - 某程序媛計划有感


最近那誰出的程序媛計划,先不說這個事情是好是壞,這個程序做的是好是壞(壞)...

只是最近微博上,尤其是非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);
    }
}
View Code
@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);

}
View Code

最后的結論是:

結果為:
方法3.1: 巴比倫(即牛頓-拉夫遜 NR)算法:17
方法3.2:  泰勒級數逼近算法:34
方法3.3:  平方根倒數速算法:7

 


最最后,附贈一個關於魔法數字 0x5f3759df 的鏈接(看不懂)

http://blog.jobbole.com/105295/

 


 

怎么樣?編程很好玩吧?(不

也許我們工作中大部分的程序員用不到如此級別的算法,但是說 “算法完全用不到” 這是不可能的,即使是最最不需要數學的二分法,也是算法了。

而且,在學習編程的時候,研究這些 “聰明” 的方法,或者僅僅是觀看它們,也會享受到編程的樂趣,和邏輯的美。

這對提高興趣有極大的幫助,總不能一輩子做一個苦逼的編程初學者吧。

 

希望本文能夠為初學者提供一個正確學習編程的思路:“ 編程為什么需要研究算法?”

 


 

對了,如果你問為什么一個計算平方根,要搞得如此變態?反正電腦的計算速度很強不是嗎?

其實方法3.3 最早於計算機圖形學的硬件與軟件領域有所應用,如SGI和3dfx就曾在產品中應用此算法。

在 3D 圖形中,你使用平面法線,長度為 1 的三坐標向量,來表示光線和反射。

你會使用很多平面法線,計算它們時需要對向量進行標准化。

而如何標准化一個向量呢?每個坐標分別除以向量的長度,因此,每個坐標需乘上

計算平方和加法相對開銷很小,但計算平方根和除法,就需要很大的開銷了。

 

想想3D圖形中,用到這個計算的地方會有多少?當這個開銷被放大到非常大時,還使用笨辦法,電腦的計算能力還扛得住嗎?

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM