递归与优化

1,229 阅读5分钟

一、递归的思想

递归的思想就是:把问题分解成规模更小,但与原文题有着相同解法的问题。

既然递归的思想是把问题分解成更小的问题但与原问题相同解法的问题,是不是所有具有这样特性的问题都可以用递归解决呢?答案是否定的。除了这个特性,能用递归解决的问题还有一个必须有特性:存在一种简单情境,能让递归在简单情境下退出,结束递归,即有一个递归的出口。

总结来说,能用递归解决的问题,必须满足下列两个条件:

  • 一个问题能够被分解成规模更小的子问题,且该子问题与原文题有同样的解法。
  • 存在一个能让递归调用停止的出口。

二、递归的效率

递归导致一个函数反复调用自身,我们知道函数调用是通过一个函数栈来实现的。每一次的函数调用,需要花费系统一定量的开销。系统在调用一个函数时,会在系统函数栈中为该函数建立一个栈帧(stack frame),将参数和返回地址、寄存器的信息保存在该栈帧,然后向被调用函数跳转,执行被调用函数。因此在递归还未到达出口时,这些栈帧都不会被弹出,因为栈空间是有限的,随着递归的不断进行,栈空间有可能会溢出。

因此,递归的总的开销是很大的,递归的深度越深,开销越大。


三、递归与尾递归

说起尾递归,我们先来谈谈尾调用把。

1.什么是尾调用?

尾调用的概念很简单,就是指某个函数的最后一步是调用另一个函数

elemType  f(elemType x){
    return g(x);
}

上面的代码中,函数f的最后一步调用了g函数,是尾调用。

//情况一
elemType f(elemType x){
    elemType var = g(x);
    return var;
}

//情况二
elemType f(elemType x){
    return g(x) + 1;
}

对于上面两种情况都不是尾调用。即使语义完全一样。因为它们在函数调用之后还有其他操作。尾调用不一定要出现在最后一行,只要是最后一步即可。

elemType f(elemType x){
    if(x > 0){
        return g(x)
    }
    return h(x)
}

2. 尾调用有啥好处吗

尾调用可以用于优化。

前面也说了,每一个函数的调用都会在系统函数栈中创建一个栈帧。尾调用由于它的特殊性,在函数执行的最后一步,所以不需要保存外层函数的调用记录,因为调用位置,内部的局部变量等信息都不会在用到了,只要直接用内层函数的栈帧取代外层函数的栈帧就可以了。

elemType f(){
    elemType m = 5;
    elemType n = 4;
    return g(m + n);
}
f();

//等同于
elemType f(){
    return g(9);
}
f();

//等同于
g(9);

如果函数不是尾调用,那么它的栈帧里的信息可能还会用到,需要保存起来。如果是尾调用,由于被调用函数执行完,调用函数也就结束了,所以执行到最后一步,完全可以删除调用函数的栈帧,只保留被调用函数的栈帧。

想象一下,如果所有函数都是尾调用,那么完全可以做到,每次函数执行时,系统函数栈都只有一个栈帧,这将大大节约栈空间,也不会出现栈溢出的现象了。

递归是函数调用自身,尾递归就是函数尾调用自身。

递归调用效率低,需要花费系统额外的开销。并且需要同时保存大量栈帧,很容易发生栈溢出。但对于尾递归来说,由于任何时候只存在一个栈帧,所以永远不会出现栈溢出的情况。

//求阶乘 普通写法 需要保存n个栈帧,空间复杂度为O(n)
int f(int n){
    if(n == 1) return n;
    return f(n-1) * n;
}
f(5);  // 120

//尾递归写法,只有一个栈帧,空间复杂度为O(1)
int f(int n, int total){
    if(n == 1) return total;
    return f(n-1, total * n);
}
f(5,1);  // 120

//上面代码可读性不好,使用一个普通写法的形式,通用尾递归函数可读性更强
int factorial(int n){
    return f(n,1);
}

由此可见,尾调用优化对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。

不是所有编译器都对尾调用进行了优化,或者默认为不优化。


3. 尾递归代码怎么写?

为了确保函数最后一步只调用自身,需要把所有用到的内部变量改写成函数的参数。例如上面求阶乘中的total中间变量。


四、递归与迭代转换

递归代码一定能转换成迭代函数吗?答案是肯定的!

递归本质上是隐式地使用系统函数栈,保存每次函数调用所需要的信息。,因此我们可以使用显式栈的方式模拟系统函数栈的行为来将递归转换成迭代的形式。

比如二叉树的中序遍历:

void visit_mid(TreeNode* root){
    if(root == NULL){
        return;
    }
    visit_mid(root->left);
    printf("%d",root->val);
    visit_mid(root->right);
}

/*
为了模拟系统函数栈的行为,我们首先要理解遍历过程。
对于每一个节点:
分三个状态:1.若该节点的左子节点不空,则将p入栈(因为无局部变量,所以只需要保存p),然后对该节点的左子节点进行遍历。
          2.若该节点的左子节点为空,则取栈顶元素并打印它的值,再将该节点弹出,然后对该节点的右子节点遍历。
          3.直到p为空,并且栈空遍历结束。
*/

void visit_mid(TreeNode* root){
    stack<TreeNode*> s;
    TreeNode* cur_node = root;
    
    //
    while(p != NULL || !s.empty()){
      while(p != NULL){
        s.push(p);
        p = p->left;
    }
    if(!s.empty()){
        p = s.top();
        printf("%d", p->val);
        s.pop();
        p = p->right;
    }
}


五、参考资料

阮一峰的网络日志-尾调用优化

漫谈递归转非递归

关于语言不可知:每次递归都可以转换成迭代吗?

二叉树的非递归遍历