7. 回溯算法
⏰ 组合问题
77. 组合
- 77. 组合
- 0510,mid,answer
- 回溯、剪枝
回溯
n
代表数字组合的范围:[1, n]
;k
代表组合的长度:结果集合中,每一成员的长度均为k
。- 在代码中,
path
表示了每一个成员,表示其中一个组合;res
是path
的集合,也就是组合的集合。
正确的把题意拆分为一个数结构,是解题的关键。
- 在本题中:
- 横向 for 循环的是
[1, n]
这个可选的去值区间;纵向递归的是path
这个结果组合。 - 横向的范围就是
[startIndex, n]
,纵向的范围是[path.length, k]
;
- 横向 for 循环的是
方法一:回溯
var combine = function (n, k) {
const res = [];
const path = [];
backtracking(n ,k ,1);
return res;
// 递归:每次传入 startIndex 确定下一次 for 循环的范围
function backtracking(n, k, startIndex) {
// 边界:当找到一个组合时,登记结果,结束递归。
if (path.length == k) {
res.push([...path]);
return;
}
for (let i = startIndex; i <= n; i++) {
path.push(i);
backtracking(n, k, i + 1);
path.pop(i);
}
}
};
方法二:回溯|剪枝优化
- 假设:
n = 4
,k = 4
,直观的告诉我们只有一种组合结果,就是[1,2,3,4]
。但之前的算法中,第一层递归的 for 循环会依次遍历 1,2,3,4。而我们知道只需要遍历 1 就够了。- 缩小 for 循环的范围:若当前 path 的剩余位置(
k - path.length
),大于 n 中剩余的可用数字(n - startIndex + 1
)。换句话说如果 n 中剩余的可用数字即使全部放在 path 中,也无法达到 k 的长度。那么此时就不需要 for 遍历了(剪枝)。
- 缩小 for 循环的范围:若当前 path 的剩余位置(
var combine = function (n, k) {
const res = [];
const path = [];
backtracking(n, k, 1);
return res;
function backtracking(n, k, startIndex) {
if (path.length == k) {
res.push([...path]);
return;
}
// 剪枝,不执行接下来的 for 循环
if (k - path.length > n - startIndex + 1) return;
for (let i = startIndex; i <= n; i++) {
path.push(i);
backtracking(n, k, i + 1);
path.pop(i); // 回溯
}
}
};
直接合并到 for 循环中:
for
循环从 i 开始,i
必须小于这个值,startIndex <= n - k - path.length + 1
,放到 for 循环中:
var combine = function (n, k) {
const res = [];
const path = [];
backtracking(n, k, 1);
return res;
function backtracking(n, k, startIndex) {
if (path.length == k) {
res.push([...path]);
return;
}
for (let i = startIndex; i <= n - (k - path.length) + 1; i++) {
path.push(i);
backtracking(n, k, i + 1);
path.pop(i); // 回溯
}
}
};
216. 组合总和 III
- 216. 组合总和 III
- 0510,mid,quick
- 回溯、剪枝
回溯的整体思路和上一题(77)大致相同,要确定这三个事情:
for
循环的范围:[1,9]
,这9个数字;- 需要注意这 9 个数字需要筛选
(2)
:用过的不能再用,数字相加要 =9
;
- 需要注意这 9 个数字需要筛选
dfs
递归的深度:k
,遍历的深度就是组合的长度,为 k;- 最后确定声明的变量,dfs 返回的边界,res.push 的条件。
方法一:回溯
var combinationSum3 = function (k, n) {
const res = [];
const path = [];
dfs(k, n, path, 1);
return res;
function dfs(k, n, path, startIndex) {
// 边界
if (path.length === k) {
const count = path.reduce((prev, curv) => prev + curv);
if (count === n) res.push([...path]);
return;
}
// 递归
for (let i = startIndex; i <= 9; i++) {
path.push(i);
dfs(k, n, path, i + 1);
path.pop();
}
}
};
方法二:回溯|剪枝优化
- 优化的思路也和上一个题相同,如果剩余的数字(
n
)不足以填充 k 的剩余长度,就没必要再执行 for 循环了。- 换句话说,startIndex 不仅需要满足 '小于 9 ' 的要求,还要满足下面的要求:
9 - startIndex + 1 >= k - path.length
// 转化一下
startIndex <= 10 - (k - path.length)
同时,求和 count
也可以在每次递归的时候计算,不需要在 res.push()
执行 reduce 进行计算。
var combinationSum3 = function (k, n) {
// for:[1,9],这9个数字;需要注意这 9 个数字需要筛选:用过的不能再用,数字相加要=9
// dfs:k,遍历的深度就是组合的长度,为k
const res = [];
const path = [];
dfs(k, n, path, 1, 0);
return res;
function dfs(k, n, path, startIndex, sum) {
// 边界:结束递归
if (path.length === k) {
// res:结束递归不一定满足条件,满足条件才能放入 res 中
if (sum === n) res.push([...path]);
return;
}
// 递归
for (let i = startIndex; i <= 10 - (k - path.length); i++) {
path.push(i);
sum += i;
dfs(k, n, path, i + 1, sum);
path.pop();
sum -= i;
}
}
};
17. 电话号码的字母组合
- 17. 电话号码的字母组合
- 0510,mid,quick
- 回溯
依然是确定回溯的三个条件:
- for:每层for 循环的范围都不一样,这取决于当前的具体数字,数字不同则范围不同。同时,剪枝思路和(77.组合)相同。
- dfs:
digits.length
,即输入数字的长度。如果输入数字的长度为3,那结果组合的长度就是 3; - 返回:当
path.length === digits.length
即,结果组合的长度,和传入数字的长度相同时,返回结果并结束递归。
这里不仅可以用 map,也可以用 数组,实际上数组在这里更快:
const map = [
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
];
解答:
var letterCombinations = function (digits) {
if (digits.length === 0) return [];
const map = new Map([
['2', 'abc'],
['3', 'def'],
['4', 'ghi'],
['5', 'jkl'],
['6', 'mno'],
['7', 'pqrs'],
['8', 'tuv'],
['9', 'wxyz']
])
const res = [];
const path = [];
dfs(digits, path);
return res;
// digits = ‘23’
function dfs(digits, path) {
// 结束
if (path.length === digits.length) {
res.push(path.join(""));
return;
}
//soruce = 'abc'
//path.length 表明目前遍历到哪一个数字了,如果值时1,表明已经遍历过 digits[0],下一层该遍历 digits[1] 了。
const source = map.get(digits[path.length]);
// 递归
for (let i = 0 ; i < source.length ; i++) {
path.push(source[i]);
dfs(digits, path);
path.pop();
}
}
};
39. 组合总和
- 39. 组合总和
- 0518,mid,mormal
- 回溯
思路:
做回溯时,一定要在脑海中构思一个树来。然后考虑这棵树的横向遍历(for循环),和纵向递归(dfs 递归),最后考虑递归的结束。
- for:横向遍历,题目中数字可以重复取,所以如图,当
[2,5,3]
中本轮取 2,那下一轮 2,5,3 都可以取。本轮取 5,下一轮只能取5,3。所以,只能取 >= 当前数字的范围。 - dfs:纵向递归,题目中数字的取用不限次数,就遍历数字而言,意味着 dfs 的深度可以是无限。
- 递归的结束:当取用的数字总和 >= target 时,递归结束。
- 此时如果 === ,那登记该组合,是一个答案;
- 此时如果 > target,那直接结束递归,丢弃结果。
方法一:回溯
var combinationSum = function (candidates, target) {
const res = [];
const path = [];
dfs(0, target);
return res;
function dfs(startindex, target) {
// 边界。
// 大于0,表示 path 还没有填满,需要再加数字;
// 等于0,表示 path 已经是目标组合;
// 小于0,表示 path 的总和超出target,丢弃。
if (target < 0) return;
else if (target === 0) {
res.push([...path]);
return;
}
// 下一层只能用大于等于当前层的数字
for (let i = startIndex; i < candidates.length; i++) {
target -= candidates[i];
path.push(candidates[i]);
dfs(i, target);
path.pop(candidates[i]);
target += candidates[i];
}
}
};
方法二:回溯|剪枝优化
优化的目的是提前结束递归,省去对 path 的 pop 和 push 操作。
-
如果 candidates 是按照从小到大的顺序排序(sort),那在 for 循环时,出现超出 target 的情况,后面的数字便不需要再遍历。
- 比如 candidates = [2, 3, 5, 7],terget = 6,目前的 path = [2, 2]。此时我们发现,[3,5,7] 都可以剪掉了。
-
操作:
- 对 candidates 进行排序;
- 在 for 循环的条件上额外加一条,如果当前 target 减去正要递归的数字 candidates[i],已经 <0,那不再需要递归,提前结束。
var combinationSum = function (candidates, target) {
candidates.sort((x, y) => x - y). // 排序。
const res = [];
const path = [];
// 下一层只能用大于等于当前层的数字
dfs(0, target);
function dfs(startindex, target) {
// 边界。
// 大于0,表示 path 还没有填满,需要再加数字;
// 等于0,表示 path 已经是目标组合;
// 小于0,表示 path 的总和超出target,丢弃。
if (target < 0) return;
else if (target === 0) {
res.push([...path]);
return;
}
// 剪枝:target - candidates[i] <= target 只有符合条件才进一步递归
for (let i = startindex; i < candidates.length && target - candidates[i] <= target; i++) {
target -= candidates[i];
path.push(candidates[i]);
dfs(i, target);
path.pop(candidates[i]);
target += candidates[i];
}
}
return res;
};
40. 组合总和 II
- 40. 组合总和 II
- 0518,mid,answer
- 回溯
思路:
- 这道题的难点在于去重:
示例 1:
输入: candidates = [1,1,2], target = 3,
可以看到,1 在输入是重复的,在同一个 for 横向遍历中,取两次 1 数字。for 中重复取的结果一定是重复的,比如结果 [1,2] 中的 1
可以是取 candidates[0]
,也有可能是 candidates[1]
。
但是,在 dfs 时,1 是可以重复取的:[1,1,2]
就是一个重复取出的结果。
在 for 循环中,对重复取数字进行处理:
if (i > startIndex && candidates[i] === candidates[i - 1]) continue;
- 如果当前数字
candidates[i]
和 前一个数candidates[i-1]
相等,则证明出现了重复。此时还需要额外判断是否是 dfs 重复:- 如果
i === startIndex
,则表明当前的 for 循环是一个全新的一层。对新的一层取数字不应当有 “重复” 不能取的限制,所以跳过。
- 如果
剪枝:
这里不再用方法二,指出剪枝了。如果在进行 for 循环时,当前 target - candadies[i] 已经小于 0,表明 path 中的成员总数已经超过目标 值,所以不再往下递归。
var combinationSum2 = function (candidates, target) {
candidates.sort((x, y) => x - y); // 排序
const res = [], path = [];
dfs(0, target);
return res;
function dfs(startIndex, target) {
// dfs截止
if (target < 0) return;
else if (target === 0) res.push([...path]);
// 剪枝:target - candidates[i] >= 0
for (let i = startIndex; i < candidates.length && target - candidates[i] >= 0; i++) {
// 数字重复的处理:
if (i > startIndex && candidates[i] === candidates[i - 1]) continue;
target -= candidates[i];
path.push(candidates[i]);
dfs(i + 1, target);
path.pop(candidates[i]);
target += candidates[i];
}
}
};