算法学习记录~2023.X.XX~章节DayX~题目号.题目标题 & 题目号.题目标题
基础知识
1. 什么是回溯法
回溯法也叫回溯搜索法,是一种搜索的方式。
回溯是递归的副产品,只要有递归就会有回溯,所以回溯函数其实也就是递归函数。
2. 回溯法的效率
回溯法的本质是穷举,如果想让回溯高效一些可以加一些剪枝操作,但仍然不是什么高效的算法。
但仍然需要使用,因为有一些问题只能通过暴力搜索才能解决。
3. 回溯法解决的问题
一般是解决如下问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等
排列:有序。{1, 2} 和 {2, 1} 就是2个集合
组合:无序。{1, 2} 和 {2, 1} 就是1个集合
4. 如何理解回溯法
回溯法解决的问题都可以抽象为树形结构
回溯法解决的就是在集合中递归查找子集,集合的大小构成了树的宽度,递归的深度构成了树的深度。
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
具体可以结合下面的图理解
5. 回溯法模板
回溯三步曲:
- 回溯函数模板返回值以及参数
函数返回值一般为void。
由于回溯算法需要的参数不像二叉树递归一样容易一次性确定下来,因此一般先写逻辑,然后需要什么参数再补。
void backTracking (参数)
- 回溯函数终止条件
一般来说搜到了叶子节点,就找到了满足条件的一条答案,此时就把答案存起来,并结束本层递归。
if (终止条件){
存放结果;
return;
}
- 回溯搜索的遍历过程
在上面提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度
集合大小和孩子的数量是相等的。
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果;
}
77. 组合
题目链接
思路
该组合问题可以抽象为如下树形结构
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度。
每次搜索到了叶子节点,我们就找到了一个结果。
回溯三步曲:
- 递归函数的返回值及参数
需要定义两个全局变量,一个用来存放当前路径(结果),一个存放所有路径的结果
vector<int> path; //存放当前路径(结果)
vector<vector<int>> result; //存放所有路径结果
集合n里取k个数,所以n和k一定为参数。
此外还需要一个index,用于记录本层递归中从哪里开始遍历,防止出现重复的组合。
void backtracking(int n, int k, int startIndex)
- 回溯函数终止条件
如果path数组的长度到达k,说明到达了叶子结点,找到了一个子集大小为看的组合,path寸的就是根结点到叶子节点的路径。
此时用result二维数组把path保存起来并终止本层递归
if (path.size() == k) {
result.push_back(path);
return;
}
- 单层搜索的过程
回溯法的搜索过程就是一个树型结构的遍历过程
for循环用来横向遍历,递归的过程是纵向遍历。
for循环每次从index开始遍历,然后用path保存渠道的节点
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back(); // 回溯,撤销处理的节点
}
代码
class Solution {
public:
vector<vector<int>> result; //存放所有路径得到的结果
vector<int> path; //存放单条路径结果
void backtracking (int n, int k, int index){
if (path.size() == k){ //路径长为k则找到了一组组合
result.push_back(path);
return ;
}
for (int i = index; i <= n; i++){ // 控制树的横向遍历
path.push_back(i); //处理节点
backtracking(n, k, i + 1); //递归
path.pop_back(); //回溯,撤销被处理的节点
}
}
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
思路2:从1的基础上剪枝优化
可以剪枝的地方就在每一层的for循环所选择的起始位置。如果for循环选择的起始位置之后的元素个数已经不足k个了,那就没必要搜索了。
已经选择的个数为 path.size(),所以剩余元素为 k - path.size(),因此起始位置最多只能到n - (k - path.size()) + 1.
之所以要 + 1,因为包括起止位置,是左闭右闭集合。
比如 n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2,从2开始搜索都是合理的,可以是组合[2, 3, 4]
因此优化后的for循环为
for (int i = index; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
其他代码均和普通的一致
代码
class Solution {
public:
vector<vector<int>> result; //存放所有路径得到的结果
vector<int> path; //存放单条路径结果
void backtracking (int n, int k, int index){
if (path.size() == k){ //路径长为k则找到了一组组合
result.push_back(path);
return ;
}
for (int i = index; i <= n - (k - path.size()) + 1; i++){ //优化的地方
path.push_back(i); //处理节点
backtracking(n, k, i + 1); //递归
path.pop_back(); //回溯,撤销被处理的节点
}
}
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
总结
216.组合总和III
题目链接
思路
思路和 77. 组合 大致相同,需要注意的点有两个
- 终止条件为k,也就是元素个数,树的深度,如果path.size() 和 k相等了,就终止。在这个前提下,再判断总和是不是等于目标和,如果是则加入到result
- 在这次的回溯中,除了需要回溯每次取的元素,还需要回溯总和
处理过程 和 回溯过程 是一一对应的,处理有加,回溯就要有减
代码1
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(int k, int n,int sum, int index){
if (path.size() == k){
if (sum == n){
result.push_back(path);
}
return ; //注意return位置,如果path.size() == k 但sum != targetSum 直接返回
}
for (int i = index; i <= 9; i++){
path.push_back(i); //处理
sum += i; //处理
backtracking(k, n, sum, i + 1); //注意i+1调整index
sum -= i; //回溯
path.pop_back(); //回溯
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 0, 1);
return result;
}
};
代码2:剪枝
- 首先就是关于个数的剪枝,也就是for循环条件的改变,和 77. 组合 一致
- 另外如果sum已经大于了目标和n,那么继续下去也没有意义
class Solution {
private:
vector<vector<int>> result; // 存放结果集
vector<int> path; // 符合条件的结果
void backtracking(int targetSum, int k, int sum, int startIndex) {
if (sum > targetSum) { // 剪枝操作
return;
}
if (path.size() == k) {
if (sum == targetSum) {
result.push_back(path);
}
return;
}
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝
sum += i; // 处理
path.push_back(i); // 处理
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
result.clear(); // 可以不加
path.clear(); // 可以不加
backtracking(n, k, 0, 1);
return result;
}
};
总结
17.电话号码的字母组合
题目链接
思路
主要需要解决的有三个问题:
- 数字和字母如何映射
- 两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来,因此需要回溯算法
- 输入1 * #按键等等异常情况(本题测试数据倒是不涉及)
对于数字和字母的映射:
可以使用map,也可以定义一个二维数组来做映射。比如用二位数组定义的话就如下所示
const string letterMap[10] = {
"", //0
"", //1
"abc", //2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
}
对于回溯算法,按照之前的回溯三步曲即可,具体参照下面代码的注释。
需要注意的点是for循环要记住循环的是什么,在本题中为当前所指节点的所有可能字母,因此应该是lettersd的size而不是别的
此外也要注意下将index给int化,以及如何获取当前index所指数字的字母集
代码
class Solution {
public:
const string letterMap[10] = {
"", //0
"", //1
"abc", //2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
string path;
vector<string> result;
void backtracking(const string& digits, int index){
if (index == digits.size()){
result.push_back(path); //找到了一种组合
return ;
}
int digit = digits[index] - '0'; //将index指向的数字转化为int
string letters = letterMap[digit]; //存放index当前所指数字对应的字母集
for (int i = 0; i < letters.size(); i++){ //本层的所有可能选项是当前所指数字对应的字母集,因此应注意是letters的size
path.push_back(letters[i]); //处理
backtracking(digits, index + 1); //index+1递归下一层,也就是下一个数字
path.pop_back(); //回溯
}
}
vector<string> letterCombinations(string digits) {
if (digits.size() == 0) { //不写这个的话,输入为空是结果会是[""]而不是空[]
return result;
}
backtracking (digits, 0);
return result;
}
};
总结
主要需要解决的有三个问题:
- 数字和字母如何映射
- 两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来,因此需要回溯算法
- 输入1 * #按键等等异常情况(本题测试数据倒是不涉及)