1. 程式人生 > >理解C語言遞歸up_and_down

理解C語言遞歸up_and_down

eas 一個 一次 表示 amp 最簡 聯系 ruby 思考

函數調用、理解遞歸

對於程序,編譯器會對其分配一段內存,在邏輯上可以分為代碼段,數據段,堆,棧。

  • 代碼段:保存程序文本,指令指針EIP就是指向代碼段,可讀可執行不可寫
  • 數據段:保存初始化的全局變量和靜態變量,可讀可寫不可執行
  • BSS:未初始化的全局變量和靜態變量
  • 堆(Heap):動態分配內存,向地址增大的方向增長,可讀可寫可執行
  • 棧(Stack):存放局部變量,函數參數,當前狀態,函數調用信息等,向地址減小的方向增長,非常非常重要,可讀可寫可執行

來一張圖:
[圖片上傳失敗...(image-d902e7-1512804060985)]

上面這些對理解調用棧有什麽用呢。其實想要徹底弄明白,還需要懂匯編才行。這裏我們只需要知道棧會存放局部變量,函數參數,當前狀態,函數調用信息

對後面的理解就夠了。

下面通過一個例子來理解遞歸調用的執行過程(Xcode)

void up_and_down(int n)
{
    printf("before: Level %d:n location %p\n",n,&n); /* 1 */
    if(n<4)
        up_and_down(n+1);
    printf("after: Level %d:n location %p\n",n,&n); /* 2 */
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        up_and_down(1);
    }
    return 0;
}

執行結果

before: Level 1:n location 0x7fff5fbff75c
before: Level 2:n location 0x7fff5fbff73c
before: Level 3:n location 0x7fff5fbff71c
before: Level 4:n location 0x7fff5fbff6fc
after: Level 4:n location 0x7fff5fbff6fc
after: Level 3:n location 0x7fff5fbff71c
after: Level 2:n location 0x7fff5fbff73c
after: Level 1:n location 0x7fff5fbff75c
Program ended with exit code: 0

分析過程: 
首先, main() 使用參數 1 調用了函數 up_and_down() ,於是 up_and_down() 中形式參數 n 的值是 1, 故打印語句 #1 輸出了 Level1 。然後,由於 n 的數值小於 4 ,所以 up_and_down() (第 1 級)使用參數 n+1 即數值 2 調用了 up_and_down()( 第 2 級 ). 使得 n 在第 2級調用中被賦值 2, 打印語句 #1 輸出的是 Level2 。與之類似,下面的兩次調用分別打印出 Level3 和 Level4 。

當開始執行第 4 級調用時, n 的值是 4 ,因此 if 語句的條件不滿足。這時候不再繼續調用 up_and_down() 函數。第 4 級調用接著執行打印語句 /* 2 */,即輸出 Level4 ,因為 n 的值是 4 。現在函數需要執行 return 語句,此時第 4 級調用結束,把控制權返回給該函數的調用函數,也就是第 3 級調用函數。第 3 級調用函數中前一個執行過的語句是在 if 語句中進行第 4 級調用。因此,它繼續執行其後繼代碼,即執行打印語句 /* 2 */,這將會輸出 Level3 .當第 3 級調用結束後,第 2 級調用函數開始繼續執行,即輸出Level2 .依次類推.

註意,每一級的遞歸都使用它自己的私有的變量 n .可以查看地址的值來證明。也就是棧保存了調用的參數。

如果還沒看懂,沒關系,我再用一種最為簡單的方式在解釋一下。完全可以簡單就是把遞歸函數一層一層展開。比如上面的例子,如果展開就可以寫成下面這樣

void up_and_down_simple(int n)
{
    printf("before: Level %d:n location %p\n",n,&n); /* 1 */
    if(n<4)
        up_and_down1(n+1);
    printf("after: Level %d:n location %p\n",n,&n); /* 2 */
}
void up_and_down1(int n)
{
    printf("before: Level %d:n location %p\n",n,&n); /* 1 */
    if(n<4)
        up_and_down2(n+1);
    printf("after: Level %d:n location %p\n",n,&n); /* 2 */
}

void up_and_down2(int n)
{
    printf("before: Level %d:n location %p\n",n,&n); /* 1 */
    if(n<4)
        up_and_down3(n+1);
    printf("after: Level %d:n location %p\n",n,&n); /* 2 */
}
void up_and_down3(int n)
{
    printf("before: Level %d:n location %p\n",n,&n); /* 1 */
    if(n<4)
        up_and_down4(n+1);
    printf("after: Level %d:n location %p\n",n,&n); /* 2 */
}
void up_and_down4(int n)
{
    printf("before: Level %d:n location %p\n",n,&n); /* 1 */
    if(n<4)
        up_and_down(n+1);
    printf("after: Level %d:n location %p\n",n,&n); /* 2 */
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
//        up_and_down(1);
        up_and_down_simple(1);
    }
    return 0;
}

打印的結果:

before: Level 1:n location 0x7fff5fbff75c
before: Level 2:n location 0x7fff5fbff73c
before: Level 3:n location 0x7fff5fbff71c
before: Level 4:n location 0x7fff5fbff6fc
after: Level 4:n location 0x7fff5fbff6fc
after: Level 3:n location 0x7fff5fbff71c
after: Level 2:n location 0x7fff5fbff73c
after: Level 1:n location 0x7fff5fbff75c
Program ended with exit code: 0

這樣一對比二者的結果是一樣的所以說,如果你對遞歸還是很難理解,就去用展開的思路理解吧。

總結一下

  • 每一次函數調用都會有一次返回.當程序流執行到某一級遞歸的結尾處時,它會轉移到前一級遞歸繼續執行.
  • 遞歸函數中,位於遞歸調用前的語句和各級被調函數具有相同的順序.如打印語句 #1 位於遞歸調用語句前,它按照遞歸調用的順序被執行了 4 次;位於遞歸調用語句後的語句的執行順序和各個被調用函數的順序相反.
  • 每一級的函數調用都有自己的私有變量.
  • 遞歸函數中必須包含可以終止遞歸調用的語句.

常見遞歸問題

有了上面的基礎,現在開始來刷刷幾道簡單的題:

階乘n!

按照遞歸的套路兩個: 1. 遞歸公式: 有反復執行的過程(調用自身) 2. 退出條件: 有跳出反復執行過程的條件(遞歸出口)

  • 遞歸公式 n! = n * (n-1) * (n-2) * ...* 1(n>0)
  • 退出條件 n == 0
int recursive(int n) {
    if (0 == n) {
        return (1);
    }
    else {
        return n * recursive(n - 1);
    }
}

斐波那契數列

斐波納契數列,又稱黃金分割數列,指的是這樣一個數列:1、1、2、3、5、8、13、21、……

  • 遞歸公式 Fib(n) = Fib(n-1) + Fib(n-2);
  • 退出條件 n == 0 ,n == 1
int Fib(int n) {
    if (0 == n) {
        return 1;
    }
    if (1 == n) {
        return 1;
    }
    
    return Fib(n -1) + Fib(n - 2);
}

全排列

從n個不同元素中任取m(m≤n)個元素,按照一定的順序排列起來,叫做從n個不同元素中取出m個元素的一個排列。當m=n時所有的排列情況叫全排列。

如1,2,3三個元素的全排列為:

  1,2,3
  1,3,2
  2,1,3
  2,3,1
  3,1,2
  3,2,1 

這種問題遞歸公式和退出條件並不是那麽明顯,需要深入分析。如何去分析呢。一般思路就是總結歸納,先用最簡單的例子找到規律,再提煉成公式。

把123的全排列可以看出三組,分別是1xx,2xx, 3xx。可以想成一個數列的全排列的公式 :n個元素的全排列=(一個元素作為前綴)+(其余n-1個元素的全排列);

退出條件:如果只有一個元素的全排列,則說明已經排完,則輸出數組;

不斷換排頭通過for循環就可以實現。然後就是前綴需要交換。先把基本的寫好

交換函數:

void Swap(char str[], int a, int b) {
    char temp = str[a];
    str[a] = str[b];
    str[b] = temp;
}

主函數

//全排列
int sum = 0;
void Perm(char str[], int begin, int end) {
    if (begin == end)
    {
        for (int i = 0; i <= end; i++)
        {
            cout << str[i];
        }
        cout << endl;
        sum++;
        return;
    }
    else
    {
        for (int j = begin; j <= end; j++)
        {
            printf("\n swap begin:%d j:%d \n", begin, j);
            Swap(str, begin, j);//交換是第幾個
            Perm(str, begin + 1, end);
            Swap(str, j, begin);//歸位
        }
    }
}

為了看清整個交換流程,加了個日誌

4
abcd

 swap begin:0 j:0 

 swap begin:1 j:1 

 swap begin:2 j:2 
abcd

 swap begin:2 j:3 
abdc

 swap begin:1 j:2 

 swap begin:2 j:2 
acbd

 swap begin:2 j:3 
acdb

 swap begin:1 j:3 

 swap begin:2 j:2 
adcb

 swap begin:2 j:3 
adbc

 swap begin:0 j:1 

 swap begin:1 j:1 

 swap begin:2 j:2 
bacd

 swap begin:2 j:3 
badc

 swap begin:1 j:2 

 swap begin:2 j:2 
bcad

 swap begin:2 j:3 
bcda

 swap begin:1 j:3 

 swap begin:2 j:2 
bdca

 swap begin:2 j:3 
bdac

 swap begin:0 j:2 

 swap begin:1 j:1 

 swap begin:2 j:2 
cbad

 swap begin:2 j:3 
cbda

 swap begin:1 j:2 

 swap begin:2 j:2 
cabd

 swap begin:2 j:3 
cadb

 swap begin:1 j:3 

 swap begin:2 j:2 
cdab

 swap begin:2 j:3 
cdba

 swap begin:0 j:3 

 swap begin:1 j:1 

 swap begin:2 j:2 
dbca

 swap begin:2 j:3 
dbac

 swap begin:1 j:2 

 swap begin:2 j:2 
dcba

 swap begin:2 j:3 
dcab

 swap begin:1 j:3 

 swap begin:2 j:2 
dacb

 swap begin:2 j:3 
dabc
24
Program ended with exit code: 0

根據日誌結合代碼來分析就很容易理解了。

河內塔問題

n個盤子和3根柱子:A(源)、B(備用)、C(目的),盤子的大小不同且中間有一孔,可以將盤子“串”在柱子上,每個盤子只能放在比它大的盤子上面。起初,所有盤子在A柱上,問題是將盤子一個一個地從A柱子移動到C柱子。移動過程中,可以使用B柱,但盤子也只能放在比它大的盤子上面。

從上面的分析得出:
該問題可以分解成以下子問題:
第一步:將n-1個盤子從A柱移動至B柱(借助C柱為過渡柱)
第二步:將A柱底下最大的盤子移動至C柱
第三步:將B柱的n-1個盤子移至C柱(借助A柱為過渡柱)

int i;    //記錄步數  
//i表示進行到的步數,將編號為n的盤子由from柱移動到to柱(目標柱)  
void move(int n, char from, char to) {  
    printf("第%d步:將%d號盤子%c---->%c\n", i++, n, from, to);  
}  
  
//漢諾塔遞歸函數  
//n表示要將多少個"圓盤"從起始柱子移動至目標柱子  
//start_pos表示起始柱子,tran_pos表示過渡柱子,end_pos表示目標柱子  
void Hanio(int n, char start_pos, char tran_pos, char end_pos){  
    if(n == 1) {    //很明顯,當n==1的時候,我們只需要直接將圓盤從起始柱子移至目標柱子即可.  
        move(n,start_pos, end_pos);  
    }  
    else {  
        Hanio(n-1, start_pos, end_pos, tran_pos);   //遞歸處理,一開始的時候,先將n-1個盤子移至過渡柱上  
        move(n, start_pos, end_pos);                //然後再將底下的大盤子直接移至目標柱子即可  
        Hanio(n-1, tran_pos, start_pos, end_pos);    //然後重復以上步驟,遞歸處理放在過渡柱上的n-1個盤子  此時借助原來的起始柱作為過渡柱(因為起始柱已經空了)  
    }  
}

這個思考起來有點麻煩,所以註釋寫得很多。

更多

除了上面列舉的幾個例子,還有比較常見的,二分查找,快排也用到了遞歸的思想。先這樣吧。腦子還是得多用才能更加靈活。



作者:紙簡書生
鏈接:https://www.jianshu.com/p/99ca6dba3be6
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯系作者獲得授權並註明出處。

理解C語言遞歸up_and_down