遞歸思想詳解

前言

說白了遞歸就象我們講的那個故事:山上有座廟,廟裏有個老和尚,老和尚在講故事,它講的故事是:山上有座廟,廟裏有個老和尚,老和尚在講故事,它講的故事是:……也就是直接或間接地調用了其自身。

就象上面的故事那樣,故事中包含了故事本身。因爲對自身進行調用,所以需對程序段進行包裝,也就出現了函數。

函數的利用是對數學上函數定義的推廣,函數的正確運用有利於簡化程序,也能使某些問題得到迅速實現。對於代碼中功能性較強的、重複執行的或經常要用到的部分,將其功能加以集成,通過一個名稱和相應的參數來完成,這就是函數或子程序,使用時只需對其名字進行簡單調用就能來完成特定功能。

例如我們把上面的講故事的過程包裝成一個函數,就會得到:

void Story()

{

    puts(“從前有座山,山裏有座廟,廟裏有個老和尚,老和尚在講故事,它講的故事是:”);

    getchar();//按任意鍵聽下一個故事的內容

    Story(); //老和尚講的故事,實際上就是上面那個故事

}

函數的功能是輸出這個故事的內容,等用戶按任意鍵後,重複的輸出這段內容。我們發現由於每個故事都是相同的,所以出現導致死循環的迂迴邏輯,故事將不停的講下去。出現死循環的程序是一個不健全的程序,我們希望程序在滿足某種條件以後能夠停下來,正如我們聽了幾遍相同的故事後會大叫:“夠了!”。於是我們可以得到下面的程序:

#include<stdio.h>

 

const int MAX = 3;

 

void Story(int n);//講故事

 

int main(void)

{

    Story(0);

   

       getchar();

    return 0;

}

 

void Story(int n)

{

    if (n < MAX)

    {

        puts(“從前有座山,山裏有座廟,廟裏有個老和尚,老和尚對小和尚說了一個故事:”);

        getchar();

        Story(n+1);

    }

    else

    {

        printf(“都講%d遍了!你煩不煩哪?\n”, n);

        return ;

    }

}

上面的Story函數設計了一個參數n,用來表示函數被重複的次數,當重複次數達到人們忍受的極限(MAX次)時,便停下來。

 

基本遞歸

    數學歸納法表明,如果我們知道某個論點對最小的情形成立,並且可以證明一個情形暗示着另一個情形,那麼我們就知道該論點對所有情形都成立。

    數學有時是按遞歸方式定義的。

例1:假設S(n)是前n個整數的和,那麼S(1)= 1,並且我們可以將S(n)寫成S(n)= S(n-1)+ n。

    根據遞歸公式,我們可以得到對應的遞歸函數:

int S(int n)    //求前n個整數的和

{

    if (n == 1)

        return 1;

    else

        return S(n-1) + n;

}

    函數由遞歸公式得到,應該是好理解的,要想求出S(n),得先求出S(n-1),遞歸終止的條件(遞歸出口)是(n == 1)。

    再舉一個典型的例子:斐波那契(Fibonacci)數列。

例2:斐波那契數列爲:1、1、2、3、5、8、13、21、…,即 fib(1)=1; fib(2)=1;  fib(n)=fib(n-1)+fib(n-2) (當n>2時)。

    我們曾經用迭代法解決了這個問題,實際上數列公式本身是一個遞歸公式,如果用遞歸算法來解將更自然。根據遞歸公式,很容易遞歸函數:

int Fib(int n)

{

    if (n < 1)//預防錯誤

        return 0;

if (n == 1 || n == 2)// 遞歸出口

        return 1;

       

    return Fib(n-1) + Fib(n-2);

}

 

    注意:儘管Fib似乎是在調用自身,但實際上它在調用自身的一個副本。該副本是具有不同參數的另一個函數。任何時候只有一個副本是活動的,其餘的都將被掛起。遞歸實現要求進行某種簿記工作來跟蹤掛起的遞歸調用,如果遞歸調用鏈非常長,計算機就會耗盡內存。

實際在上例中,當n = 40時,程序將變得緩慢,當n再大一些,內存就不夠了。

    不用說,這種特例不能說明遞歸調用是最佳的調用,因爲問題如此簡單,不用遞歸也能解決。大多數恰當使用遞歸的地方不會耗盡計算機內存,只是比非遞歸耗費稍多時間。但是,遞歸一般可以得到更緊湊的代碼。

     在實際編程中,有許多定義或者問題本身就具有遞歸性質。所以我們順其自然就想到用遞歸來解決,這樣不僅代碼少,而且結構清晰。但是問題是我們應該怎樣設計遞歸呢?這確實一個問題。由於許多問題並不是很明顯的表現出遞歸的關係,所有很大一部分需要我們進行推導,從而得出遞歸關係,有了遞歸關係,編寫代碼就相對的比較簡單了。

首先,我們瞭解遞歸算法的特點,所謂的遞歸,就是把一個不能或不好直接求解的“大問題”轉化成一個或幾個與原問題相似的“小問題”來解決,再把這些“小問題”進一步分解成更小的“小問題”來解決,如此分解,直至每個“小問題”都可以直接解決(此時分解到遞歸出口)。在逐步求解“小問題”後,再返回得到“大問題”的解。

因此,遞歸的執行過程由分解和求值兩部分構成。首先是逐步把“大問題”分解成形式相同但規模減小的“小問題”,直至分解到遞歸出口。一旦遇到遞歸出口,分解過程結束,開始求值過程,所以分解過程是“量變”過程,即原來的“”大問題”在慢慢變小,但尚未解決。遇到遞歸出口後,便發生了“質變”,即原遞歸問題轉換成直接問題。

由於遞歸只需要少量的步驟就可描述解題過程中所需要的多次重複計算,所以大大的減少了代碼量。遞歸算法設計的關鍵在於,找出遞歸方程和遞歸終止條件(又叫邊界條件或遞歸出口)。遞歸關係就是使問題向邊界條件轉化的過程,所以遞歸關係必須能使問題越來越簡單,規模越小。一定要知道,沒有設定邊界的遞歸是隻能‘遞’不能‘歸’的,即死循環。

因此,遞歸算法設計,通常有以下3個步驟:

1. 分析問題,得出遞歸關係。

2. 設置邊界條件,控制遞歸。

3. 設計函數,確定參數。

我們來看一個簡單的應用。

例3:樓梯有n階臺階,上樓可以一步上1階,也可以一步上2階,編一程序計算共有多少種不同的走法。例如,當n=3時,共有3種走法,即1+1+1,1+2,2+1。

算法分析:設n階臺階的走法數爲f(n),顯然有:

1                 n = 1 

f(n) = {  2                 n = 2

f(n-1) + f(n-2)   n > 2

 

得到相應的函數如下:

int F(int n)

{

    if (n == 1 || n == 2)

        return n;

 

    return F(n-1) + F(n-2);

}

例4:整數劃分的問題:對於一個整數n的劃分,就是把n表示成一系列的正整數的和的表達式,注意劃分與次序無關.

例如,6可以可以劃分爲:

6;

5+1;

4+2,4+1+1;

3+3,3+2+1,3+1+1+1;

2+2+2,2+2+1+1,2+1+1+1+1;

1+1+1+1+1+1

現在問題是,給一個n求他的所有劃分.

這個問題初看,很難找出大規模問題與小規模問題之間的關係,我們注意了,對於上面的第一行,所有加數不超過6,第2行, 所有加數不超過5,…..第6行所有加數不超過1.因此,我們可以定義一個q(n, m)的函數,表示 n所有加數不超過m的劃分數目.所以n的劃分總數目可以表示爲q(n, n).那我們怎樣才能把找出q(n, n)的遞歸關係呢?

很顯然,我們可以立即得到以下關係,

q(n,n) = q(n,n-1) + 1;

所以問題規模變小,但是我們很不能根據這個關係轉化爲更小的問題,所以我們主要考慮這種情況:q(n, m)(其中m < n),怎樣把這中情況分解呢。我們嘗試的把q(n, m)變爲q(n, m-1);我們驚奇的發現,只要把q(n, m-1)加上包含加數m的項就等於q(n, n),即q(n, m) = q(n, m-1) + 包含m加數的表達式數。例如m = 4,我們可以把q(n, 4) = q(n, 3) + 2(包含4加數的表達使有兩個:4+2, 4+1+1)。而我們發現,包含4的表達可以轉化爲q(n-4, 4) (想想?),所以的遞歸關係式就出來了:

q(n,m) = q(n,m-1) + q(n-m,m);

接下來就是找邊界條件了,我們知道當n = 1時,q(n, n) = 1;當m =1時,q(n, m)

= 1;有了邊界條件,我們遞歸基本上完成了.得到遞歸公式:

             1,                           n = 1, m = 1;

q(n,m) = {  q(n,n),                     n < m;

             1 + q(n, n-1),               n = m;

             q(n,m-1) + q(n-m,m),       n > m > 1.

編寫代碼如下:

#include<stdio.h>

 

int F(int n,int m);

 

int main()

{

    int i;

   

    for (i=1; i<21; i++)

        printf(“%d – %d\n”, i, F(i, i));

       

    getchar();

    return 0;

}

 

int F(int n, int m)

{

    if (n == 1 || m == 1)//遞歸出口

        return 1;

   

    if (n == m)

        return F(n, n-1) + 1;

    else if (n < m)

        return F(n, n);

    else

        return F(n, m-1) + F(n-m, m);

}

 

例5:Hanoi塔問題:設A,B,C是3個塔座。開始時,在塔座A上有一疊共n個圓盤,這些圓盤自上而下,由小到大地疊在一起。各圓盤從小到大編號爲1,2,…,n,現要求將塔座A上的這一疊圓盤移到塔座C上,並仍按同樣順序疊置。在移動圓盤時應遵守以下移動規則:

1.每次只能移動1個圓盤;

2.任何時刻都不允許將較大的圓盤壓在較小的圓盤之上;

3.在滿足移動規則1和2的前提下,可將圓盤移至A,B,C中任一塔座上.

算法分析:這是一個典型的適合用遞歸算法來解決的問題。試想要把n個盤子從柱A移到柱C上,則必須先把上面n-1個盤子從柱A全部移到柱B,然後把第n個盤子由柱A移到柱C ,再把柱B上的n-1個盤子全部移到柱C。這樣就把移動n個盤子的問題變成了移動n-1個盤子的問題,如此不斷減小遞歸的規模,直到遞歸出口。遞歸的邊界條件是,當n == 1時,直接把它由柱A移到柱C。

#include<stdio.h>

 

void Hanoi(int n, char a, char b, char c); //移動漢諾塔的主要函數

 

int main(void)

{

      int n = 4;

     

      Hanoi(n, ‘A’, ‘B’, ‘C’);

 

      getchar();

      return 0;

}

 

void Hanoi(int n, char a, char b, char c)//漢諾塔,把柱A所有的盤子移到柱C

{

    if (n == 1)//如果只有一個盤子,直接由柱A移到柱C

        printf(“Move disk from %c to %c\n”, a, c);

    else

    {

        Hanoi(n-1, a, c, b);//先把上面n-1個盤子從柱A全部移到柱B

        printf(“Move disk from %c to %c\n”, a, c);//再把第n個盤子由柱A移到柱C 

        Hanoi(n-1, b, a, c);// 再把柱B上的n-1個盤子全部移到柱C

    }

}

    從上述的例子中我們可以發現,遞歸算法具有以下三個基本規則:基本情形:至少有一種無需遞歸即可獲得解決的情形,也即前面說的邊界條件。進展:任意遞歸調用必須向基本情形邁進,即前面所說的使得問題規模變小。正確性假設:總是假設遞歸調用是有效的。

遞歸調用的有效性是可以用數學歸納法證明的,所以當我們在設計遞歸函數時,不必設法跟蹤可能很長的遞歸調用途徑(比如Hanoi塔問題)。這種任務可能很麻煩,易於使設計和驗證變得更加困難。所以我們一旦決定使用遞歸算法,則必須假設遞歸調用是有效的。

遞歸和非遞歸的轉換

在第一講《算法設計之枚舉法》中我們有一道練習題:

例6:構造一個3*3的魔方:把數字1-9添入如圖的表格中

2

7

6

9

5

1

4

3

8

要求每橫,豎,斜列之和均相等(如圖是一種情況)。輸出所有可能的魔方。

當時我們是使用枚舉法解的,通過剪枝等優化後得到一個8重嵌套循環,而且每個循環的結構都是一樣的,既繁瑣,又複雜。既然如此,那麼我們是否可以用一個遞歸函數來實現呢?答案是肯定的。程序如下:

#include<stdio.h>

#define MAX 9

 

int IsElement(int a[], int len, int x);

void F(int a[], int len);

 

int main()

{

    int a[MAX] = {0};

    int i;

   

    for (a[0]=1; a[0]<=MAX; a[0]++)

    {

        F(a, 0);

    }

   

    getchar();

    return 0;      

}

 

void F(int a[], int len)//以遞歸代替多重嵌套循環

{

    int i;

    if (len < MAX-2 && IsElement(a, len, a[len]))

    {

        len++;

        for (a[len]=1; a[len]<=9; a[len]++)

        {

            F(a, len);

        }

    }

    else if (len == MAX-2)

    {

        a[8] = 45-a[0]-a[1]-a[2]-a[3]-a[4]-a[5]-a[6]-a[7];

        if ((a[0]+a[1]+a[2]) == (a[3]+a[4]+a[5]) && (a[0]+a[1]+a[2]) == (a[6]+a[7]+a[8])

         && (a[0]+a[3]+a[6]) == (a[1]+a[4]+a[7]) && (a[0]+a[3]+a[6]) == (a[2]+a[5]+a[8])

         && (a[0]+a[1]+a[2]) == (a[0]+a[4]+a[8]) && (a[0]+a[1]+a[2]) == (a[2]+a[4]+a[6]))

        {

            for (i=0; i<9; i++)

            {

                printf(“%5d”, a[i]);

                if ((i+1)%3 == 0)

                    printf(“\n”);

            }

            printf(“\n\n”);

        }

    }

}

 

int IsElement(int a[], int len, int x)

{

    int i;

    for (i=0; i<len; i++)

    {

        if (a[i] == x)

            return 0;

    }

  

    return 1;

}

從本例中我們可以發現,用遞歸代替多重嵌套循環不僅使程序結構清晰,可讀性強,且容易用數學規納法證明算法的正確性,因此設計算法與調試程序都很方便。

實際上,遞歸是軟件設計中的一種重要的方法和技術.遞歸函數是通過調用自身來完成與自身要求相同的子問題的求解,編譯系統能自動實現調用過程中信息的保存與恢復.在問題的求解方法具有遞歸特徵時,採用遞歸技術就比不用遞歸技術簡捷得多,且具有較高的開發效率,所設計的程序具有更好的可讀性和可維護性.然而,在實際應用中,由於程序設計語言對遞歸的支持性和程序運行時間方面的原因,在有些情況下要求寫出問題的非遞歸函數.由於許多問題求解程序中的遞歸函數比非遞歸函數要容易設計,因此,常常先設計出遞歸函數,然後將其轉換爲等價的非遞歸函數.

要向實現遞歸和非遞歸函數的相互轉換,我們先要了解遞歸調用的內部實現原理。

在調用一個函數的過程中出現直接或間接的調用該函數本身,稱爲函數的遞歸調用.與每次調用相關的一個重要概念是遞歸函數運行的“層次”.設調用該遞歸函數的主函數爲第0層,則從主函數調用遞歸函數爲進入第1層;從第i層遞歸調用本函數爲進入“下一層”,即第i+1層.反之,退出第i層遞歸應返回至“上一層”,即第i-1層.因此,編譯系統需設立一個“工作棧”來進行調用函數和被調函數之間的鏈接和信息交換.設工作棧開始時爲空,則遞歸調用內部實現描述如下:

1)調用時需執行的操作

a.將返回地址、調用層(第i層)中的形參和局部變量的值壓人工作棧中(第0層調用不考慮);

b.爲被調層(第i+1層)準備數據:計算實在參數的值,並賦予對應的形參;

C.轉入被調函數執行.

2)函數返回時需執行的操作

a.如果函數有返回值,將返回值保存到一臨時變量中;

b.從棧頂取出返回地址及各變量、形參的值,並退棧,即恢復調用層(第i-1層)的局部變量和形參;

c.按返回地址返回到調用層(第i-1層);

d.返回後自動執行如下操作:如函數有返回值,則從臨時變量中取出返回值賦予調用層(第i-1層)相應的局部變量或代人表達式中.

瞭解了內部實現原理,現在我們來看由遞歸到非遞歸的轉換規則。既然編譯系統內部是利用“工作棧”這種數據結構來實現遞歸函數的,因此,遞歸函數用非遞歸函數實現時也必然要用“棧”來保存相應的形參和局部變量.通過仔細研究編譯系統內部實現遞歸函數的工作原理,得到轉換規則如下:

1)設置一個工作棧,用S表示,並在開始時將其初始化爲空.

2)在遞歸函數的人口處設置一個標號(如設爲L0).

3)對遞歸函數中的每一個遞歸調用,用以下幾個等價操作來替換:

a.保留現場:開闢棧頂存儲空間,用於保存調用層中的形參、局部變量的值和返回地址;

b.準備數據:爲被調層準備數據,即計算實參的值,並賦予對應的形參;

C.轉入(遞歸函數)執行,即goto L0;

d.在返回處設一個標號Li(i一1,2,3,?),並根據需要設置以下語句:如果遞歸函數有返回值,則增設一個變量保存回傳變量返回的值.

4)在返回語句前判斷棧空否,如果棧不空,則依次增加如下操作:

a.回傳數據:若函數有返回值,增設一個變量,將返回值保存到該變量(稱回傳變量)中;

b.恢復現場:從棧頂取出返回地址(不妨保存到Lx中)及各變量、形參的值,並退棧;

C.返回:按返回地址返回(即執行goto X).

5)對其中的非遞歸調用和返回語句照抄.

6)如果遞歸程序中只有一處遞歸調用,則在轉換時,返回地址不必入棧.

7)在模擬尾遞歸調用時,不必執行入棧操作.

注 尾遞歸即遞歸調用處於遞歸函數的最後位置,其後面沒有其他操作.

用上述規則可將任意的遞歸函數轉換爲等價的非遞歸函數.不過,轉換得到的函數的結構一般比較差,因而需要重新調整.

轉換規則看上去很複雜,其實我們可以理解得簡單些,求解遞歸問題有兩種方式,一種是直接求值,不需要回溯的;另一種是不能直接求值,需要回溯的。這兩種方式在轉換成非遞歸問題時採用的方法也不相同。前者使用一些中間變量保存中間結果,稱之爲直接轉換法(即轉換成迭代算法);後者需要回溯,所以要用棧保存中間結果,稱爲間接轉換法。下面分別討論這兩種方法。

直接轉換法

仍然以斐波那契(Fibonacci)數列爲例,前面我們介紹了斐波那契(Fibonacci)數列的遞歸算法,發現該算法雖然代碼短小精悍,可讀性強,但是由於做了大量的重複運算,使得效率極爲低下,所以應該轉換成非遞歸算法。斐波那契(Fibonacci)數列的非遞歸算法即迭代法,我們在第2講《算法設計之迭代法》中已經做了詳細解釋,這裏不再重複。

我們看一個其他的例子:

例7:逆序打印數字。例如考慮如何打印數字1369。我們首先需要打印9,然後打印6,再打印3,最後打印1。

很明顯我們可以把問題看成是先打印1369的個位數字,然後打印136的個位數字,然後打印13的個位數字,最後打印1的個位數字。一個很典型的遞歸問題。

#include<stdio.h>

 

void PrintInt(int n);

 

int main()

{

    int n = 138400;

   

    PrintInt(n);

   

    getchar();

    return 0;      

}

 

void PrintInt(int n)

{

    printf(“%d”, n%10);

    if (n >= 10)

        PrintInt(n/10);

}

同時我們可以發現,遞歸函數中使用了尾遞歸(僅在方法的末尾實行一次遞歸調用,這樣的遞歸叫尾遞歸)。尾遞歸很容易被循環所替換,下面是使用循環的寫法。

void PrintInt(int n)

{

    while (n > 0)

    {

        printf(“%d”, n%10);

        n /= 10;

    }

}

這個例子實在是太簡單,下面我們看一個稍微複雜點的例子。

例8:逆序排列數組。例如原數組爲a[] = {1,2,3,4,5},經過逆序排列後變成a[] = {5,4,3,2,1}。經典的逆序排列算法是使用循環。

void Reverse(int a[], int len)

{

    int l, r;

    int temp;

   

    for (l=0,r=len-1; l<r; l++,r–)

    {

        temp = a[l];

        a[l] = a[r];

        a[r] = temp;

    }

}

實際上我們可以看到逆置數組的操作是一個從兩端到中間,規模逐漸減小的過程,每次交換元素a[i]和a[n-i],動作完全是一樣的,所以也可以使用遞歸算法。

void Reverse(int a[], int len)

{

    Rev(a, 0, len-1);

}

 

void Rev(int a[], int left, int right)

{

    int temp;

    if (left < right)

    {

        temp = a[left];

        a[left] = a[right];

        a[right] = temp;

        Rev(a, left+1, right-1); 

    }

}

這裏的遞歸函數是Rev,爲了保證同循環算法的接口保持一致,我們把函數Reverse作爲Rev的驅動函數,這在比較複雜的遞歸算法中是很常見的。

間接轉換法

前面講了一個逆序打印數字的例子,現在來看一個與它類似的例子:

例9:以任意基數打印數字。例如考慮如何打印數字1369。我們首先需要打印1,然後打印3,再打印6,最後打印9。問題是獲得首位麻煩,給定數字n,我們需要循環確定n的首位。與之相反的是末位,利用n%10就可以得到它。

    用遞歸實現這個例程是非常簡單的:

#include<stdio.h>

 

void PrintInt(int n, int base);

 

int main()

{

    int n = 100;

    int base = 2;

   

    PrintInt(n, base);

   

    getchar();

    return 0;      

}

 

void PrintInt(int n, int base)//n表示被輸出的十進制數,base表示被輸出的數的基數,即進制,

{

    if (n >= base) //這裏要求base <= 10

        PrintInt(n/base, base);

   

    printf(“%d”, n%base);

}

但是使用非遞歸方式就沒那麼簡單了,這不比上一題,這裏不是尾遞歸,不能直接轉換,必須先用棧把得到的數字存儲起來,再一次性輸出。程序如下:

void PrintInt(int n, int base)//n表示被輸出的十進制數,base表示被輸出的數的基數,即進制,

{

    int a[50] = {0}; //假設n的最大位數爲50

    int i = 0;

   

    while (n > 0)//用棧把得到的數字存儲起來

    {

        a[i++] = n % base;

        n /= base;

    }

   

    while (i > 0)//逆序輸出棧的元素

    {

        printf(“%d”, a[–i]);

    }

}

二叉樹數據結構是一種典型的遞歸定義和遞歸操作的數據結構,涉及到很多遞歸算法,現在我們來看一個簡單的二叉樹算法:前序遍歷二叉樹。

鏈接存儲的二杈樹類型和結構定義如下:

typedef struct bnode

{

    ElemType data;

    struct bnode *lchild, *rchild;

} btree;

1.遞歸算法非常簡明:

void preorder(btree *p)

{

    if(p != NULL)

    {

        printf(“%d”, p->data);//輸出該結點(根結點)

        preorder(p->lchild);  //遍歷左子樹

        preorder(p->rchild);//遍歷右子樹

    }

}

2.非遞歸算法(使用棧存儲樹):

void preorder(btree *bt)

{

    btree *p, *stack[MAX];//p表示當前結點,棧stack[]用來存儲結點

    int top=0;

   

    if(bt != NULL)//先判斷是否爲空樹

    {

        stack[top] = bt; //根結點入棧

        while(top >= 0)

        {

            p = stack[top–]; //棧頂元素出棧

            printf(“%d”, p->data);//輸出該結點

            if(p->rchild != NULL) //如果該結點有右孩子,將右孩子入棧

            {

                 stack[++top] = p->rchild;

            }

            if(p->lchild != NULL) //如果該結點有左孩子,將左孩子入棧,按照後入先出原則,左孩子先出棧

            {

                 stack[++top] = p->lchild;

            }

        }

    }  

}

或者:

void preorder(btree *bt)

{

    btree *p, *stack[MAX];//p表示當前結點,棧stack[]用來存儲結點

    int top=0;

 

    if(bt != NULL)//先判斷是否爲空樹

    {

            p = bt;

            while (p || top > 0)

            {

                  if (p)

                  {

                        stack[top++] = p;

                        printf(“%c”, p->data);//輸出該結點

                        p = p->lchild;

                  }

                  else

                  {

                      p = stack[–top]; //棧頂元素出棧

                      p = p->rchild;

                  }

            }

      }

}

 

總結:遞歸算法既是一種有效的算法設計方法,也是一種有效的分析問題的方法。遞歸算法求解問題的基本思想是:對於一個較爲複雜的問題,把原問題分解成若干個相對簡單且類同的子問題,這樣,原問題就可遞推得到解。

  

適宜於用遞歸算法求解的問題的充分必要條件是:

(1)問題具有某種可借用的類同自身的子問題描述的性質;

(2)某一有限步的子問題(也稱作本原問題)有直接的解存在。

   當一個問題存在上述兩個基本要素時,該問題的遞歸算法的設計方法是:

(1)把對原問題的求解設計成包含有對子問題求解的形式。

(2)設計遞歸出口。

遞歸算法的執行過程是不斷地自調用,直到到達遞歸出口才結束自調用過程;到達遞歸出口後,遞歸算法開始按最後調用的過程最先返回的次序返回;返回到最外層的調用語句時遞歸算法執行過程結束。

練習:

1.  小猴吃棗:小猴第一天摘下若干棗子,當即吃掉了一半,不過癮又多吃了一個;第二天吃了剩下的一半又多吃了一個;以後每一天都吃了前一天剩下的一半多一個。到第十天小猴再想吃時,見到只剩下一隻棗子了。問第一天這堆棗子有多少?

 

2.  MyCathy函數定義如下:

x – 10  ,    x > 100

M(x)= { 

  M(M(x+11)), x <= 100

分別用遞歸和非遞歸方式編寫函數計算給定x的M(x)的值。

3.  使用遞歸函數表達歐幾里德算法。

 

 

4.  設對於給定的x和係數a[i]求下列n階多項式的值:

n階多項式:Pn(x) = an.x^n + a(n-1).x^(n-1) + … + a1.x + a0

函數接口:double Poly(double coeff[], int n, double x);

   

參考程序:

1. 算法分析:可用遞歸方法,返回第n天的桃子數量。

  遞歸公式:F(n) = 1, n= 10; F(n) = (F(n+1)+ 1)*2, n= 10;

#include <stdio.h>

 

int Peach(int n);

 

int main(void)

{

    int i;

    for (i=1; i<=10; i++)

        printf(“第%d天這堆棗子有%d個\n”, i, Peach(i));

  

    getchar();

    return 0;  

}  

 

int Peach(int n)

{

    if (n < 10)

        return (Peach(n+1) + 1) * 2;

    else

        return 1;

   

}

 

2.遞歸算法:

int M(int x)

{

    if (x > 100)

        return x – 10;

    else

    return M(M(x + 11));

}

非遞歸算法:

int M(int x)

{

    int level = 1;

 

    if (x > 100)

        return x – 10;

    else

    {

        level++;

        x += 11;

    }

    while (level > 0)

    {

        if (x > 100)

        {

            level–;

            x -= 10;

        }

        else

        {

            level++;

            x += 11;

        }

    }

    return x;

}

 

3. int Gcd(int m, int n)//歐幾里德方法,遞歸

{

    if (m<0 || n<0) //預防錯誤

        return 0;

    if (n == 0)

        return m;

  

    return Gcd(n, m%n);

}

 

4. #include<stdio.h>

 

double Poly(double coeff[], int n, double x);

double Horner2(double coeff[], int n, double x);

double Horner(double coeff[], int max, int n, double x);

 

int main()

{

    double a[1000] = {1, 2, 3, 4, 5, 6, 7, 8, 9};

    int n = 5;

    double x = 3.5;

   

    printf(“%f\n”, Poly(a, n, x));

    printf(“%f\n”, Horner(a, n, n, x));

   

    getchar();

    return 0;      

}

//1. 一般計算方法

double Poly(double coeff[], int n, double x)//計算n次多項式的值,coeff[0:n]爲多項式的係數

{

    int i;

    double y = 1;

    double value = coeff[0];

   

    for (i=1; i<=n; i++) //n循環, 累加下一項

    {

       y *= x; //一次乘法

       

        value += y * coeff[i]; //一次加法和一次乘法

    }

   

    return value;

} //3n次基本運算

 

//2. Horner法則(秦九韶方法):P(x)=((cn*x+cn-1)*x+cn-2)*x+···)*x+c0

double Horner2(double coeff[], int n, double x)

{

    int i;

    double value = coeff[n];

   

    for(i=1; i<=n; i++) //n循環

        value = value * x + coeff[n-i]; //一次加法和一次乘法

   

    return value;

} //2n次基本運算

 

//3.遞歸算法

/*

多項式求值的遞歸算法

n階多項式:Pn(x) = an.x^n + a(n-1).x^(n-1) + … + a1.x + a0

利用Horner法則,把上面公式改寫成:

    Pn(x) = an.x^n + a(n-1).x^(n-1) + … + a1.x + a0

         = ((…((((an)x + a(n-1))x + a(n-2))x + a(n-3))x…)x + a1)x + a0

*/

double Horner(double coeff[], int max, int n, double x)

{

    double value;

   

    if (n == 0)

        value = coeff[max-n];

    else

        value = Horner(coeff, max, n-1, x) * x + coeff[max-n];

 

    return value;

} //2n次基本運算

 

//也可以寫成

double Horner(double coeff[], int max, int n, double x)

{

    double coeff[max-n];

   

    if (n > 0)

        value = Horner(coeff, max, n-1, x) * x + coeff[max-n];

 

    return value;

} //2n次基本運算

点赞