要理解递归就要先理解递归:手把手教你写递归
问:何为递归函数?
说人话:自己调用自己的函数就叫递归函数。
递归函数写法
实现一个递归函数,我将其概括为是一个“推卸责任”的过程,分为3个步骤:
- 列出方法签名:明确该函数/方法的输入输出,由此写出它的方法签名(method’s signature),方法签名包括修饰符,返回值类型,方法名和参数列表。
- 完成边界情况(base case,也称基准情况):即找到“替罪羊”,因为不停地把问题往下级推卸,最后总得有人出来背锅。
- 完成递归情况(recursive case):即完成责任推卸链,把问题从第\(n\)层推卸到第\(n - 1\)层。
我们将用求阶乘的例子来详细解释这3个步骤。
列出方法签名
首先,明确输入输出:我们希望只要给该方法/函数一个整数,就能返回它的阶乘。那么该递归方法的方法签名即为如下:
public int factorial(int n){};
这样我们就完成了实现递归函数的第1个步骤了。
完成边界情况
很显然阶乘的边界情况就是求1的阶乘,它没有理由再把计算的责任推卸给任何人了,因为它自己就能直接得出计算结果1,所以求1的阶乘就是我们要找的那只“替罪羊”。
因此,基准情况即为如下:
if (n == 1) return n;
完成递归情况
提前找好了“替罪羊”,接下来我们只需要把“责任推卸链”完成,让责任一级一级最终推卸到替罪羊身上,就可以大功告成!
而要完成这条“责任推卸链”,其实就是列出关于原问题和子问题的数学等价关系式。
对于求阶乘而言,原问题和子问题的等价关系式为:\(n! = n \times (n - 1)!\)。这样,就将问题从第\(n\)层成功地转移到了第\(n-1\)层。
那么对应的代码实现即为如下:
else return n * factorial(n - 1);
综合以上3个步骤,完整版的实现阶乘的递归函数即为:
/** Returns n factorial. */
public int factorial(int n) {
// Base case
if (n == 1)
return n;
// Recursive case
else
return n * factorial(n - 1);
}
在这个递归函数里,我们定义了递归情况来一步步简化问题,也定义边界情况来计算出最终的边界值。所以我们大可放心地调用该函数,让它自行去解决问题。
看到这里,相信你已经掌握了递归函数的写法。可以看出,写出一个递归函数并没有那么容易。而我们可不希望当我们绞尽脑汁憋出一个递归函数后,却换来老板一句“多此一举,净瞎折腾”。
那要避免这种情况,我们得先弄清楚一个前提:什么时候派上递归最为合适?
什么时候应用递归
我们先来谈谈递归的缺点:
- 对空间消耗大,容易导致栈溢出
递归是调用自身的函数,而函数的每次调用都会在栈中产生调用帧(call frame, 用来保存内部变量、返回点等信息),可栈的空间是有限的,没法一次同时保存过多的调用帧。所以如果调用的次数多了就容易导致栈溢出,对空间消耗大。
(对于某些语言,利用尾递归可以有效避免栈溢出这一问题,我们将在尾调用与尾递归该帖中对其进行详细介绍) - 对时间消耗大,导致运行效率低
首先,往栈中压入和弹出数据需要时间;此外,递归中经常会产生很多重复计算,例如在斐波那契数列的递归实现中,当n = 5
时,需要计算一遍的fibonacci(3)
,推导到n = 4
时,又需要计算一遍fibonacci(3)
。也就是说求一个5的阶乘需要计算两遍的fibonacci(3)
,所以说时间效率低。
(可以利用一个数组或哈希map来保存已计算出的结果来避免重复计算)
而递归主要是用于以下两种情况:
- 数据结构本身就是按递归的形式定义的。
例如斐波那契数列、n的阶乘、二叉树的遍历、图的搜索等。由于数据结构本身就是按递归的形式定义的,所以数据很容易从一层推到下一层,自然用上递归就很方便。 - 问题能以同样的解法逐级减小问题规模
例如分治算法、回溯算法等,它们能用同样的解法去一步步减小问题规模,所以用递归实现起来就很方便。
所以,除非是以上两种情况,否则尽量避免使用递归,以避免对空间和时间产生大的消耗。(P.S. 把浪费的这些功夫拿去打王者荣耀它不香吗...)
总结
递归函数是自己调用自己的函数。通过列出方法签名,完成基准情况和递归情况即可完成一个递归函数。但是用递归实现通常会对时间和空间产生大的消耗,因此除非数据结构本身就是按递归的形式定义或是问题能以同样的解法逐级减小问题规模,否则应尽量避免使用递归。
参考
创作不易,点个赞再走叭~