递归与循环的区别(P:尾递归优化)


递归算法

递归就是函数体中调用自己,如果不加控制,将无休止的调用自己,直到堆栈溢出。

  • 优点:代码简洁、清晰,并且容易验证正确性。(如果你真的理解了算法的话,否则你更晕)

  • 缺点:

    1. 它的运行需要较多次数的函数调用(消耗空间和时间),每次函数调用,都需要增加额外的堆栈处理,比如参数传递需要压栈等操作,会对执行效率有一定影响。
    2. 递归算法的运行效率较低。在递归调用的过程中系统为每一层的返回点、局部变量等开辟了栈来储存。递归次数过多容易造成栈溢出等,而且往栈里压入数据和弹出数据都需要时间,所以不难理解递归的实现效率不如循环。

使用递归策略时要注意的几个条件:

  1. 必须有一个明确的递归结束条件,称为递归出口。
  2. 递归需要有边界条件、递归前进段和递归返回段。
  3. 当边界条件不满足时,递归前进。当边界条件满足时,递归返回。

循环算法

循环就是反复执行某一段区域内的代码,如果不加控制,就会形成死循环。

  • 优点:速度快,结构简单。

  • 缺点:并不能解决所有的问题。有的问题适合使用递归而不是循环。如果使用循环并不困难的话,最好使用循环。

递归算法和循环算法总结:

  1. 两者设计思路区别在于:函数或算法是否具备收敛性!!!当且仅当一个算法存在预期的收敛效果时,采用递归算法才是可行的,否则,就不能使用递归算法。
  2. 对于循环算法,局部变量占用的内存是一次性的,也就是O(1)的空间复杂度;而对于递归(不考虑尾递归优化的情况),每次函数调用都要压栈,那么空间复杂度是O(n),和递归次数呈线性关系。( 现在的编译器在优化后,对于多次调用的函数处理会有非常好的效率优化,效率未必低于循环。)
  3. 一般递归调用可以处理的算法,也通过循环去解决需要额外的低效处理 。(能用循环表示的,都可以用递归表示;用递归表示的,不一定能用循环表示(后面半句话,有待商榷

尾递归

要讲尾递归,我们要先从递归讲起。首先选择一个最简单的例子——阶乘。以下是一个用普通递归形式写的用来计算 n 的阶乘的函数:

function fact(n) {
    if (n <= 0) {
        return 1;
    }
    else {
        return n * fact(n - 1);
    }
}

当我们计算 fact(6) 的时候,会产生如下展开:

6 * fact(5)
6 * (5 * fact(4))
6 * (5 * (4 * fact(3))))
// two thousand years later...
6 * (5 * (4 * (3 * (2 * (1 * 1)))))) // <= 最终的展开

注意了,到这里为止,程序做的仅仅还只是展开而已,并没有运算真正运运算,接下来才是运算:

6 * (5 * (4 * (3 * (2 * 1)))))
6 * (5 * (4 * (3 * 2))))
6 * (5 * (4 * 6)))
// two thousand years later...
720 // <= 最终的结果

我们普通递归的问题在于展开的时候会产生非常大的中间缓存,而每一层的中间缓存都会占用我们宝贵的栈上空间,所有导致了当这个 n 很大的时候,栈上空间不足则会产生“爆栈”的情况。

那有没有一种方法能够避免这样的情况呢?那当然是有的,那就是我们这篇文章的主角——尾递归了。

尾递归的概念

若函数在尾位置调用自身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归。尾递归也是递归的一种特殊情形。尾递归是一种特殊的尾调用,即在尾部直接调用自身的递归函数。 对尾递归的优化也是关注尾调用的主要原因。尾调用(函数的最后一步是调用另一个函数。)不一定是递归调用,但是尾递归特别有用,也比较容易实现。

尾递归在普通尾调用的基础上,多出了2个特征:

  1. 在尾部调用的是函数自身 (Self-called);
  2. 可通过优化,使得计算仅占用常量栈空间 (Stack Space)。

我们以上面的阶乘函数为例,写成尾递归的形式:

int fact(int n, int r) {
    if (n <= 0) {
        return 1 * r;
    }
    else {
        return fact(n - 1, r * n);
    }
}

我们像上面一个普通递归函数一样来展开和运算 fact(6):

fact(6, 1) // 1 是 fact(0) 的值,我们需要手动写一下
fact(5, 6)
fact(4, 30)
fact(3, 120)
fact(2, 360)
fact(1, 720)
720 // <= 最终的结果

跟上面的普通递归函数比起来,貌似尾递归函数因为在展开的过程中计算并且缓存了结果,使得并不会像普通递归函数那样展开出非常庞大的中间结果,所以不会爆栈是吗?

当然不是!我看到过很多博客和或者教程都犯有这样的错误。尾递归函数依然还是递归函数,如果不优化依然跟普通递归函数一样会爆栈,该展开多少层依旧是展开多少层。不会爆栈是因为语言的编译器或者解释器所做了“尾递归优化”,才让它不会爆栈的。

函数栈的作用

栈这种数据结构怎么定义的以及怎么用大家都非常了解了,也就是后入先出。当一个函数被调用的时候,我们会把函数扔进一个叫做“函数栈“的地方,但是我们为什么要用栈而不用其他的呢?

栈的意义其实非常简单,五个字——保持入口环境。我们结合一段简单代码来展示一下:

function main() {
    //...
    foo1();
    //...
    foo2();
    //...
    return;
}

main();

上面是一个简单的示例代码,我们现在简单在大脑里面模拟一下这个 main 函数调用的整个过程,$ 字符用于表示占地:

  1. 首先我们建立一个函数栈。 $
  2. main 函数调用,将 main 函数压进函数栈里面。$ main
  3. 做完了一些操作以后,调用 foo1 函数,foo1 函数入栈。$ main foo1
  4. foo1 函数返回并出栈。$ main
  5. 做完一些操作以后,调用 foo2 函数,foo2 函数入栈。$ main foo2
  6. foo2 函数返回并出栈。$ main
  7. 做完余下的操作以后,main函数返回并出栈。$

上面这个过程说明了函数栈的作用是什么?就是第 4 和第 6 步的作用,让 foo1 和 foo2 函数执行完了以后能够在回到 main 函数调用 foo1 和 foo2 原来的地方。这就是栈,这种”后入先出“的数据结构的意义所在。

尾递归为什么可以优化

上面说了,函数栈的目的是啥?是保持入口环境。那么在什么情况下可以把这个入口环境给优化掉?

  答案不言而喻,入口环境没意义的情况下为啥要保持入口环境?尾递归,就恰好是这种情况。

因为尾递归的情况下,我们保持这个函数的入口环境没意义,所以我们就可以把这个函数的调用栈给优化掉。比如还是那个阶乘函数把它写成尾递归的形式。

int fact(int n, int r) {
    if (n <= 0) {
        return 1 * r;
    }
    else {
        return fact(n - 1, r * n); // <= 这里的入口环境没有必要保留。
    }
}

这时,当里面这个 fact(n - 1, r * n) 返回的时候,外面的 fact(n, r) 就马上要返回了,所以保存栈是没有任何意义的,既然没意义我们毫无疑问就要优化掉。

手动优化尾递归:

好了,现在我们有一个尾递归函数了。假设我们的语言没有原生支持尾递归优化,那么要怎么在语言层面上手动实现一个尾递归优化呢?这其实就是一个把递归变成循环的过程嘛。那么,只需要执行以下步骤,就能手动将尾递归优化成迭代循环:

  1. 首先,把上面尾递归代码抄过来。
  2. 将参数提取出来,成为迭代变量。原来的参数则用来初始化迭代变量。
  3. 递归终止的 return 不变,尾递归的 return 替换成while循环。
  4. 再循环中更新迭代变量。

尾递归实现代码:

int fact(int n, int r) { // <= 这里把 n, r 作为迭代变量提出来
    if (n <= 0) {
        return 1 * r; // <= 递归终止
    }
    else {
        return fact(n - 1, r * n); // <= 用循环替代 fact。
    }
}

转换后得到的代码:

int fact(int _n, int _r) { // <= _n, _r 用作初始化变量
    int n = _n;
    int r = _r; // <= 将原来的 n, r 变量提出来编程迭代变量
    while (true) { // <= 生成一个迭代循环
        if (n <= 0) {
            return r;
        }
        else {
            // 更新迭代变量
            r = r * n;
            n = n - 1;
        }
    }
}

到这里,我们就已经将一个尾递归函数转换成循环迭代函数了。


如有不同见解,欢迎留言讨论~~~

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值