回溯框架
TypeScript | JavaScript | 回溯框架
排列 | 组合 | 子集 | |
---|---|---|---|
无重复 + 不可复选 | 46 | 77 | 78 |
可重复 + 不可复选 | 47 | 90 | 90 |
无重复 + 可复选 | 全排列(元素无重可复选) | 39 | 39 |
无论是排列、组合还是子集问题,简单说无非就是让你从序列 nums
中以给定规则取若干元素,主要有以下几种变体:
形式一、元素无重不可复选,即 nums
中的元素都是唯一的,每个元素最多只能被使用一次,这也是最基本的形式。
以组合为例,如果输入 nums = [2,3,6,7]
,和为 7 的组合应该只有 [7]
。
形式二、元素可重不可复选,即 nums
中的元素可以存在重复,每个元素最多只能被使用一次。
以组合为例,如果输入 nums = [2,5,2,1,2]
,和为 7 的组合应该有两种 [2,2,2,1]
和 [5,2]
。
形式三、元素无重可复选,即 nums
中的元素都是唯一的,每个元素可以被使用若干次。
以组合为例,如果输入 nums = [2,3,6,7]
,和为 7 的组合应该有两种 [2,2,3]
和 [7]
。
当然,也可以说有第四种形式,即元素可重可复选。但既然元素可复选,那又何必存在重复元素呢?元素去重之后就等同于形式三,所以这种情况不用考虑。
上面用组合问题举的例子,但排列、组合、子集问题都可以有这三种基本形式,所以共有 9 种变化。
除此之外,题目也可以再添加各种限制条件,比如让你求和为 target
且元素个数为 k
的组合,那这么一来又可以衍生出一堆变体,怪不得面试笔试中经常考到排列组合这种基本题型。
但无论形式怎么变化,其本质就是穷举所有解,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽。
子集(元素无重不可复选)
78. 子集
给你输入一个无重复元素的数组 nums
,其中每个元素最多使用一次,请你返回 nums
的所有子集。
function subsets(nums: number[]): number[][] {
// 设置结果集
const res: number[][] = [];
// 记录回溯算法的递归路径
const track: number[] = [];
// 回溯算法核心函数,遍历子集问题的回溯树
const helper = (nums: number[], start: number) => {
// 前序位置,每个节点的值都是一个子集
res.push(track.concat());
// 回溯算法标准框架
for (let i = start; i < nums.length; i++) {
// 做出选择
track.push(nums[i]);
// 通过 start 参数控制树枝的遍历,避免产生重复的子集
helper(nums, i + 1);
// 撤销选择
track.pop();
}
}
// 开始主函数
helper(nums, 0)
// 返回结果
return res;
};
组合(元素无重不可复选)
77. 组合
function combine(n: number, k: number): number[][] {
// 设置结果集
const res: number[][] = [];
// 记录回溯算法的递归路径
const track: number[] = [];
// 回溯算法核心函数,遍历子集问题的回溯树
const helper = (start: number, n: number, k: number) => {
// base case
if (k == track.length) {
// 遍历到了第 k 层,收集当前节点的值
res.push(track.concat());
return;
}
// 回溯算法标准框架
for (let i = start; i <= n; i++) {
// 做出选择
track.push(i)
// 通过 start 参数控制树枝的遍历,避免产生重复的子集
helper(i + 1, n, k);
// 撤销选择
track.pop();
}
}
// 开始主函数
helper(1, n, k)
// 返回结果
return res;
};
子集/组合(元素可重不可复选)
90. 子集 II
子集/组合(元素可重不可复选)
给你一个整数数组 nums
,其中可能包含重复元素,每个元素只能被选择一次,请你返回该数组所有可能的子集。
function subsetsWithDup(nums: number[]): number[][] {
// 设置结果集
const res: number[][] = [];
// 记录回溯算法的递归路径
const track: number[] = [];
// 回溯算法核心函数,遍历子集问题的回溯树
const helper = (nums: number[], start: number) => {
// 前序位置,每个节点的值都是一个子集
res.push(track.concat());
// 回溯算法标准框架
for (let i = start; i < nums.length; i++) {
// 剪枝逻辑,值相同的相邻树枝,只遍历第一条
if (i > start && nums[i] == nums[i - 1]) {
continue;
}
track.push(nums[i]);
helper(nums, i + 1);
track.pop();
}
}
// 开始主函数
// 先排序,让相同的元素靠在一起
nums.sort()
helper(nums, 0)
// 返回结果
return res;
};
子集/组合(元素无重可复选)
39. 组合总和
给你一个无重复元素的整数数组 candidates
和一个目标和 target
,找出 candidates
中可以使数字和为目标数 target
的所有组合。candidates
中的每个数字可以无限制重复被选取。
function combinationSum(candidates: number[], target: number): number[][] {
// 设置结果集
const res: number[][] = [];
// 记录回溯算法的递归路径
const track: number[] = [];
// 记录 track 中的路径和
let trackSum = 0;
// 回溯算法核心函数,遍历子集问题的回溯树
const helper = (nums: number[], start: number, target: number) => {
// base case,找到目标和,记录结果
if (trackSum == target) {
res.push(track.concat());
return;
}
// base case,超过目标和,停止向下遍历
if (trackSum > target) {
return;
}
// 回溯算法标准框架
for (let i = start; i < nums.length; i++) {
// 选择 nums[i]
trackSum += nums[i];
track.push(nums[i]);
// 递归遍历下一层回溯树
// 同一元素可重复使用,注意参数
helper(nums, i, target);
// 撤销选择 nums[i]
trackSum -= nums[i];
track.pop();
}
}
// -----------------开始主函数-------------
if (candidates.length == 0) {
return res;
}
helper(candidates, 0, target);
return res;
};
排列(元素无重不可复选)
46. 全排列
function permute(nums: number[]): number[][] {
// 1. 设置结果集
const result: number[][] = [];
// 2. 回溯
const recursion = (path, set) => {
// 2.1 设置回溯终止条件
if (path.length === nums.length) {
// 2.1.1 推入结果集
result.push(path.concat());
// 2.1.2 终止递归
return;
}
// 2.2 遍历数组
for (let i = 0; i < nums.length; i++) {
// 2.2.1 必须是不存在 set 中的坐标
if (!set.has(i)) {
// 2.2.2 本地递归条件(用完记得删除)
path.push(nums[i]);
set.add(i);
// 2.2.3 进一步递归
recursion(path, set);
// 2.2.4 回溯:撤回 2.2.2 的操作
path.pop();
set.delete(i);
}
}
};
recursion([], new Set());
// 3. 返回结果
return result;
};
排列(元素可重不可复选)
47. 全排列 II
function permuteUnique(nums: number[]): number[][] {
// 设置结果集
const res: number[][] = [];
// 记录回溯算法的递归路径
const track: number[] = [];
// 已经使用的
const used: boolean[] = new Array(nums.length).fill(false);
// 回溯算法核心函数,遍历子集问题的回溯树
const helper = (nums: number[]) => {
// base case
if (track.length == nums.length) {
res.push(track.concat());
return;
}
// 回溯算法标准框架
for (let i = 0; i < nums.length; i++) {
if (used[i]) {
continue;
}
// 新添加的剪枝逻辑,固定相同的元素在排列中的相对位置
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
// 做出选择
track.push(nums[i]);
used[i] = true;
helper(nums);
// 撤销选择
track.pop();
used[i] = false;
}
}
// 开始主函数
// 先排序,让相同的元素靠在一起
nums.sort()
helper(nums)
// 返回结果
return res;
};
排列(元素无重可复选)
全排列(元素无重可复选)
还是全排列问题,这一次在nums
数组中的元素无重复且可复选的情况下,会有哪些排列?
比如输入 nums = [1,2,3]
,那么这种条件下的全排列共有 3^3 = 27 种:
[
[1,1,1],[1,1,2],[1,1,3],[1,2,1],[1,2,2],[1,2,3],[1,3,1],[1,3,2],[1,3,3],
[2,1,1],[2,1,2],[2,1,3],[2,2,1],[2,2,2],[2,2,3],[2,3,1],[2,3,2],[2,3,3],
[3,1,1],[3,1,2],[3,1,3],[3,2,1],[3,2,2],[3,2,3],[3,3,1],[3,3,2],[3,3,3]
]
标准的全排列算法利用 used
数组进行剪枝,避免重复使用同一个元素。如果允许重复使用元素的话,直接放飞自我,去除所有 used
数组的剪枝逻辑就行了。
function permuteRepeat(nums: number[]): number[][] {
// 设置结果集
const res: number[][] = [];
// 记录回溯算法的递归路径
const track: number[] = [];
// 回溯算法核心函数,遍历子集问题的回溯树
const helper = (nums: number[]) => {
// base case 到达叶子节点
if (track.length == nums.length) {
res.push(track.concat());
return;
}
// 回溯算法标准框架
for (let i = 0; i < nums.length; i++) {
// 做出选择
track.push(nums[i]);
// 进入下一层回溯树
helper(nums);
// 撤销选择
track.pop();
}
}
// 开始主函数
helper(nums)
// 返回结果
return res;
};
40. 组合总和 II
function combinationSum2(candidates: number[], target: number): number[][] {
// 设置结果集
const res: number[][] = [];
// 记录回溯算法的递归路径
const track: number[] = [];
// 记录 track 中的路径和
let trackSum = 0;
// 回溯算法核心函数,遍历子集问题的回溯树
const helper = (nums: number[], start: number, target: number) => {
// base case,找到目标和,记录结果
if (trackSum == target) {
res.push(track.concat());
return;
}
// base case,超过目标和,停止向下遍历
if (trackSum > target) {
return;
}
// 回溯算法标准框架
for (let i = start; i < nums.length; i++) {
// 剪枝逻辑,值相同的树枝,只遍历第一条
if (i > start && nums[i] == nums[i - 1]) {
continue;
}
// 做选择
trackSum += nums[i];
track.push(nums[i]);
// 递归遍历下一层回溯树
// 同一元素不可重复使用,注意参数
helper(nums, i + 1, target);
// 撤销选择 nums[i]
trackSum -= nums[i];
track.pop();
}
}
// -----------------开始主函数-------------
if (candidates.length == 0) {
return res;
}
// 先排序,让相同的元素靠在一起
candidates.sort()
helper(candidates, 0, target);
return res;
};
216. 组合总和 III
function combinationSum3(k: number, n: number): number[][] {
// 设置结果集
const res: number[][] = [];
// 记录回溯算法的递归路径
const track: number[] = [];
// 记录 track 中的路径和
let trackSum: number = 0;
// 回溯算法核心函数,遍历子集问题的回溯树
const helper = (start: number, n: number, k: number) => {
// base case,找到目标和,记录结果
if (k == track.length && trackSum == n) {
// 遍历到了第 k 层,trackSun == n,收集
res.push(track.concat());
return;
}
// base case,超过目标和,停止向下遍历
if (trackSum > n) {
return;
}
// 回溯算法标准框架
for (let i = start; i <= 9; i++) {
// 选择
trackSum += i;
track.push(i);
// 递归遍历下一层回溯树
helper(i + 1, n, k);
// 撤销选择 nums[i]
trackSum -= i;
track.pop();
}
}
// -----------------开始主函数-------------
helper(1, n, k);
return res;
};
小结
形式一
元素无重不可复选,即 nums
中的元素都是唯一的,每个元素最多只能被使用一次,backtrack
核心代码如下:
/* 组合/子集问题回溯算法框架 */
function backtrack( nums, start) {
// 回溯算法标准框架
for (let i = start; i < nums.length; i++) {
// 做选择
track.push(nums[i]);
// 注意参数
backtrack(nums, i + 1);
// 撤销选择
track.removeLast();
}
}
/* 排列问题回溯算法框架 */
function backtrack( nums) {
for (let i = 0; i < nums.length; i++) {
// 剪枝逻辑
if (used[i]) {
continue;
}
// 做选择
used[i] = true;
track.push(nums[i]);
backtrack(nums);
// 撤销选择
track.removeLast();
used[i] = false;
}
}
形式二
元素可重不可复选,即 nums
中的元素可以存在重复,每个元素最多只能被使用一次,其关键在于排序和剪枝,backtrack
核心代码如下:
nums.sort();
/* 组合/子集问题回溯算法框架 */
function backtrack( nums, let start) {
// 回溯算法标准框架
for (let i = start; i < nums.length; i++) {
// 剪枝逻辑,跳过值相同的相邻树枝
if (i > start && nums[i] == nums[i - 1]) {
continue;
}
// 做选择
track.push(nums[i]);
// 注意参数
backtrack(nums, i + 1);
// 撤销选择
track.removeLast();
}
}
nums.sort();
/* 排列问题回溯算法框架 */
function backtrack( nums) {
for (let i = 0; i < nums.length; i++) {
// 剪枝逻辑
if (used[i]) {
continue;
}
// 剪枝逻辑,固定相同的元素在排列中的相对位置
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
continue;
}
// 做选择
used[i] = true;
track.push(nums[i]);
backtrack(nums);
// 撤销选择
track.removeLast();
used[i] = false;
}
}
形式三
元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次,只要删掉去重逻辑即可,backtrack 核心代码如下:
/* 组合/子集问题回溯算法框架 */
function backtrack( nums, let start) {
// 回溯算法标准框架
for (let i = start; i < nums.length; i++) {
// 做选择
track.push(nums[i]);
// 注意参数
backtrack(nums, i);
// 撤销选择
track.removeLast();
}
}
/* 排列问题回溯算法框架 */
function backtrack( nums) {
for (let i = 0; i < nums.length; i++) {
// 做选择
track.push(nums[i]);
backtrack(nums);
// 撤销选择
track.removeLast();
}
}