問題描述:Fibonacci數(Fibonacci Number)的定義是:F(n) = F(n - 1) + F(n - 2),並且F(0) = 0,F(1) = 1。對於任意指定的整數n(n ≥ 0),計算F(n)的精確值,並分析算法的時間、空間複雜度。
假設系統中已經提供任意精度長整數的運算,可以直接使用。
這其實是個老生常談的問題了,不過可能在複雜度分析的時候,很多人忽略了一些事情。另外這個問題恰好有幾種複雜度迥異的算法,在剛剛介紹完算法複雜度之後,正好來直觀地理解一下。
一、遞歸法
一個看起來很直觀、用起來很恐怖的算法就是遞歸法。根據Fibonacci的遞推公式,對於輸入的n,直接遞歸地調用相同的函數分別求出F(n - 1)和F(n - 2),二者相加就是結果。遞歸的終止點就是遞推方程的初值,即n取0或1的時候。
程序(in Python)寫出來那也是相當的簡潔直觀(爲了跟後面的程序區分開來,這裏取名SlowFibonacci)。
|
def SlowFibonacci(n):
assert n >= 0, 'invalid n'
if n < 2: return n # F(0) = 0, F(1) = 1
return SlowFibonacci(n - 1) + SlowFibonacci(n - 2)
|
這個算法的時間複雜度有着跟Fibonacci類似的遞推方程:T(n) = T(n - 1) + T(n - 2) + O(1),很容易得到T(n) = O(1.618 ^ n)(1.618就是黃金分割,(1+5√)/2 )。空間複雜度取決於遞歸的深度,顯然是O(n)。
二、遞推法
雖然只是一字之差,但遞推法的複雜度要小的多。這個方法就是按照遞推方程,從n = 0和n = 1開始,逐個求出所有小於n的Fibonacci數,最後就可以算出F(n)。由於每次計算值需要用到前兩個Fibonacci數,更小的數就可以丟棄了,可以將空間複雜度降到最低。算法如下:
|
def NormFibonacci(n):
assert n >= 0, 'invalid n'
if n == 0: return 0
(prev, curr) = (0, 1) # F(0), F(1)
for i in xrange(n - 1):
(prev, curr) = (curr, prev + curr)
return curr
|
顯然時間複雜度是O(n),空間複雜度是O(1)。
比較一下遞歸法和遞推法,二者都用了分治的思想——把目標問題拆爲若干個小問題,利用小問題的解得到目標問題的解。二者的區別實際上就是普通分治算法和動態規劃的區別。
三、矩陣法
算Fibonacci數精確值的最快的方法應該就是矩陣法,看過的人都覺得這個方法很好。如果你跟我一樣,曾經爲記住這個方法中的矩陣而煩惱,那今天就來看看怎麼進行推導。其實方法非常簡單,想清楚了也就自然而然地記住了。
我們把Fibonacci數列中相鄰的兩項:F(n)和F(n - 1)寫成一個2x1的矩陣,然後對其進行變形,看能得到什麼:
是不是非常自然呢?把等式最右邊繼續算下去,最後得到:
因此要求F(n),只要對這個二階方陣求n - 1次方,最後取結果方陣第一行第一列的數字就可以了。
看起來有點兒化簡爲繁的感覺,但關鍵點在於,冪運算是可以二分加速的。設有一個方陣a,利用分治法求a的n次方,有:
可見複雜度滿足T(n) = T(n / 2) + O(1),根據Master定理可得:T(n) = O(log n)。
在實現的時候,可以用循環代替遞歸實現這裏的二分分治,好處是降低了空間複雜度(用遞歸的話,空間複雜度爲O(log n))。下面的Python程序直接利用的numpy庫中的矩陣乘法(當然這個庫也實現了矩陣的冪運算,我把它單獨寫出來是爲了強調這裏的分治算法)。另外如果不用第三方庫,我也給出了矩陣乘法的簡單實現。
- Using numpy Library
|
from numpy import matrix
def MatrixPower(mat, n):
assert n > 0, 'invalid n'
res = None
temp = mat
while True:
if n & 1:
if res is None: res = temp
else: res = res * temp
n >>= 1
if n == 0: break
temp = temp * temp
return res
def FastFibonacci(n):
assert n >= 0, 'invalid n'
if n < 2: return n # F(0) = 0, F(1) = 1
mat = matrix([[1, 1], [1, 0]], dtype=object)
mat = MatrixPower(mat, n - 1)
return mat[0, 0]
|
- Without numpy Library
|
def DotProduct(x, y):
n = len(x)
assert len(y) == n, 'x and y must have the same length'
s = 0
for i in xrange(n):
s += x[i] * y[i]
return s
def MatrixMultiply(x, y):
# x is a m*a matrix, y is a a*n matrix.
# x * y is a m*n matrix.
m = len(x)
n = len(y[0])
a = len(x[0])
assert len(y) == a
# transpose y
y = [[y[i][j] for i in xrange(a)] for j in xrange(n)]
res = [[DotProduct(x[j], y[i]) for i in xrange(n)] for j in xrange(m)]
return res
def MatrixPower(mat, n):
assert n > 0, 'invalid n'
res = None
temp = mat
while True:
if n & 1:
if res is None: res = temp
else: res = MatrixMultiply(res, temp)
n >>= 1
if n == 0: break
temp = MatrixMultiply(temp, temp)
return res
def FastFibonacci(n):
assert n >= 0, 'invalid n'
if n < 2: return n # F(0) = 0, F(1) = 1
mat = [[1, 1], [1, 0]]
mat = MatrixPower(mat, n - 1)
return mat[0][0]
|
二階方陣相乘一次可以看成是常數時間(雖然這個常數會比較大),因此整個算法的時間複雜度是O(log n),空間複雜度是O(1)。
四、運行時間大比拼
至此,我們得到的時間複雜度分別是O(1.618 ^ n)、O(n)和O(log n)的算法,讓我們來直觀地比較比較它們。
用Python的timeit模塊對以上三個算法的運行時間進行了測量,記錄了每個算法對於不同的n的每千次運算所消耗的時間(單位是秒),部分數據記錄在fibonacci_data。利用Mathematica可以很方便地對這些數據進行擬合,對於較小的n,用三個複雜度表達式分別去擬合,得到的效果都非常好。尤其值得注意的是,對於第一個算法,我用a * b ^ n去擬合,結果得到b等於1.61816,這與黃金分割數的正確值相差無幾。
- 遞歸法擬合結果:0.000501741 * 1.61816 ^ n,RSquare = 0.999993。
- 遞推法擬合結果:0.000788421 + 0.000115831 * n,RSquare = 0.999464。
- 矩陣法擬合結果:-0.0114923 + 0.0253609 log(n),RSquare = 0.986576。
下圖是n <= 35時,三種算法的千次運行耗時比較。其中紅色爲O(1.618 ^ n)的遞歸法;藍色爲O(n)的遞推法;綠色爲O(log n)的矩陣法。散點爲實際測量到的運行時間,實線爲擬合方程的曲線。
當n > 10的時候,指數時間就已經超出畫面範圍了。另外在這張圖裏,身爲對數時間複雜度的矩陣法似乎沒有任何優勢,其耗時遠遠高於線性時間複雜度的遞推法。這是因爲n還不夠大,體現不出log(n)的優勢。在考慮更大的n之前,先來看看指數時間複雜度會增大到什麼程度。
五、大整數情況下的複雜度
Python內置了大整數支持,因此上面的程序都可以直接接受任意大的n。當整數在32位或64位以內時,加法和乘法都是常數時間,但大整數情況下,這個時間就不能忽略了。
先來看一下Fibonacci數的二進制位數。我們知道Fibonacci數的通項公式是:
當n充分大(其實都不需要很大)的時候,第二項就可以忽略不計了。把第一項對2取對數,就可以得到Fibonacci數的二進制位數的近似表達式,大概是log21.618×n−0.5log25=log21.618×n−1.161=O(n) 。由此可以算出,F(47)是32位無符號整數可以表達的最大的Fibonacci數,F(93)是64位無符號整數可以表達的最大的Fibonacci數。上面圖中的n在36以內,不需要動用大整數運算,複雜度也比較符合之前的結論。但對於更大的n,之前的複雜度就不再適用了。
指數複雜度的算法就不管了,還不等用到大整數,它就已經慢到不行了。
來看看O(n)時間複雜度的遞推法。每次遞推的時候都要計算兩個Fibonacci數之和,第i次運算時,這兩個Fibonacci數分別有O(i)個二進制位,完成加法需要O(i)的時間。因此總的時間大約是:
可見對於很大的n,遞推法的時間複雜度實際上是O(n ^ 2)的,空間複雜度是O(n)用來存儲Fibonacci數的各個二進制位。
再看矩陣法,注意到矩陣運算中有乘法,兩個長度爲n的大整數相乘,傳統算法是O(n ^ 2)時間複雜度,較好的Karatsuba算法是O(n ^ (log 3 / log 2))時間,更快的快速傅立葉變換法是O(n log n)時間。Python 2.5中使用的是Karatsuba算法(Python 3裏面似乎是快速傅立葉變換法)(參見Python源碼中的算法分析 之 大整數乘法)。以Karatsuba算法爲例,矩陣法的時間複雜度遞推方程爲:T(n)=T(n/2)+O(nlog23) ,應用Master定理求得T(n)=O(nlog23) 。因此對於很大的n,矩陣法的時間複雜度爲O(n ^ 1.585),空間複雜度O(n)。
利用Mathematica對大n情況下這兩種算法每千次運行時間進行擬合,分別得到:
- 遞推法大整數擬合結果:0.0131216 + 0.000102101 * n + 2.44765 * 10 ^ -7 * n ^ 2,RSquare = 0.999482。
- 矩陣法大整數擬合結果:0.171487 + 9.74496 * 10 ^ -7 * n ^ 1.51827,RSquare = 0.998395。
看一下n在4000以內時,兩種複雜度的對比情況:
從圖中可以看出,遞推法的增長速度也是很快的,當n增大到60多的時候,它的運行時間就超過矩陣法了。矩陣法的增長速度非常慢,看起來像是線性的,讓我們把n調的更大來看一下。
六、更快的算法?
試了試Mathematica中的Fibonacci函數,發現其運算速度相當驚人,估計時間複雜度在O(n log n)上下,而且對於相同的n,運算速度遠遠高於我的矩陣法。可惜我還不瞭解它的算法,只是在幫助文檔裏看到:
Fibonacci[n] uses an iterative method based on the binary digit sequence of n.
來看看它到底有多快:
好吧,這個問題留待以後慢慢研究。
最後相關的Mathematica命令文件放在這裏:fibonacci_timecost