代码随想录1刷—回溯篇(一)
- 回溯理论基础
- [77. 组合](https://leetcode.cn/problems/combinations/)
- [216. 组合总和 III](https://leetcode.cn/problems/combination-sum-iii/)
- [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/)
- [131. 分割回文串](https://leetcode.cn/problems/palindrome-partitioning/)
- [93. 复原 IP 地址](https://leetcode.cn/problems/restore-ip-addresses/)
- [78. 子集](https://leetcode.cn/problems/subsets/)
回溯理论基础
回溯是递归的副产品,只要有递归就会有回溯。
回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。虽然回溯法是个非常低效的办法,但一些问题只能用回溯来解决。还没有更高效的解法。
回溯法可以解决的问题
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
回溯算法模板框架
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
![回溯算法理论基础](https://i-blog.csdnimg.cn/blog_migrate/34c777365367c6aece29e987f1fa3083.png)
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
77. 组合
![77.组合](https://i-blog.csdnimg.cn/blog_migrate/8899c3dea15c0e604cbfdbdf642280f8.png)
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。(因此需要一个参数,为int型变量startIndex,这个参数用来记录本层递归的中,集合从哪里开始遍历)
n相当于树的宽度,k相当于树的深度。图中每次搜索到了叶子节点,我们就找到了一个结果。
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;
}
for(int i = startIndex;i <= n;i++){ //横向遍历
path.push_back(i); //处理结点
backtracking(n,k,i+1); //递归
path.pop_back(); //回溯,撤销处理的结点
}
}
public:
vector<vector<int>> combine(int n, int k) {
result.clear();
path.clear();
backtracking(n,k,1);
return result;
}
};
剪枝处理
可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。比如n=4,k=4的情况下,只要选1,2,3,4就完成了,选2,3,4根本无法满足。
优化过程如下:
- 已经选择的元素个数: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。从2开始搜索都是合理的,可以是组合[2, 3, 4]。
// 将for循环进行优化剪枝
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
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;
}
for(int i = startIndex;i <= n - (k-path.size()) + 1;i++){
path.push_back(i); //处理结点
backtracking(n,k, i + 1); //递归
path.pop_back(); //回溯,撤销处理的结点
}
}
public:
vector<vector<int>> combine(int n, int k) {
result.clear();
path.clear();
backtracking(n,k,1);
return result;
}
};
216. 组合总和 III
处理过程 和 回溯过程是一一对应的,处理有加,回溯就要有减!
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int k,int n,int startIndex,int sum){
if(path.size() == k){
if(sum == n)
result.push_back(path);
else
return;
}
for(int i = startIndex;i <= 9;i++){
sum += i;
path.push_back(i);
backtracking(k,n,i + 1,sum); //递归
sum -= i;
path.pop_back(); //回溯
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
result.clear();
path.clear();
backtracking(k,n,1,0);
return result;
}
};
剪枝处理
已选元素总和如果已经大于n了,那么往后遍历就没有意义了,直接剪掉。
if(sum > n){ //剪枝优化
return;
}
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int k,int n,int startIndex,int sum){
if(sum > n){
return;
}
if(path.size() == k){
if(sum == n)
result.push_back(path);
else
return;
}
for(int i = startIndex;i <= 9;i++){
sum += i;
path.push_back(i);
backtracking(k,n,i + 1,sum); //递归
sum -= i;
path.pop_back(); //回溯
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
result.clear();
path.clear();
backtracking(k,n,1,0);
return result;
}
};
17. 电话号码的字母组合
数字和字母的映射
可以使用map或者定义一个二维数组。
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
![17. 电话号码的字母组合](https://i-blog.csdnimg.cn/blog_migrate/37f3f7d659a490f47443d01af78cfdc2.png)
class Solution {
private:
const string letterMap[10] = {
"",
"",
"abc",
"def",
"ghi",
"jkl",
"mno",
"pqrs",
"tuv",
"wxyz",
};
vector<string> result;
string s;
void backtracking(const string& digits , int index){
//const string&为常引用
//引用作为函数参数进行传递时,实质上传递的是实参本身,即传递进来的不是实参的一个拷贝,因此对形参的修改其实是对实参的修改,所以在用引用进行参数传递时,不仅节约时间,而且可以节约空间。
//index 为记录遍历第几个数字了,就是用于遍历digits的
if(index == digits.size()){ //终止条件
result.push_back(s);
return;
}
int digit = digits[index] - '0'; // 将digits[index]指向的字符转为int
string letters = letterMap[digit]; // 取得数字对应的字符集
for(int i = 0;i < letters.size();i++){
//注意:这里for循环,可不像是在[77. 组合]和[216. 组合总和 III]中从startIndex开始遍历的。
//因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而76和216的题都是是求同一个集合中的组合
s.push_back(letters[i]);
backtracking(digits,index + 1);
s.pop_back();
}
}
public:
vector<string> letterCombinations(string digits) {
s.clear();
result.clear();
if(digits.size() == 0){
return result;
}
backtracking(digits,0);
return result;
}
};
回溯藏在递归参数中的写法
在递归参数和递归函数使用两句话中有变化,其他部分都一样,这种写法不直观,不建议这样写,但要理解。
class Solution {
private:
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
public:
vector<string> result;
void getCombinations(const string& digits, int index, const string& s) {
// 注意参数的不同
if (index == digits.size()) {
result.push_back(s);
return;
}
int digit = digits[index] - '0';
string letters = letterMap[digit];
for (int i = 0; i < letters.size(); i++) {
getCombinations(digits, index + 1, s + letters[i]);
// 注意这里的不同
}
}
vector<string> letterCombinations(string digits) {
result.clear();
if (digits.size() == 0) {
return result;
}
getCombinations(digits, 0, "");
return result;
}
};
39. 组合总和
本题和77题和216题的区别是:本题没有数量要求,可以无限重复,但是有总和的限制。
在77和216题中知道要递归K层,因为要取k个元素的组合。但本题不是,注意图中叶子节点的返回条件,因为本题没有组合数量要求,是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& 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<vector<int>> combinationSum(vector<int>& candidates, int target) {
result.clear();
path.clear();
backtracking(candidates,target,0,0);
return result;
}
};
剪枝处理
对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。所以可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。注意:是排序之后!!
![39.组合总和1](https://i-blog.csdnimg.cn/blog_migrate/581e618deec170b1fff10cfe432c707f.png)
for循环剪枝代码如下:
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& 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++){
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates,target,sum,i); //不用i+1了,因为可以重复读取数值
sum -= candidates[i];
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
result.clear();
path.clear();
sort(candidates.begin(),candidates.end()); //记得排序
backtracking(candidates,target,0,0);
return result;
}
};
在求和问题中,排序之后加剪枝是常见的套路!
40. 组合总和 II
- 本题candidates 中的每个元素在每个组合中只能使用一次。
- 本题数组candidates的元素是有重复的
- 解集不能包含重复的组合。
难点就在于2和3,在搜索的过程中需要去掉重复组合,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。所以要去重的是同一树层上的“使用过”,而同一树枝上的都是一个组合里的元素,不用去重。另一方面需要注意的是:树层去重的话,需要对数组排序!
![40.组合总和II](https://i-blog.csdnimg.cn/blog_migrate/8185aa183a45d3d79de6fed408d30af2.png)
bool型数组used:是用来记录同一树枝上的元素是否使用过。这个集合去重的重任就是used来完成的。
![40.组合总和II1](https://i-blog.csdnimg.cn/blog_migrate/5bfcaafab4ed59d5bf6acb1f41750b99.png)
可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
如果candidates[i] == candidates[i - 1]
并且 used[i - 1] == false
,说明前一个树枝使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1],此时for循环里就应该做continue的操作。
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates,int target,int sum,int startIndex,vector<bool>& used){
if(sum == target){
result.push_back(path);
return;
}
for(int i = startIndex;i < candidates.size() && sum + candidates[i] <= target;i++){
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); //i是元素可以重复,i+1是元素不可以重复
used[i] = false;
sum -= candidates[i];
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
vector<bool> used(candidates.size(),false);
path.clear();
result.clear();
sort(candidates.begin(),candidates.end()); //一定要记得排序!
backtracking(candidates,target,0,0,used);
return result;
}
};
题外话:continue和break的区别
continue语句的作用是跳过本次循环体中余下尚未执行的语句,立即进行下一次的循环条件判定,可以理解为仅结束本次循环。而break会直接结束该循环。
131. 分割回文串
本题涉及到两个关键问题:
- 切割问题
- 切割后判断回文
其实切割问题类似组合问题。例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中在选组第三个…。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中在切割第三段…。
![131.分割回文串](https://i-blog.csdnimg.cn/blog_migrate/58669a9bf8fc553affdd136867fb6551.jpeg)
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
class Solution {
private:
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;
}
vector<vector<string>> result;
vector<string> path;
void backtracking(const string& s,int startIndex){
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);
//substr 复制子字符串(从指定位置开始,指定的长度)
path.push_back(str);
}else{
continue;
}
backtracking(s,i + 1); //切割过的位置不可以重复切割,所以i+1
path.pop_back();
}
}
public:
vector<vector<string>> partition(string s) {
result.clear();
path.clear();
backtracking(s,0);
return result;
}
};
93. 复原 IP 地址
![131.分割回文串](https://i-blog.csdnimg.cn/blog_migrate/58669a9bf8fc553affdd136867fb6551.jpeg)
class Solution {
private:
vector<string> result;
void backtracking(string& s, int startIndex,int pointNum){
if(pointNum == 3){ // 三个点分成四段
//判断第四段是否合法
if(isValid(s,startIndex,s.size()-1)){
result.push_back(s);
}
return;
}
for(int i = startIndex;i < s.size();i++){
if(isValid(s,startIndex,i)){
s.insert(s.begin() + i + 1,'.');
pointNum++;
backtracking(s,i + 2,pointNum); //因为加了个点 所以从i+1变成i+2了
pointNum--;
s.erase(s.begin() + i + 1);
}else break; //不合法直接结束本层循环
}
}
bool isValid(const string& s,int start,int end){
if(start > end) return false;
if(s[start] == '0' && start != end){
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){
return false;
}
}
return true;
}
public:
vector<string> restoreIpAddresses(string s) {
result.clear();
if(s.size() < 4 || s.size() > 12)
return result;
backtracking(s,0,0);
return result;
}
};
78. 子集
![78.子集](https://i-blog.csdnimg.cn/blog_migrate/2bd56a3d12ff98a2a1248e193bba4e0b.png)
剩余集合为空的时候,就是叶子节点。那么什么时候剩余集合为空呢?实际上,当startIndex已经大于数组的长度了,就终止了,因为没有元素可取了。
需要注意的是,for (int i = startIndex; i < nums.size(); i++) 中如果startIndex >= nums.size(),for循环也就结束了,所以这个终止条件可加可不加,不影响最终结果。
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums,int startIndex){
result.push_back(path);
//注意要在终止条件之前收集子集,否则会漏掉终止时的那个集合
if(startIndex >= nums.size()){
//由于后面for循环的条件,此终止条件判断可加可不加,为了逻辑的完整性还是加上
return;
}
for(int i = startIndex;i < nums.size();i++){
path.push_back(nums[i]);
backtracking(nums,i + 1);
path.pop_back();
}
}
public:
vector<vector<int>> subsets(vector<int>& nums) {
result.clear();
path.clear();
backtracking(nums,0);
return result;
}
};