相信提到斐波那契數列,大家都不陌生,這個是在我們學習 C/C++ 的過程中必然會接觸到的一個問題,而作為一個經典的求解模型,我們怎么能少的了去研究這個模型呢?筆者在不斷地學習和思考過程中,發現了這類經典模型竟然有如此多的有意思的求解算法,能讓這個經典問題的時間復雜度降低到 \(O(1)\) ,下面我想對這個經典問題的求解做一個較為深入的剖析,請聽我娓娓道來。
我們可以用如下遞推公式來表示斐波那契數列 \(F\) 的第 \(n\) 項:
回顧一下我們剛開始學 \(C\) 語言的時候,講到函數遞歸那節,老師總是喜歡那這個例子來說。
斐波那契數列就是像蝸牛的殼一樣,越深入其中,越能發覺其中的奧秘,形成一條條優美的數學曲線,就像這樣:
遞歸在數學與計算機科學中,是指在函數的定義中使用函數自身的方法,可能有些人會把遞歸和循環弄混淆,我覺得務必要把這一點區分清楚才行。
遞歸查找
舉個例子,給你一把鑰匙,你站在門前面,問你用這把鑰匙能打開幾扇門。
遞歸:你打開面前這扇門,看到屋里面還有一扇門(這門可能跟前面打開的門一樣大小,也可能門小了些),你走過去,發現手中的鑰匙還可以打開它,你推開門,發現里面還有一扇門,你繼續打開。若干次之后,你打開面前一扇門,發現只有一間屋子,沒有門了。 你開始原路返回,每走回一間屋子,你數一次,走到入口的時候,你可以回答出你到底用這鑰匙開了幾扇門。
循環:你打開面前這扇門,看到屋里面還有一扇門,(這門可能跟前面打開的門一樣大小,也可能門小了些),你走過去,發現手中的鑰匙還可以打開它,你推開門,發現里面還有一扇門,(前面門如果一樣,這門也是一樣,第二扇門如果相比第一扇門變小了,這扇門也比第二扇門變小了),你繼續打開這扇門,一直這樣走下去。 入口處的人始終等不到你回去告訴他答案。
簡單來說,遞歸就是有去有回,循環就是有去無回。
我們可以用如下圖來表示程序中循環調用的過程:
於是我們可以用遞歸查找的方式去實現上述這一過程。
時間復雜度:\(O(2^n)\)
空間復雜度:\(O(1)\)
/**
遞歸實現
*/
int Fibonacci_Re(int num){
if(num == 0){
return 0;
}
else if(num == 1){
return 1;
}
else{
return Fibonacci_Re(num - 1) + Fibonacci_Re(num - 2);
}
}
線性遞歸查找
It's amazing!!!如此高的時間復雜度,我們定然是不會滿意的,該算法有巨大的改進空間。我們是否可以在某種意義下對這個遞歸過程進行改進,來優化這個時間復雜度。還是從上面這個開門的例子來講,我們經歷了順路打開門和原路返回數門這兩個過程,我們是不是可以考慮在邊開門的過程中邊數我們一路開門的數量呢?這對時間代價上會帶來極大的改進,那我們想想看該怎么辦呢?
為消除遞歸算法中重復的遞歸實例,在各子問題求解之后,及時記錄下其對應的解答。比如可以從原問題出發自頂向下,每當遇到一個子問題,都首先查驗它是否已經計算過,以此通過直接調閱紀錄獲得解答,從而避免重新計算。也可以從遞歸基出發,自底而上遞推的得出各子問題的解,直至最終原問題的解。前者即為所謂的制表或記憶策略,后者即為所謂的動態規划策略。
為應用上述的制表策略,我們可以從改造 \(Fibonacci\) 數的遞歸定義入手。我們考慮轉換成如下的遞歸函數,即可計算一對相鄰的Fibonacci數:
\((Fibonacci \_ Re(k-1),Fibonacci \_ Re(k-1))\),得到如下更高效率的線性遞歸算法。
時間復雜度:$ O(n) $
空間復雜度:$ O(n) $
/**
線性遞歸實現
*/
int Fibonacci_Re(int num, int& prev){
if(num == 0){
prev = 1;
return 0;
}
else{
int prevPrev;
prev = Fibonacci_Re(num - 1, prevPrev);
return prevPrev + prev;
}
}
該算法呈線性遞歸模式,遞歸的深度線性正比於輸入 \(num\) ,前后共計僅出現 \(O(n)\) 個實例,累計耗時不超過 \(O(n)\)。遺憾的是,該算法共需要使用 \(O(n)\) 規模的附加空間。如何進一步改進呢?
減而治之
若將以上逐層返回的過程,等效地視作從遞歸基出發,按規模自小而大求解各子問題的過程,即可采用動態規划的過程。我們完全可以考慮通過增加變量的方式代替遞歸操作,犧牲少量的空間代價換取時間效率的大幅度提升,於是我們就有了如下的改進方式,通過中間變量保存 \(F(n-1)\) 和 \(F(n-2)\),利用元素的交換我們可以實現上述等價的一個過程。此時在空間上,我們由 \(O(1)\) 變成了 \(O(4)\),由於申請的空間數量仍為常數個,我們可以近似的認為空間效率仍為 \(O(1)\)。
時間復雜度:\(O(n)\)
空間復雜度:\(O(1)\)
/**
非遞歸實現(減而治之1)
*/
int Fibonacci_No_Re(int num){
if(num == 0){
return 0;
}
else if(num == 1){
return 1;
}
else{
int a = 0;
int b = 1;
int c = 1;
while(num > 2){
a = b;
b = c;
c = a + b;
num--;
}
return c;
}
}
我們甚至還可以對變量的數量進行優化,將 \(O(4)\) 變成了 \(O(3)\),減少一個單位空間的浪費,我們可以實現如下這一過程:
/**
非遞歸實現(減而治之2)
*/
int Fibonacci_No_Re(int num){
int a = 1;
int b = 0;
while(0 < num--){
b += a;
a = b - a;
}
return b;
}
分而治之(二分查找)
而當我們面對輸入相對較為龐大的數據時,每每感慨於頭緒紛雜而無從下手的你,不妨先從孫子的名言中獲取靈感——“凡治眾如治寡,分數是也”。是的,解決此類問題的最有效方法之一,就是將其分解為若干規模更小的子問題,再通過遞歸機制分別求解。這種分解持續進行,直到子問題規模縮減至平凡情況,這也就是所謂的分而治之策略。
與減而治之策略一樣,這里也要求對原問題重新表述,以保證子問題與原問題在接口形式上的一致。既然每一遞歸實例都可能做多次遞歸,故稱作為多路遞歸。我們通常都是將原問題一分為二,故稱作為二分遞歸。
按照二分遞歸的模式,我們可以再次求和斐波那契求和問題。
時間復雜度:$O(log(n)) $
空間復雜度:$ O(1) $
/**
二分查找(遞歸實現)
*/
int binary_find(int arr[], int num, int arr_size, int left, int right){
assert(arr);
int mid = (left + right) / 2;
if(left <= right){
if(num < arr[mid]){
binary_find(arr, num, arr_size, left, mid - 1);
}
else if(num > arr[mid]){
binary_find(arr, num, arr_size, mid + 1, right);
}
else{
return mid;
}
}
}
當然我們也可以不采用遞歸模式,按照上面的思路,仍采用分而治之的模式進行求解。
時間復雜度:$ O(log(n)) $
空間復雜度:$ O(1) $
/**
二分查找(非遞歸實現)
*/
int binary_find(int arr[], int num, int arr_size){
if(num == 0){
return 0;
}
else if(num == 1){
return 1;
}
int left = 0;
int right = arr_size - 1;
while(left <= right){
int mid = (left + right) >> 1;
if(num > arr[mid]){
left = mid + 1;
}
else if(num < arr[mid]){
right = mid - 1;
}
else{
return mid;
}
}
return -1;
}
矩陣快速冪
為了正確高效的計算斐波那契數列,我們首先需要了解以下這個矩陣等式:
為了推導出這個等式,我們首先有:
隨即得到:
同理可得:
所以:
又由於\(F(1) = 1\),\(F(0) = 0\),\(F(-1) = 1\),則我們得到了開始給出的矩陣等式。當然,我們也可以通過數學歸納法來證明這個矩陣等式。等式中的矩陣
被稱為斐波那契數列的 \(Q\)- 矩陣。
通過 \(Q\)- 矩陣,我們可以利用如下公式進行計算 \(F_n\):
如此一來,計算斐波那契數列的問題就轉化為了求 \(Q\) 的 \(n-1\) 次冪的問題。我們使用矩陣快速冪的方法來達到 \(O(log(n))\) 的復雜度。借助分治的思想,快速冪采用以下公式進行計算:
實現過程如下:
時間復雜度:\(O(log(n))\)
空間復雜度:\(O(1)\)
//矩陣數據結構定義
#define MOD 100000
struct matrix{
int a[2][2];
}
//矩陣相乘函數的實現
matrix mul_matrix{
matrix res;
memset(res.a, 0, sizeof(res.a));
for(int i = 0; i < 2; i++){
for(int j = 0; i < 2; j++){
for(int k = 0; k < 2; k++){
res.a[i][j] += x.a[i][k] * y.a[k][j];
res.a[i][j] %= MOD;
}
}
}
return res;
}
int pow(int n)
{
matrix base, res;
//將res初始化為單位矩陣
for(int i = 0; i < 2; i++){
res.a[i][i] = 1;
}
//給base矩陣賦予初值
base.a[0][0] = 1;
base.a[0][1] = 1;
base.a[1][0] = 1;
base.a[1][1] = 0;
while(n > 0)
{
if(n % 2 == 1){
res *= base;
}
base *= base;
n >>= 1;//n = n / 2;
}
return res.a[0][1];//或者a[1][0]
}
對於斐波那契數列,我們還有以下這樣的遞推公式:
為了得到以上遞歸式,我們依然需要利用 \(Q\)- 矩陣。由於 $ Q^m Q^n = Q^{m+n} $,展開得到:
將該式中 \(n\) 替換為 \(n+1\) 可得:
在如上兩個等式中令 \(m=n\),則可得到開頭所述遞推公式。利用這個新的遞歸公式,我們計算斐波那契數列的復雜度也為 \(O(log(n))\),並且實現起來比矩陣的方法簡單一些:
時間復雜度:\(O(log(n))\)
空間復雜度:\(O(1)\)
int Fibonacci_recursion_fast(int num){
if(num == 0){
return 0;
}
else if(num == 1){
return 1;
}
else{
int k = num % 2 ? (num + 1) / 2 : num / 2;
int fib_k = Fibonacci_recursion_fast(k);
int fib_k_1 = Fibonacci_recursion_fast(k - 1);
return num % 2 ? power(fib_k, 2) + power(fib_k_1, 2) : (2 * fib_k_1 + fib_k) * fib_k;
}
}
公式法
我們還有沒有更快的方法呢?對於斐波那契數列這個常見的遞推數列,其第 \(n\) 項的值的通項公式如下:
既然作為工科生,那肯定要用一些工科生的做法來證明這個公式呀,嘿嘿,下面開始我的表演~
我們回想一下,斐波那契數列的所有的值可以看成在數軸上的一個個離散分布的點的集合,學過數字信號處理或者自動控制原理的同學,這個時候,我們很容易想到用Z變換來求解該類問題。
\(Z\) 變換常用的規則表如下:
當 \(n>1\) 時,由 \(f(n) = f(n-1) + f(n-2)\) (這里我們用小寫的 \(f\) 來區分):
由於 \(n >= 0\),所以我們可以把其表示為\(f(n+2) = f(n+1) + f(n)\),其中 \(n >= 0\)。
所以我們利用上式前向差分方程,兩邊取 \(Z\) 變換可得:
所以有:
又 \(f(0) = 0,f(1) = 1\),整理可得:
我們取 \(Z\) 的逆變換可得:
我們最終可以得到如下通項公式:
更多的證明方法可以參考知乎上的一些數學大佬:https://www.zhihu.com/question/25217301
實現過程如下:
時間復雜度:\(O(1)\)
空間復雜度:\(O(1)\)
/**
純公式求解
*/
int Fibonacci_formula(int num){
double root_five = sqrt(5 * 1.0);
int result = ((((1 + root_five) / 2, num)) - (((1 - root_five) / 2, num))) / root_five
return result;
}
該方法雖然看起來高效,但是由於涉及大量浮點運算,在 \(n\) 增大時浮點誤差不斷增大會導致返回結果不正確甚至數據溢出。