根據高德納(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項所需的時間:
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)
斐波那契數列通項公式的推導也是個很有意思的題目,可以利用生成函式來推導,這裡就不展開了
原文連結: 從斐波那契數列談談程式碼的效能優化