本文章为个人学习笔记,学习资源:《A LeetCode Grinding Guide (C Version)》,代码随想录代码随想录,力扣题解等。
目录
1.回溯相关的题型
2.理论基础
(1)什么是回溯法
(2)回溯法的效率
(3)如何理解回溯法
(4)回溯三部曲
回溯函数模板返回值以及参数
回溯函数终止条件
回溯搜索的遍历过程
模板框架(伪代码)
3.组合问题
(1)组合的经典模板题——77组合
题解:
回溯三部曲:
代码
优化(剪枝)
(2)组合总和类型的两道非去重题——216.组合总和III、39. 组合总和
216.组合总和III
题解:
代码:
39.组合总和
题解
回溯三部曲
代码
优化
(3)需要去重的组合题——40.组合总和II
题解
回溯三部曲
代码
不用used而用startIndex控制
(4)多个集合求组合
17电话号码的字母组合
题解
回溯三部曲:
4.切割问题
(1)经典题——131分隔回文串
难点:
题解:
回溯三部曲
再次提醒:组合问题中,如果是一个集合来求组合的话,就需要startIndex,如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex
和取数的不同其实主要就是体现在这个截取子串里
代码:
优化
(2)变形——91复原IP地址
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,解数独等等
一些难点:去重、二维、取数和切割的区别,避免死循环.......
回溯法也可以叫做回溯搜索法,它是一种搜索的方式,回溯是递归的副产品,只要有递归就会有回溯。
回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
在回溯算法中,我的习惯是函数起名字为backtracking,这个起名大家随意。
回溯算法中函数返回值一般为void。但是注意有时候需要为Bool等类型,具体问题具体分析
再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数
既然是树形结构,那么遍历树形结构一定要有终止条件。
所以回溯也有要终止条件。
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了(但是排列就是要收集每一个节点),也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
下面为组合的树形结构:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了
用递归控制for循环嵌套的数量,树形结构中可以直观的看出其搜索的过程:for循环横向遍历,递归纵向遍历,回溯不断调整结果集,这个理念贯穿整个回溯法系列。
PS:优化回溯算法只有剪枝一种方法,我们可以对条件做一些小的判断来剪枝,比如要找4个元素,你一共五个元素,那么遍历到第三个时已经没用了,因为第三第四第五最多3个元素。
一般我们在for循环上做剪枝操作。
图中每次搜索到了叶子节点,我们就找到了一个结果。相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
在这里要定义两个全局变量,一个用来存放符合条件单一结果,一个用来存放符合条件结果的集合。其实不定义这两个全局变量也是可以的,把这两个变量放进递归函数的参数里
函数里一定有两个参数,既然是集合n里面取k的数,那么n和k是两个int型的参数。
然后还需要一个参数,为int型变量startIndex,这个参数用来记录本层递归的中,集合从哪里开始遍历。每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠startIndex
从下图中红线部分可以看出,在集合[1,2,3,4]取1之后,下一层递归,就要在[2,3,4]中取数了,那么下一层递归如何知道从[2,3,4]中取数呢,靠的就是startIndex。
path这个数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是根节点到叶子节点的路径。
回溯法的搜索过程就是一个树型结构的遍历过程,for循环用来横向遍历,递归的过程是纵向遍历。for循环每次从startIndex开始遍历,然后用path保存取到的节点i。
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back(); // 回溯,撤销处理的节点
}
可以看出backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。backtracking的下面部分就是回溯的操作了,撤销本次处理的结果。
class Solution {
public:
vector> result; // 存放符合条件结果的集合
vector path; // 用来存放符合条件结果
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = startIndex; i <= n; i++) {
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归
path.pop_back(); // 回溯,撤销处理的节点
}
}
public:
vector> combine(int n, int k) {
result.clear(); // 可以不写
path.clear(); // 可以不写
backtracking(n, k, 1);
return result;
}
};
n = 4,k = 4的话,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了
所以优化之后的for循环是:
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
这题其实仅仅比经典组合题多了一个和的判断条件,然后剪枝也需要,自己意会一下。
class Solution {
public:
//肯定要剪枝:
void backtracking(vector>& result, vector& path, int n, int k, int startIndex) {
if (accumulate(path.begin(), path.end(), 0) == n && path.size()==k) {
result.push_back(path);
return;
}
for (int i = startIndex; i <= 9; ++i) {
//已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉
if (accumulate(path.begin(), path.end(), 0) > n) {
return;
}
path.push_back(i);
backtracking(result, path, n, k, i+1);
path.pop_back();
}
}
vector> combinationSum3(int k, int n) {
vector> result; // 存放符合条件结果的集合
vector path; // 用来存放符合条件结果
backtracking(result, path, n, k, 1);
return result;
}
};
变化:
数字来源从1-9到candidates数组、数字可以被使用多次、没有了k的限制(组合没有数量要求)
本题搜索的过程抽象成树形结构如下:
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
从叶子节点可以清晰看到,终止只有两种情况,sum大于target和sum等于target。
sum等于target的时候,需要收集结果。
// 关键点:不用i+1了,表示可以重复读取当前的数
// 版本一
class Solution {
private:
vector> result;
vector path;
void backtracking(vector& candidates, int target, int sum, int startIndex) {
if (sum > target) {
return;
}
if (sum == target) {
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size(); i++) {
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i); // 不用i+1了,表示可以重复读取当前的数
sum -= candidates[i];
path.pop_back();
}
}
public:
vector> combinationSum(vector& candidates, int target) {
result.clear();
path.clear();
backtracking(candidates, target, 0, 0);
return result;
}
};
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。
for循环剪枝代码如下:
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)
和39组合总和的区别是:
所以:本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。
可以看到这里多了一个Used数组
与39组合总和套路相同。但是此题还需要加一个bool型数组used,用来记录同一树枝上的元素是否使用过。
一样。 sum > target
和 sum == target
。
可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
- 这个真的很厉害,体会一下,确确实实是这样,但是i-1不要整错了。
- 注意,原数组一定要排好序
- 这里树枝去重,不能像第一题那样简单i+1,需要用used数组哦
注意sum + candidates[i] <= target为剪枝操作
class Solution {
private:
vector> result;
vector path;
void backtracking(vector& candidates, int target, int sum, int startIndex, vector& used) {
if (sum == target) {
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
// used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
// used[i - 1] == false,说明同一树层candidates[i - 1]使用过
// 要对同一树层使用过的元素进行跳过
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
continue;
}
sum += candidates[i];
path.push_back(candidates[i]);
used[i] = true;
backtracking(candidates, target, sum, i + 1, used); // 和39.组合总和的区别1,这里是i+1,每个数字在每个组合中只能使用一次
used[i] = false;
sum -= candidates[i];
path.pop_back();
}
}
public:
vector> combinationSum2(vector& candidates, int target) {
vector used(candidates.size(), false);
path.clear();
result.clear();
// 首先把给candidates排序,让其相同的元素都挨在一起。
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0, used);
return result;
}
};
体会一样本质都是一样的,在同一树层下,如果左边和右边相同,而我们一层中是从左往右的,所以轮到这一层右边的时,左边肯定已经被用过了,那么由于二者相同,右边就直接跳过,去重成功。
class Solution {
private:
vector> result;
vector path;
void backtracking(vector& candidates, int target, int sum, int startIndex) {
if (sum == target) {
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
// 要对同一树层使用过的元素进行跳过
if (i > startIndex && candidates[i] == candidates[i - 1]) {
continue;
}
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i + 1); // 和39.组合总和的区别1,这里是i+1,每个数字在每个组合中只能使用一次
sum -= candidates[i];
path.pop_back();
}
}
public:
vector> combinationSum2(vector& candidates, int target) {
path.clear();
result.clear();
// 首先把给candidates排序,让其相同的元素都挨在一起。
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return result;
}
};
解决的问题:
1.数字和字母如何映射
可以使用map或者定义一个二维数组,例如:string letterMap[10],来做映射,
2.两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来
回溯法来解决n个for循环的问题:
首先需要一个字符串s来收集叶子节点的结果,然后用一个字符串数组result保存起来,这两个变量依然定义为全局。再来看参数,参数指定是有题目中给的string digits,然后还要有一个参数就是int型的index。
注意这个index可不是组合中的startIndex了。
这个index是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。
例如输入用例"23",两个数字,那么根节点往下递归两层就可以了,叶子节点就是要收集的结果集。
那么终止条件就是如果index 等于 输入的数字个数(digits.size)了(本来index就是用来遍历digits的)。
然后收集结果,结束本层递归。
首先要取index指向的数字,并找到对应的字符集(手机键盘的字符集)。
然后for循环来处理这个字符集
注意这里for循环,可不像是在回溯算法:求组合问题! (opens new window)和回溯算法:求组合总和! (opens new window)中从startIndex开始遍历的。
因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而77. 组合 (opens new window)和216.组合总和III (opens new window)都是是求同一个集合中的组合!
3.输入1 * #按键等等异常情况
题目不要求,但是面试一定要考虑
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
};
//确定回溯函数参数
//确定终止条件
//确定单层遍历逻辑
void backtracking(vector& result, const string& digits, string& s, int index) {
if (index == digits.size()) {
result.push_back(s);
return;
}
int digit = digits[index] - '0'; // 将index指向的数字转为int
string letters = letterMap[digit]; // 取数字对应的字符集
//太妙了!!!!
for (int i = 0; i < letters.size(); i++) {
s.push_back(letters[i]); // 处理
backtracking(result,digits,s, index + 1); // 递归,注意index+1,一下层要处理下一个数字了
s.pop_back(); // 回溯
}
}
vector letterCombinations(string digits) {
vector result;
if (digits.size() == 0) {
return result;
}
string s;
int index = 0;
backtracking(result, digits, s, index);
return result;
}
};
如果想到了用求解组合问题的思路来解决 切割问题本题就成功一大半了,接下来就可以对着模板照葫芦画瓢。
切割问题类似组合问题。例如对于字符串abcdef:
所以依旧可以画出树形结构图:但是细节的书写还是有点不一样
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法 。
全局变量数组path存放切割后回文的子串,二维数组result存放结果集。 (这两个参数可以放到函数参数里)本题递归函数参数还需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
切割过的地方不能重复切割所以递归函数需要传入i + 1
class Solution {
private:
vector> result;
vector path; // 放已经回文的子串
void backtracking (const string& s, int startIndex) {
// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
if (startIndex >= s.size()) {
result.push_back(path);
return;
}
for (int i = startIndex; i < s.size(); i++) {
if (isPalindrome(s, startIndex, i)) { // 是回文子串
// 获取[startIndex,i]在s中的子串
string str = s.substr(startIndex, i - startIndex + 1);
path.push_back(str);
} else { // 不是回文,跳过
continue;
}
backtracking(s, i + 1); // 寻找i+1为起始位置的子串
path.pop_back(); // 回溯过程,弹出本次已经填在的子串
}
}
bool isPalindrome(const string& s, int start, int end) {
for (int i = start, j = end; i < j; i++, j--) {
if (s[i] != s[j]) {
return false;
}
}
return true;
}
public:
vector> partition(string s) {
result.clear();
path.clear();
backtracking(s, 0);
return result;
}
};
vector> isPalindrome; // 放事先计算好的是否回文子串的结果
//主要for循环里面的判断其实就是查询,会快很多
void computePalindrome(const string& s) {
// isPalindrome[i][j] 代表 s[i:j](双边包括)是否是回文字串
isPalindrome.resize(s.size(), vector(s.size(), false));
// 根据字符串s, 刷新布尔矩阵的大小
for (int i = s.size() - 1; i >= 0; i--) {
// 需要倒序计算, 保证在i行时, i+1行已经计算好了
for (int j = i; j < s.size(); j++) {
if (j == i) {isPalindrome[i][j] = true;}
else if (j - i == 1) {isPalindrome[i][j] = (s[i] == s[j]);}
else {isPalindrome[i][j] = (s[i] == s[j] && isPalindrome[i+1][j-1]);}
}
}
}
这题就直接做了
//这里的难点也是和分割字符串一样,是插入点,而不是像基础里面的单纯选取
//但是记住,抽象的思维还是一模一样的,切割问题就可以使用回溯搜索法把所有可能性搜出来
class Solution {
public:
// 判断字符串s在左闭又闭区间[start, end]所组成的数字是否合法,主要是奶奶滴stoi不能用
bool isValid(const string& s, int start, int end) {
if (start > end) {
return false;
}
if (s[start] == '0' && start != end) { // 0开头的数字不合法
return false;
}
int num = 0;
for (int i = start; i <= end; i++) {
if (s[i] > '9' || s[i] < '0') { // 遇到非数字字符不合法
return false;
}
num = num * 10 + (s[i] - '0');
if (num > 255) { // 如果大于255了不合法
return false;
}
}
return true;
}
void backtracking(vector& rs, vector&path, const string&s,int startIndex,int len) {
//先看终止条件
if (startIndex >= s.size()&&len==4) {
//插入点的起始坐标满了,数字的数量=4后结束
string t;
for (int i = 0; i < path.size(); ++i) {
t.append(path[i]);
t.push_back('.');
}
t.pop_back();
rs.push_back(t);
return;
}
//这是其他出错的结果
if (len > 4|| startIndex >= s.size()) {
return;
}
for (int i = startIndex; i < s.size(); ++i) {
//if (s[startIndex] == '0') {
// 说明这个截取的是0开头,那只能0单独作为一个数字
// continue;
//}
//int x = std::stoi(s.substr(startIndex, i - startIndex + 1));
if (isValid(s,startIndex,i) ){
//说明这一段是符合的
path.push_back(s.substr(startIndex, i - startIndex + 1));
++len;
backtracking(rs, path, s, i + 1, len);
--len;
path.pop_back();
}
else {
//剪枝,就是说如果不符合,那么本层后面的都不符合了,就break,仔细想想
break;
}
}
}
vector restoreIpAddresses(string s) {
vector rs;
vector path;
int startIndex = 0;
backtracking(rs, path, s, startIndex, 0);
return rs;
}
};
这里的剪枝注意一下:
如果合法就在字符串后面加上符号
.
表示已经分割。如果不合法就结束本层循环,如图中剪掉的分支: