回溯算法
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为 “回溯点”。
回溯是递归的副产品,只要有递归就会有回溯
回溯法解决的问题
- 组合问题:N 个数里面按一定规则找出 k 个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个 N 个数的集合里有多少符合条件的子集
- 排列问题:N 个数按一定规则全排列,有几种排列方式
- 棋盘问题:N 皇后,解数独等等
🔥🔥🔥
组合是不强调元素顺序的,排列是强调元素顺序。
INFO
回溯法解决的问题都可以抽象为树形结构
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度
![](/assets/20210130173631174.bfbc447e.png)
组合
给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。
示例: 输入: n = 4, k = 2 输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4]]
![](/assets/20201123195223940.59fd18c5.png)
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现 n 相当于树的宽度,k 相当于树的深度
图中每次搜索到了叶子节点,我们就找到了一个结果。
剪枝优化
如果 for 循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
for (int i = startIndex; i <= n; i++) {
已经选择的元素个数:path.size();
还需要的元素个数为: k - path.size();
在集合 n 中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
为什么有个+1 呢,因为包括起始位置,我们要是一个左闭的集合。
举个例子,n = 4,k = 3, 目前已经选取的元素为 0(path.size 为 0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。所以优化之后的 for 循环是
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
function backtrace(num,k){
let t = [],r = [];
function combineHelper (i){
if(t.length == k){
// t 只有一份,根据 递归 进行不断修改
r.push([...t])
return
}
//剪枝
for(;i<num - k - t.length + 1){
// 未减枝
for(;i<=num;i++){
t.push(i);
combineHelper (i+1)
t.pop()
}
}
combineHelper (1)
return r
}
let r = backtrace(num,k)
组合总和III
找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
所有数字都是正整数。 解集不能包含重复的组合。
- 示例 1: 输入: k = 3, n = 7 输出: [[1,2,4]]
- 示例 2: 输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]]
let k = 2,n = 4;
function backtrace(n, k) {
let t = [],r = [],total = 0;
function combineHelper(startIndex) {
if(total>n)return;
if (t.length == k) {
if (total == n) {
r.push([...t]);
}
}
// 限定在 9 - total 里面
for (; startIndex <= 9 - total; startIndex++) {
t.push(startIndex);
total += startIndex;
combineHelper(startIndex + 1);
total -= t.pop();
}
}
combineHelper(1);
return r;
}
let r = backtrace(n,k)
组合总和
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。 所有数字(包括 target)都是正整数。 解集不能包含重复的组合。
- 示例 1: 输入:candidates = [2,3,6,7], target = 7, 所求解集为: [ [7], [2,2,3] ]
- 示例 2: 输入:candidates = [2,3,5], target = 8, 所求解集为: [ [2,2,2,2], [2,3,3], [3,5]
核心问题在于无限制重复选取,解集中不能有重复的组合,
所以由于是无限制重复选取,开始位置使用 i
,而不是 i+1
function backtrace(nums, target) {
let t = [],
r = [],
total = 0;
function combineHelper(startIndex) {
if (total == target) {
r.push([...t]);
return;
}
for (let i = startIndex; i < nums.length; i++) {
if(nums[i] > target - total) break;
t.push(nums[i]);
total += nums[i];
combineHelper(i);
total -= t.pop();
}
}
combineHelper(0);
return r;
}
let r = backtrace(nums, target);
组合总和II
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。 说明: 所有数字(包括目标数)都是正整数。解集不能包含重复的组合。
- 输入: candidates = [10,1,2,7,6,1,5], target = 8; 输出: [ [1, 7], [1, 2, 5], [2, 6], [1, 1, 6] ]
🔥🔥🔥
used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
used[i - 1] == false,说明同一树层candidates[i - 1]使用过
因为同一树层,used[i - 1] == false 才能表示,当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的。
而 used[i - 1] == true,说明是进入下一层递归,去下一个数,所以是树枝上
![](/assets/20221021163812.84daff0d.png)
let nums = [10,1,2,7,6,1,5], target = 8;
function backtrace(nums){
// 先要排序
nums = nums.sort((a,b)=>a-b)
let r = [],t = [],total = 0,used = new Array(nums.length).fill(false);
function helper(startIndex){
if(total == target){
r.push([...t])
return
}
for(let i = startIndex;i<nums.length;i++){
// 如果不加 !used[i-1],会失去 [1,1,6]
if(!used[i-1] && i > 0 && nums[i] == nums[i-1] ) continue;
used[i] = true
total+=nums[i]
t.push(nums[i]);
helper(i+1)
total-= t.pop()
used[i] = false
}
}
helper(0)
return r
}
let r = backtrace(nums,target)
子集
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例: 输入: nums = [1,2,3] 输出: [ [3], [1], [2], [1,2,3], [1,3], [2,3], [1,2], [] ]
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
![](/assets/78.子集.ac68ef3c.png)
var subsets = function(nums) {
let result = []
let path = []
function backtracking(startIndex) {
result.push([...path])
for(let i = startIndex; i < nums.length; i++) {
path.push(nums[i])
backtracking(i + 1)
path.pop()
}
}
backtracking(0)
return result
}
子集II
给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)
解集不能包含重复的子集
输入:[1,2,2],输出: [ [2], [1], [1,2,2], [2,2], [1,2], [ ] ]
使用 used
数组记录使用过的元素,也可以使用i > startIndex && nums[i] == nums[i-1]
简单的判断
let nums = [1,2,2];
function backtrace(nums){
nums = nums.sort((a,b)=>a-b)
let r = [],t = [],used = new Array(nums.length).fill(false);
function helper(startIndex){
r.push([...t])
for(let i = startIndex;i<nums.length;i++){
// if(!used[i-1] && i > 0 && nums[i] == nums[i-1] ) continue;
if(i > startIndex && nums[i] == nums[i-1] ) continue;
used[i] = true
t.push(nums[i]);
helper(i+1)
t.pop()
used[i] = false
}
}
helper(0)
return r
}
递增子序列
给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是2。
输入: [4, 6, 7, 7],输出: [[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]
![](/assets/20201124200229824-20230310131640070.f027874f.png)
在图中可以看出,同一父节点下的同层上使用过的元素就不能再使用了
let nums = [4, 6, 7, 7];
function backtrace(nums){
let r = [],t = [];
function helper(startIndex){
if(t.length>=2){
r.push([...t])
}
// 只记录本层元素是否重复使用,新的一层会被重新定义(清空)
let userd = []
for(let i = startIndex;i<nums.length;i++){
if((t.length>0 && nums[i] < t[t.length - 1]) || userd[nums[i]]){
continue;
}
userd[nums[i]] = true
t.push(nums[i]);
helper(i+1)
t.pop()
}
}
helper(0)
return r
}
let r = backtrace(nums)
全排列
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
输入: [1,2,3],输出:[ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]
INFO
这个不能设置起始索引,因为有 [3,1,2]
这种
同时要求不能有重复,所以使用 userd
进行收集 同一个树枝上的重复项
![](/assets/20201209174225145.bbcf14e7.png)
从图中可以看出,在同一树枝中不需要收集已经用过的
let nums = [1,2,3];
function backtrace(nums){
let r = [],t = [],userd = new Array(nums.length).fill(false);
function helper(){
if(t.length==nums.length){
r.push([...t])
return
}
for(let i = 0;i<nums.length;i++){
// 删除当前树枝中已经用过的
if(userd[i])continue
userd[i] = true
t.push(nums[i]);
helper()
t.pop()
userd[i] = false
}
}
helper(0)
return r
}
全排列 II
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
- 输入:nums = [1,1,2],输出:[[1,1,2], [1,2,1], [2,1,1]]
- 输入:nums = [1,2,3],输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
这道题目和全排列的区别在与给定一个可包含重复数字的序列,要返回所有不重复的全排列。
强调的是去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了。
![](/assets/20201124201406192.4054b0bb.png)
图中我们对同一树层,前一位(也就是nums[i-1])如果使用过,那么就进行去重。
let nums = [1, 1, 2];
function backtrace(nums) {
let r = [],
t = [],
used = new Array(nums.length).fill(false);
function helper() {
if (t.length == nums.length) {
r.push([...t]);
return;
}
for (let i = 0; i < nums.length; i++) {
// 同一树层下不能取相同的
if (i > 0 && nums[i] == nums[i - 1] && !used[i-1]) continue;
// 因为是可以重复选取,所以要把自己给排除掉
if(used[i]) continue
used[i] = true;
t.push(nums[i]);
helper();
t.pop();
used[i] = false;
}
}
helper(0);
return r;
}
let r = backtrace(nums);
回溯总结
深刻理解下面的这张图