1. 程式人生 > >遞迴程式設計心得與體會

遞迴程式設計心得與體會

用遞迴設計出來的程式總是簡潔易讀,極具美感。但是對於剛入門的學者來說,當遇到遞迴場景時,自己卻難以正確的設計出合理的遞迴程式。博主曾經也是困惑不已,寫的多了,也就漸漸的熟悉了遞迴設計。特談一下自己的感受,有些術語是博主自己總結,有可能有不合理之處。

學習遞迴程式設計,建議首先應該從小規模的遞迴開始研究,小規模就是說自己可以除錯跟蹤程式碼,且自己不會暈。這個過程完成之後,才能熟練掌握遞迴層次之間的轉換,明白遞迴的執行過程。在這裡推薦一篇文章:http://blog.chinaunix.net/uid-20196318-id-31175.html,文章的第一個案例有一定的參考價值,第二個案例是全排列,將在後面討論到。

在平時的程式設計中,遇到的遞迴問題一般可以分為兩類,一類是過程遞迴,就是說執行的過程有明顯的遞迴性,典型的就是求階乘,斐波拉契數列,矩陣染色。。。大多數問題可以歸結為第一類;第二類是結構遞迴,比如二叉樹的各序遍歷,連結串列逆轉,反向列印等問題。兩類問題的設計考慮是有所不同的,如果採用同樣的思路去考慮兩類不同問題,一定得不到正確的程式碼。建議從過程遞迴設計開始學習。

一、過程遞迴性

不管是過程遞迴還是結構遞迴首先要明確的就是一定要拋棄程式設計的細節,如果在設計過程中扣住細節,試圖弄清楚每一步執行過程,你就失敗了。遞迴設計的設計者首先要明確的是你的遞迴函式的功能,比如階乘int fun(int n),他的功能就是返回n的階乘,設計過程中要時時記住自己遞迴函式的設計目的。其次就是遞迴程式的出口設計,這一點是比較靈活的,不同問題有不同的設計;最後就是一定要有規模的遞減。在整個遞迴設計過程中,一定要嚴格注意和把握這幾點,缺一不可。

案例1:階乘

階乘基本就是遞迴入門級案例,現在將用上面的思路來設計。

1、設計出函式原型,明確其功能;

int fun(int n)  //函式功能,返回n的階乘結果
{
    /*設計遞迴出口,在這個程式中,出口明顯是根據n的變化來確定的,而0!=0,1!=1,所以我們就以0或者1來結束遞迴*/
    if(n==0||n==1)
        return 1;
    /*注意不要做任何的細節處理,明確你函式的功能,*/
    //函式的功能是返回n的階乘,那麼直接就return fun(n);這樣做可以嗎?這樣的話只做到了兩點,沒有規模的遞減
    /*要規模遞減,只需做簡單的處理*/
    return n*fun(n-1);
    /*那下面這個語句可以嗎*/
    /*return n*fun(n-1)*fun(n-2),這樣肯定是不可以的,時刻記住函式的功能,fun(n-1)代表n-1的階乘,在乘以n-2的階乘就不對了,可以這樣寫return n*(n-1)*fun(n-2),出口條件在改一下if(n<=1) return 1即可,因為1-2=-1判斷條件出不來*/
}

2、矩陣染色

上面的案例其實我們並沒有明顯的感覺到過程的遞迴性,但這個下面的案例我們可以感覺到過程是有明顯的遞迴性的。這個案例是小米2016招聘的一個筆試題,同時在2016中興捧月比賽初賽中出現了一個極其類似的題。題目描述如下:對於一個矩陣,例如:

                                                              0 1 1 0

                                                              2 1 2 1

                                                              1 1 2 1

                                                              0 1 1 0

這個矩陣代表了一個影象,現在要給這個影象的指定位置染色,函式原型如下void fillwithcolor(int i,int j,int c),表示在影象的i,j位置和i,j位置臨近的同色區域染色為c,注意:對角元素不算臨近,對於上圖如果呼叫fillwithcolor(1,1,5)的話,將得到下面的矩陣。

                                                              0 5 5 0

                                                              2 5 2 1  

                                                              5 5 2 1

                                                              0 5 5 0

由於 對角不算臨近,所以第二排的最後一個1沒有被染色。

對於這個問題,函式原型已經給出,void fillwithcolor(int i,int j,int c)把i,j位置以及相鄰近位置染色為c,我們試想,由於不考慮對角的位置,所以我們只需要考慮上下左右的位置,對於滿足要求的上下左右的位置是不是成為了新的i,j位置呢。只需要在新的位置呼叫函式即可.

void fillwithcolor(int ** map,int i,int j,int c,int m,int n)//函式的功能是給i,j位置及其臨近位置染色c,m,n表示矩陣的行數和列數。
{
    /*出口設計,出口設計是i,j位置總不可能跑到矩陣外面去了吧*/
    if (i > m-1 || j > n-1)
        return;
    int temp = arr[i][j];  //先儲存初始色
    arr[i][j] = c;//染色
   /*考慮往上面走的情況*/
    if (i-1 >= 0)//不能走到圖片外面去
   {
        if(map[i-1][j] == temp)
        fillwithcocor(arr,i-1,j,m,n);//如果上面位置同色的話,也將上面的點染色c
   }
    /*考慮向下走的情況*/
    if (i+1 <= m-1)//不能走到圖片外面去
   {
        if(map[i+1][j] == temp)
            fillwithcocor(arr,i+1,j,m,n);//如果上面位置同色的話,也將上面的點染色c
   }
/*向左和向右是同理的,在這裡不做處理了*/
/*.........................................................*/

這個問題的過程是喲明顯的遞迴性的,依次用同樣的方法處理其上下左右的位置。

3、非波拉契數列

斐波拉契數列在這裡不做介紹。

首先同樣明確函式的目的

int fei(int n)//返回非波拉契數列中第n個位置的元素
{
    /*設計出口,當位置為1或者2的時候,這兩個位置上的數字都是1*/
    if(n == 1|| n==2)
        return 1;
    /*同樣做到規模有減小,不考慮任何細節,明確 遞迴函式的目的,fei(n-1)+fei(n-2)就是第n個位置的數,直接返回即可*/
    return fei(n-1)+fei(n-2);
    /*和階乘的設計比起來,這個就更加的順理成章,因為n位置上的數等於n-1位置上的加上n-2位置上的數,理所當然的同時也做到了     規模的遞減*/
}

4、全排列問題

全排列遞迴程式設計是一個很好的理解遞迴設計的例子,有一定的難度。但是是很典型的遞迴程式設計案例,其過程有明顯的過程遞迴性,下面將用上面的步驟來設計。

1、明確函式功能

我們假定傳入的是一個數組,長度為n,遞迴函式初步設計成這樣void permutation(int * arr,int n),然後我們要明確的是全排列的具體遞迴過程,例如我們考慮1 2 3的全排列。

首先將1固定在排列首,然後求2 3的全排列,然後將2固定在排列首,求13的全排列,最後將3固定在排列首,求12的全排列。這是第一層遞迴,將1固定在排列首時候,對於2,3兩個數構成的全全排列依然要重複上面的過程,即把2固定在首和把3固定在首,明顯是一個遞迴的過程。如果我們所求的陣列較長,加入1,2, 3,.....,n個數,我們就會依次求取2~n的排列,3~n的全排列,i~n的全排列,所以我們在設計函式的時候,需要加上一個引數m,代表m到n的全排列,所以函式的定義就如下void permutation(int *arr,int m,int n)函式的功能是求取陣列第m個數到第n個數的全排列。

2、由於是求全排列,所以不建議用一個二維陣列去儲存所有的排列,打印出所有的全排列即可,為了便於理解,我們可以先不考慮出口,先來寫程式碼。

void permutation(int * arr,int m,int n)
{
    /*基於上面的分析,陣列中的每一個數都應當作為一次排列的首部,每一個數都和第一個數進行交換*/
    for(int i = 0;i < n;i++)
   {
        swap(arr[i],arr[0]);
        /*這裡為什麼是i+1,因為前面的語句保證每一個數都作為一次排列首,舉個例子,對於1 2 3的全排列,過程是這樣的,把1當做排列首,求2 3的全排列,所以是i+1*/
        permutation(arr,i+1,n);
        /*最後一句是把把陣列還原會來,為什麼要還原回來,對於這個問題,還是以1,2,3求全排列為例,文中提到,每一個數都會當一次排列首,首先1當做排列首,這裡i為0,自己和自己交換,不存在啥問題,程式會打印出1,2,3,和1,3,2兩種排列出來,1為首的情況結束,現在數列是1,3,2,此時i為1,1和3交換,會得到3,1,2和3,2,1兩種排列,如果不執行第二個swap的話,此時數列是3,2,1,然後此時1,3交換,形成1,2,3發現1又變成數列首了,2為數列首的情況沒有打印出來。所以說不能保證每個數都能作為一次數列首部。你在看看,如果你執行第二個swap之後,就不會出現這種情況*/
        swap(arr[i],arr[0]);
    }
}

上面的程式碼已經初出具雛形,但是是不對的,為什麼呢?因為畢竟是一個遞迴的過程,比如1234我們將1固定在排列首之後,後面的234依然要重複前面的過程,針對234也要做將2,3,4依次固定在234構成排列的排列首。所以上面的程式碼只考慮了第一層遞迴的交換,後面的都沒考慮了,始終都是在和arr[0]座交換。上面函式中我們還有個引數m沒用到,所以考慮用上。

void permutation(int * arr,int m,int n)
{
    /*迴圈的目的是用於交換的*/
    for (int i = m;i < n;i++)
   {
        /*k從0開始,表示第一個位置上的數,依次完成和k之後的數交換*/
        swap(arr[m],arr[i]); 
        /*求除了第1,2,3,..,n個之後的數的全排列,m+1也存在了規模的遞減*/
        permutation(arr,m+1,n);
        /*同樣需要換回來*/
        swap(arr[m],arr[i]);
   }
     /*考慮設計出口問題*/
     /*m是不停的在向後遊走的,n是長度,所以最後的位置是n-1,m不可能游到n-1之外去吧。所以其實m遊走到n-1的位置時,恰好代表了完成了一次全排列的求解,再次宣告,不要去考慮細節,整體上是合理的就是正確的*/
       if(m > n-1)
       {
       /*這就是出口,當m到了n-1的位置時,表明以某個數為首的全排列計算完成,直接列印即可*/
           for (int j = 0;j < n;j++)
               cout << arr[j]<<" ";
           cout << endl;
       }
      /*所以對於上面迴圈的程式碼,肯定是else分支執行,改動一下即可*/
}

/***************************************************************************************************************************************************/

//最終結果

void permutation(int * arr,int m,int n){
    if (m > n-1){
        for (int j = 0;j < n;j++)
            cout << arr[j]<<" ";
        cout << endl;
      }
else{
    for (int i = k;i < n;i++){
        swap(arr[m],arr[i]);
        permutation(arr,m+1,n);
        swap(arr[m],arr[i]);
        }
    }
}

對於過程遞迴來說,在函式的設計過程中只要覺得這個位置所需要實現的功能就是這個函式的功能,就可以理所當然的遞迴呼叫自己。不要考慮任何的細節,滿足條件即可。

二,結構上具有遞迴性

遞迴還有一種設計思路是按照本身結構上具有遞迴性,用上面的思路解釋不通。例如逆向列印,連結串列逆轉,二叉樹遍歷等,很多帶有逆向操作的都可以遞迴,為什麼呢?因為遞迴是按層執行,每一層函式呼叫產生的變數,結果等都會壓入函式棧中,知道遇到出口,才依次彈棧,所以我們利用其彈棧的特性,在逐漸彈的過程中,去執行我們需要的程式碼,就可以實現逆向效果。

案例1:用遞迴逆向列印一個數組

逆向列印陣列其實簡單的不要不要的了,但是考慮過用遞迴去列印嗎?逆向列印我們在過程上完全感覺不到存在什麼遞迴性,但是由於這種線性結構,使得其具有遞迴結構性,所以也是可以逆向列印的。

遞迴逆向列印的設計思路就是讓遞迴一直下去,知道達到最後的位置,然後設定為出口條件,此時就會依次彈出,在彈出的位置依次列印每一層的值。

和過程遞迴相同的是同樣要時刻明確自己函式的功能。

void reprint(int * arr,int i,int n)//用一個i來描述當前的位置,便於遞迴退出,如果沒有i,就無法退出
{
    /*出口條件*/
    if (i > n-1)
        return;
    /*這裡在不停的遞迴,知道當i == n的時候就開始彈他會彈到上一層呼叫的地方,並且開始執行呼叫函式下面的語句*/
    reprint(i+1)
    /*彈出後就會到這個位置來開始執行下面的語句,所以我們只需要在這裡列印即可*/
    cout << arr[i];
}

案例2:單向連結串列逆轉

單向連結串列的逆轉也是用遞迴來實現了,有了上面的案例一,是否會有些啟發呢。單向連結串列只能從後面開始逆轉,從前面開始逆轉的話,由於沒有前指標,所以會斷掉,只能從後開始逆轉。所以可以考慮用遞迴先將節點位置定位到最後,然後利用其彈棧的過程實現逆轉。

  1. void reverse(ListNode * node){  
  2.     /*到最後的時候node肯定為空,就相當於遇到了出口*/
  3.     if (node && node->next){  
  4.         reverse(node->next);  
  5.         /*這個位置就相當於處理遞歸回彈過程中next指標的指向*/
  6.         node->next->next = node;  
  7.     }  
  8. }  

案例3:二叉樹中序遍歷

  1. //中序遍歷以引數root為根的子樹
  2. staticvoid travel(BSTREE_NODE * root){  
  3.     if (root){  
  4. /*這個過程有兩個位置有遞迴*/
  5.         travel(root->left);  
  6.         printf("%d ",root->data);  
  7.         travel(root->right);  
  8.     }  
  9. }  
二叉樹的前序遍歷和後序遍歷同理。理解二叉樹各序遍歷組好找個二叉樹圖,然後結合程式碼來理解。