從斐波那契數列談談程式碼的效能優化

黎潯發表於2019-02-27

根據高德納(Donald Ervin Knuth)的《計算機程式設計藝術》(The Art of Computer Programming),
1150年印度數學家Gopala和金月在研究箱子包裝物件長寬剛好為1和2的可行方法數目時,首先描述這個數列。
在西方,最先研究這個數列的人是比薩的列奧那多(義大利人斐波那契Leonardo Fibonacci),
他描述兔子生長的數目時用上了這數列:

  • 第一個月初有一對剛誕生的兔子
  • 第二個月之後(第三個月初)它們可以生育
  • 每月每對可生育的兔子會誕生下一對新兔子
  • 兔子永不死去

假設在n月有兔子總共a對,n+1月總共有b對。在n+2月必定總共有a+b對:

因為在n+2月的時候,前一月(n+1月)的b對兔子可以存留至第n+2月(在當月屬於新誕生的兔子尚不能生育)。而新生育出的兔子對數等於所有在n月就已存在的a對

費波那契數列由0和1開始,之後的費波那契係數就是由之前的兩數相加而得出:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233……

如果用數學語言來描述大概是下面這個樣子:

$$F_0 = 0$$

$$F_1 = 1$$

$$Fn = F(n-1) + F_(n-2)$$

遞迴求解

學過程式設計的人,第一反應肯定是用遞迴求解:

def fib(n):
    assert n >= 0, 'input invalid'
    return n if n<=1 else fib(n-1) + fib(n-2)複製程式碼
function fib(n) {
    return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}複製程式碼

遞迴的好處就是程式碼清晰明瞭,寫起來乾淨利索,絲毫沒有拖泥帶水的感覺

這種遞迴求解的方法的過程可以簡化為如下圖所示的二叉樹:

斐波那契二叉樹
斐波那契二叉樹

總的計算量近似可以等於高度為n-1的二叉樹的節點總數,所以它的時間複雜度為O(2^n)

下面是我統計不同語言用遞迴演算法求解斐波那契數列第41項所需的時間:

fibonacci_speed_compare
fibonacci_speed_compare

ps:這裡直接用的系統自帶的time命令來統計的執行時間等資訊

為什麼不建議使用遞迴來求解

由於龜叔認為程式設計師根本用不到遞迴,所以一直拒絕為python加上尾遞迴優化,甚至當遞迴深度超過1000時,直接丟擲RuntimeError: maximum recursion depth exceeded
具體內容請參見:Tail Recursion Elimination

那篇09年的部落格裡是這樣說的:

我不認為遞迴是程式設計的基礎。遞迴是一些電腦科學家們,尤其是那些熱愛Scheme (lisp的一支)和喜歡用‘cons’ 來教表頭表尾和遞迴的人們。
但是對我(Guido)來說,遞迴只是一些為基礎數學研究而存在的理論手段(例如分形幾何學),而不是日常的程式設計工具。

Python的哲學是“做一件事情有且只有一種方法”(There should be one-- and preferably only one --obvious way to do it.)
龜叔堅持不給Python加上尾遞迴的優化恰恰體現了這種哲學,這個設計哲學不僅減輕了人們在開發時的認知負擔和選擇成本,對於提高開發效率是很有幫助的。
同時,這個特點使得不同的人用Python寫出來的程式碼不至於相差很大,這對於團隊合作也是很有用的。

遞迴轉化為非遞迴求解

所以我們的斐波那契數列當然不能直接用遞迴求解啦,比較常見的思路是把遞迴改為遞推,把斐波那契的前兩項先初始化為陣列,
然後根據f(n) = f(n-1) + f(n-2)用迴圈一次算出後面的每一項,這種演算法的時間複雜度為O(n)。
我在我的電腦上測了一下,下面這段程式碼求第41項只用了0.02秒。

def fast_fib(n):
    f = [0, 1]
    for i in range(2, n+1):
        f.append(f[i-1] + f[i-2])
    return f[n]複製程式碼

比較一下遞迴法和遞推法:

二者都用了分治的思想——把目標問題拆為若干個小問題,利用小問題的解得到目標問題的解。
二者的區別實際上就是普通分治演算法和動態規劃的區別。

問題結束了嗎

其實還有一個更加巧妙的辦法(利用通項公式求解除外)

我們先把斐波那契數列中相鄰的兩項:F(n)和F(n - 1)寫成一個2x1的矩陣,然後對其進行變形:

繼續推導可以得到:

利用矩陣來運算的話,整個演算法的時間複雜度是O(log n),空間複雜度是O(1)

斐波那契數列通項公式的推導也是個很有意思的題目,可以利用生成函式來推導,這裡就不展開了

原文連結從斐波那契數列談談程式碼的效能優化

相關文章