[C#] Fibonacci sequence 費氏數列
最近把 Codewars : The Millionth Fibonacci Kata 解出來,想說都出到 BigInteger 還考慮了負整數 n,大概也夠完整了吧,就想把這個主題整理一下。
費氏數列定義
費波那契數列 (Fibonacci sequence) 或常簡稱費氏數列,是中學數學常見、程式設計界也常見的主題,他有的一些大自然現象(像兔子、蜜蜂數量與黃金比例)我就不討論了,純粹討論數學與程式。費氏數列是如下的數列:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...
規律是「每一項是前兩項的和」,例如 8 = 3 + 5,或 144 = 55 + 89,而「最初兩項是 0 和 1」(數學版本因為足標正整數從 1 開始,故最初兩項為 1 和 1,我這裡討論程式版本 index 從 0 開始,故最初兩項為 0 和 1),其遞迴關係式為:
費氏數列中的數稱為費氏數 (Fibonacci number),若有一函數 F(n) 表費氏數列中 index = n 的數,例如 n = 6 的 a6 即 F(6) = 8,則此 F(n) 函數定義為:
這個 F(n) 的取法就成為常見問題。
遞迴函數 Recursive Function
顯然上面已經是個遞迴函數的 base case 和 recursive case 了,所以程式碼:
public int Fib(int n) { if (n == 0) // base case { return 0; } if(n == 1) // base case { return 1; } return Fib(n - 2) + Fib(n - 1); // recursive case }
這是個很好訓練 Recursion 的練習,但這個作法效能太差,例如我求 Fib(40) 花了近 5 秒才得到 102334155 ,更不用說 50、100,如果目標想很到更大的結果使用 long 甚至 BigInteger,但效能追不上,所以再思考別的方法。
費氏數的通解 Binet’s Formula
在高中數學的數列章節,有個費氏數列一般項公式與證明,是用數學歸納法求證的,這裡我不討論他的推導與證明,只使用他:
可參考 Mathematics LibreTexts : 10.4: Fibonacci Numbers and the Golden Ratio 。在 1843 年由數學家 雅可比內 Jacques Philippe Marie Binet 提出,故稱 Binet's Formula,可由 n 直接得到費氏數,若將那兩個無理數分式令為 Φ 和 φ 可得簡潔版本:
在使用這個方法前先看一下需要用到的兩個命名空間 System 下的方法:
- Math.Sqrt(n) 回傳 n 的平方根,資料型態為 double,例如要得到 √5 就是 Math.Sqrt(5)
- Math.Pow(a,b) 回傳 a 的 b 次方,資料型態為 double,例如要得到 35 就是 Math.Pow(3,5)
程式碼:
using System; public int Fib(int n) { double Phi = (1 + Math.Sqrt(5)) / 2; // Φ double phi = (1 - Math.Sqrt(5)) / 2; // φ return (int)((Math.Pow(Phi, n) - Math.Pow(phi, n)) / Math.Sqrt(5)); }
雖然一次取到費氏數是非常快的方法,但他有準確度的問題,如果取 n = 90 (上面要用 long 回傳)得到的是 2880067194370824704,可是實際上第 90 個費氏數 為 2880067194370816120,如果 n 不太大(例如 50 以內)是既快速且準確的方法,但 n 很大時會因為 double 的準確度開始有誤差,雖然雙精度浮點算很精確的,但畢竟還是逼近,差之毫釐失之千里。
排列組合等價問題
回想高中排列組合有一種問題:10 階的階梯,如果每次有兩種走法:一次走 1 階和一次走 2 階,那有幾種走完階梯的方法?看下列的基本例子:
- 共 1 階,有 (1) 共 1 種走法。
- 共 2 階,有 (2), (1,1) 共 2 種走法。
- 共 3 階,有 (1,2), (2,1), (1,1,1) 共 3 種走法。
- 共 4 階,有 (2,2), (1,1,2), (1,2,1), (2,1,1), (1,1,1,1) 共 5 種走法。
- 共 5 階,有 (1,2,2), (2,1,2), (1,1,1,2), (2,2,1), (1,1,2,1), (1,2,1,1), (2,1,1,1), (1,1,1,1,1) 共 8 種走法。
- 共 6 階,有 (2,2,2), (1,1,2,2), (1,2,1,2), (2,1,1,2), (1,1,1,1,2), (1,2,2,1), (2,1,2,1), (1,1,1,2,1), (2,2,1,1), (1,1,2,1,1), (1,2,1,1,1), (2,1,1,1,1), (1,1,1,1,1,1) 共 13 種走法。
會發現走法數1, 2, 3, 5, 8, 13 ... 正是費氏數列,原因是用最後一步來分類,最後一步有兩種走法,就是一次走 2 階的走法,和一次走 1 階的走法,
若總共要走 n 階,走法分成下面兩種:
- 走法之中最後一步是走 2 階的話代表前面有 ( n - 2 ) 階要走,這些走法就是「共 ( n - 2 ) 階的走法,最後再一次走 2 階」
- 走法之中最後一步是走 1 階的話代表前面有 ( n - 1 ) 階要走, 這些走法就是「共 ( n - 1 ) 階的走法,最後再一次走 1 階」
以上面「共 5 階的 8 種走法」:(1,2,2), (2,1,2), (1,1,1,2), (2,2,1), (1,1,2,1), (1,2,1,1), (2,1,1,1), (1,1,1,1,1),可以分類成兩種:
- 「最後一步是走 2 階」:(1,2,2), (2,1,2), (1,1,1,2),這些走法也就是「共 3 階的走法,再多一步走 2 階」
- 「最後一步是走 1 階」:(2,2,1), (1,1,2,1), (1,2,1,1), (2,1,1,1), (1,1,1,1,1) ,這些走法也就是「共 4 階的走法,再多一步走 1 階」
得到結論「共 5 階的走法數」即「共 4 階的走法數」+「共 3 階的走法數」:
- 共 3 階,有 (1,2), (2,1), (1,1,1) 共 3 種走法。
- 共 4 階,有 (2,2), (1,1,2), (1,2,1), (2,1,1), (1,1,1,1) 共 5 種走法。
- 共 5 階
- 最後一步走 2 階,有 (1,2,2), (2,1,2), (1,1,1,2)
- 最後一步走 1 階,有 (2,2,1), (1,1,2,1), (1,2,1,1), (2,1,1,1), (1,1,1,1,1)
以此類推,即得到費氏數列的遞迴關係式 F(n) = F(n-1) + F(n-2),所以我們的費氏數問題可以轉化成這種走樓梯問題,尋求 ( n - 1 ) 階的走法有多少。
而這種問題還有一個排列組合的解法,如果要解共 5 階的走法數,假設走 1 階的 x 次,走 2 階的 y 次,即 1 ‧ x + 2 ‧ y = 5,求 x + 2y = 5 的非負整數解,共有 (x,y) = (1,2), (3,1), (5,0) 三組,分別再求「1,2,2」的排列數 3!/2! = 3,求「1,1,1,2」的排列數 4!/3! = 4,求「1,1,1,1,1」的排列數 5!/5! = 1,得走法數共 3 + 4 + 1 = 8 種。
故要解 F(n) 第 n 個費氏數,可考慮成「共走 ( n - 1 ) 階的問題」轉化成等價的排列組合解法,去求「x + 2y = n - 1 的 x , y 非負整數解,再分別求出排列數 C(x + y, x)」,取總和」。
這個等價解法速度快也精確,就是前置作業稍微多了點,要有存放 x, y 非負整數解數對的物件:
class Pair { public int x { get; set; } public int y { get; set; } }
然後是計算排列數的 Comibination() 方法,雖然是 x 個 1 和 y 個 2 的不盡相異物之排列數,但可換成 C ( x + y, x ) 的組合數,若選擇求出所有階乘的作法,數字偏大且再一次遞迴,又再被約分,我個人認為是蠻不必要的浪費,所以選擇求組合數的作法,只要分母分子依序列出等約分即可。程式碼:
private int Combination(int n, int r) { r = (r > n - r) ? (n - r) : r; // 餘組合定理,C(n,r) = C(n,n-r) 取小的算 int result = 1; for(int i = 0; i < r; i++) { result = result * (n - i) / (i + 1); } return result; }
最後是 Fib() 的內容,程式碼:
public int Fib(int n) { int target = n - 1; int count = target / 2 + 1; // 非負整數解個數 Pair[] solutions = new Pair[count]; for (int i = 0; i < count; i++) { solutions[i] = new Pair { x = 2 * i + target % 2, // target 為奇數時 x = 1,3,5 ... y = target / 2 - i // target 為偶數時 x = 0,2,4,... }; } int result = 0; for(int i = 0; i < count; i++) { Pair pair = solutions[i]; result += Combination(pair.x + pair.y, pair.x); // 計算C( x + y, y ) } return result; }
其實這個解法是意外中想到的,我本來這篇目標是寫下面要談的矩陣解法,想起之前教高中數學時有這個等價問題,嘗試將他應用在程式上發現效率頗佳,故附上。以上都可以改成回傳 BigInteger 更進一步。
矩陣與log(N)求費氏數
這個解法是在 Codewars : The Millionth Fibonacci Kata 中的提示 1.2 Procedures and the Processes They Generate 1.2.4 Exponentiation ,其下的 Exercise 1.19. ,利用 Divide-and-conquer algorithm ,其實就是我之前寫的 Pow(x,n)實作 想到的對半步進求法,後來看演算法的書才知道這是一個固定的解問題思維。
不過 Codewars 的提示,最開始我很疑惑這和費氏數有什麼關係,因為費氏數是用累加得出來的,並不像指數 Pow() 的實作中是利用連乘出來的,去看前人的 Discussion 發現線性組合化為矩陣的作法(我竟然沒聯想到!),如果是矩陣的連乘,確實可以將上面的對半步進作法用上。考慮每個費氏數是由前面兩個費氏數之和求得:
以 F(8) 為例是前兩個 F(7) 和 F(6) 之和,而 F(7) 當然是前兩個 F(6) 和 F(5) 之和,但這裡不這樣寫,F(7) 還是 F(7),然後將這兩個式子用 F(7) 與 F(6) 來表示:
組合數對分別為 (1,1) 和 (1,0),用這兩個列向量做成一個二階方陣,則 F(8), F(7) 組成行向量就是用這個方陣與 F(7), F(6) 組成的行向量做矩陣乘法得來的:
同理,F(7), F(6) 組成的行向量,可用此二階方陣乘上 F(6), F(5) 組成的行向量得到:
將上式代入上上式,就可逐一化簡:
反覆操作,將相同的二階方陣 [ [ 1 , 1 ] , [ 1 , 0 ] ] 合併成次方 :
最後就可得到「 F(8), F(7) 所成的行向量」用「 F(1), F(0) 所成的行向量」表示的結果:
令 M = [ [ 1 , 1 ] , [ 1 , 0 ] ] ,則「 F(n), F(n-1) 所成的行向量」可利用 M 的 (n-1) 次方再乘上「 F(1), F(0) 所成的行向量」來求得:
由以上推論,就有了方向:
- 想求 F(n) 第 n 個費氏數
- 先找出 M 矩陣的 n-1 次方之結果
- 要求 Mn-1,利用對半步進,如果 n-1 是偶數,找 M(n-1)/2,再平方
- 如果 n-1 是奇數,找 M(n-2)/2,平方後再多乘一個 M
- Mn-1 再乘上 [ [ F(1) ] , [ F(0) ] ] 後的第一列即 F(n),也就是 Mn-1 的第一列第一行元素
首先先做出矩陣乘法,這裡我不考慮二維陣列,反正二階方陣也才四個元素,我將 [ [ a, b ] , [ c , d ] ] 視為 { a , b , c , d } ,而二階方陣的乘法為:
故程式碼:
public int[] MatrixMultiply(int[] A, int[] B) { int a = A[0], b = A[1], c = A[2], d = A[3]; int p = B[0], q = B[1], r = B[2], s = B[3]; return new[] { a * p + b * r, a * q + b * s, c * p + d * r, c * q + d * s }; }
而求 M 的 n 次方,就利用對半步進來遞迴,base case 是 0 次與 1 次,recursive case 要判斷 n 是偶數還是奇數,
- n 是偶數,n = 2 * k,則先求 k 次,用 k 次的結果來平方。
- n 是奇數,n = 2 * k + 1,則先求 k 次,用 k 次的結果平方並多乘 1 次
求 M 的 n 次方的程式碼:
public int[] MatrixExp(int n) { if (n == 0) { return new int[] { 1, 0, 0, 1 }; // base case } if (n == 1) { return new int[] { 1, 1, 1, 0 }; // base case } int[] matrixHalf = MatrixExp(n / 2); // 求 k 次 int[] matrixN = MatrixMultiply(matrixHalf, matrixHalf); if (n % 2 == 0) // n 為偶數時 n/2 + n/2 = n { return matrixN; } else // n 為奇數時 n/2 + n/2 + 1 = n { return MatrixMultiply(matrixN, new[] { 1, 1, 1, 0 }); } }
重要的遞迴完成後,求費氏數的函數本身就只要呼叫他即可,這邊要注意的是 F(0) 要獨自給他,還有本來還有一步 Mn-1 要乘以「 F(1), F(0) 成的行向量」,但是剛好 F(1)=1, F(0)=0 ,故乘開結果的第一列,其實就是 Mn-1 的第一列第一行,也就是 [0] 元素,故我直接回傳即完成。
Fib() 程式碼:
public int Fib(int n) { if (n == 0) { return 0; } return MatrixExp(n - 1)[0]; }
這個是該 Codewars kata 的提示作法,通關之後看大家作法都差不多,有興趣的人可以改成 BigInteger 來看他的強大。
留言
張貼留言