【leetcode 题解】46题-全排列

474 阅读4分钟

这是我参与8月更文挑战的第5天,活动详情查看:8月更文挑战

题目

题目地址:leetcode-cn.com/problems/pe…

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

示例 1:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:

输入:nums = [0,1]
输出:[[0,1],[1,0]]

示例 3:

输入:nums = [1]
输出:[[1]]

提示:

  • 1 <= nums.length <= 6
  • -10 <= nums[i] <= 10
  • nums 中的所有整数 互不相同

我的思路历程

这题的核心实现逻辑是,将所有的路径都走一遍,不重复不遗漏。

首先是所有路径都走一遍,一开始我是想,应该要是用迭代的,应该涉及 回溯

但是我是想不通,怎么才能记录迭代中的状态。假设输入为 [1,2,3,4],我第一步走了 1,那么第二步就应该走 2 或 3,如果我第二步走的是 3,第三步就应该走 4 ?我如何知道我是否走过 2。因为我对回溯题目的通用直觉是,从前往后走,走完一条就会退走另一条,或者跳过某个步骤。

但全排列这题,我找不到 递归的路径,我习惯了从 0 -> 1 -> 2 -> arr.length - 1 的这种走向。但全排列不同,它不允许跳过任何一个数组元素,可能会走出这种路径:1 -> 3 -> 2 -> 4

不停地思考后,我发现,或许我需要一个数组来记录待访问的路径节点,这样我就知道还有哪些路径节点没有访问。于是用这个思路尝试去实现,结果成功通过了 leetcode 测试。

题解以及优化

初稿

  • 初始化一个 toVisit 数组,值为 [0, 1, ... , nums.length - 1],记录的是待访问的路径节点(索引值)
  • 遍历 toVisit 数组,使用作为下一步选择的节点,此时 toVisit 和 comb(最终路径数组) 会拷贝一份,toVisit 还会丢掉当前的节点。为什么要拷贝?这是因为数组是引用类型,直接传原数组会被修改。我们希望递归完一个路径回来的时候,数组还是原来的样子。所以我们的一个方法是 通过拷贝来实现快照,防止数组被修改。当然我们还有个方法,就是迭代完将数组复原,能够减少时间复杂度,后面代码优化的时候我们再详细说明。

代码实现如下:

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function (nums) {
  const rets = [];

  // 初始化待访问路径节点数组
  const toVisit = new Array(nums.length);
  for (let i = 0; i < toVisit.length; i++) {
    toVisit[i] = i;
  }

  r(toVisit, []);

  function r(toVisit, comb) {
    if (toVisit.length === 0) {
      rets.push(comb);
      return; // 递归结束
    }
    toVisit.forEach((p, i) => {
      r(
        omitArr(toVisit, i), // 拷贝耗性能,可优化
        [...comb, nums[p]] // 拷贝耗性能,可优化
      );
    });
  }
  return rets;
};

// 返回移除指定索引后的新数组
function omitArr(arr, index) {
  return arr.filter((item, i) => i !== index)
}

优化

回溯 中,经常会有需要保持迭代前的数组状态的情况,通常方案有两种(1)拷贝数组对象,可读性好(2)动态维护数组,迭代结束后复原数组,性能高。

初稿的写法可读性好,就是用了第一种方案,每次遍历都要拷贝数组,时间复杂度和空间复杂度都很高,尤其是 nums 很大的时候。但算法题一般都要求极致的时间复杂度,所以这里我们就优化一下上面的代码,改成方案 2 的实现。

首先 comb 数组,是比较方便动态维护的,因为它都是数组末尾一个个添加元素。在迭代前 comb.push(nums[p]),迭代结束后 comb.pop() 即可。

但是 toVisit 就不好整了,因为它可能需要中间丢掉一个元素,然后再补回来。对此,我们要丢掉 toVisited 数组,使用全新的 visited 布尔值数组,它的索引值 i 和布尔值代表 nums[i] 是否被访问过。因为数据结构发生了改变,迭代的代码也做了调整。

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function (nums) {
  const rets = [];

  // 初始化索引数组
  const visited = new Array(nums.length).fill(false);

  r(visited, [], 0);

  function r(visited, comb, depth) {
    if (depth === nums.length) {
      rets.push([...comb]);
      return; // 递归结束
    }

    visited.forEach((_, i) => {
      if (visited[i] === true) return;
      visited[i] = true;
      comb.push(nums[i]);
      r(visited, comb, depth + 1);
      comb.pop(); // 复原
      visited[i] = false; // 复原
    });
  }
  return rets;
};

因为优化后,comb 永远都指向一个数组对象,所以最后需要放入到返回数组时,要做一下拷贝,即 rets.push([...comb])。否则返回的数组,所有数组元素都将会是相同的,因为它们指向同一个对象。

你可以看看中国 leetcode 的官方答案:leetcode-cn.com/problems/pe… 。它更简洁,空间的消耗更低,也减少了无谓的递归(相比我的优化写法),对我来说稍微也难理解一些,读者可以自行理解一波。

总结

总的来说,这是一道回溯的题目,需要动态记录每个组合的过程中尚未访问的路径节点,下一次迭代(走下一步)时从中挑选,直至不存在未访问路径节点时结束。

此外,还要考虑对象的引用问题,可以使用生成数组快照的方式,也可以使用动态维护数组的方式。