1. 程式人生 > >演算法精解-C語言描述 遞迴和尾遞迴 (圖解+例項)

演算法精解-C語言描述 遞迴和尾遞迴 (圖解+例項)


遞迴是一種強大的方法,它允許一個物件以其自身更小的形式來定義自己。

讓我們來觀察一下自然界中出現的遞迴現象:蕨類植物的葉子,每片葉子葉脈中的小分支都是整片葉子的較小縮影;又或者兩個反光的物體,相互對映對方漸遠的影像。這樣的例子使我們明白,儘管大自然的力量是強大的,在許多方面它那種出乎意料的簡潔更讓我們覺得優美。同樣的道理也可以用在遞迴演算法上,從很多方面來說遞迴演算法都是簡潔而優美的,而且非常強大。

在電腦科學領域中,遞迴是通過函式來實現的。遞迴函式是一種可以呼叫自身的函式。

基本遞迴

假設我們想計算整數n的階乘。n的階乘可能寫作n!,其結果是1~n之間的各數之積。比如,4!=4 x 3 x 2 x 1。一種方法是迴圈遍歷其中的每一個數,然後與它之前的數相乘作為結果再參與下一次計算。這種方法稱為迭代法,可以正式定義為:

n! = n(n-1)(n-2)...(1)

看待這個問題的另一種方式是將n!定義為更小的階乘形式。我們將n!定義為n-1階乘的n倍。再把(n-1)!定義為n-1倍的(n-2)!,(n-2)!看作(n-2)倍的(n-3)!,一直到n=1時,我們就計算完了。這就是遞迴的方式,可以正式定義為:

1
如果 n=0,n=1
f(n)=
nf(n)
如果 n>1

4!的遞迴樹

圖1: 以遞迴的方式計算4的階乘

上圖(1)展示了利用遞迴計算4!的過程。它也說明了遞迴過程中的兩個基本階段:遞推和迴歸。在遞推階段,每一個遞迴呼叫通過進一步呼叫自己來記住這次遞迴過程。當其中有呼叫滿足終止條件時,遞推結束。比如,在計算n!時,終止條件是當n=1和n=0,此時函式只需簡單的返回1即可。每一個遞迴函式都必須擁有至少一個終止條件;否則遞推階段永遠不會結束了。一旦遞推階段結束,處理過程就進入迴歸階段,在這之前的函式呼叫以逆序的方式迴歸,直到最初呼叫的函式為止,此時遞迴過程結束。

以遞迴的方式計算n的階乘的函式實現:

C函式fact的工作方式如下:它接受一個整數n作為引數,如果n小於0,該函式直接返回0,這代表一個錯誤。如果n等於0或1,該函式返回1,這是因為0!和1!都等於1,以上是終止遞迴的條件。否則,函式返回n-1的階乘的n倍。而n-1的階乘又會以遞迴的方式再次呼叫fact來計算,如此繼續。

程式碼例項(1):fact.c

/*fact.c*/
#include "fact.h"
int fact(int n){
if (n<0)
    return 0;
else if(n==0)
    return 1;
else if(n==1)
    return 1;
else 
    return n*f(n-1);
}

為理解遞迴究竟是如何工作的,有必要先看看C語言中函式的執行方式。我們先來看看C程式在記憶體中的組織方式(見圖2-a)。基本上,一個可執行程式由4個區域組成:程式碼段、靜態資料區、堆與棧。程式碼段包含程式執行時所執行的機器指令。靜態資料區包含在程式生命週期內一直持久的資料,比如全域性變數和靜態區域性變數。堆包含程式執行時動態分配的儲存空間,比如malloc分配的記憶體。棧包含函式呼叫的資訊。

當C中呼叫了一個函式時,棧中會分配一塊空間來儲存與這個呼叫相關的資訊。每一個呼叫都被當做是活躍的。棧上的那塊儲存空間稱為活躍記錄(見圖2-b),或稱為棧幀。棧幀由5個區域組成:輸入引數、返回值空間、計算表示式時用到的臨時儲存空間、函式呼叫時儲存的狀態資訊以及輸出引數。輸入引數是傳遞到活躍記錄中的引數;輸出引數是傳遞給在活躍記錄中呼叫的函式所使用的。一個活躍記錄中的輸出引數就成為棧中下一個活躍記錄的輸入引數。函式呼叫所產生的活躍記錄將一直存在於棧中直到這個函式呼叫結束。


圖2: a)  C程式在記憶體中的組織形式  b)  一份活躍記錄

我們以示例fact.c為例,考慮一下當計算4!時棧中都發生了什麼(見圖3)?初始呼叫fact會在棧中產生一個活躍記錄,輸入引數n=4。由於這個呼叫沒有滿足函式的終止條件,因此fact將繼續以n=3為引數遞迴呼叫。這將在棧上建立另一個活躍記錄,但這次輸入引數n=3。這裡,n=3也是第一個活躍期中的輸出引數,因為正是在第一個活躍期內呼叫fact產生了第二個活躍期。這個過程將一直繼續,直到n的值變為1,此時滿足終止條件,fact將返回1。


圖3:遞迴計算4!時的C程式的棧

一旦n=1時的活躍期結束,n=2時的遞迴計算結果就是2X1=2,因而n=2時的活躍期也將結束,返回值為2。結果就是n=3時的遞迴計算結果表示為3X2=6,因此n=3時的活躍期結束,返回值為6。最終,當n=4時的遞迴計算結果將表示為6X4=24,n=4時的活躍期將結束,返回值為24。此時,函式已經從最初的呼叫中返回,遞迴過程結束。

棧是用來儲存函式呼叫資訊的絕好方案。這正是由於其後進先出的特點精確滿足了函式呼叫和返回的順序。然而,使用棧也有一些缺點,棧維護了每個函式呼叫的資訊直到函式返回後才釋放,這需要佔用相當大的空間,尤其是在程式中使用了許多遞迴呼叫的情況下。除此之外,因為有大量的資訊需要儲存和恢復,因此生產和銷燬活躍記錄需要耗費一定的時間。如此一來,當函式呼叫的開銷變的很大時,我們就需要考慮應該採用迭代的方案。幸運的是,我們可以使用一種稱為尾遞迴的特殊遞迴方式來避免前面提到的這些缺點。

尾遞迴 

如果一上函式中所有遞迴形式的呼叫都出現在函式的末尾,我們稱這個遞迴函式是尾遞迴的。當遞迴呼叫是整個函式體中最後執行的語句且它的返回值不屬於表示式的一部分時,這個遞迴呼叫就是尾遞迴。

尾遞迴函式的特點是在迴歸過程中不用做任何操作。

當編譯器檢測到一個函式呼叫是尾遞迴的時候,它就覆蓋當前的活躍記錄而不是在棧中去建立一個新的。編譯器可以做到這一點,因為遞迴呼叫是當前活躍期內最後一條待執行的語句,於是當這個呼叫返回時棧幀中並沒有其他事情可做,因此也就沒有儲存棧幀的必要了。通過覆蓋棧幀而不是在其之上重新新增一個,這樣所使用的棧空間就大大縮減了,這使得實際的執行效率會變得更高。因此, 只要有可能我們就需要將遞迴函式寫成尾遞迴的形式。

回憶之前對計算n!的定義:在每個活躍期計算n倍的(n-1)!的值,讓n=n-1並持續這個過程直到n=1為止。這種定義不是尾遞迴的,因為每個活躍期的返回值都依賴於用n乘以下一個活躍期的返回值,因此每次呼叫產生的棧幀不得不儲存在棧上直到下一個子呼叫的返回值確定。現在讓我們考慮以尾遞迴的形式來定義計算n!的過程。函式可以定義成如下形式:

a 如果  n=0,n=1
f(n,a)=
f(n-1,na) 如果  n>1
這種定義還需要接受第二個引數a,除此以外並沒有太大區別。a(初始化為1)維護遞迴層次的深度。這就避免了每次還需要將返回值再乘以n。而是在每次遞迴呼叫中令n=n-1並且a=na。繼續遞迴呼叫,直到n=1,這滿足結束條件,此時直接返回a即可。下圖(圖4)說明了用尾遞迴計算4!的過程。注意在迴歸的過程中不需要做任何的操作,這是所有尾遞迴函式的標誌。


圖4:以尾遞迴的方式計算4!

程式碼例項(2):facttail.c

facttail.c接受一個整數n並以尾遞迴的形式計算n的階乘。這個函式還接受一個引數a,a的初始值為1。函式使用a來維護遞迴層次的深度,除此之外它和fact很相似。

/*facttail.c*/
#include "facttail.h"
int facttail(int n,int a)
{
    if(n<0)
    return 0;
    else if(n==0)
    return 1;
    else if(n==1)
    return a;
    else
    return facttail(n-1,n*a);
}
facttail.c函式是尾遞迴的,因為對facttail的單次遞迴呼叫是函式返回前最後執行的一條語句。但這並不是必需的,換句話說,在遞迴呼叫之後還可以有其他語句執行,只是它們只能在遞迴呼叫沒有執行時才可以執行。下圖(圖5)展示了當使用尾遞迴函式計算4!時棧的使用情況,我們可以和上面講的未使用尾遞迴時棧的使用情況作一下對比:


圖5:以尾遞迴形式計算4!時棧的使用情況

遞迴和反向計算

下面我們來考慮一個使用遞迴處理反序的問題(在這類問題中使用遞迴比使用迴圈更簡單)。

問題是這樣的,編寫一個函式將一個整數轉換成二進位制形式。二進位制的意思是指數值以2為底數進行表示。

解決上述問題,需要使用一個演算法(algorithm)。因為奇數的二進位制形式的最後一位一定是1,而偶數的二進位制數的最後一位是0,所以可以通過5%2得出5的進位制形式中最後一位數字是1或者是0。一般來講,對於數值n,其二進位制數的最後一位是n%2因此計算出的第一個數字恰好是需要輸出的最後一位。這就需要使用一個遞迴函式實現。在函式中,首先在遞迴呼叫之前計算n%2的數值然後在遞迴呼叫語句之後進行輸出,這樣計算出的第一個數值反而在最後一個輸出。

為了得出下一個數字,需要把原數值除以2。這種計算就相當於在十進位制下把小數點左移一位。如果此時得出的數值是偶數,則下一個二進位制數是0;若得出的數值是奇數,則下一個二進位制數是1.例如,5/2的數值是2(整數除法),所以下一位值是0。這時已經得到了數值01.重複以上計算,即使用2/2得出1,而1%2的數值是1,因此下一位數是1.這時得到的數值是101.那麼何時停止這種計算呢?因為只要被2除的結果大於或等於2,那麼就還需要一位二進位制位進行表示,所以只有被2除的結果小於2時才停止計算。每次除以2就可以得出一位二進位制位值,直到計算出最後一位為止。

程式碼例項(3):binary.c

/*binary.c  --以二進位制形式輸出整數*/
#include <stdio.h>
void to_binary(unsigned long n);
int main(void)
{
    unsigned long number;
    printf("Enter an integer (q to quit): \n");
    while(scanf("%ul",&number)==1)
    {
        printf("Binary equivalent: ");
        to_binary(number);
        putchar('\n');
        printf("Enter an integer (q to quit): \n");
    }
    printf("Done.\n");
    return 0;
}
void to_binary(unsigned long n)/*遞迴函式*/
{
    int r ;
    r = n%2;
    if(n>=2)
        to_binary(n/2);
    putchar('0'+r);  /*以字元形式輸出*/

    return 0;
}

示例程式中,如果r 是0,表示式‘0’+r就是字元‘0’;當r為1時,則該表示式的值為字元‘1’。得出這種結果的前提假設是字元‘1’的數值編碼比字元‘0’的數值編碼大1.ASCII和EBCDIC兩種編碼都滿足上述條件。更一般的方式,你可以使用如下方法:

putchar(r ? '1' : '0' );

當然,不使用遞迴也能實現這個演算法。但是由於本演算法先計算出最後一位的數值,所以在顯示結果之前必須對所有的數值進行儲存。

遞迴的優缺點

優點是在於為某些程式設計問題提供了最簡單的方法,而缺點是一些遞迴演算法會很快耗盡記憶體。同時,使用遞迴的程式難於閱讀和維護。從下面的例子,可以看出遞迴的優缺點。

斐波納契數列定義如下:第一個和第二個數字都是1,而後續的每個數字是前兩個數字之和。例如,數列中前幾個數字是1,1,2,3,5,8,13.下面我們建立一個函式,它接受一個正整數n作為引數,返回相應的斐波納契數值。

首先,關於遞迴深度,遞迴提供了一個簡單的定義。如果呼叫函式Fionacci(),當n為1或2時Fabonacci(n)應返回1;對於其他數值應返回Fibonacci(n-1)+Fabonacci(n-2) :

程式碼例項(4)

long Fabonacci(int n)
{
    if(n>2)
        return Fibonacci(n-1)+Fibonacci(n-2);
    else 
        return 1;
}

這個C遞迴只是講述了遞迴的數學定義。同時本函式使用了雙重遞迴(double recursion);也就是說,函式對本身進行了兩次呼叫。這就會導致一個弱點。

為了具體說明這個弱點,先假設呼叫函式Fibonacci(40)。第1級遞迴會建立變數n。接著它兩次呼叫Fibonacci(),在第2級遞迴中又建立兩個變數n。上述的兩次呼叫中的每一次又進行了再次呼叫,因而在第3級呼叫中需要4個變數n,這時變數總數為7.因為每級呼叫需要的變數數是上級的兩倍,所以變數的個數是以指數規律增長的!這種情況下,指數增長的變數數會佔用大量記憶體,這就可能導致程式癱瘓。當然,以上是一個比較極端的例子,但它也表明了必須小心使用遞迴,尤其效率處於第一位時。

相關主題

遞迴樹:畫圖表能幫助我們形象地理解函式的呼叫順序。遞迴樹在形式上有所不同,展示遞迴計算階乘的圖1和圖4都是遞迴樹。遞迴樹最常用在包含兩個或更多個遞迴呼叫的函式中。