我对递归的理解和总结

看了本身的动态记录,发现本身已经遗忘了曾经的本身,有一条动态,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必定会优化的, 若是经过试验测试,建议数字要足够大,由于优化以后的程序可能占用栈空间很小,而进程的栈空间可能又很大,因此没有进行尾递归优化依然可能不会栈溢出。

 

总之,递归是一种很是好的模型,栈空间通常可预测,尾递归由于利用函数实现,局部变量隔离,也会增长安全性,可是记得开优化,尽可能不用栈和循环替代递归,增长代码可读性。

相关文章
相关标签/搜索