文章目录
回溯算法理论基础
回溯法
定义:回溯法也可以叫做回溯搜索法,是一种搜索的方式。有递归就会有回溯,回溯函数也就是递归函数。
回溯法的效率:回溯的本质是穷举,穷举所有可能,然后选出想要的答案。剪枝操作可以让回溯法高效一些,但回溯法仍然是穷举。
回溯法解决的问题
一般可以解决如下几种问题:
组合问题:N个数里面按一定规则找出k个数的集合
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式
棋盘问题:N皇后,解数独等等
注意:组合是不强调元素顺序的,排列是强调元素顺序。组合无序,排列有序。
回溯法理解
- 回溯法解决的问题都可以抽象为树形结构。
- 因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度。
- 递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
回溯法/递归法模板
- 回溯函数模板返回值以及参数:
回溯算法中函数返回值一般为void;
回溯算法中一般是先写逻辑,然后需要什么参数,就填什么参数。 - 回溯函数终止条件:一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
- 回溯搜索的遍历过程:
for循环,横向遍历,遍历集合区间。一个节点有多少个孩子,这个for循环就执行多少次;
处理节点;
递归;
回溯,撤销处理结果。
回溯法的题目
- 组合:77.组合、17.电话号码的字母组合、39.组合总和、40.组合总和Ⅱ、216.组合总和Ⅲ
- 分割:131.分割回文串、93.复原IP地址
- 子集:78.子集、90.子集Ⅱ
- 排列:46.全排列、47.全排列Ⅱ
- 棋盘问题:51.N皇后、37.解数独
- 其他:491.递增子序列、332.重新安排行程
回溯法伪代码
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
77. 组合
![](https://i-blog.csdnimg.cn/blog_migrate/f31f68d083dd0d1bb19663039d68ac19.png)
三要素及思路
先写逻辑,再确定递归函数参数:
n,题目中的n。
k,题目中的k,集合个数。
startindex,下一层for循环搜索的起始位置。
终止条件:
k就是树的深度,只取k个元素,path.size() =k就终止。
单层搜索:
path收集每次选取的元素,相当于树型结构里的边。
全局变量:
一维数组path来存放符合条件的结果,二维数组result来存放结果集。
![](https://i-blog.csdnimg.cn/blog_migrate/9cdc4bffc31613e4e0bc84d83c31b6af.png#pic_center)
代码
class Solution {
public:
vector<vector<int>> result;//二维数组,存放符合条件结果的集合
vector<int> path;//一维数组,存放符合条件的一个结果 也是存放路径
//参数就是树的宽度n,深度k,startindex是为了控制单次递归时从哪里开始遍历,防止出现重复的组合
//如,一个结果集合是[1, 2] [1, 3] [1, 4];那么下一个结果集合遍历的开始位置就是2开始,即[2, 3] [2, 4]
void backtracking(int n, int k, int startindex)
{
//遍历到树的叶子节点,也就是走过了两个节点,即path的大小为k时,回溯终止
//相当于 一维数组path大小=k,找到了一个子集大小为k的组合,需要在result中保存path,也就是保存了根节点到叶子节点的路径,然后终止回溯
if(path.size()==k)
{
result.push_back(path);
return;
}
//单层递归
for(int i = startindex;i<=n;i++)//控制树的横向遍历,i为本次搜索的起始位置
{
path.push_back(i);//处理节点
backtracking(n, k, i+1);//递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back();//回溯,撤销处理的节点
}
}
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
剪枝优化
![](https://i-blog.csdnimg.cn/blog_migrate/653a011b35c3a19694fec8332604e8e2.png#pic_center)
优化过程如下:
已经选择的元素个数:path.size();
还需要的元素个数为: k - path.size();
在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历
+1是因为包括起始位置,要的是一个左闭的集合
把上面代码的for(int i = startindex;i<=n;i++)
替换成for (int i = startIndex; i <= n - (k - path.size()) + 1; i++)
216.组合总和Ⅲ
![](https://i-blog.csdnimg.cn/blog_migrate/9dc1fab2435bb070a5de6f2d6fab3d8c.png)
三要素及思路
先写逻辑,再确定递归函数参数:
目标和targetsum,题目中的n。
k,题目中的k,集合个数。
sum,每条路径累加和,path里元素总和。
startindex,下一层for循环搜索的起始位置。
终止条件:
k就是树的深度,只取k个元素,path.size() =k就终止。
结合题目,如果此时path里收集到的元素和sum和题目要求的和n,即目标和targetSum相同,就用result收集当前的结果。
单层搜索:
path收集每次选取的元素,相当于树型结构里的边,sum来统计path里元素的总和。
全局变量:
一维数组path来存放符合条件的结果,二维数组result来存放结果集。
![](https://i-blog.csdnimg.cn/blog_migrate/5114d97cf917143ca9a04d17a3773cd2.png)
代码
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(int k, int targetsum, int sum, int startindex)
{
//终止条件
if(path.size()==k)
{
if(sum==targetsum) result.push_back(path);//如果累加和与目标和相同,存放结果
return;//搜索到叶子节点
}
//单层搜索
//数字只能在1-9之间
for(int i = startindex; i<=9; i++)
{
sum += i;//累加求和
path.push_back(i);//存放组合
backtracking(k, targetsum, sum, i+1);//startindex调整为i+1
sum -= i;//回溯
path.pop_back();//回溯
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 0, 1);
return result;
}
};
剪枝优化
![](https://i-blog.csdnimg.cn/blog_migrate/e1ab207a5e55184f20ac5464c21027b3.png)
如果第二次取元素值大于n,即目标和,后续操作就没有意义了,直接返回,做剪枝处理if (sum > targetSum) return;
,有三种方式:
- 方式1:遍历前剪枝, 回溯函数开始就剪枝
- 方式2:调用递归函数开始前剪枝,但要先回溯,再剪枝
- 方式3:也可以在for循环的控制条件剪枝,
i <= 9-(k-path.size())+1
方式1:回溯开始就剪枝
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(int k, int targetsum, int sum, int startindex)
{
//剪枝1 回溯开始就剪枝
if(sum>targetsum) return;
//终止条件
if(path.size()==k)
{
if(sum==targetsum) result.push_back(path);//如果累加和与目标和相同,存放结果
return;//搜索到叶子节点
}
//单层搜索
//数字只能在1-9之间
for(int i = startindex; i<=9; i++)
{
sum += i;//累加求和
path.push_back(i);//存放组合
backtracking(k, targetsum, sum, i+1);//startindex调整为i+1
sum -= i;//回溯
path.pop_back();//回溯
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 0, 1);
return result;
}
};
方式2:调用递归函数开始前剪枝,先回溯,再剪枝
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(int k, int targetsum, int sum, int startindex)
{
//终止条件
if(path.size()==k)
{
if(sum==targetsum) result.push_back(path);//如果累加和与目标和相同,存放结果
return;//搜索到叶子节点
}
//剪枝2 调用递归前剪枝 要先回溯再剪枝
for(int i = startindex; i<=9; i++)
{
sum += i;//累加求和
path.push_back(i);//存放组合
if(sum > targetsum)//剪枝
{
sum -= i;//剪枝之前先回溯
path.pop_back();//剪枝之前先回溯
return;
}
backtracking(k, targetsum, sum, i+1);//startindex调整为i+1
sum -= i;//回溯
path.pop_back();//回溯
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 0, 1);
return result;
}
};
方式3:for循环控制条件剪枝
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(int k, int targetsum, int sum, int startindex)
{
//终止条件
if(path.size()==k)
{
if(sum==targetsum) result.push_back(path);//如果累加和与目标和相同,存放结果
return;//搜索到叶子节点
}
//剪枝3
for(int i = startindex; i<=9 - (k-path.size()) + 1; i++)
{
sum += i;//累加求和
path.push_back(i);//存放组合
backtracking(k, targetsum, sum, i+1);//startindex调整为i+1
sum -= i;//回溯
path.pop_back();//回溯
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n, 0, 1);
return result;
}
};
17.电话号码的字母组合
![](https://i-blog.csdnimg.cn/blog_migrate/92eec27effcb93859412cd1fa1508380.png)
三个问题
- 数字和字母如何映射——map或二维数组映射
- 输入1 * #按键等等异常情况——可以剔除这三种字符
- 两个字母两个for循环,三个字符三个for循环,以此类推,怎么解决n个循环——回溯
三要素及思路
先写逻辑,再确定递归函数参数:
digits,题目中的digits,数字串。
index,遍历数字串digits,记录遍历的第几个数字,也表示树的深度
终止条件:
当数字串遍历完就终止,即index==digits.size()
。存放当前层的结果,再结束递归
单层搜索:
首先要用index获取数字串的数字
然后找到该数字对应的字母集
最后通过for循环处理这个字母集:s存放每次在字母集选取的元素,然后递归,再回溯
全局变量:
一个字符串s收集叶子节点结果
一个字符串数组result存放结果集
![](https://i-blog.csdnimg.cn/blog_migrate/fc16108045fcea8c4e9a28f805a0f464.png)
代码
- 二维数组
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:
string s;//保存符合条件的一个结果
vector<string> result;//保存结果集
//参数:1.题目给的字符串digits 引用传入 const修饰;2.当前数字,字符串当前元素
void backtracking(const string& digits, int index)
{
//终止条件 如果字符串遍历完就终止
if(index==digits.size())
{
result.push_back(s);
return;
}
//单层搜索 二维数组
int digit = digits[index] - '0';//index指向的数字由string转成int
string letters = letterMap[digit];//获取index指向的数字对应的字母
for(int i=0; i<letters.size();i++)
{
s.push_back(letters[i]);//存放字母
backtracking(digits, index+1);//递归,index+1表示处理下一个数字
s.pop_back();//回溯
}
}
vector<string> letterCombinations(string digits) {
if(digits.size()==0) return result;
backtracking(digits, 0);
return result;
}
};
- map
class Solution {
//私有属性 map 数字与字母映射
private:
const unordered_map<char, string> phoneMap{
{'2', "abc"},
{'3', "def"},
{'4', "ghi"},
{'5', "jkl"},
{'6', "mno"},
{'7', "pqrs"},
{'8', "tuv"},
{'9', "wxyz"}
};
public:
string s;//保存符合条件的一个结果
vector<string> result;//保存结果集
//参数:1.题目给的字符串digits 引用传入 const修饰;2.当前数字,字符串当前元素
void backtracking(const string& digits, int index)
{
//终止条件 如果字符串遍历完就终止
if(index==digits.size())
{
result.push_back(s);
return;
}
//单层搜索 map
char digit = digits[index];//获取当前遍历的数字
string letters = phoneMap.at(digit);//获取当前遍历数字 对应的 字母集 注意访问方式
for(int i=0; i<letters.size();i++)
{
s.push_back(letters[i]);//存放字母
backtracking(digits, index+1);//递归,index+1表示处理下一个数字
s.pop_back();//回溯
}
}
vector<string> letterCombinations(string digits) {
if(digits.size()==0) return result;
backtracking(digits, 0);
return result;
}
};
39.组合总和
![](https://i-blog.csdnimg.cn/blog_migrate/9dc1fab2435bb070a5de6f2d6fab3d8c.png)
三要素及思路
先写逻辑,再确定递归函数参数:
candidates,集合
target,目标值
sum,每条路径累加和,path里元素总和。也可以用target做减法,target=0就说明找到符合的结果了。
startindex,下一层for循环搜索的起始位置。
终止条件:
sum大于target和sum等于target
单层搜索:
path收集每次选取的元素,相当于树型结构里的边,sum来统计path里元素的总和。
全局变量:
一维数组path来存放符合条件的结果,二维数组result来存放结果集。
![](https://i-blog.csdnimg.cn/blog_migrate/2180ef2d50a61ffd37f377c7e10649f2.png)
本题和77.组合、216.组合总和Ⅲ不同的是
- 本题元素可以重复,77和216不可以。所以回溯时,本题的startindex从i开始,77和216的startindex从i+1开始。
- 组合没有数量限制
代码
class Solution {
public:
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();//回溯
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return result;
}
};
剪枝优化
sum大于target时,还是进入了下一层递归,但其实可以直接返回,不需要进入下一层递归。
因此,sum大于target时再for循环所有范围进行剪枝。对总集合排序,如果下一层的sum,即本层的sum+candidates[i]大于target时,就可以结束本轮for循环的遍历。
class Solution {
public:
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() && 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();//回溯
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end()); // 需要排序 剪枝前需要排序
backtracking(candidates, target, 0, 0);
return result;
}
};
40.组合总和Ⅱ
![](https://i-blog.csdnimg.cn/blog_migrate/2c8cc1731f63383d46bef60407ff98c2.png)
三要素及思路
和39.组合总和的不同点
- 区别1:本题candidates 中的每个数字在每个组合中只能使用一次。
- 区别2:本题数组candidates的元素是有重复的,而39.组合总和是无重复元素的数组candidates
和39.组合总和的共同点:
最后本题和39.组合总和要求一样,解集不能包含重复的组合。
题目难点:
在于区别2中,集合(数组candidates)有重复元素,但还不能有重复的组合。元素在同一个组合内是可以重复的,但两个组合不能相同。
因此,去重操作就是要去重的是同一树层上的使用过,同一树枝上的都是一个组合里的元素,不用去重。树层去重的话,需要对数组排序
![](https://i-blog.csdnimg.cn/blog_migrate/838a93bd75888200f9a381b0fd2f5118.png)
先写逻辑,再确定递归函数参数:
candidates,集合
target,目标值
sum,每条路径累加和,path里元素总和。也可以用target做减法,target=0就说明找到符合的结果了。
startindex,下一层for循环搜索的起始位置。
used,bool型数组,用来记录同一树枝上的元素是否使用过,用于集合去重
终止条件:
和39.组合总和一样,sum大于target和sum等于target,同样的,剪枝操作时可以省掉sum大于target条件了。
单层搜索:
path收集每次选取的元素,相当于树型结构里的边,sum来统计path里元素的总和。
全局变量:一维数组path来存放符合条件的结果,二维数组result来存放结果集。
去重方式1-使用标记数组
[i] == candidates[i - 1]时:
- used[i - 1] == true,表示同一树枝 candidates[i - 1]使用过,说明取了下一个数,进入了下一层递归,所以是树枝
- used[i - 1] == false,表示同一树层 candidates[i - 1]使用过,说明当前取的 candidates[i]是从candidates[i - 1] 回溯而来的,所以是同一树层。
![](https://i-blog.csdnimg.cn/blog_migrate/d44306fff9d6a0ab5282f01c82c71c05.png)
去重方式2-使用下标索引: 排序之后,相同元素会挨在一起。去重时,从i的下一个位置取元素,判断前后元素是否相同,相同则跳过
去重方式3-使用set去重: set记录哪些元素使用过,对同一父节点下同一层去重
代码
- 去重方式1-标记数组used,使用sum
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
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++)
{
//判断当前值在同一树层是否使用过 同一树层元素不能重复,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]看元素是在树枝还是树层
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,i+1,每个数字在每个组合中只能使用一次
used[i] = false;
sum -= candidates[i];
path.pop_back();
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
vector<bool> used(candidates.size(), false);//bool型数组 判断元素在树层还是树枝
// 先candidates排序,让其相同的元素都挨在一起。
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0, used);
return result;
}
};
- 去重方式2-索startindex索引下标,使用sum
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
// 索引下标 不使用数组
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++)//剪枝
{
//去重 从i下一个位置取元素 前后元素相同则跳过
if(i>startindex && candidates[i]==candidates[i-1]) continue;
//操作
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i+1);//每个数字在每个组合中只能使用一次
sum -= candidates[i];
path.pop_back();
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target)
{
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return result;
}
};
- 去重方式2-startindex索引下标,不使用sum
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
//不使用sum
void backtracking(vector<int>& candidates, int target, int startindex)
{
//终止条件
if(target==0)
{
result.push_back(path);
return;
}
//单层搜索
for(int i=startindex; i<candidates.size() && target-candidates[i] >= 0; i++)//剪枝
{
//去重 从i下一个位置取元素 前后元素相同则跳过
if(i>startindex && candidates[i]==candidates[i-1]) continue;
//操作
target -= candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, i+1);//每个数字在每个组合中只能使用一次
target += candidates[i];
path.pop_back();
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target)
{
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0);
return result;
}
};
- 去重方式3-set去重,不使用sum
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
//3.set去重+不使用sum
void backtracking(vector<int>& candidates, int target, int startindex)
{
if(target==0)
{
result.push_back(path);
return;
}
unordered_set<int> uset;
for(int i=startindex; i<candidates.size() && target-candidates[i]>=0; i++)
{
if(uset.find(candidates[i]) != uset.end()) continue;
uset.insert(candidates[i]);
target -= candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, i+1);
target += candidates[i];
path.pop_back();
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target)
{
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return result;
}
};
剪枝优化和39题的一样
总结
1. 回溯法的理论基础
- 回溯法的定义:回溯搜索法,一种搜索的方式
- 回溯法解决的问题:组合、切割、子集、排列、棋盘,注意组合无序,排列有序
- 回溯法的理解:本质是穷举,回溯问题可以抽象为一棵高度有限的树(N叉树),在集合中递归查找子集。树的宽度就是集合大小,树的深度就是递归的深度。
- 递归法模板以及伪代码:
//先写逻辑再确定参数
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
2. 组合问题
-
77.组合:
- 回溯三要素
- 用递归控制for循环嵌套的数量,把回溯问题抽象为树形结构,直观看搜索过程:for循环横向遍历,递归纵向遍历,回溯不断调整结果集
- 剪枝精髓是for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够 题目要求的k个元素了,就没有必要搜索了
-
216.组合总和Ⅲ:
- 三种剪枝方式
- 一个是已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉
- 还有一个是对for循环选择的起始范围的剪枝
-
17.电话号码的字母组合:
- 和前面两道题不一样的是,这道题的每一个数字代表的是不同集合,也就是求不同集合之间的组合。77和216都是求同一个集合中的组合。
- 注意输入异常的情况,例如输入1 * #按键
- 使用二维数组或者map建立数字与字母的映射
- 对当前遍历数字的获取和对应字母集的获取
-
39.组合总和:
- 组合没有数量要求
- 元素可以重复选
-
40.组合总和Ⅱ:
- 先排序,再去重
- 多了一个bool类型的数组used,用来判断元素是在树层还是树枝。即当candidates[i] == candidates[i - 1]时,判断candidates[i]是在树层还是树枝上:
- used[i - 1] == true,说明同一树枝 candidates[i - 1]使用过,树枝可以重复
- used[i - 1] == false,说明同一树层 candidates[i - 1]使用过,同一树层元素不能重复,要去重
- 三种去重方式,使用标记数组去重,使用下标索引去重,使用set去重
- 使用sum和使用target两种方式找到符合条件的结果,适用于39、216题
-
对于组合问题,什么时候需要startIndex?
- 如果是一个集合来求组合的话,就需要startIndex,例如:77.组合,216.组合总和Ⅲ,39.组合总和,40.组合总和Ⅱ
- 如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17.电话号码的字母组合