我對遞歸的理解和總結

看了本身的動態記錄,發現本身已經遺忘了曾經的本身,有一條動態,2013年的時候,我看了一篇關於尾遞歸的博文,那時候還只是一個初學者,胡亂評論了一下,做者但願我能寫一篇博文發表一下本身的見解,當時沒有寫,然而如今卻想寫點什麼總結一下,不保證說的沒問題,只但願若是有像我當年同樣的初學者看到,能夠參考借鑑,或許能有些幫助,在此也感謝給我啓發,教會我知識的陌生朋友們。算法

 

1. 遞歸就是一個函數直接或間接的本身調用本身,間接就是f0 -> f1 -> f0 ->f1這種,但也就是一個名詞而已,並不神奇,它只是一個普普統統的函數調用,但因爲本身調用本身這個特性,有一種自動化的力量,給它一個規則,它能夠自動的持續運行下去,直到觸發中止條件。一個遞歸函數就像是一個簡單的計算機。編程

2. 全部的遞歸均可以用循環來替代,全部的循環也均可以用遞歸來替代,二者是等價的。數組

3. 遞歸是人腦的直接思惟方式,循環是當前多數(我所知道的全部的)cpu的直接思惟方式。安全

4. 對於cpu來說,函數調用只是寄存器,內存,跳轉等操做,若是不涉及額外棧空間使用(極簡單函數的特殊狀況),函數調用和循環的差異可能僅僅是使用的寄存器不一樣。app

5. 遞歸能夠把複雜的問題變得簡單,是一種處理問題的模型。好比漢諾塔,快速排序,二叉樹遍歷和查找,若是學會使用遞歸這種思惟方式思考問題,不少問題會變得很簡單,大事化小,小事化了,每一步都是很簡單的操做。編程語言

6. 正確的思惟會使問題很簡單,錯誤的思惟會讓人發懵,使用遞歸的思考方式是忘記調用的是本身,本身調用的是任意一個函數,那個函數有沒有實現,是否實現得正確不是我如今要關心的事,我只要保證,在那個函數正確實現的前提下,我如今寫的這個函數是沒問題的,當我寫完當前函數的時候,被調用的函數也就寫完了(副產物),由於它們是同一個,這有點像數學概括法。函數

7. 正確的實現一個遞歸函數,須要保證有退出的條件,除非你是在寫一個死循環,同時隨着遞歸層數變深,問題逐漸簡單化,規模逐漸縮小,或者是向退出條件逼近(收斂)。oop

8. 遞歸對棧空間的佔用分兩種,尾遞歸開啓相應的優化以後不會致使棧空間使用不停擴大,非尾遞歸對棧空間的調用要看遞歸的層數,遞歸層數是可預測的,通常二分的遞歸(理想的狀況,極端的狀況二叉樹會變成鏈表,這時候已經不是二分法了,但二叉樹是能夠事先保證平衡的)層數大約爲log2(n),30層函數調用使用的棧空間不多(使用超級龐大的數組局部變量這樣的特殊狀況除外),可是n是10億級別,這個時候要關注的已經不是棧空間了,而是保存數據的內存空間或cpu等資源,好比用遞歸方法計算Fibonacci數列,如今的我的電腦默認棧空間(M級別),不可能棧溢出的,忙不過來的是cpu,多分的狀況棧空間通常都不會過深,緣由是一邊調用增長深度,一邊返回減小深度,用徹底平衡二叉樹爲例,畫一個圖看一下調用過程就一目瞭然。post

 

下面就棧空間的使用,尾遞歸,遞歸循環的轉換等問題詳細分析。性能

除非是特殊的狀況,編譯器能優化成不使用棧空間,不然遞歸是須要棧空間的,這和任何一個函數調用都是同樣的,對於解決實際問題的函數,通常沒有不須要棧空間的,在函數調用的時候,須要保存cpu寄存器到棧空間(用於恢復函數的執行),局部變量也有可能會致使棧空間的使用,每個函數執行的時候局部變量都會佔用一次棧空間,每一次函數調用也會觸發一次棧空間的使用,這就是每一次遞歸調用的棧空間代價,函數調用老是有調用就有返回的,最大代價就是最大遞歸層數,尾遞歸是一種特殊狀況,考慮下面的函數。

int f(int n)
{
    if(n <= 0)
       return n;
    // body;
    return f(n-1);
}
return f(n-1);是函數f的最後一個語句。f(n-1)的返回值就是f(n)的返回值,也就是說對於當前函數f(n)已經沒有必要保存現場了,它的棧空間不須要恢復了,f(n-1)返回到f(n),f(n)再向上返回,那爲何要留個中介呢,爲何不直接向上返回呢,因此棧空間中保存的返回地址等不動,進入f(n)時保存的寄存器(callee-saved registers)不動,也就是f(n)的上層現場不動,他們直接繼承給f(n-1),f(n-1)再也不
保存它的返回地址(f(n)的最後),也再也不保存使用的寄存器(f(n)已經不須要恢復了),f(n)的局部變量使用的棧空間直接被f(n-1)的給覆蓋掉,一樣的邏輯再向上遞推,會發現,每一層函數調用引發的棧空間佔用都至關於沒有了,實際上上述代碼就變成了
int f(int n)
{
    while( n > 0 )
    {
        //body;
        n--;
    }
    return n;
}

這種遞歸叫作尾遞歸,即遞歸調用以後不須要再有額外的操做,而且遞歸以前沒有其餘遞歸調用,開啓優化以後(gcc, O2默認開啓)編譯器能夠將尾遞歸優化成循環。

再考慮下面的函數

int f(int n)
{
    if(n <= 0)
       return n;
    // body;
    return n + f(n-1);
}

這種遞歸調用是沒法 直接 變成循環的,這裏用直接,是由於這種狀況太簡單了,編譯器不會那麼傻,gcc O1就會變成循環,爲何不能直接變成循環呢,由於f(n-1)以後還有其餘操做(返回值+n),爲了繼續其餘操做可以繼續執行,調用f(n-1)以前須要保存現場,須要用到棧空間,每一層調用都會保存一次棧空間,這時候棧空間的佔用是O(n)的,由於不是二分,三分,n的數量稍大一點就會致使棧溢出。固然這裏實在是太簡單了!換個複雜的,編譯器就不會優化了(只是寫本文的時候用的gcc,不排除之後編譯器愈來愈智能的可能)。

unsigned long fib(int n)
{
    if(n < 2)
       return 1;
    return fib(n-1) + fib(n-2);
}

fib(3) 調用 fib(2)和fib(1),假定編譯器生成的指令是先調用fib(2),那麼就要在棧空間中保存現場,以便fib(2)返回的時候可以繼續執行fib(1)和一個加法操做,fib(2)調用fib(1)和fib(0),仍是假定先調用左邊的,調用fib(1)的時候須要保存現場,而後返回1, 恢復現場,保存現場,調用fib(0),而後恢復現場,加法運算,而後再返回上層,即fib(2)返回,恢復現場,fib(2)下面的全部調用佔用的棧空間都已釋放了(遞減棧棧頂寄存器數值增長),而後保存現場,調用fib(1),返回1, 恢復現場,加法運算,返回,整個fib(3)就是這樣完成的,可見每次調用+左邊的分支的時候,遞歸層數會增長一層,每次調用+右面的分支的時候,左面增長的層數都已經恢復,這是一個動態增減的過程,遞歸層數是有限的。這種Fibonacci數列算法慢的根源在於重複計算。不重複計算的方法以下:

unsigned long fib2(int n,  unsigned long left, unsigned long right)
{
    if( n < 2 )
        return right;
    return fib2(n - 1, right, left+right);
}

這裏是一個尾遞歸,至關於循環, 固然若是不優化,棧空間佔用是O(n),n足夠大是會溢出的。

可見,循環和尾遞歸是直接互相轉換的,循環變量至關於函數中的參數,循環退出條件至關於函數退出的條件,循環變量的增減至關於參數傳遞時的增減計算,循環體至關於函數體,因此像scheme這樣的編程語言沒有循環可是並不影響表達能力。

Fibonacci數列循環的算法是從數列的左邊開始,不符合直觀定義,須要知道原理才能想到,直觀的定義是從右到左,然而左邊又沒有準備好,因此須要借用棧。

考慮一個更明顯的例子,單向非循環鏈表的正向遍歷和逆向遍歷,前者是尾遞歸(循環),後者非尾遞歸(使用循環須要藉助棧),正向遍歷不須要額外的棧空間,可是如何實現逆向遍歷呢?首先要拿到最後一個節點,可是訪問完最後一個節點了,到哪裏去找上一個節點呢,單向鏈表並無prev指針,很明顯,須要在內存中保存,因爲訪問的順序是後進先出,用的應該是棧這種模型,而函數調用原本就是棧的模型的,因此若是使用函數調用的方式是很天然的,很符合人的思惟邏輯的,用遞歸的方式都不用考慮棧的問題,由於這是一種很天然的符合人的邏輯的思考模型,代碼以下:

struct list{
    int c;
    struct list *next;
};

#define print_list(list) (void)list
void visit(const struct list *cur)
{
    if(cur == NULL)
        return;
    print_list(cur);
    visit(cur->next);
}
void visit_reverse(const struct list *cur)
{
    if(cur == NULL)
        return;
    // 訪問後面的,怎麼訪問的不用管,會有人保證它的逆序
    visit_reverse(cur->next);
    // 後面的全都訪問完了,訪問當前的
    print_list(cur);
}

#define list_append(tail_p, cur) ((*tail_p)->next = cur, (*tail_p) = cur)
struct list * _list_reverse(struct list *cur, struct list **tail_after_reverse)
{
    // 最後一個
    if(cur->next == NULL)
    {
        // 記錄末尾,方便list_append
        *tail_after_reverse = cur;
        return cur;
    }
    struct list *head = _list_reverse(cur->next, tail_after_reverse);
    list_append(tail_after_reverse, cur);
    return head;
}
// 逆序單向鏈表
struct list * list_reverse(struct list *cur)
{
    struct list *tail_after_reverse;
    if(cur == NULL)
        return cur;
    struct list *head = _list_reverse(cur, &tail_after_reverse);
    list_append(&tail_after_reverse, NULL);
    return head;
}

 

尾遞歸和循環能夠互相轉換,這是很明顯的,那麼非尾遞歸如何和循環互相轉換呢,理論上是必定能夠完成的,由於對於cpu來說遞歸就是用棧來實現的,下面以二叉樹的先序,中序,後序的遍歷方式來舉例說明,不過可以實現不表明應該這樣作,代碼的可讀性和看法性很是重要,而且轉變成循環也未必就能感覺到性能的變化。

#include <assert.h>
#include <stdio.h>


struct tree{
    int n;
    struct tree *left;
    struct tree *right;
};

static const void *stack[128];
static char stack_flag[128];
static int stack_i;
#define visit(t) printf("%d\t", t->n)
#define push(x) do{if(x) stack[stack_i++] = x;}while(0)
#define pop() (stack_i == 0 ? NULL : stack[--stack_i])
#define push2(x, flag) do{if(x) {stack[stack_i] = x; stack_flag[stack_i++] = flag;}}while(0)
#define pop2(flag) (stack_i == 0 ? NULL : ((flag=stack_flag[--stack_i]), (stack_flag[stack_i] = 0), stack[stack_i]))

// 二叉樹的先序遍歷
//
// 遞歸版本
void preorder(const struct tree *t){
    if(t == NULL)
        return;
    visit(t);
    preorder(t->left);
    preorder(t->right);
}

// 循環版本
// 如何變成循環呢,方法就是遞歸怎麼來,咱們就怎麼來,
// 1. 調用visit,可是調用以後要恢復兩個函數調用,爲了恢復現場,須要在棧空間中保存後續要作的事,咱們這裏顯然不須要保存cpu寄存器等,只須要保存t->left和t->right就能夠了,因爲是先調用t->left,後調用t->right,因此入棧就要反過來。
//push(t->right);
//push(t->left);
//能不能直接push(t)呢,答案是不能,除非標記t已經訪問過了,不然就循環訪問t了,可是標記t訪問過了仍是要把t->right和t->left入棧,不如就直接來,更直接。
// 2. t訪問完了,遞歸程序就恢復現場,返回到visit的下一個地址執行,恢復現場就對應咱們的出棧,繼續執行一樣的preorder邏輯就至關於咱們重複一次循環,
// 3. 遞歸程序繼續這個過程,直到函數棧上的最底層,也就是最後一個函數調用返回,對應咱們的繼續這個過程,直到棧裏面沒有數據了爲止。
// 代碼以下
void preorder_loop0(const struct tree *in)
{
    const struct tree *t = in;
    if(t == NULL)
        return;
    do{
        visit(t);
        push(t->right);
        push(t->left);
    }while((t = pop()) != NULL);
}
//這個程序是最原始的貼近遞歸的版本,還能夠繼續優化,visit(t)以後pop出來的必定是t->left,那麼下次必定是visit(t->left),往下遞推,每一次都是visit(t->left),也就是說按照一直向左的方向遍歷就能夠了,須要入棧的只是右子樹,可是右子樹誰先誰後呢,從遞歸程序能夠看出,全部的左子樹成員都在右子樹的前面遍歷,也就是說最接近樹根的大叉是優先級最低的,遠離樹根的在左子樹上的右子樹更優先,也就是說,入棧的順序和訪問的順序相同,即
//

void preorder_loop1(const struct tree *in)
{
    const struct tree *t = in;
    if(t == NULL)
        return;
    do{
        while(t)
        {
            visit(t);
            push(t->right);
            t = t->left;
        }
    }while((t = pop()) != NULL);
}
// 將上面兩種版本對比,想象一下preorder_loop0的執行過程,也能夠直接優化爲preorder_loop1

//中序遍歷
// 遞歸版本
void inorder(const struct tree *t){
    if(t == NULL)
        return;
    inorder(t->left);
    visit(t);
    inorder(t->right);
}

// 第一步仍是按照和遞歸一一對應的方式來轉換成循環,這個地方有點複雜,由於第一個函數不是visit,自己就是個遞歸的,這時候的處理方式不惟一,能夠直接把遞歸版本函數中最上面的那個inorder展開,也能夠按照通用的循環中的邏輯來處理那個inorder,前者直接就是優化以後的了。另外因爲inorder和visit是兩種操做,爲了區分是哪種操做,還須要在入棧的時候加標記等。
void inorder_loop0(const struct tree *in)
{
    int is_visit = 0;
    const struct tree *t = in;
    if(t == NULL)
        return;
    do{
        if(is_visit)
            visit(t);
        else
        {
            push2(t->right, 0);
            push2(t, 1);
            push2(t->left, 0);
        }
    }while((t = pop2(is_visit)) != NULL);
}
// 簡化push(t->left); 同preorder的方法
void inorder_loop1(const struct tree *in)
{
    int is_visit = 0;
    const struct tree *t = in;
    if(t == NULL)
        return;
    do{
        if(is_visit)
            visit(t);
        else
        {
            while(t)
            {
                push2(t->right, 0);
                push2(t, 1);
                t = t->left;
            }
        }
    }while((t = pop2(is_visit)) != NULL);
}
// 繼續優化,每次pop出來的必定是先visit的,而後接着就是它的right,那麼二者能夠合成一個總體,這樣也不用標記是不是is_visit了
void inorder_loop2(const struct tree *in)
{
    const struct tree *t = in;
    if(t == NULL)
        return;

    while(t)
    {
        push(t);
        t = t->left;
    }
    while((t = pop()) != NULL)
    {
        visit(t); // pop -> t
        if(t->right) // pop -> t->right
        {
            t = t->right;
            while(t)
            {
                push(t);
                t = t->left;
            }
        }
    }
}

// 後續遍歷
// 遞歸版本
void postorder(const struct tree *t){
    if(t == NULL)
        return;
    postorder(t->left);
    postorder(t->right);
    visit(t);
}
// 和中序遍歷相同的方式,惟一一個區別就是 visit(t) 和postorder(t->right)的順序換了一下,也就是入棧的順序換了一下。代碼以下:
void postorder_loop0(const struct tree *in)
{
    int is_visit = 0;
    const struct tree *t = in;
    if(t == NULL)
        return;
    do{
        if(is_visit)
            visit(t);
        else
        {
            push2(t, 1);
            push2(t->right, 0);
            push2(t->left, 0);
        }
    }while((t = pop2(is_visit)) != NULL);
}

// 前面已經用過這種思路,去掉push t->left(附帶的pop也一塊兒去掉了)
void postorder_loop1(const struct tree *in)
{
    int is_visit = 0;
    const struct tree *t = in;
    if(t == NULL)
        return;
    do{
        if(is_visit)
            visit(t);
        else
        {
            while(t)
            {
                push2(t, 1);
                push2(t->right, 0);
                t = t->left;
            }
        }
    }while((t = pop2(is_visit)) != NULL);
}

// t->right 先出棧,處理完整棵樹才能繼續處理t(即visit(t)),因此t和t->right不能當成一個總體來優化
// 但仍然能夠繼續優化,t->right的處理過程是先push 它本身,也就是說visit(t)的時候上一次pop必定是
// t->right,而且pop -> t->right 而後pop -> t 的時候必定是訪問t的時候,這是後序遍歷的定義的必然
// 即 t->right == NULL 或last_pop/visit == t->right就是visit(t)的時刻,這樣能夠去掉is_visit的標記
// 使用更簡單的push 和 pop
void postorder_loop2(const struct tree *in)
{
    const struct tree *t = in, *last_visit = NULL;
    if(t == NULL)
        return;

    while(t)
    {
        push(t);
        push(t->right);
        t = t->left;
    }
    while((t = pop()) != NULL)
    {
        if(t->right == NULL || last_visit == t->right)
        {
            visit(t);
            last_visit = t;
        }
        else
        {
            while(t)
            {
                push(t);
                push(t->right);
                t = t->left;
            }
        }
    }
}

int main(int argc, char *argv[])
{
    /*
     *
     *                        4
     *                     /    \
     *                   2        6
     *                 /  \      /   \
     *                1    3    5      8
     *                                / \
     *                               7   9
     *
     *
     */
    struct tree t[9] = {
        {1, NULL,NULL},
        {2, &t[0],&t[2]},
        {3, NULL,NULL},
        {4, &t[1],&t[5]},
        {5, NULL,NULL},
        {6, &t[4],&t[7]},
        {7, NULL,NULL},
        {8, &t[6],&t[8]},
        {9, NULL,NULL},
    };
    preorder(&t[3]);
    puts("");
    assert(stack_i == 0);
    preorder_loop0(&t[3]);
    assert(stack_i == 0);
    puts("");
    preorder_loop1(&t[3]);
    assert(stack_i == 0);
    puts("");
    inorder(&t[3]);
    assert(stack_i == 0);
    puts("");
    inorder_loop0(&t[3]);
    assert(stack_i == 0);
    puts("");
    inorder_loop1(&t[3]);
    assert(stack_i == 0);
    puts("");
    inorder_loop2(&t[3]);
    puts("");
    postorder(&t[3]);
    puts("");
    assert(stack_i == 0);
    postorder_loop0(&t[3]);
    assert(stack_i == 0);
    puts("");
    postorder_loop1(&t[3]);
    assert(stack_i == 0);
    puts("");
    postorder_loop2(&t[3]);
    assert(stack_i == 0);
    return 0;
}

尾遞歸和非尾遞歸是否能轉換呢,對於Fibonacci數列這樣的特殊狀況固然能夠,但有些問題就是遞歸模型,好比快速排序,因此是沒法直接轉換的,若是非要轉換的話,也是能夠的,藉助額外實現的棧,由於循環和尾遞歸是等價的,循環加額外的棧能實現的,尾遞歸加額外的棧也能實現,可是這樣作是否有意義呢,人工精心優化的循環/尾遞歸程序和非尾遞歸相比,有時候也許能得到少許性能提高,可是代碼的可讀性卻不好,而非尾遞歸程序倒是很是直觀。

補充一點,看編譯器是否進行了尾遞歸優化,能夠經過gdb或objdump看彙編,看尾遞歸發生的時候後面是否有其餘操做,或者是否還有遞歸的調用,gcc O2必定會優化的, 若是經過試驗測試,建議數字要足夠大,由於優化以後的程序可能佔用棧空間很小,而進程的棧空間可能又很大,因此沒有進行尾遞歸優化依然可能不會棧溢出。

 

總之,遞歸是一種很是好的模型,棧空間通常可預測,尾遞歸由於利用函數實現,局部變量隔離,也會增長安全性,可是記得開優化,儘可能不用棧和循環替代遞歸,增長代碼可讀性。

相關文章
相關標籤/搜索