[最全演算法總結]我是如何將遞迴演算法的複雜度優化到O(1)的

Angel_Kitty發表於2019-07-16

相信提到斐波那契數列,大家都不陌生,這個是在我們學習 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

遞迴在數學與電腦科學中,是指在函式的定義中使用函式自身的方法,可能有些人會把遞迴和迴圈弄混淆,我覺得務必要把這一點區分清楚才行。

遞迴查詢

舉個例子,給你一把鑰匙,你站在門前面,問你用這把鑰匙能開啟幾扇門。

遞迴:你開啟面前這扇門,看到屋裡面還有一扇門(這門可能跟前面開啟的門一樣大小,也可能門小了些),你走過去,發現手中的鑰匙還可以開啟它,你推開門,發現裡面還有一扇門,你繼續開啟。若干次之後,你開啟面前一扇門,發現只有一間屋子,沒有門了。 你開始原路返回,每走回一間屋子,你數一次,走到入口的時候,你可以回答出你到底用這鑰匙開了幾扇門。

迴圈:你開啟面前這扇門,看到屋裡面還有一扇門,(這門可能跟前面開啟的門一樣大小,也可能門小了些),你走過去,發現手中的鑰匙還可以開啟它,你推開門,發現裡面還有一扇門,(前面門如果一樣,這門也是一樣,第二扇門如果相比第一扇門變小了,這扇門也比第二扇門變小了),你繼續開啟這扇門,一直這樣走下去。 入口處的人始終等不到你回去告訴他答案。

簡單來說,遞迴就是有去有回,迴圈就是有去無回

我們可以用如下圖來表示程式中迴圈呼叫的過程:

return

於是我們可以用遞迴查詢的方式去實現上述這一過程。

時間複雜度:\(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\) 變換常用的規則表如下:

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\) 增大時浮點誤差不斷增大會導致返回結果不正確甚至資料溢位。

相關文章