递归

427 阅读1分钟

一、什么是递归

1.1 递归的概念

维基百科中是这样定义递归的:在数学和计算机科学中,递归指由一种(或多种)简单的基本情况定义的一类对象或方法,并规定其他所有情况都能被还原为其基本情况。

1.2 理解递归

百科的表述的定义虽然没问题,但是对于新手或者不熟悉递归的人显然太过于深奥了。

通常来说,我们把自调用的函数就叫做递归函数,这个自调用的过程通常就叫做「递归」,如:

function add(n) {
    if (n <= 0) {
        return n
    }
    return n + add(n - 1) 
}

那么怎么搞懂递归呢?我理解可以从这几个方面下手:

  1. 找出问题的子问题
  2. 完善子问题的逻辑
  3. 明确递归的结束条件

1.2.1 子问题

对于某个实际的问题 X,我们尝试将它分解为若干个抽象的子问题 Y,在经过某种逻辑的转化之后,问题 X 能够被解决,那么这个问题 X 应该就能够用递归来理解以及解决。

Untitled Diagram.drawio (2).png

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

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c95c6a842d974d598c6b35a8095a7d77~tplv-k3u1fbpfcp-zoom-1.image

如果仔细思考的话这是一个非常简单的问题:判断所有的值是否相同,那么只要在遇到有两个值不相同不就行了么?

因此我们先抽象出一个子问题 Y,这个子问题用来比较两个值是否相等:

function compare(a, b) {
    return a === b
}

应用到二叉树当中,这个子问题就变成了:比较某个值与某个节点的值是否相等,那么问题就剩下:如何遍历一颗二叉树了,即递归

Untitled Diagram.drawio (1).png

我们很容易就可以写出下面的代码

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 从复杂的问题中找出子问题

为了得出更加通用的结论,我们下面来看一道更复杂的问题:

力扣

这个问题描述看起来非常复杂,我们尝试把这个游戏逻辑梳理下👇

Untitled Diagram.drawio (4).png

我们尝试把这个游戏的逻辑继续细化👇

Untitled Diagram.drawio (5).png

那么我们可以把这个祖玛游戏的子问题理解为:将手中的球插入第 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 误区

😀尽量不要使用调试工具来看递归代码,深入细节在初学阶段经常会让你晕头转向😵‍💫。更加推荐的方法是,梳理出一个「逻辑闭环」,然后尝试去理解它的调用形式。

三、总结

「递归」只是实现思路的一种手段,如上面举的两个代码例子,我们往往欠缺的是解决问题的思路 & 找到问题规律的「思维惯性」。但不管如何,递归作为一种自调用形式的代码,只有找到问题的子问题能够更好的理解它~

未完待续……