回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
在二叉树系列中,我们已经不止一次,提到了回溯,例如二叉树:以为使用了递归,其实还隐藏着回溯 (opens new window)。
回溯是递归的副产品,只要有递归就会有回溯。
回溯函数也就是递归函数,指的都是一个函数。
回溯法并不是什么高效的算法。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
回溯法,一般可以解决如下几种问题:
组合是不强调元素顺序的,排列是强调元素顺序。
例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。
记住组合无序,排列有序,就可以了。
回溯法解决的问题都可以抽象为树形结构
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
这里给出Carl总结的回溯算法模板。
在讲二叉树的递归 (opens new window)中我们说了递归三部曲,这里我再给大家列出回溯三部曲。
回溯算法中函数返回值一般为void。
再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。
回溯函数伪代码如下:
void backtracking(参数)
既然是树形结构,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
所以回溯函数终止条件伪代码如下:
if (终止条件) {存放结果;return;
}
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
如图:
注意图中,我特意举例集合大小和孩子的数量是相等的!
回溯函数遍历过程伪代码如下:
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {处理节点;backtracking(路径,选择列表); // 递归回溯,撤销处理结果
}
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
分析完过程,回溯算法模板框架如下:
void backtracking(参数) {if (终止条件) {存放结果;return;}for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {处理节点;backtracking(路径,选择列表); // 递归回溯,撤销处理结果}
}
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[[2,4],[3,4],[2,3],[1,2],[1,3],[1,4],
]
示例 2:
输入:n = 1, k = 1
输出:[[1]]
提示:
1 <= n <= 20
1 <= k <= n
实际上求组合问题与求子集问题类似,均使用dfs,单层递归逻辑都是递归处理每个位置选或是不选的问题,为节约空间,用一个临时全局数组保存每次选择的结果,最后汇总,就是子集或是组合的问题。
vector temp;
void dfs(int cur, int n) {if (cur == n + 1) {// 记录答案// ...return;}// 考虑选择当前位置temp.push_back(cur);dfs(cur + 1, n, k);temp.pop_back();// 考虑不选择当前位置dfs(cur + 1, n, k);
}
class Solution {
public:vector> ans;vector opt; // 临时数组记录每次选择结果vector> combine(int n, int k) {dfs(n, k, 1); // 题中从1遍历到n,走了n+1步return ans;}void dfs(int n, int k, int startIndex) {// 1. 剪纸:数量超过k,或后面元素全选也不够k,一定不满足if(opt.size() > k || opt.size() + n - startIndex + 1 < k) return;// 2. 递归终止:每个分支走完了集合宽度if(startIndex == n + 1) {ans.push_back(opt);return;}// 3. 本层逻辑:递归判断每个元素选或不选,递归与回溯一一对应// 不选-继续递归判断下个位置dfs(n, k, startIndex + 1);// 选-将当前选择的元素放入opt数组记录,再继续下轮循环opt.push_back(startIndex); // 添加dfs(n, k, startIndex + 1);opt.pop_back(); // 回溯}
};
把组合问题抽象为如下树形结构:
发现n相当于树的宽度,k相当于树的深度
图中每次搜索到了叶子节点,我们就找到了一个结果
函数里一定有两个参数,既然是集合n里面取k个数,那么n和k是两个int型的参数。
然后还需要一个参数,为int型变量startIndex,这个参数用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,…,n] )。
class Solution {
private:vector> result;vector path;void backtracking(int n, int k, int startIndex) {if (path.size() == k) {result.push_back(path);return;}// 每层按住一个数-集合宽度// 一直递归到边界-叶子节点// 剪枝优化:继续走下去:当前小于k,后面的数都选上>=k// path.size() + n - i + 1 >= kfor (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { path.push_back(i); // 处理节点-先按住一端backtracking(n, k, i + 1); // 递归寻找与i的组合path.pop_back(); // 回溯,撤销处理的节点,找另个与i的组合}}
public:vector> combine(int n, int k) {backtracking(n, k, 1);return result;}
};
给定一个仅包含数字 2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
示例 1:
输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
示例 2:
输入:digits = ""
输出:[]
示例 3:
输入:digits = "2"
输出:["a","b","c"]
提示:
0 <= digits.length <= 4
digits[i]
是范围 ['2', '9']
的一个数字。1.提取信息:
2.定义状态:
3.定义搜索框架:
子集问题,需要深度遍历,记录每个分支的结果,因此是DFS
状态先作为参数,
递归边界:index遍历到最后一个号码
如果局部变量,不用还原现场,代码优化时,状态作为全局变量,要还原现场
每个号码的字母不重复,因此无需判重
class Solution {
public:vector letterCombinations(string digits) {if(digits.empty()) return ans;this->digits = digits;// 初始化数据map.insert(pair('2', "abc"));map.insert(pair('3', "def"));map.insert(pair('4', "ghi"));map.insert(pair('5', "jkl"));map.insert(pair('6', "mno"));map.insert(pair('7', "pqrs"));map.insert(pair('8', "tuv"));map.insert(pair('9', "wxyz"));dfs(0);return ans;}
private:vector ans;string opt = ""; // 全局临时变量string digits;unordered_map map;void dfs(int index) {// 1. 终止条件:数字遍历完了,记录一个答案if(index == digits.size()) {ans.push_back(opt); return;}// 2. 本层逻辑:取数字中的一种字母参与组合for(char c : map[digits[index]]) {opt.push_back(c);dfs(index + 1);opt.pop_back();}}
};
给你一个 无重复元素 的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为目标数 target
的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates
中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target
的不同组合数少于 150
个。
示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。
示例 2:
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]
示例 3:
输入: candidates = [2], target = 1
输出: []
提示:
1 <= candidates.length <= 30
2 <= candidates[i] <= 40
candidates
的所有元素 互不相同1 <= target <= 40
没有限制个数。没有深度上的剪枝
// sum 在参数表中,自动回溯,包含相同i,i递归不变dfs(i, sum - candidates[i]);
class Solution {
public:vector> combinationSum(vector& candidates, int target) {// 1.先对数组进行排序,免得子集重复sort(candidates.begin(), candidates.end());this->candidates = candidates;dfs(0, target);return ans;}
private:vector> ans;vector opt;vector candidates;void dfs(int start, int sum) {// 1. 递归边界-找到一组结果if(sum == 0) {ans.push_back(opt); return;} if(sum > 0) { // sum < 0剪枝掉// 没有限制个数。没有深度上的剪枝for(int i = start; i < candidates.size(); i++) { opt.push_back(candidates[i]);// sum 在参数表中,自动回溯,包含相同i,i递归不变dfs(i, sum - candidates[i]); opt.pop_back();}}}
};
给定一个候选人编号的集合 candidates
和一个目标数 target
,找出 candidates
中所有可以使数字和为 target
的组合。
candidates
中的每个数字在每个组合中只能使用 一次 。
**注意:**解集不能包含重复的组合。
示例 1:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
示例 2:
输入: candidates = [2,5,2,1,2], target = 5,
输出:
[
[1,2,2],
[5]
]
提示:
1 <= candidates.length <= 100
1 <= candidates[i] <= 50
1 <= target <= 30
注意:
都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重
强调一下,树层去重的话,需要对数组排序!
选择过程树形结构如图所示:
class Solution {
public:vector> combinationSum2(vector& candidates, int target) { sort(candidates.begin(), candidates.end()); // 先排序,再改变指向this->candidates = candidates;dfs(0, target);return ans;}
private:vector> ans;vector opt;vector candidates;void dfs(int start, int sum) {if(sum == 0) {ans.push_back(opt);return;}if(sum > 0) {for(int i = start; i < candidates.size(); i++) {// 注意:对同一树层上相同的两个元素要去重// 例如[1,1,2],已经选取了0,1号的[1,2],不能再选1,2号的[1,2]if(i > start && candidates[i] == candidates[i - 1])continue;opt.push_back(candidates[i]);dfs(i + 1, sum - candidates[i]);opt.pop_back();}}}
};
找出所有相加之和为 n
的 k
个数的组合,且满足下列条件:
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
示例 1:
输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。
示例 2:
输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]
解释:
1 + 2 + 6 = 9
1 + 3 + 5 = 9
2 + 3 + 4 = 9
没有其他符合的组合了。
示例 3:
输入: k = 4, n = 1
输出: []
解释: 不存在有效的组合。
在[1,9]范围内使用4个不同的数字,我们可以得到的最小和是1+2+3+4 = 10,因为10 > 1,没有有效的组合。
提示:
2 <= k <= 9
1 <= n <= 60
class Solution {
public:vector> combinationSum3(int k, int n) {dfs(k, n, 1);return ans;}
private:vector> ans;vector opt;void dfs(int k, int n, int i) {// 1. 剪枝if(opt.size() > k || opt.size() + 9 - i + 1 < k) return;// 2. 递归终止:遇到叶子节点-处理一个分支的结果if(i > 9) {int sum = 0;for(int j : opt) sum += j;if(sum == n) ans.push_back(opt);}// 3. 本层逻辑// 选opt.push_back(i);dfs(k, n, i + 1);opt.pop_back();// 不选dfs(k, n, i + 1);}
};
class Solution {
public:vector> combinationSum3(int k, int n) {dfs(k, n, 1);return ans;}
private:vector> ans;vector opt;void dfs(int k, int sum, int start) { // sum 为target-i// 1.先剪枝:提出总和不满足的情况if(sum < 0 || opt.size() > k) return;// 1.递归终止条件:先触发k个元素if(sum == 0 && opt.size() == k) {ans.push_back(opt); return;}// 剩下情况就是sum > 0for(int i = start; opt.size() + 9 - i + 1 >= k; i++) {opt.push_back(i);dfs(k, sum - i, i + 1);opt.pop_back();}}
};