漫談遞歸:從斐波那契開始瞭解尾遞歸

本文轉自:http://www.nowamagic.net/librarys/veda/detail/2325 

尾遞歸(tail recursive),看名字就知道是某種形式的遞歸。簡單的說遞歸就是函數自己調用自己。那尾遞歸和遞歸之間的差別就只能體現在參數上了。

尾遞歸wiki解釋如下:

尾部遞歸是一種編程技巧。遞歸函數是指一些會在函數內調用自己的函數,如果在遞歸函數中,遞歸調用返回的結果總被直接返回,則稱爲尾部遞歸。尾部遞歸的函數有助將算法轉化成函數編程語言,而且從編譯器角度來說,亦容易優化成爲普通循環。這是因爲從電腦的基本面來說,所有的循環都是利用重複移跳到代碼的開頭來實現的。如果有尾部歸遞,就只需要疊套一個堆棧,因爲電腦只需要將函數的參數改變再重新調用一次。利用尾部遞歸最主要的目的是要優化,例如在Scheme語言中,明確規定必須針對尾部遞歸作優化。可見尾部遞歸的作用,是非常依賴於具體實現的。

我們還是從簡單的斐波那契開始瞭解尾遞歸吧。

用普通的遞歸計算Fibonacci數列:

#include "stdio.h"
#include "math.h"

int factorial(int n);

int main(void)
{
    int i, n, rs;

    printf("請輸入斐波那契數n:");
    scanf("%d",&n);

    rs = factorial(n);
    printf("%d \n", rs);

    return 0;
}

// 遞歸
int factorial(int n)
{
    if(n <= 2)
    {
        return 1;
    }
    else
    {
        return factorial(n-1) + factorial(n-2);
    }
}

程序員運行結果如下:

請輸入斐波那契數n:20
6765

Process returned 0 (0x0)   execution time : 3.502 s
Press any key to continue.

在i5的CPU下也要花費 3.502 秒的時間。

下面我們看看如何用尾遞歸實現斐波那契數。

#include "stdio.h"
#include "math.h"

int factorial(int n);

int main(void)
{
    int i, n, rs;

    printf("請輸入斐波那契數n:");
    scanf("%d",&n);

    rs = factorial_tail(n, 1, 1);
    printf("%d ", rs);

    return 0;
}

int factorial_tail(int n,int acc1,int acc2)
{
    if (n < 2)
    {
        return acc1;
    }
    else
    {
        return factorial_tail(n-1,acc2,acc1+acc2);
    }
}

程序員運行結果如下:

請輸入斐波那契數n:20
6765
Process returned 0 (0x0)   execution time : 1.460 s
Press any key to continue.

快了一倍有多。當然這是不完全統計,有興趣的話可以自行計算大規模的值,這裏只是介紹尾遞歸而已。

我們可以打印一下程序的執行過程,函數加入下面的打印語句:

int factorial_tail(int n,int acc1,int acc2)
{
    if (n < 2)
    {
        return acc1;
    }
    else
    {
        printf("factorial_tail(%d, %d, %d) \n",n-1,acc2,acc1+acc2);
        return factorial_tail(n-1,acc2,acc1+acc2);
    }
}

程序運行結果:

請輸入斐波那契數n:10
factorial_tail(9, 1, 2)
factorial_tail(8, 2, 3)
factorial_tail(7, 3, 5)
factorial_tail(6, 5, 8)
factorial_tail(5, 8, 13)
factorial_tail(4, 13, 21)
factorial_tail(3, 21, 34)
factorial_tail(2, 34, 55)
factorial_tail(1, 55, 89)
55
Process returned 0 (0x0)   execution time : 1.393 s
Press any key to continue.

從上面的調試就可以很清晰地看出尾遞歸的計算過程了。acc1就是第n個數,而acc2就是第n與第n+1個數的和,這就是我們前面講到的“迭代”的精髓,計算結果參與到下一次的計算,從而減少很多重複計算量。

fibonacci(n-1,acc2,acc1+acc2)真是神來之筆,原本樸素的遞歸產生的棧的層次像二叉樹一樣,以指數級增長,但是現在棧的層次卻像是數組,變成線性增長了,實在是奇妙,總結起來也很簡單,原本棧是先擴展開,然後邊收攏邊計算結果,現在卻變成在調用自身的同時通過參數來計算。

小結

尾遞歸的本質是:將單次計算的結果緩存起來,傳遞給下次調用,相當於自動累積。

在Java等命令式語言中,尾遞歸使用非常少見,因爲我們可以直接用循環解決。而在函數式語言中,尾遞歸卻是一種神器,要實現循環就靠它了。

很多人可能會有疑問,爲什麼尾遞歸也是遞歸,卻不會造成棧溢出呢?因爲編譯器通常都會對尾遞歸進行優化。編譯器會發現根本沒有必要存儲棧信息了,因而會在函數尾直接清空相關的棧。

以上爲轉載全文。

另外一篇文章:http://www.cnblogs.com/Anker/archive/2013/03/04/2943498.html 

百度百科對尾遞歸的一些解釋如下:

如果一個函數中所有遞歸形式的調用都出現在函數的末尾,我們稱這個遞歸函數是尾遞歸的。當遞歸調用是整個函數體中最後執行的語句且它的返回值不屬於表達式的一部分時,這個遞歸調用就是尾遞歸。尾遞歸函數的特點是在迴歸過程中不用做任何操作,這個特性很重要,因爲大多數現代的編譯器會利用這種特點自動生成優化的代碼。

原理:當編譯器檢測到一個函數調用是尾遞歸的時候,它就覆蓋當前的活動記錄而不是在棧中去創建一個新的。編譯器可以做到這點,因爲遞歸調用是當前活躍期內最後一條待執行的語句,於是當這個調用返回時棧幀中並沒有其他事情可做,因此也就沒有保存棧幀的必要了。通過覆蓋當前的棧幀而不是在其之上重新添加一個,這樣所使用的棧空間就大大縮減了,這使得實際的運行效率會變得更高。雖然編譯器能夠優化尾遞歸造成的棧溢出問題,但是在編程中,我們還是應該儘量避免尾遞歸的出現,因爲所有的尾遞歸都是可以用簡單的goto循環替代的。

本人理解(怎麼識別尾遞歸):

尾遞歸是把函數(當前遞歸函數)的一些計算結果作爲參數傳遞給遞歸函數(下一遞歸函數),遞歸函數的返回值不參與計算;

而一般遞歸中,函數(當前遞歸函數)需要遞歸函數(下一遞歸函數)的返回值參與計算。



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