計算斐波納契數,分析算法複雜度

問題描述: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)。

1
2
3
4
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數,更小的數就可以丟棄了,可以將空間複雜度降到最低。算法如下:

1
2
3
4
5
6
7
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的矩陣,然後對其進行變形,看能得到什麼:

[FnFn1]=[Fn1+Fn2Fn1]=[1×Fn1+1×Fn21×Fn1+0×Fn2]=[1110]×[Fn1Fn2]

是不是非常自然呢?把等式最右邊繼續算下去,最後得到:

[FnFn1]=[1110]n1×[F1F0]=[1110]n1×[10]

因此要求F(n),只要對這個二階方陣求n - 1次方,最後取結果方陣第一行第一列的數字就可以了。

看起來有點兒化簡爲繁的感覺,但關鍵點在於,冪運算是可以二分加速的。設有一個方陣a,利用分治法求a的n次方,有:

an={an/2×an/2a(n1)/2×a(n1)/2×a, if x is even, if x is odd

可見複雜度滿足T(n) = T(n / 2) + O(1),根據Master定理可得:T(n) = O(log n)。

在實現的時候,可以用循環代替遞歸實現這裏的二分分治,好處是降低了空間複雜度(用遞歸的話,空間複雜度爲O(log n))。下面的Python程序直接利用的numpy庫中的矩陣乘法(當然這個庫也實現了矩陣的冪運算,我把它單獨寫出來是爲了強調這裏的分治算法)。另外如果不用第三方庫,我也給出了矩陣乘法的簡單實現。

  • Using numpy Library
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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)的矩陣法。散點爲實際測量到的運行時間,實線爲擬合方程的曲線。

compare_a

三種算法的運行時間比較

當n > 10的時候,指數時間就已經超出畫面範圍了。另外在這張圖裏,身爲對數時間複雜度的矩陣法似乎沒有任何優勢,其耗時遠遠高於線性時間複雜度的遞推法。這是因爲n還不夠大,體現不出log(n)的優勢。在考慮更大的n之前,先來看看指數時間複雜度會增大到什麼程度。

compare_b

三種算法的運行時間比較(對數座標軸)

五、大整數情況下的複雜度

Python內置了大整數支持,因此上面的程序都可以直接接受任意大的n。當整數在32位或64位以內時,加法和乘法都是常數時間,但大整數情況下,這個時間就不能忽略了。

先來看一下Fibonacci數的二進制位數。我們知道Fibonacci數的通項公式是:

Fn=15(1+52)n15(152)n

當n充分大(其實都不需要很大)的時候,第二項就可以忽略不計了。把第一項對2取對數,就可以得到Fibonacci數的二進制位數的近似表達式,大概是log21.618×n0.5log25=log21.618×n1.161=O(n) 。由此可以算出,F(47)是32位無符號整數可以表達的最大的Fibonacci數,F(93)是64位無符號整數可以表達的最大的Fibonacci數。上面圖中的n在36以內,不需要動用大整數運算,複雜度也比較符合之前的結論。但對於更大的n,之前的複雜度就不再適用了。

指數複雜度的算法就不管了,還不等用到大整數,它就已經慢到不行了。

來看看O(n)時間複雜度的遞推法。每次遞推的時候都要計算兩個Fibonacci數之和,第i次運算時,這兩個Fibonacci數分別有O(i)個二進制位,完成加法需要O(i)的時間。因此總的時間大約是:

i=1nO(i)=O(n2)

可見對於很大的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以內時,兩種複雜度的對比情況:

compare_c

遞推法(藍色)與矩陣法(綠色)運行時間比較(大整數)

從圖中可以看出,遞推法的增長速度也是很快的,當n增大到60多的時候,它的運行時間就超過矩陣法了。矩陣法的增長速度非常慢,看起來像是線性的,讓我們把n調的更大來看一下。

compare_d

矩陣法的運行時間(更大的n)

六、更快的算法?

試了試Mathematica中的Fibonacci函數,發現其運算速度相當驚人,估計時間複雜度在O(n log n)上下,而且對於相同的n,運算速度遠遠高於我的矩陣法。可惜我還不瞭解它的算法,只是在幫助文檔裏看到:

Fibonacci[n] uses an iterative method based on the binary digit sequence of n.

來看看它到底有多快:

compare_e

矩陣法(綠色)與Mathematica Fibonacci函數(橙色)運行時間比較

好吧,這個問題留待以後慢慢研究。

最後相關的Mathematica命令文件放在這裏:fibonacci_timecost

Share on: Twitter ❄ Facebook ❄ Google+ ❄ Email

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章