持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情
回溯算法将排列问题一网打尽
1. 全排列
1.1 问题描述
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
1.2 要求
示例 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.3 思路
以数组 [1, 2, 3] 的全排列为例。
- 先写以
1开头的全排列,它们是:[1, 2, 3], [1, 3, 2],即1 + [2, 3]的全排列(注意:递归结构体现在这里); - 再写以
2开头的全排列,它们是:[2, 1, 3], [2, 3, 1],即2 + [1, 3]的全排列; - 最后写以
3开头的全排列,它们是:[3, 1, 2], [3, 2, 1],即3 + [1, 2]的全排列。
总结搜索的方法:按顺序枚举每一位可能出现的情况,已经选择的数字在 当前 要选择的数字中不能出现。按照这种策略搜索就能够做到 不重不漏。这样的思路,可以用一个树形结构表示。
****
说明:
- 每一个结点表示了求解全排列问题的不同的阶段,这些阶段通过变量的 不同的值 体现,这些变量的不同的值,称之为状态;
- 使用深度优先遍历有 回头 的过程,在 回头 以后, 状态变量需要设置成为和先前一样 ,因此在回到上一层结点的过程中,需要撤销上一次的选择,这个操作称之为 状态重置 ;
- 深度优先遍历,借助系统栈空间,保存所需要的状态变量,在编码中只需要注意遍历到相应的结点的时候,状态变量的值是正确的,具体的做法是:往下走一层的时候,
path变量在尾部追加,而往回走的时候,需要撤销上一次的选择,也是在尾部操作,因此path变量是一个栈; - 深度优先遍历通过 回溯 操作,实现了全局使用一份状态变量的效果。
使用编程的方法得到全排列,就是在这样的一个树形结构中完成 遍历,从树的根结点到叶子结点形成的路径就是其中一个全排列。
1.4 代码
/**
* @param {number[]} nums
* @return {number[][]}
*/
var permute = function(nums) {
const length = nums.length;
const paths = [];
const used = Array(length).fill(false);
const backtracking = (path = []) => {
if(path.length === length) {
paths.push([...path]);
return;
}
for(let i = 0; i < length; i++) {
if (used[i]) continue;
used[i] = true;
path.push(nums[i]);
backtracking(path);
path.pop();
used[i] = false;
}
}
backtracking();
return paths;
};
2. 全排列 II
2.1 问题描述
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
2.2 要求
示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
示例 2:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
2.3 思路
这一题在 全排列 的基础上增加了 序列中的元素可重复 这一条件,但要求:返回的结果又不能有重复元素。
重点在于:在遍历的过程中,一边遍历一遍检测,在一定会产生重复结果集的地方剪枝。
一个比较容易想到的办法是在结果集中去重。但是问题来了,这些结果集的元素是一个又一个列表,对列表去重不像用哈希表对基本元素去重那样容易。
如果要比较两个列表是否一样,一个容易想到的办法是对列表分别排序,然后逐个比对。既然要排序,我们就可以 在搜索之前就对候选数组排序,一旦发现某个分支搜索下去可能搜索到重复的元素就停止搜索,这样结果集中不会包含重复列表。
画出树形结构如下:重点想象深度优先遍历在这棵树上执行的过程,哪些地方遍历下去一定会产生重复,这些地方的状态的特点是什么? 对比图中标注 ① 和 ② 的地方。相同点是:这一次搜索的起点和上一次搜索的起点一样。不同点是:
- 标注 ① 的地方上一次搜索的相同的数刚刚被撤销;
- 标注 ② 的地方上一次搜索的相同的数刚刚被使用。
产生重复结点的地方,正是图中标注了剪刀,且被绿色框框住的地方。
大家也可以把第 2 个 1 加上 ' ,即 [1, 1', 2] 去想象这个搜索的过程。只要遇到起点一样,就有可能产生重复。这里还有一个很细节的地方:
- 在图中 ② 处,搜索的数也和上一次一样,但是上一次的
1还在使用中; - 在图中 ① 处,搜索的数也和上一次一样,但是上一次的
1刚刚被撤销,正是因为刚被撤销,下面的搜索中还会使用到,因此会产生重复,剪掉的就应该是这样的分支。
2.4 代码
/**
* @param {number[]} nums
* @return {number[][]}
*/
var permuteUnique = function(nums) {
const length = nums.length;
const paths = [];
const used = Array(length).fill(false);
nums.sort((a, b) => a - b);
const backtracking = (path = []) => {
if(path.length === length) {
paths.push([...path]);
return;
}
for(let i = 0; i < length; i++) {
if (used[i]) continue;
// 小剪枝:同一层相同数值的结点,从第 2 个开始,结果一定发生重复,因此跳过
if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue;
used[i] = true;
path.push(nums[i]);
backtracking(path);
path.pop();
used[i] = false;
}
}
backtracking();
return paths;
};
3. 字母大小写全排列
3.1 问题描述
给定一个字符串 s ,通过将字符串 s 中的每个字母转变大小写,我们可以获得一个新的字符串。
返回 所有可能得到的字符串集合 。以 任意顺序 返回输出。
3.2 要求
示例 1:
输入:s = "a1b2"
输出:["a1b2", "a1B2", "A1b2", "A1B2"]
示例 2:
输入: s = "3z4"
输出: ["3z4","3Z4"]
3.3 思路
3.4 代码
/**
* @param {string} s
* @return {string[]}
*/
var letterCasePermutation = function(s) {
const strArr = s.split('');
const paths = [];
const indexList = strArr.reduce((pre, cur, curIndex) => {
if (isNaN(cur)) pre.push(curIndex);
return pre;
}, []);
const length = indexList.length;
const backtracking = (startIndex, path = []) => {
if (startIndex === length) {
paths.push(path.join(''));
return;
}
path[indexList[startIndex]] = path[indexList[startIndex]].toUpperCase();
backtracking(startIndex + 1, path);
path[indexList[startIndex]] = path[indexList[startIndex]].toLowerCase();
backtracking(startIndex + 1, path);
}
backtracking(0, strArr);
return paths;
};