一、什么是递归
1.1 递归的概念
维基百科中是这样定义递归的:在数学和计算机科学中,递归指由一种(或多种)简单的基本情况定义的一类对象或方法,并规定其他所有情况都能被还原为其基本情况。
1.2 理解递归
百科的表述的定义虽然没问题,但是对于新手或者不熟悉递归的人显然太过于深奥了。
通常来说,我们把自调用的函数就叫做递归函数,这个自调用的过程通常就叫做「递归」,如:
function add(n) {
if (n <= 0) {
return n
}
return n + add(n - 1)
}
那么怎么搞懂递归呢?我理解可以从这几个方面下手:
- 找出问题的子问题
- 完善子问题的逻辑
- 明确递归的结束条件
1.2.1 子问题
对于某个实际的问题 X,我们尝试将它分解为若干个抽象的子问题 Y,在经过某种逻辑的转化之后,问题 X 能够被解决,那么这个问题 X 应该就能够用递归来理解以及解决。
1.2.2 结束条件
递归必须要明确结束的条件,否则递归函数就会一直调用下去,直至堆栈溢出。
function add(n) {
// 如果这段判断去掉,那么函数永远不会结束,然后就爆栈了😊
if (n <= 0) {
return n
}
return n + add(n - 1)
}
二、怎么用好递归
实际写代码的时候,很多人可能只停留在「递归是自调用的过程」这个理解中。往往只能写写非常简单的递归逻辑,如上面的 add
函数。因此,掌握常见的递归用法,知其然而知其所以然就很重要。
2.1 如何抽象出子问题
以我常在面试中提问候选人的问题为例子:
请你实现这样一个函数,它的入参为一颗「二叉树」的根节点,这棵树的节点结构为:
function TreeNode(val) {
this.val = val
this.left = null
this.right = null
}
你需要判断这颗「二叉树」中所有的节点的值是否都相同,是返回 true
,否则返回 true
如果仔细思考的话这是一个非常简单的问题:判断所有的值是否相同,那么只要在遇到有两个值不相同不就行了么?
因此我们先抽象出一个子问题 Y,这个子问题用来比较两个值是否相等:
function compare(a, b) {
return a === b
}
应用到二叉树当中,这个子问题就变成了:比较某个值与某个节点的值是否相等,那么问题就剩下:如何遍历一颗二叉树了,即递归。
我们很容易就可以写出下面的代码
function solve(root) {
if (!root) {
return true
}
return compare(root, root.left) && compare(root, root.right)
}
为了使得 compare
函数适配上面的代码,我们可以稍加修改
function compare(a, b) {
if (!b) {
return true
}
return a.val === b.val
}
2.2 从复杂的问题中找出子问题
为了得出更加通用的结论,我们下面来看一道更复杂的问题:
这个问题描述看起来非常复杂,我们尝试把这个游戏逻辑梳理下👇
我们尝试把这个游戏的逻辑继续细化👇
那么我们可以把这个祖玛游戏的子问题理解为:将手中的球插入第 i
个位置之后,桌面的球发生的变化。
🤔但是我们不是需要统计消除所有小球所花费的步数么?这个问题显然可以在球被消除之后计算出来,只要能通过「子问题」消除「问题」,最终的步数就是花费的步数,那么**「最小花费的步数」就是尝试插入所有位置之后找到的「最小花费的步数」**。
2.2.1 插入小球
我们手中有若干个小球,存在 hand
数组中;桌面上有若干个小球,存在 board
数组中。
那么插入小球的过程就可以理解为:拿出 hand[i]
这个小球,插入到 board[j]
小球的前面或者后面。我们可以把它看成一个子问题,那么我们就需要从 hand
中拿出若干个小球,插入到 board
中的若干个位置中。
/**
* run
* @param board
* @param hand
* @param step
*/
const run = (board, hand, step) => {
if (isGameOver(board, hand, step)) {
return;
}
for (let i = 0; i < hand.length; i++) {
for (let j = 0; j <= board.length; j++) {
const nextHand = hand.substr(0, i) + hand.substr(i + 1);
const combo = board.substr(0, j) + hand[i] + board.substr(j);
const key = `${combo}-${nextHand}`;
if (!map.has(key)) {
const nextBoard = removeSameBall(combo);
map.set(key, true);
run(nextBoard, nextHand, step + 1);
}
}
}
};
2.2.2 消除小球
小球插入后如果消除,那么重新合并的球中相邻超过三个颜色相同的,还能再继续进行消除。
很显然,这是一个递归问题。其中子问题为:插入手中的小球,消除桌面中相邻超过三个颜色相同的球。
/**
* 消除小球
* @param board
* @return {string|*|string}
*/
const removeSameBall = (board) => {
let count = 1;
let i = 1;
for (; i < board.length; i++) {
if (board[i] === board[i - 1]) {
count++;
} else {
if (count >= 3) {
break;
}
count = 1;
}
}
if (count >= 3) {
return removeSameBall(board.substr(0, i - count) + board.substr(i));
}
return board;
};
2.2.3 游戏结束
最后还需要实现 isGameOver
函数,我们在手中的球或者桌面的球用完结束即可。
/**
* 游戏结束判断
* @param curBoard
* @param curHand
* @param step
* @return {boolean}
*/
const isGameOver = (curBoard, curHand, step) => {
if (!curBoard) {
res = Math.min(hand.length - curHand.length, res);
return true;
}
if (!curHand) {
return true;
}
return false;
};
2.3 误区
😀尽量不要使用调试工具来看递归代码,深入细节在初学阶段经常会让你晕头转向😵💫。更加推荐的方法是,梳理出一个「逻辑闭环」,然后尝试去理解它的调用形式。
三、总结
「递归」只是实现思路的一种手段,如上面举的两个代码例子,我们往往欠缺的是解决问题的思路 & 找到问题规律的「思维惯性」。但不管如何,递归作为一种自调用形式的代码,只有找到问题的子问题能够更好的理解它~
未完待续……