相信提到斐波那契數列,你們都不陌生,這個是在咱們學習 C/C++ 的過程當中必然會接觸到的一個問題,而做爲一個經典的求解模型,咱們怎麼能少的了去研究這個模型呢?筆者在不斷地學習和思考過程當中,發現了這類經典模型居然有如此多的有意思的求解算法,能讓這個經典問題的時間複雜度下降到 \(O(1)\) ,下面我想對這個經典問題的求解作一個較爲深刻的剖析,請聽我娓娓道來。算法
咱們能夠用以下遞推公式來表示斐波那契數列 \(F\) 的第 \(n\) 項:
\[ F(n) = \begin{cases} 0, & n = 0 \\ 1, & n = 1 \\ F(n-1) + F(n-2), & n > 1 \end{cases} \]
回顧一下咱們剛開始學 \(C\) 語言的時候,講到函數遞歸那節,老師老是喜歡那這個例子來講。shell
斐波那契數列就是像蝸牛的殼同樣,越深刻其中,越能發覺其中的奧祕,造成一條條優美的數學曲線,就像這樣:
數據結構
遞歸在數學與計算機科學中,是指在函數的定義中使用函數自身的方法,可能有些人會把遞歸和循環弄混淆,我以爲務必要把這一點區分清楚才行。函數
舉個例子,給你一把鑰匙,你站在門前面,問你用這把鑰匙能打開幾扇門。學習
遞歸:你打開面前這扇門,看到屋裏面還有一扇門(這門可能跟前面打開的門同樣大小,也可能門小了些),你走過去,發現手中的鑰匙還能夠打開它,你推開門,發現裏面還有一扇門,你繼續打開。若干次以後,你打開面前一扇門,發現只有一間屋子,沒有門了。 你開始原路返回,每走回一間屋子,你數一次,走到入口的時候,你能夠回答出你到底用這鑰匙開了幾扇門。優化
循環:你打開面前這扇門,看到屋裏面還有一扇門,(這門可能跟前面打開的門同樣大小,也可能門小了些),你走過去,發現手中的鑰匙還能夠打開它,你推開門,發現裏面還有一扇門,(前面門若是同樣,這門也是同樣,第二扇門若是相比第一扇門變小了,這扇門也比第二扇門變小了),你繼續打開這扇門,一直這樣走下去。 入口處的人始終等不到你回去告訴他答案。spa
簡單來講,遞歸就是有去有回,循環就是有去無回。3d
咱們能夠用以下圖來表示程序中循環調用的過程:code
因而咱們能夠用遞歸查找的方式去實現上述這一過程。orm
時間複雜度:\(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; }
爲了正確高效的計算斐波那契數列,咱們首先須要瞭解如下這個矩陣等式:
\[ \left[ \begin{matrix} F_{n+1} & F_n\\ F_n & F_{n-1} \end{matrix} \right] = \left[ \begin{matrix} 1 & 1\\ 1 & 0 \end{matrix} \right] \]
爲了推導出這個等式,咱們首先有:
\[ \left[ \begin{matrix} F_{n+1} \\ F_n \end{matrix} \right] = \left[ \begin{matrix} 1 & 1\\ 1 & 0 \end{matrix} \right] \left[ \begin{matrix} F_{n} \\ F_{n-1} \end{matrix} \right] \]
隨即獲得:
\[ \left[ \begin{matrix} F_{n+1} \\ F_n \end{matrix} \right] = \left[ \begin{matrix} 1 & 1\\ 1 & 0 \end{matrix} \right]^n \left[ \begin{matrix} F_{1} \\ F_{0} \end{matrix} \right] \]
同理可得:
\[ \left[ \begin{matrix} F_{n} \\ F_{n-1} \end{matrix} \right] = \left[ \begin{matrix} 1 & 1\\ 1 & 0 \end{matrix} \right]^{n-1} \left[ \begin{matrix} F_{1} \\ F_{0} \end{matrix} \right] = \left[ \begin{matrix} 1 & 1\\ 1 & 0 \end{matrix} \right]^{n} \left[ \begin{matrix} F_{0} \\ F_{-1} \end{matrix} \right] \]
因此:
\[ \left[ \begin{matrix} F_{n+1} & F_n\\ F_{n} & F_{n-1} \end{matrix} \right] = \left[ \begin{matrix} 1 & 1\\ 1 & 0 \end{matrix} \right]^n \left[ \begin{matrix} F_{1} & F_{0}\\ F_{0} & F_{-1} \end{matrix} \right] \]
又因爲\(F(1) = 1\),\(F(0) = 0\),\(F(-1) = 1\),則咱們獲得了開始給出的矩陣等式。固然,咱們也能夠經過數學概括法來證實這個矩陣等式。等式中的矩陣
\[ \left[ \begin{matrix} 1 & 1\\ 1 & 0 \end{matrix} \right] \]
被稱爲斐波那契數列的 \(Q\)- 矩陣。
經過 \(Q\)- 矩陣,咱們能夠利用以下公式進行計算 \(F_n\):
\[ F_n = (Q^{n-1})_{1,1} \]
如此一來,計算斐波那契數列的問題就轉化爲了求 \(Q\) 的 \(n-1\) 次冪的問題。咱們使用矩陣快速冪的方法來達到 \(O(log(n))\) 的複雜度。藉助分治的思想,快速冪採用如下公式進行計算:
\[ A^n = \begin{cases} A(A^2)^{\frac{n-1}{2}}, & if \ n \ is \ odd \\ (A^2)^{\frac{n}{2}}, & if \ n \ is \ even \end{cases} \]
實現過程以下:
時間複雜度:\(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] }
對於斐波那契數列,咱們還有如下這樣的遞推公式:
\[ F_{2n - 1} = F_n^{2} + F_{n-1}^2 \]
\[ F_{2n} = (2F_{n-1} + F_n) \cdot F_n \]
爲了獲得以上遞歸式,咱們依然須要利用 \(Q\)- 矩陣。因爲 $ Q^m Q^n = Q^{m+n} $,展開獲得:
\[ F_mF_n + F_{m-1}F_{n-1} = F_{m+n-1} \]
將該式中 \(n\) 替換爲 \(n+1\) 可得:
\[ F_mF_{n+1} + F_{m-1}F_{n} = F_{m+n} \]
在如上兩個等式中令 \(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\) 項的值的通項公式以下:
\[ a_n = \dfrac{(\dfrac{1+\sqrt{5}}{2})^n - (\dfrac{1-\sqrt{5}}{2})^n}{\sqrt{5}}, (n> = 0) \]
既然做爲工科生,那確定要用一些工科生的作法來證實這個公式呀,嘿嘿,下面開始個人表演~
咱們回想一下,斐波那契數列的全部的值能夠當作在數軸上的一個個離散分佈的點的集合,學過數字信號處理或者自動控制原理的同窗,這個時候,咱們很容易想到用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\) 變換可得:
\[ \sum_{n=-\infty}^{+\infty}f(n+2) \cdot Z^{-n} = \dfrac{\sum_{n=-2}^{+\infty}f(n+2) \cdot Z^{-n} \cdot Z^{-2}}{Z^{-2}} - Z \cdot f(1) - Z^2 \cdot f(0) = Z^2F(Z) - Z^2f(0) - Zf(1) \]
\[ \sum_{n=-\infty}^{+\infty}f(n+1) \cdot Z^{-n} = \dfrac{\sum_{n=-1}^{+\infty}f(n+1) \cdot Z^{-n} \cdot Z^{-1}}{Z^{-1}} - Z \cdot f(0) = ZF(Z) - Zf(0) \]
\[ \sum_{n=-\infty}^{+\infty}f(n) \cdot Z^{-n} = F(Z) \]
因此有:
\[ Z^{2}F(Z)-Z^{2}f(0) -Zf(1) = ZF(Z) - Zf(0) + F(Z) \]
又 \(f(0) = 0,f(1) = 1\),整理可得:
\[ F(Z) = \dfrac{Z}{Z^{2} - Z} = \dfrac{1}{\sqrt{5}}\left(\dfrac{Z}{Z-\dfrac{1 + \sqrt{5}}{2}} - \dfrac{Z}{Z-\dfrac{1 - \sqrt{5}}{2}}\right) \]
咱們取 \(Z\) 的逆變換可得:
\[ f(n) = \dfrac{(\dfrac{1+\sqrt{5}}{2})^n - (\dfrac{1-\sqrt{5}}{2})^n}{\sqrt{5}}, (n > 1) \]
咱們最終能夠獲得以下通項公式:
\[ a_n = \dfrac{(\dfrac{1+\sqrt{5}}{2})^n - (\dfrac{1-\sqrt{5}}{2})^n}{\sqrt{5}}, (n> = 0) \]
更多的證實方法能夠參考知乎上的一些數學大佬: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\) 增大時浮點偏差不斷增大會致使返回結果不正確甚至數據溢出。