文章目录
一、引言
许多情况下,回溯算法相当于暴力搜索的方式 进行实现,性能一般不理想。但是对于某些问题,就算采用最复杂的时间复杂度能求得结果,也是一种里程碑式的进步。
回溯算法一个实际的具体例子就是在一套新房子内摆放家具的问题。
开始什么也不拜访,之后是每件家具被摆放到室内的某个部分,如果所有家具都摆好且都满意,则算法结束。
如果摆放了某一个家具之后,但是对于当前拜访的方式不理想,那么我们必须撤销这一步,尝试其他的摆放方式。如果我们一直撤销,直到撤销到第一个摆放的家具,那么不存在满意的家具摆放的方法;否则我们将在满意的摆放位置上结束算法。
在摆放过程中,直接不去考虑某些必然不满意的摆放方法,例如将沙发摆进厨房必然是不满意的摆放方法。这种直接不考虑不合理子集的方式就叫做 剪枝。
二、回溯法基本逻辑
回溯的原理就是采用递归的方式 ,将问题看作为解空间树的形式。注意是看作空间树的形式,具体的数据结构可能是列表、树、字符串等等
在解空间树中,按照深度优先的方式,从根结点出发进行搜索,搜索至任意结点时,先判断该结点是否包含问题的解①如果不包含,则向祖先节点回溯(即退出该层递归)②如果包含,则进入该子树,继续进行深度优先。
需要去重的题目有
子集Ⅱ、组合总和Ⅱ、全排列Ⅱ
去重的逻辑在子集Ⅱ中说明。
三、回溯法代码模板
// t表示当前递归深度
// n为树高,用来控制递归深度
// f(n,t)表示在当前扩展结点处未搜索过的子树的起始编号
// g(n,t)表示在当前扩展结点处未搜索过的子树的终止编号
// h(i)表示在当前扩展结点处x[t]的第i个可选值
// Constraint(t)表示在当前扩展节点处的约束函数
// Bound(t)表示在当前扩展节点处的界限函数
void backtracking(int t){
if(t>n){//表示搜索到了叶节点
outPut(x);//输出可行解x
}
else{
//
for(int i = f(n,t) ; i <= g(n,t) ; i++){
x[t] = h(i); // 收集结果
if(Constraint(t)&&Bound(t)){// 通过约束和界限进行剪枝操作,满足时才继续向下递归
backtracking(t+1);
}
}
}
}
三、回溯法常见问题
3.1 组合
力扣题库序号77
逻辑
以n = 4, k=2为例
代码
class Solution {
public:
// 最终返回结果列表
vector<vector<int>> rst;
// 每一次结果
vector<int> path;
// n: 数字1~n进行组合 k:组合列表的大小
vector<vector<int>> combine(int n, int k) {
backtracking(n,k,1);
return rst;
}
// 从[startIndex,n]中找到大小为k的排列
// 例如:backtracking(n,k,1) n: 数字1~n进行组合 k:组合列表的大小 1表示从第1个数开始
void backtracking(int n,int k,int startIndex){
// 如果当前路径大小等于组合列表的大小,表示收集到了叶节点,那么push入最后的结果
if(path.size()==k){
rst.push_back(path);
return;
}
// 从startIndex~n
for(int i=startIndex;i<n+1;i++){
// 每一层将i放入
path.push_back(i);
// 继续从递归(从[i+1,n]中找到剩余大小的排列)
backtracking(n,k,i+1);
// 退出该层时将i移出,就是回溯的过程
path.pop_back();
}
return ;
}
};
可以输出path,查看path收集数据的变化。
3.2 子集
力扣题库序号78
逻辑
组合和子集区别就是,子集不限制大小,而组合规定了大小。
表现在实现的代码中就是,子集需要在递归出口时收集结果,而组合需要在数据变换后就收集结果。
在代码实现时需要注意,本题的集合是已经给出的非连续的数字列表;而上一题的组合题是给出一个数字范围。
代码
class Solution {
public:
// 最终返回结果列表
vector<vector<int>> rst;
// 每一次结果
vector<int> path;
vector<vector<int>> subsets(vector<int>& nums) {
rst.push_back({});
backtracking(nums,0);
return rst;
}
void backtracking(vector<int> &nums,int startIndex){
for(int i = startIndex;i<nums.size();i++){
path.push_back(nums[i]);
// 在每一次数据变化时进行结果的收集。
rst.push_back(path);
// 继续从递归(i+1之后的组合)
backtracking(nums,i+1);
// 退出该层时将nums[i]移出,就是回溯的过程
path.pop_back();
}
return;
}
};
3.3 子集Ⅱ
力扣题库序号90
逻辑
去重的方法:
①首先对数组进行排序
②在同一层内,如果与上一项相同则不进行考虑,因为相同的第一项已经考虑了,第二项和第一项相同,那么之后构成的也都相同。
③如果i==startIndex表示重新调用的,代表新的一层(path的大小代表所处层数)
代码
class Solution {
public:
// 最终返回结果列表
vector<vector<int>> rst;
// 每一次结果
vector<int> path;
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
rst.push_back({});
sort(nums.begin(),nums.end());
backtracking(nums,0);
return rst;
}
void backtracking(vector<int> &nums,int startIndex){
for(int i = startIndex;i<nums.size();i++){
if(i==startIndex||nums[i]!=nums[i-1]){
path.push_back(nums[i]);
// 在每一次数据变化时进行结果的收集。
rst.push_back(path);
// 继续从递归(i+1之后的组合)
backtracking(nums,i+1);
// 退出该层时将nums[i]移出,就是回溯的过程
path.pop_back();
}
}
return;
}
};
3.4 分割回文串
力扣题库序号131
逻辑
这类题通常需要分析切割的逻辑
切割范围就是每一次的startIndex~i的范围,注意startIndex≠i,因为i是在不断变化的
剪枝的操作就是本次切割的范围产生的字符串并不是回文子串,那么本次切割不再考虑,直接考虑i+1后startIndex~i切割的字符串。
例如aabb,
第一次切割0 ~ 1 =>“a” | 第二次切割1 ~ 2 => “a” | 第三次切割2 ~ 3 => “b” | 第四次切割3 ~ 4 => “b” | 产生【“a”,“a”,“b”,“b”】 |
- | 第二次切割1 ~ 3 => “ab” “ab”不是回文,之后不管怎么切都不可能符合要求,因此之后的分支就不再考虑,这就是剪枝操作 | |||
- | 第二次切割1 ~ 4 => “abb” "abb"不是回文,之后不管怎么切都不可能符合要求,剪枝。 | |||
第一次切割0 ~ 2 => “aa” | 第二次切割2 ~ 3 => “b” | 第三次切割3 ~ 4 => “b” | 产生【“aa”,“b”,“b”】 | |
第一次切割0 ~ 3 => “aab” "aab"不是回文,之后不管怎么切都不可能符合要求,剪枝 | ||||
第一次切割0-4 => “aabb” | 产生【“aabb”】 |
手绘以aab为例
代码
class Solution {
public:
// 最终返回结果列表
vector<vector<string>> rst;
// 每一次结果
vector<string> path;
vector<vector<string>> partition(string s) {
backtracking(s,0);
return rst;
}
void backtracking(string s, int startIndex){
// 递归出口:割到最后一位,收集结果
if(startIndex>=s.size()){
rst.push_back(path);
}
for(int i=startIndex;i<s.size();i++){
string a;
// a = s[startIndex~i]
for(int k=startIndex;k<i+1;k++){
a += s[k];
}
// 剪枝操作,如果是回文串才加入并且向后切割,否则不再向下递归
if(isCycle(a)){
// 将已经切的回文字符串加入结果,并且递归切割从i之后开始的字符串
path.push_back(a);
backtracking(s,i+1);
// 退出该层时将a移出,就是回溯的过程
path.pop_back();
}
}
}
// 判断是否回文
bool isCycle(string s){
for(int i=0;i<s.size()/2;i++){
if(s[i]!=s[s.size()-i-1])return false;
}
return true;
}
};
3.5 组合总和Ⅰ
逻辑
代码
class Solution {
public:
// 最终返回结果列表
vector<vector<string>> rst;
// 每一次结果
vector<string> path;
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates, target, 0);
return rst;
}
// candidates 从 startIndex开始 寻找总和为 target 的组合
void backtracking(vector<int>& candidates, int target, int startIndex){
// 如果要求总和为0,代表path内的数字已经满足总和
if(target==0){
rst.push_back(path);
return;
}else{
for(int i=startIndex;i<candidates.size();i++){
// 剪枝操作:当candidate[i] <= target时才满足,
// 否则代表本次组合必然不符合target, 因为没有candidate中的数均为正数
if(target>=candidates[i]){
path.push_back(candidates[i]);
// 因为可以重复选取,因此还是从i开始;如果不能重复,则从i+1开始。
backtracking(candidates,target-candidates[i],i);
path.pop_back();
}
}
}
}
};
3.6 组合总和Ⅱ
逻辑
本题同样需要去重,去重的逻辑与子集Ⅱ相同。
需要注意的是当目标值为0是,代表结果收集阶段。
循环时需要判断只有当目标值大于当前项时,才继续操作。
代码
class Solution {
public:
vector<vector<int>> rst;
vector<int> path;
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end());
backtracking(candidates,target,0);
return rst;
}
void backtracking(vector<int>& candidates, int target, int startIndex){
if(target==0){
rst.push_back(path);
return;
}else{
for(int i = startIndex;i<candidates.size()&&candidates[i]<=target;i++){
if(i==startIndex||candidates[i]!=candidates[i-1]){
path.push_back(candidates[i]);
backtracking(candidates,target-candidates[i],i+1);
path.pop_back();
}
}
}
}
};
3.7 组合总和Ⅲ
逻辑
代码
class Solution {
public:
vector<vector<int>> rst;
vector<int> path;
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(k, n ,1);
return rst;
}
void backtracking(int k, int n ,int startIndex){
if(n==0&&k==0){
rst.push_back(path);
return;
}
if(n!=0&&k!=0){
for(int i=startIndex;i<10;i++){
if(n>=i){
path.push_back(i);
backtracking(k-1, n-i,i+1);
path.pop_back();
}
}
}
}
};
3.8 全排列Ⅰ
逻辑
选择没有选取过的元素作为path数组,当path数组大小等于nums的大小的时候进行结果收集。
因此思路就是建立used数组标记是否使用过nums中的某个元素。
代码
// 通过used标记使用过的元素
class Solution {
public:
// 最终返回结果列表
vector<vector<int>> rst;
// 每一次结果
vector<int> path;
vector<vector<int>> permute(vector<int>& nums) {
// 标记nums中使用过的元素
vector<bool> used(nums.size(),false);
backtracking(nums,used);
return rst;
}
void backtracking(vector<int>& nums, vector<bool> used){
// 当path数组大小==nums数组大小的时候,表示元素已经被全部使用
if(path.size()==nums.size()){
rst.push_back(path);
return ;
}else{
for(int i=0;i<nums.size();i++){
// 如果没有被使用,则加入path数组,并且对used进行标记
if(used[i]==false){
path.push_back(nums[i]);
used[i] = true;
// 向下递归
backtracking(nums,used);
// 回溯
used[i] = false;
path.pop_back();
}
}
}
}
};
优化
本题的思路就是如何判断某元素是否被使用,因此可以通过交换元素在nums中的位置来判断元素是否被使用
nums中靠前的元素被使用,靠后的元素没有被使用,被使用元素的个数就是path数组的大小。
因此backtracking调用时,startIndex使用当前path数组的大小。
代码
// 将使用过的元素放置
class Solution {
public:
// 最终返回结果列表
vector<vector<int>> rst;
// 每一次结果
vector<int> path;
vector<vector<int>> permute(vector<int>& nums) {
backtracking(nums,0);
return rst;
}
void backtracking(vector<int>& nums, int startIndex){
if(startIndex==nums.size()){
rst.push_back(path);
return ;
}else{
for(int i=startIndex;i<nums.size();i++){
// 将使用过的nums[i]前置
path.push_back(nums[i]);
swap(nums[startIndex],nums[i]);
backtracking(nums,path.size());
// 回溯:移出元素并且恢复原来的元素位置关系
swap(nums[startIndex],nums[i]);
path.pop_back();
}
}
}
};
优化
继续优化,backtracking被调用时其startIndex均为调用函数的startIndex+1,
而最后只需要startIndex指向最后一位时,就可以进行元素收集,因为nums是进行交换产生的,那么直接收集当前nums的元素就可以。
代码
class Solution {
public:
vector<vector<int>> rst;
vector<vector<int>> permute(vector<int>& nums) {
backtracking(nums,0);
return rst;
}
void backtracking(vector<int>& nums, int startIndex){
// 交换至最后一位进行结果的收集
if(startIndex==nums.size()){
rst.push_back(nums);
return ;
}else{
for(int i=startIndex;i<nums.size();i++){
swap(nums[startIndex],nums[i]);
backtracking(nums,startIndex+1);// 被调用的startIndex参数 = 调用函数的startIndex+1
swap(nums[startIndex],nums[i]);
}
}
}
};
3.9 全排列Ⅱ
逻辑
因为本题包含重复数字,不同于全排列Ⅰ的是需要进行去重。因此就不能采用交换的方式进行优化,只能通过used进行操作。
在去重的时候同样与子集Ⅱ类似。
如果i>0,代表是当前层的第二个分支之后的某个分支,如果与上一项相同则不进行考虑。因为相同的第一项已经考虑了,第二项和第一项相同,那么之后构成的也都相同。但是如果used[i-1]==1,代表在递归到本层前已经被使用,因此产生的条件仍然符合条件。
即:同层不能使用相同的,不同层可以使用相同的。
代码
class Solution {
public:
vector<vector<int>> rst;
vector<int> path;
vector<vector<int>> permuteUnique(vector<int>& nums) {
sort(nums.begin(),nums.end());
vector<bool> used(nums.size(),false);
backtracking(nums,used);
return rst;
}
void backtracking(vector<int>& nums, vector<bool> used){
if(path.size() == nums.size()){
rst.emplace_back(path);
return;
}
for(int i = 0; i < nums.size(); i++){
if(used[i] == 1)continue;
if(i > 0 && nums[i] == nums[i - 1] && !used[i - 1]){
continue;
}
path.emplace_back(nums[i]);
used[i] = true;
backtracking(nums,used);
path.pop_back();
used[i] = false;
}
}
};
3.10 解数独(未解答)
力扣题库序号37
逻辑
代码
3.11 N皇后Ⅰ
逻辑
以4皇后举例
1、
如果第一行选取的是第一个格子,
那么第二行必然不能选取的,由两部分构成 ①第一个格子 (同一列)②第二个格子(第一行对角线上的);假设第二行选取第三个格子
那么第三行必然不能选取的,由四部分构成 ①第一个格子(与第一行同一列)②第三个格子(与第二行同一列)③第三个格子(第一行产生的对角线)④第二个格子、第四个格子(第二行产生的对角线)。从中可以知道1、2、3、4都不能选择,直接进行剪枝操作。
2、
如果第一行选取的是第二个格子,
那么第二行必然不能选取的,由两部分构成 ①第二个格子(同一列)②第一个格子、第三个格子(第一行对角线上的);第二行只能选取第四个格子
那么第三行必然不能选取得,由四部分构成 ①第二个格子(与第一行同一列)②第四个格子(与第二行同一列)③第四个格子(第一行产生的对角线)④第三个格子(第二行产生的对角线);那么第二行只能选取第一个格子
同样可以推断出,第四行只能选取第三个格子
最重要的就是如何判断某一行中的某一列是否可选,其实不可选的部分就是两部分 ①已经选区的列 ②已经选区的列产生的对角线。
- 第①个很容易,通过col数组记录已经选取的列,就像之前的used数组
- 第②个,也就是如何找到棋子的对角线,可以知道第1行第2列,在第二行产生的对角线是在第1 3 列,这个是怎么来的呢?其实就是通过 棋子列 + (当前行 - 棋子行) 以及 棋子列 - (当前行-棋子行) ,就产生了 2 + (2-1)= 3与2 - (2-1)= 1
同理在第三行产生的对角线是在第0列(不存在),第4列。
那么在进行第二个判断时,需要知道的参数有:已经选取的棋子、各棋子所在的行列、当前行高、当前列高
代码
class Solution {
public:
vector<vector<string>> rst;
vector<string> path;
vector<bool> block;
vector<vector<string>> solveNQueens(int n) {
if(n==1)return {{"Q"}};
vector<bool> col(n,false);
vector<short> height(n,0);
block.resize(n, false);
backtracking(col,height,0,n);
return rst;
}
// col 表示某列是否有棋子,height 表示棋子所在的行,k 表示当前所层,n 表示总高度
void backtracking(vector<bool>& col,vector<short>& height,int k,int n){
// 递归出口:选取完最后一行
if(k==n){
rst.emplace_back(path);
return;
}
for(int i=0;i<n;i++){
// 如果是不可选取部分,那么直接跳过本次
if(col[i]||!isValid(col,height,k,i,n)){
continue;
}
// 选取第i列格子
col[i] = true;
// 生成本次字符串
string str="";
for(int j=0;j<n;++j)
if(j==i)
str+="Q";
else
str+=".";
path.emplace_back(str);
// 第i列棋子的行高
height[i] = k;
backtracking(col,height, k+1,n);
// 回溯
path.pop_back();
col[i] = false;
}
}
// 查看第k行第j个数,是否在对角线上
bool isValid(vector<bool>& col,vector<short>& height,int k,int j,int n){
// 第k行各列被占用情况
vector<bool> block(n,0);
for(int i=0;i<n;++i){
// 如果第i列已经被选取,那么设置第k行对角线占用情况
if(col[i]){
// 当前行-棋子行
int gap = abs(k-height[i]);
// 如果没有超出棋盘右侧
if(i+gap<n)
block[i+gap] = 1;
// 如果没有超出棋盘左侧
if(i-gap>=0)
block[i-gap] = 1;
}
}
// j列是否被占用
return !block[j];
}
};
3.12 N皇后Ⅱ
逻辑
与N皇后Ⅰ完全全一样的思路,省去了收集string的过程,只需要结果满足时+1即可
代码
class Solution {
public:
int rst;
int totalNQueens(int n) {
vector<bool> col(n,0);
vector<short> height(n,0);
backtracking(col,height,0,n);
return rst;
}
void backtracking(vector<bool>& col, vector<short>& height, int k, int n){
if(k==n){
rst++;
return;
}
for(int i=0;i<n;i++){
if(col[i]||inValid(col,height,k,i,n))continue;
col[i] = 1;
height[i] = k;
backtracking(col,height,k+1,n);
col[i] = 0;
}
}
// 第i行、j列是否被占用
bool inValid(vector<bool>& col, vector<short>& height, int i, int j,int n){
int gap;
vector<int> block(n,0);
for(int k=0;k<n;k++){
if(col[k]){
gap = i - height[k];
if(k-gap>=0)block[k-gap]=1;
if(k+gap<n)block[k+gap]=1;
}
}
return block[j];
}
};