开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天
递归是一种工具,意思是指函数自己调用自己,通过这个操作可以实现很多算法思想,比如分治、二分查找、甚至动态规划。
因为函数调用在栈中实现,所以在写递归函数时要注意基线条件(base case)的编写,也就是递归调用到什么时候会停止递归调用,否则函数执行时会无休止地调用下去,从而导致栈溢出并程序终止。
比如下面代码实现的倒计时函数,可以通过调用countdown(5)打印:> 5 4 3 2 1,如果去掉基线条件,那么这个函数运行时就会没完没了:> 5 4 3 2 1 0 -1 -2 ……
递归函数的另一个重要的部分是递归条件(recursive case),意指何时调用自己,我们需要注意两点:调用的时机和调用时传入的参数。比如下面的调用语句countdown(i-1)写为countdown(i)那么函数也会无休止地调用下去,并且不会倒计时。因此递归条件是让函数实现我们的想法的关键部分。
function countdown (i){
// 基线条件
if(i === 0) return
console.log(i)
countdown(i-1)
}
递归与动态规划
递归与动态规划的本质思想是一样的,都是通过解决相似的子问题来解决更大的问题,区别在于递归通过递归函数从后往前推,动态规划通过表记录子问题的答案,从前往后推。递归更接近算法的本质,但函数调用需要栈空间,动态规划通过表记录迭代实现,是递归的一种优化。
一个简单的例子:
计算8个1相加: 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = ?
递归:定义一个递归函数sum(n)用来计算n个1相加,那么计算8个1相加可以表示为:
function sum (n) {
if(n === 1)
return 1
return sum(n-1) + 1
}
动态规划:定义一个长度为9的数组dp记录n个1相加的值,已知dp[1] = 1,dp[i] = dp[i-1] +1,依次迭代,得到dp[8]就是8个1相加的值。
function sum (n) {
const dp = new Array(n+1)
dp[1] = 1
for(let i = 2; i < dp.length; i++) {
dp[i] = dp[i-1] + 1
}
return dp[n]
}
递归的思想
上述简单的问题可以通过动态规划解决,因为动态转移方程比较简单,而递归可以解决很难写出动态转移方程的情况,此时应用递归更加容易理解,比如二叉树的遍历:
// 前序遍历:访问顺序:本节点 -> 左子树 -> 右子树
function inorderTraversal (root) {
if(!root)
return
console.log(root.val)
inorderTraversal(root.left)
inorderTraversal(root.right)
}
// 中序遍历:访问顺序:左子树 -> 本节点 -> 右子树
function preorderTraversal (root) {
if(!root)
return
inorderTraversal(root.left)
console.log(root.val)
inorderTraversal(root.right)
}
// 后序遍历:访问顺序:左子树 -> 右子树 -> 本节点
function preorderTraversal (root) {
if(!root)
return
inorderTraversal(root.left)
inorderTraversal(root.right)
console.log(root.val)
}
非常简洁,但初学的时候会有疑问:为什么这样写就能实现想要的效果?为什么前中后序遍历只需要简单地改变一下3个语句的顺序?实际在调用的过程是什么样的?
3个步骤理解递归
1.递推
把大问题拆解为小问题,小问题解决了,大问题就随之而解了,比如我们前面的二叉树的遍历,我们简单看看前序遍历:先访问本节点,然后前序遍历左子树,最后前序遍历右子树,下图的二叉树的前序遍历顺序为:1 2 4 5 3。
我们可以想,如果我们只管遍历根节点,左子树和右子树的遍历都让别人干,把这活儿外包出去,那么我们遍历这棵树是不是就很简单了。
我们假设外包给函数inorderTraversal来做遍历子树的活,左子树和右子树的遍历都是一样的,那么遍历左子树就是inorderTraversal(left),遍历右子树就是inorderTraversal(right),其中left和right分别是左子树和右子树的根节点。
通过外包了这两部分,那么我们遍历这棵树的代码就可以写作:
console.log(root.val)
inorderTraversal(root.left)
inorderTraversal(root.right)
至于外包的inorderTraversal是怎么实现的,我们不用管。
类似的,假如接到遍历左子树这个活的是我们,怎么做呢?很简单,像“前东家”一样,继续把左子树和右子树的遍历都外包出去。
以此类推,一层层地外包下去,这个步骤就是递推。
2. 基线
一步步递推下去,最终我们发现,子树总有是空的时候,空树的遍历也最终有个包工头来做,这就是基线条件:
if(root === null)
return
3. 回归
空树遍历完了,那么空树的父节点代表的子树也就能遍历了,一层层依此类推,最终直到原来树的根节点,这个问题就解决了,这个步骤就是回归。
如何用递归解题
判断一个问题是否能用递归解决,我们可以这样看:不管问题多复杂,只要可以通过分解为相似的子问题,并且经过一层层的分解之后,得到的最简单的情况可以轻松解决,那么就可以用递归。
我们的任务只有两个:
- 写程序告诉电脑“如何分解一个问题”
- 写程序告诉电脑“当该问题分解到最简时如何处理”
而具体的递归过程如何执行,是电脑操心的事。