递归算法
递归就是函数体中调用自己,如果不加控制,将无休止的调用自己,直到堆栈溢出。
-
优点:代码简洁、清晰,并且容易验证正确性。(如果你真的理解了算法的话,否则你更晕)
-
缺点:
- 它的运行需要较多次数的函数调用(消耗空间和时间),每次函数调用,都需要增加额外的堆栈处理,比如参数传递需要压栈等操作,会对执行效率有一定影响。
- 递归算法的运行效率较低。在递归调用的过程中系统为每一层的返回点、局部变量等开辟了栈来储存。递归次数过多容易造成栈溢出等,而且往栈里压入数据和弹出数据都需要时间,所以不难理解递归的实现效率不如循环。
使用递归策略时要注意的几个条件:
- 必须有一个明确的递归结束条件,称为递归出口。
- 递归需要有边界条件、递归前进段和递归返回段。
- 当边界条件不满足时,递归前进。当边界条件满足时,递归返回。
循环算法
循环就是反复执行某一段区域内的代码,如果不加控制,就会形成死循环。
-
优点:速度快,结构简单。
-
缺点:并不能解决所有的问题。有的问题适合使用递归而不是循环。如果使用循环并不困难的话,最好使用循环。
递归算法和循环算法总结:
- 两者设计思路区别在于:函数或算法是否具备收敛性!!!当且仅当一个算法存在预期的收敛效果时,采用递归算法才是可行的,否则,就不能使用递归算法。
- 对于循环算法,局部变量占用的内存是一次性的,也就是O(1)的空间复杂度;而对于递归(不考虑尾递归优化的情况),每次函数调用都要压栈,那么空间复杂度是O(n),和递归次数呈线性关系。( 现在的编译器在优化后,对于多次调用的函数处理会有非常好的效率优化,效率未必低于循环。)
- 一般递归调用可以处理的算法,也通过循环去解决需要额外的低效处理 。(能用循环表示的,都可以用递归表示;用递归表示的,不一定能用循环表示(后面半句话,有待商榷))
尾递归
要讲尾递归,我们要先从递归讲起。首先选择一个最简单的例子——阶乘。以下是一个用普通递归形式写的用来计算 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个特征:
- 在尾部调用的是函数自身 (Self-called);
- 可通过优化,使得计算仅占用常量栈空间 (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 函数调用的整个过程,$ 字符用于表示占地:
- 首先我们建立一个函数栈。 $
- main 函数调用,将 main 函数压进函数栈里面。$ main
- 做完了一些操作以后,调用 foo1 函数,foo1 函数入栈。$ main foo1
- foo1 函数返回并出栈。$ main
- 做完一些操作以后,调用 foo2 函数,foo2 函数入栈。$ main foo2
- foo2 函数返回并出栈。$ main
- 做完余下的操作以后,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) 就马上要返回了,所以保存栈是没有任何意义的,既然没意义我们毫无疑问就要优化掉。
手动优化尾递归:
好了,现在我们有一个尾递归函数了。假设我们的语言没有原生支持尾递归优化,那么要怎么在语言层面上手动实现一个尾递归优化呢?这其实就是一个把递归变成循环的过程嘛。那么,只需要执行以下步骤,就能手动将尾递归优化成迭代循环:
- 首先,把上面尾递归代码抄过来。
- 将参数提取出来,成为迭代变量。原来的参数则用来初始化迭代变量。
- 递归终止的 return 不变,尾递归的 return 替换成while循环。
- 再循环中更新迭代变量。
尾递归实现代码:
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;
}
}
}
到这里,我们就已经将一个尾递归函数转换成循环迭代函数了。
如有不同见解,欢迎留言讨论~~~