简单讲讲经典的回溯类题目 -- 全排列的解法

160 阅读3分钟

Leetcode 上,有一类题目是用「回溯」法解的。在做这类题之前其实并未了解过什么是回溯,因此如果在实际面试考察过程中遇到还是比较棘手的。

当然,在做了一些此类题之后,发现此类题目的整体思路比较类似,因此简单记录下我个人的思路,算是一个总结。

我们以这道比较「经典」的题:《46. 全排列》 为例。

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

比如:[1, 2, 3] 的全排列就是 [1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]

我们需要先将问题分解,[1, 2, 3] 的全排列,是:

  1. 第一个元素,以数组中每一个元素起始 1.1. 在这里是 123
  2. 第二个元素,则是剩下元素中的每一个元素 2.1. 例如第一个是 1 的话,第二个就分别是 23
  3. 然后第三个元素是再剩下元素中的每一个元素 3.1. 如果前两个是 12,那第三个元素只能是 3

抽象成一个简单的流程图如下:

graph TD
Start --> A[第一个元素];
A --> B[剩下的两个元素];
B --> C[剩下的一个元素];
C -- 循环 --> B;
C -- 循环 --> A;
C --> End;

其中第一步(获取第一个元素),我们可以再换个说法:剩下的三个元素。

这样,我们就可以从「剩余元素」的遍历开始着手编码了。

const items = [1, 2, 3];
// 记录结果
const result = [];

// 1. 第一次遍历,获取第一个元素
items.forEach((item, index) => {
  // 求剩下的元素数组,先复制下当前数组
  const rest = items.slice();
  // 在复制的数组中,移除当前遍历到的元素,例如第一个循环,移除了 1,剩余 [2,3]
  rest.splice(index, 1);  
});

这时候,我们会发现剩下的 [2, 3] 仍需要进行相同的遍历。

因此可以将这个遍历的代码抽成一个函数进行递归:

const items = [1, 2, 3];

function traverse(items) {
    // 递归要记得加中止条件
    if (items.length === 0) {
        return;
    }
    
    items.forEach((item, index) => {
        const rest = items.slice();
        rest.splice(index, 1);
        traverse(rest);
    });
}

traverse(items);

此时我们的解法已经写的差不多了,但似乎还忘记了什么重要的步骤?

……

保存结果!

要知道,我们是需要完整的排列组合的,也就是说当「剩余元素」这个数组为空的时候,才需要将当前结果放到返回的数组中 -- 而这正好是我们的递归中止条件。

另外,每一步的结果,都需要在执行递归的时候向下传递:

const items = [1, 2, 3];
const result = [];

function traverse(items, current) {
    // 递归要记得加中止条件
    if (items.length === 0) {
        result.push(current);
        return;
    }
    
    items.forEach((item, index) => {
        const rest = items.slice();
        rest.splice(index, 1);
        traverse(rest, current.concat(item));
    });
}

traverse(items, []);

至此,我们的题解已经完成了 -- 而这种解题思路,可以用在一些与此题题目的解法上。

总结下,核心的解题要素:递归 -- 计算当前层级所需的元素和解,并将解带入到之后的递归过程中,在递归中止条件中,处理最终的结果。