數據結構——遞歸與非遞歸


目錄

一、遞歸

  1.1 什么是遞歸?

  1.2 遞歸三部曲

  1.3 尾遞歸

  1.4 經典遞歸例題

   *1.5 函數棧

二、非遞歸

  2.1 為什么需要將遞歸轉化為非遞歸(迭代)?

  2.2 遞歸轉化為非遞歸(迭代)

  2.3 一般步驟

三、總結

四、參考文獻

 

一、遞歸

1.1 什么是遞歸

  我們先來看一下官方對遞歸的定義,編程語言中,函數 F(type a, type b, ……)直接或間接地調用函數本身,則稱該函數為遞歸函數。 初學者對這個概念可能不是很理解,我們可以通過一些簡單的例子來理解一下遞歸的概念。先看一張圖,

從這張圖中,我們可以看到,當一個人手中拿着一面鏡子同時照鏡子的時候,就會呈現出上面的情形,似乎進入了一種死循環——鏡子照鏡子照鏡子照鏡子……相信大家也一定聽過一個故事,從前有一座山,山上有一座廟,廟里有一個老和尚再給小和尚講故事,從前有一座山……這其實也是一種遞歸。

 

1.1.1 遞歸函數調用

  為了能夠更好地理解遞歸地定義,我們假設有如下遞歸函數:

1 void func(int a, int b)
2 {
3     a++;
4     b++;
5  func(a, b); 6     a++;
7     b++;
8 }

 

我們看到第五行就是函數調用自己本身,這便是一個遞歸函數。

 

1.2 遞歸三部曲

  當我們遇到一些問題想要使用遞歸進行解決時,我們會發現很困難,理解看懂遞歸很容易,可是使用遞歸卻非常地困難。然而遞歸有着一般的解題思路,我將其稱為遞歸三部曲

1.2.1 第一部、確定函數的功能

  我們已經知道了遞歸就是函數直接或間接地調用本身,那么我們首先需要的就是一個函數,確定函數的功能,就是要知道這個函數要完成一件什么樣的事,而這件事,是由編寫函數的人自己確定的。換句話說,就是我們不需要給出這個函數內部的具體定義,只要先明白函數是用來干嘛的就可以了。

  比如,我們現在需要計算一個數n的階乘。

1 // function:
2 //  計算n的階乘(函數功能)
3 // params:
4 //  n為需要計算階乘的數值
5 int f(int n)
6 {
7     // TODO 具體定義
8 }

 

這里函數的功能就是計算n的階乘。

 

1.2.2 第二部、確定遞歸終止條件

  在1.1什么是遞歸中我們舉了兩個例子,照鏡子和山上有座廟故事的例子,我們發現這兩個例子都是死循環,會無休止地遞歸下去,這樣地函數是我們需要避免寫出來地,因為計算機的資源是有限的,如果遞歸的層次過深,就會造成"爆棧"的風險,如果不能理解什么是"爆棧",沒有關系,你可以先認為遞歸層次過深,內存會發生溢出。而照鏡子的例子是一個無限遞歸,他需要的內存資源是無限的,所以這樣就會發生內存溢出的風險。所以我們需要給遞歸確定終止條件,這樣才能防止內存溢出。

  我們繼續看計算一個數n的階乘的例子。我們知道當n=1時,n! = 1;所以,我們就找到了遞歸函數的終止條件,將上面的代碼完善如下:

 1 // function:
 2 //  計算n的階乘(函數功能)
 3 // params:
 4 //  n為需要計算階乘的數值
 5 int f(int n)
 6 {
 7     if (1 == n)
 8         return n;
 9 
10 }

這樣,我們就找到了遞歸終止條件,完成了二部曲。

 

1.2.3 第三部、確定遞推關系式

  這一部往往是寫遞歸函數的難點,因為他需要你具有遞歸邏輯思維才能寫出來,在這里由於本人水平有限,不能找到一般遞推關系式的推演方法。如果大家有興趣可以參考相應書籍並進行相應習題練習。

  不過在計算一個數n的階乘的例子中,遞推關系式很容易就可以找到: f(n) = n * f(n-1),我們可以繼續完善上述代碼:

 1 // function:
 2 //  計算n的階乘(函數功能)
 3 // params:
 4 //  n為需要計算階乘的數值
 5 int f(int n)
 6 {
 7     if (1 == n) // 終止條件
 8         return n;   
 9     else
10         return n * f(n - 1);  // 遞推關系式
11 
12 } 

到這里,我們就已經初步學習了遞歸函數如何撰寫的一般步驟了。

 

 

1.3 尾遞歸

我們已經知道了什么是遞歸函數,就是函數調用自己本身的函數。那么什么是尾遞歸呢?

尾遞歸就是函數調用本身的位置處於函數最后一行的位置,這就是尾遞歸

1 void func(int a, int b)
2 {
3     a++;
4     b++;
5     func(a, b);
6 }

 

這個遞歸函數在末尾調用了自身,所以這是一個尾遞歸。

那么,我們再來看上面的n的階乘的例子:

 1 // function:
 2 //  計算n的階乘(函數功能)
 3 // params:
 4 //  n為需要計算階乘的數值
 5 int f(int n)
 6 {
 7     if (1 == n) // 終止條件
 8         return n;   
 9     else
10         return n * f(n - 1);  // 遞推關系式
11 
12 }

 

這里return n*f(n-1);是不是尾遞歸呢?答案不是的。盡管這條語句處於函數的末尾,但是f(n)函數進行了運算,所以他並不是尾遞歸。

我們需要注意,只有當函數本身沒有參與操作,並處於末尾的時候,才是尾遞歸!

 

那么尾遞歸有什么用呢?尾遞歸其實是為了解決上面說到的"爆棧"問題而存在的,現在大多數的編程語言編譯的時候都進行了尾遞歸優化。詳細的內容可以看1.5函數棧部分。

 

 

1.4 經典遞歸例題

1.4.1 階乘問題

題目:求數n的階乘,這個問題我們已經講過了,代碼如下所示:

 1 // function:
 2 //  計算n的階乘(函數功能)
 3 // params:
 4 //  n為需要計算階乘的數值
 5 int f(int n)
 6 {
 7     if (1 == n) // 終止條件
 8         return n;   
 9     else
10         return n * f(n - 1);  // 遞推關系式
11 
12 }

 

 

 

 

 

1.4.2 斐波那契數列

題目:斐波那契數列的是這樣一個數列:1、1、2、3、5、8、13、21、34....,即第一項 f(1) = 1,第二項 f(2) = 1.....,第 n 項目為 f(n) = f(n-1) + f(n-2)。求第 n 項的值是多少。

根據遞歸三部曲,我們依次給出解題步驟:

(1)確定函數功能

  我們假設 fabonic(n) 的功能是求第 n 項的值,代碼如下:

 1 // function:
 2 //  求第n項的值
 3 // params:
 4 //  n   第n項
 5 // return:
 6 //  第n項的值
 7 int fabonic(int n)
 8 {
 9     // TODO 具體定義
10 }

 

(2)確定終止條件

  我們可以知道當n=1時,fabonic(1)=1;當n=1時,fabonic(1)=2;所以代碼如下所示:

 1 // function:
 2 //  求第n項的值
 3 // params:
 4 //  n   第n項
 5 // return:
 6 //  第n項的值
 7 int fabonic(int n)
 8 {
 9     if (n <= 2)  // 終止條件
10     {
11         return 1;
12     }
13 }

 

(3)確定遞推關系式

  題目中已經給出了遞推關系式,所以代碼如下:

 1 // function:
 2 //  求第n項的值
 3 // params:
 4 //  n   第n項
 5 // return:
 6 //  第n項的值
 7 int fabonic(int n)
 8 {
 9     if (n <= 2)  // 終止條件
10     {
11         return 1;
12     }
13     else
14     {
15         return f(n - 1) + f(n - 2);  // 遞推關系式
16     }
17 }

 

 

 

1.4.3 漢諾塔問題

問題:相傳在古印度聖廟中,有一種被稱為漢諾塔(Hanoi)的游戲。該游戲是在一塊銅板裝置上,有三根柱子(編號A、B、C),在A柱自下而上、由大到小按順序放置n個盤子(如下圖)。

游戲的目標:把A柱上的盤子全部移到C柱上,並仍保持原有順序疊好。操作規則:每次只能移動一個盤子,並且在移動過程中三根柱上都始終保持大盤在下,小盤在上,操作過程中盤子可以置於A、B、C任一柱上。

 

  Hanio_img1

 

 

 

 

           圖1.3.1_0

 

為了能夠更好地解決這道題目,我們先通過畫圖來理解一下這道題目。

(1)當n=1時情況如下圖所示:

    =>將1盤從A柱移動到C柱

    總移動次數=1

Hanio_2

 

 

 

 

 

Hanio_3

 

           圖1.3.1_1

    

 

(2)當n=2時,我們需要進行如下移動步驟:

    將1號盤從A柱移動到B柱

    =>將2號盤從A柱移動到C柱

    將1號盤從B柱移動到C柱

    總移動次數=3

 

 

 

 

 

                                                                  

                                                                                                                            圖1.3.1_2

 

(3)當n=3時,我們需要進行如下移動步驟:

    將1號盤從A柱移動到C柱

    將2號盤從A柱移動到B柱

    將1號盤從C柱移動到B柱

    =>將3號盤從A柱移動到C柱

    將1號盤從B柱移動到A柱

    將2號盤從B柱移動到C柱

    將1號盤從A柱移動到C柱

    總移動次數=7

                    

 

 

 

 

 

                                                                                                                                  圖1.3.1_3

 

通過上圖,我們先來推出遞歸解法。根據遞歸三部曲,

第一步,先確定函數功能,把A柱上編號1~n的圓盤移到C柱上,以B為輔助柱,函數形式為f(n,A,B,C )

第二步,找到函數終止條件,此處為,當n=1時,將1號盤從A柱移動到C柱

第三步,找到遞推公式,我們經過分析,發現需要先將1號盤至n-1號盤移動到B柱上,其中C柱作為輔助柱,然后將最大的盤--A柱n號盤,從A柱移動到C柱上,最后將B柱上的1號盤至n-1號盤移動至C柱上;

綜上,我們可以得到如下函數公式:

 1 // 漢諾塔問題
 2 // parameters:
 3 //      n   圓盤數目
 4 //      A   A柱,初始塔(柱)
 5 //      B   B柱,輔助塔(柱)
 6 //      C   C柱,目標塔(柱)
 7 void hanoi(int n, char A, char B, char C)
 8 {
 9     if (1 == n)
10     {
11         move(A, 1, C);           // 將編號為1 的圓盤從A 移到 C
12     }
13     else
14     {
15         hanoi(n - 1, A, C, B);  // 遞歸,把A塔上編號1~n-1的圓盤移到B上,以C為輔助塔
16         move(A, n, C);          // 將編號為n 的圓盤從A 移到 C
17         hanoi(n - 1, B, A, C);  // 遞歸,把B塔上編號1~n-1的圓盤移到C上,以A為輔助塔
18     }
19 }

 至此,漢諾塔問題解決。

 

 

*1.5 函數棧

  我們知道函數的遞歸調用,編程實現的本質其實就是借助函數棧來實現的。我們來看一個遞歸函數n的階乘的例子。

 

 

 函數其實是在一個函數棧中實現運行的,棧的特點是后進先出。當我們運行f(3)這個函數時,函數運行到return n * f(n-1)這條語句時,會產生函數調用,而進行函數調用之前,需要先將函數的局部變量(如上圖藍色框內所示)保存起來,也就是所謂地先將現場保存起來,然后進行參數地更新函數調用。當函數調用完成返回時,需要將棧中的信息彈出,恢復現場,繼續執行。

 

 

 上圖其實就是函數棧。

所以當我們的遞歸層次越深時,函數棧需要的空間便越大(因為需要棧空間來保存局部變量等信息),所以"爆棧"的風險便越大,那么有沒有辦法能解決上述問題呢?

顯然是有的。還記得我們之前說的尾遞歸嗎,因為尾遞歸處於函數的最末端,尾遞歸結束后,函數也相應地結束了,后續並沒有操作了,這就意味着沒有保存當前函數局部變量地必要了,我們可以把當前局部變量地棧空間用來存儲尾遞歸地局部變量,這樣尾遞歸所需要地空間就是O(1)了,而且也沒有恢復現場地必要了,程序運行地時間也會變少。

 

二、非遞歸(迭代)

2.1 為什么需要將遞歸轉化為非遞歸(迭代)?

我們知道了當遞歸函數地層次過深時,有可能會發生"爆棧"地風險,所以有時候我們需要將遞歸進行非遞歸話,也叫迭代化。

 

 

2.2  遞歸轉化為非遞歸(迭代)

我們已經知道了函數棧,那么函數遞歸的本質其實可以歸結為如下幾點:

(1)在函數遞歸調用之前將局部變量保存到棧中

(2)修改參數列表

(3)進行函數遞歸調用

(4)獲得棧頂元素(恢復現場)

(5)彈出棧頂元素(釋放內存空間)

我們通過將幾道經典地例題遞歸解法轉化為迭代解法來初步探究一下。

 

2.2.1 n的階乘問題

遞歸解法:

// function:
//  計算n的階乘(函數功能)
// params:
//  n為需要計算階乘的數值
int f(int n)
{
    if (1 == n) // 終止條件
        return n;   
    else
        return n * f(n - 1);  // 遞推關系式

}

 

函數棧圖解:

迭代解法:

 1 struct Params 
 2 {
 3     int n;
 4     int result;
 5 };
 6 
 7 
 8 int f(int n)
 9 {
10     // 初始化棧
11     Params stack[10];
12     int top = -1;
13     // n 和 result 是局部變量
14     int result = 0;
15 
16     // 正向遞歸
17     while (1 != n)
18     {
19         // 保存局部變量
20         stack[++top].n = n;
21         stack[top].result = result;
22 
23         // 對遞歸參數進行修改
24         n = n - 1;
25     }
26 
27     // 終止條件
28     result = 1;
29     
30     // 反向傳參
31     while (-1 != top)
32     {
33         n = stack[top--].n;
34         result = result * n;
35     }
36 
37     return result;
38 }

 

 

 

2.2.2 二叉樹的中序遍歷

遞歸解法:

/* 二叉樹結點 */
template <typename DataType>
struct BiNode
{
    DataType data;
    BiNode<DataType>* lChild;
    BiNode<DataType>* rChild;
};


/* 中序遍歷 */
// bt   二叉樹根節點
template <typename DataType>
void inOrder(BiNode<DataType>* bt)
{
    if (nullptr == bt)
    {
        return;
    }
    else
    {
        inOrder(bt->lChild);
        std::cout << bt->data;
        inOrder(bt->rChild);
    }
}

 

函數棧圖解:

 

 

 

 

迭代解法:

 1 template <typename DataType>
 2 void inOrderIter()
 3 {
 4     BiNode<DataType>* stack[20];
 5     int top = -1;
 6     BiNode<DataType>* bt = root;
 7 
 8     while (nullptr != bt || -1 != top)
 9     {
10         while (nullptr != bt)
11         {
12             stack[++top] = bt;
13             bt = bt->lChild;
14         }
15 
16         if (-1 != top)
17         {
18             bt = stack[top--];
19             std::cout << bt->data;
20             bt = bt->rChild;
21         }
22     }
23 }

 

 

 

2.2.3 漢諾塔問題
遞歸解法:

 1 #include <iostream>
 2 
 3 struct HanioNode
 4 {
 5     int n;   // 盤數量
 6     char A;  // 初始塔
 7     char B;     // 輔助塔
 8     char C;     // 目標塔
 9 };
10 
11 
12 class Hanio
13 {
14 public:
15     Hanio();
16     // Hanio(int n, char A = 'A', char B = 'B', char C = 'C');
17     void play(HanioNode hanio);
18     void play();
19     void move(char A, int n, char C);
20     HanioNode hanioNode;
21 };
22 
23 
24 Hanio::Hanio()
25 {
26     hanioNode.n = 10;
27     hanioNode.A = 'A';
28     hanioNode.B = 'B';
29     hanioNode.C = 'C';
30 }
31 
32 
33 void Hanio::move(char A, int n, char C)
34 {
35     std::cout << n << "" << A << "移動到了" << C << std::endl;
36 }
37 
38 
39 void Hanio::play(HanioNode hanio)
40 {
41     if (1 == hanio.n)
42     {
43         move(hanio.A, 1, hanio.C);
44     }
45     else
46     {
47         HanioNode temp = hanio;
48         temp.n = hanio.n - 1;
49         temp.A = hanio.A;
50         temp.B = hanio.C;
51         temp.C = hanio.B;
52         play(temp);
53 
54         move(hanio.A, hanio.n, hanio.C);
55 
56         temp.n = hanio.n - 1;
57         temp.A = hanio.B;
58         temp.B = hanio.A;
59         temp.C = hanio.C;
60         play(temp);
61     }
62 }
漢諾塔遞歸解法

 

 

函數棧圖解:

 迭代解法:

 1 void Hanio::play()
 2 {
 3     HanioNode hanio = hanioNode;
 4     HanioNode stack[10];
 5     int top = -1;
 6     int count = 0;
 7 
 8     while (1 != hanio.n || -1 != top)
 9     {
10         while (1 != hanio.n)
11         {
12             stack[++top] = hanio;
13 
14             HanioNode temp = hanio;
15             hanio.n = temp.n - 1;
16             hanio.A = temp.A;
17             hanio.B = temp.C;
18             hanio.C = temp.B;
19         }
20 
21         move(hanio.A, 1, hanio.C);
22         count++;
23 
24         if (-1 != top)
25         {
26             hanio = stack[top--];
27             move(hanio.A, hanio.n, hanio.C);
28             count++;
29 
30             HanioNode temp = hanio;
31             hanio.n = temp.n - 1;
32             hanio.A = temp.B;
33             hanio.B = temp.A;
34             hanio.C = temp.C;
35         }
36     }
37 
38     move(hanio.A, 1, hanio.C);
39     count++;
40     std::cout << "count:" << count << std::endl;
41 }
View Code

 

 

 

2.3 一般步驟

遞歸迭代化的本質就是掌握函數棧,

(1)在函數遞歸調用之前將局部變量保存到棧中

(2)修改參數列表

(3)進行函數遞歸調用

(4)獲得棧頂元素(恢復現場)

(5)彈出棧頂元素(釋放內存空間)

因為編者水平有限,很難總結出普適的一般規律,抱歉。

 

三、總結

當遞歸層次不是很深時(小於1000),我們其實沒有必要將其進行迭代化,迭代后的代碼會比較難理解。

 

四、參考文獻

1、 [漢諾塔圖解遞歸算法](https://www.cnblogs.com/dmego/p/5965835.html)

2、 [遞歸轉化為非遞歸的一般方法](https://blog.csdn.net/biran007/article/details/4156351)

3、 [尾遞歸為啥能優化](https://zhuanlan.zhihu.com/p/36587160)

4、 [漢諾塔的非遞歸算法](https://www.cnblogs.com/blogzcan/p/7485372.html)

5、 [尾調用優化](http://www.ruanyifeng.com/blog/2015/04/tail-call.html)

6 、[尾遞歸是個什么鬼](https://www.cnblogs.com/zhanggui/p/7722541.html)

7 、[一種將遞歸過程轉化為非遞歸過程的方法研究]

8、 [遞歸算法非遞歸化的一般規律]

9、 [對於遞歸有沒有什么好的理解方法? - 帥地的回答 - 知乎 ](https://www.zhihu.com/question/31412436/answer/683820765)

10、[什么是遞歸?先了解什么是遞歸](https://www.cnblogs.com/Pushy/p/8455862.html)

 11、[遞歸的核心,生活中的遞歸例子](https://www.cnblogs.com/max-hou/p/8946310.html)

感謝上面的作者提供的知識!

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM