这是我参与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… 。它更简洁,空间的消耗更低,也减少了无谓的递归(相比我的优化写法),对我来说稍微也难理解一些,读者可以自行理解一波。
总结
总的来说,这是一道回溯的题目,需要动态记录每个组合的过程中尚未访问的路径节点,下一次迭代(走下一步)时从中挑选,直至不存在未访问路径节点时结束。
此外,还要考虑对象的引用问题,可以使用生成数组快照的方式,也可以使用动态维护数组的方式。