每日一題12:用數組加速遞歸

許多程序設計教學書上都用斐波那契數列(數列中第一二項都是1,其它任意一項都是其前兩項之和)作爲講解遞歸的例子,作爲教學例子,它確實十分合適,但是如果用在實際計算中,那麼遞歸實現的斐波那契數列求值實在是太慢了,其中主要的原因是重複計算太多,這樣的遞歸算法不僅速度效率低下,還容易造成棧溢出。如果能夠保留下已經計算過的值,但需要時直接取用而不是重複計算,那麼必然會提高程序性能。
對於斐波那契數列求解使用一個一維數組來保存之前的計算值,是十分合適的:

#include "stdafx.h"

#include <iostream>

using namespace std;
//遞歸解法
double Fibonacci(int n)
{
    if(n < 0) return -1;
    if(n == 0 || n == 1) return 1;
    //重複計算:比如計算Fibonacci(n - 1)時,就會計算
    //Fibonacci(n - 2)
    return Fibonacci(n - 1) + Fibonacci(n - 2);
}
//利用一維數組的解法
double Fibonacci_a(int n)
{
    if(n < 0) return -1;
    if(n == 0 || n == 1) return 1;
    int m = n + 1;
    double *fibo = new double[m];
    fibo[0] = fibo[1] = 1;
    for (int i = 2; i < m; ++i)
    {
        fibo[i] = fibo[i - 1] + fibo[i - 2];
    }
    return fibo[n];
}

上述程序中,當n爲50的時候程序運行遞歸版就已經十分緩慢了,而數組版當n達到我的系統能接受的最大值1475時,在覺察不到的等待時間內就得出了答案。而內存佔用無非就是n加一些必須的字節而已。
這個解法讓我想起了剛開始接觸編程那會學院裏組織的一次編程比賽,其中有一個就是需要利用數組加速遞歸的,那個題目已經忘記了,但是憑記得的大概也可以構造出一個來(不確定是不是原題目,但是思路是一樣的),當時我提交的是一段投機取巧的解法,也許對於給定的輸入可以給出答案,但是其他的就沒有保證了。題目大概如下:
輸入m、n、p三個非負整數,計算f(m,n,p)的值,其中,當m、n、p都不爲0時,f(m,n,p) = f(m - 1,n,p) + f(m,n - 1,p) + f(m,n,p - 1),如果m、n、p中任一個爲0時,f(m,n,p) = 1。按照定義使用遞歸可以很容易地得出解法,但是當m、n、p中任意一個數達到10的時候,程序運行起來就十分緩慢,原因就是重複計算實在太多,遞歸分支以指數級別增加(以3爲底),所以必然不能用遞歸的方式求值。比賽完了之後,出題人(同班同學)問我用什麼方式實現的,我說是假的,他提示我應該用一個三維數組,當時也沒反應過來,不知道怎麼用,這段時間開始學算法(之前都學了一些花架子,悔不當初啊),總算反應過來,所以實現了一個利用三維數組替換遞歸的方法,解題思路和斐波那契數列是一摸一樣的,只是在計算數組元素位置、初始化記錄數組的時候有一點點不一樣而已(主要是因爲在c或C++中三維數組與一維數組的對應關係是由程序員編寫造成的,否則哪那麼多事),多餘的就不說了,直接上代碼:

//遞歸版
double f(int i,int j, int k)
{
    if(i < 0 || j < 0 || k < 0) return -1;
    if(i == 0 || j == 0 || k == 0) return 1;
    return f(i - 1,j,k) + f(i,j - 1,k) + f(i,j,k - 1);
}

//三維數組版
double f_a(int m,int n, int p)
{
    if(m < 0 || n < 0 || p < 0) return -1;
    if(m == 0 || n == 0 || p == 0) return 1;
    m++;
    n++;
    p++;
    double *f_array = new double[m*n*p];
    memset(f_array,0,m*n*p*sizeof(double));
    for (int j = 0; j < n; ++j)
    {
        for (int k = 0; k < p; ++k)
        {
            f_array[j*p + k] = 1;
        }
    }
    for (int i = 0; i < m; ++i)
    {
        for (int k = 0; k < p; ++k)
        {
            f_array[i*n*p + k] = 1;
        }
    }
    for (int i = 0; i < m; ++i)
    {
        for (int j = 0; j < p; ++j)
        {
            f_array[(i*n + j)*p] = 1;
        }
    }
    //這個三重循環不是按行、列、“厚度”的順序,而是倒過來的
    for (int k = 1; k < p; ++k)
    {
        for (int j = 1; j < n; ++j)
        {
            for (int i = 1; i < m; ++i)
            {
                f_array[(i*n + j)*p + k] = f_array[((i-1)*n + j)*p + k] + f_array[(i*n + j - 1)*p + k] + f_array[(i*n + j)*p + k - 1];
            }
        }
    }
    return f_array[m* n * p - 1];
}


int _tmain(int argc, _TCHAR* argv[])
{
    int n = 100;
    cout<<f_a(n,n,n)<<endl;
    return 0;
}

當n取100時基本上也是立馬出結果,當n取到200時,需要等待1s左右,這時候大量的計算不是花在真正的加法上,而是花在了數據的讀取(包括位置計算、指針移動),計算出來的數也是快到10的300次方了(當輸入較小時,我對比了兩個版本的結果,所以數組版的程序還是比較可信,我只保證理論上是完全正確的,數比較大時,我沒有提供任何看得到的證據)。
用《數據結構與算法分析:C語言描述》第一章裏的對於遞歸算法設計的準則作爲結語吧:
1)基準情形。必須總有某些基準情形,它無需遞歸就能解出。
2)不斷推進。對於那些需要遞歸求解的情形,每一次遞歸調用都必須要使求解狀況朝接近基準情形的方向推進。
3)設計法則。假設所有的遞歸調用都能運行。
4)合成效益法則。在求解一個問題的同一實例時,切勿在不同的遞歸調用中做重複的工作。
很明顯上面兩個問題的遞歸解法都違背了第四條準則。

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