文章目录
- 零、回溯算法理论
- 总览
- 什么是回溯法
- 回溯法的效率
- 回溯法解决的问题
- 如何理解回溯法
- 回溯法模板
- 一、组合问题
- [77. 组合](https://leetcode.cn/problems/combinations/)
- 题解
- 递归实现组合型枚举:每个点选与不选
- 子集问题模板
- 组合问题解决思路
- 回溯思路:遍历整棵树-找到所有组合的叶子节点
- [17.电话号码的字母组合 ](https://leetcode.cn/problems/letter-combinations-of-a-phone-number/)
- 题解
- 搜索解题思路
- 字母组合-子集问题
- [39. 组合总和 ](https://leetcode.cn/problems/combination-sum/)
- 题解
- [40. 组合总和 II ](https://leetcode.cn/problems/combination-sum-ii/)
- 题解
- [216. 组合总和 III ](https://leetcode.cn/problems/combination-sum-iii/description/)
- 题解
- 递归枚举
- 递归回溯
- 二、切割问题
- 三、子集问题
- 四、排列问题
- 五、棋盘问题
零、回溯算法理论
总览
什么是回溯法
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
在二叉树系列中,我们已经不止一次,提到了回溯,例如二叉树:以为使用了递归,其实还隐藏着回溯 (opens new window)。
回溯是递归的副产品,只要有递归就会有回溯。
回溯函数也就是递归函数,指的都是一个函数。
回溯法的效率
回溯法并不是什么高效的算法。
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?
因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
回溯法解决的问题
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
组合是不强调元素顺序的,排列是强调元素顺序。
例如:{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(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
一、组合问题
77. 组合
给定两个整数 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,单层递归逻辑都是递归处理每个位置选或是不选的问题,为节约空间,用一个临时全局数组保存每次选择的结果,最后汇总,就是子集或是组合的问题。
子集问题模板
- 1.相同的逻辑单元:每个位置的元素,选或者不选,然后进入下个位置的判断
- 2.边界条件:所有位置都验证完毕,返回
- 3.每个分支需要用数组储存选择的结果,用全局变量,免得每个分支开销
- 每个分支使用后,要对称的清空数组元素
- 保证下个分支使用前,数组是干净的
vector<int> 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);
}
组合问题解决思路
- 1.与求子集问题类似,也是递归判断每个位置选或不选的分支问题
- 2.本题有k个元素的限制,因此,可以进行剪枝处理
- 即,如果子集个数已经大于k,当前分支无需继续遍历判断
- 如果后面所有元素的个数全部都选,都不够k,当前分支也舍去
class Solution {
public:
vector<vector<int>> ans;
vector<int> opt; // 临时数组记录每次选择结果
vector<vector<int>> 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个数,那么n和k是两个int型的参数。
然后还需要一个参数,为int型变量startIndex,这个参数用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,…,n] )。
- 即每次按住一个数,然后往后找可能的组合
class Solution {
private:
vector<vector<int>> result;
vector<int> 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 >= k
for (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<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
17.电话号码的字母组合
题解
给定一个仅包含数字 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.提取信息:
- 给定号码,每个号码有三个字母可以选择,
- n个字母,共有3^n中可能,是子集问题,指数问题
-
2.定义状态:
- 号码是给定的,
- 动态改变的状态是:当前处理第几个号码index,以及每个号码选择的字母组合后的子串str
-
3.定义搜索框架:
-
子集问题,需要深度遍历,记录每个分支的结果,因此是DFS
-
状态先作为参数,
-
递归边界:index遍历到最后一个号码
-
如果局部变量,不用还原现场,代码优化时,状态作为全局变量,要还原现场
-
每个号码的字母不重复,因此无需判重
-
class Solution {
public:
vector<string> letterCombinations(string digits) {
if(digits.empty()) return ans;
this->digits = digits;
// 初始化数据
map.insert(pair<char, string>('2', "abc"));
map.insert(pair<char, string>('3', "def"));
map.insert(pair<char, string>('4', "ghi"));
map.insert(pair<char, string>('5', "jkl"));
map.insert(pair<char, string>('6', "mno"));
map.insert(pair<char, string>('7', "pqrs"));
map.insert(pair<char, string>('8', "tuv"));
map.insert(pair<char, string>('9', "wxyz"));
dfs(0);
return ans;
}
private:
vector<string> ans;
string opt = ""; // 全局临时变量
string digits;
unordered_map<char, string> 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();
}
}
};
39. 组合总和
给你一个 无重复元素 的整数数组 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<vector<int>> combinationSum(vector<int>& candidates, int target) {
// 1.先对数组进行排序,免得子集重复
sort(candidates.begin(), candidates.end());
this->candidates = candidates;
dfs(0, target);
return ans;
}
private:
vector<vector<int>> ans;
vector<int> opt;
vector<int> 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();
}
}
}
};
40. 组合总和 II
给定一个候选人编号的集合 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<vector<int>> combinationSum2(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end()); // 先排序,再改变指向
this->candidates = candidates;
dfs(0, target);
return ans;
}
private:
vector<vector<int>> ans;
vector<int> opt;
vector<int> 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();
}
}
}
};
216. 组合总和 III
找出所有相加之和为 n
的 k
个数的组合,且满足下列条件:
- 只使用数字1到9
- 每个数字 最多使用一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
示例 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<vector<int>> combinationSum3(int k, int n) {
dfs(k, n, 1);
return ans;
}
private:
vector<vector<int>> ans;
vector<int> 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<vector<int>> combinationSum3(int k, int n) {
dfs(k, n, 1);
return ans;
}
private:
vector<vector<int>> ans;
vector<int> 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 > 0
for(int i = start; opt.size() + 9 - i + 1 >= k; i++) {
opt.push_back(i);
dfs(k, sum - i, i + 1);
opt.pop_back();
}
}
};