【算法进阶】回溯(backtracking)基本逻辑,以及常见回溯问题(全排列、解数独、八皇后)

一、引言

许多情况下,回溯算法相当于暴力搜索的方式 进行实现,性能一般不理想。但是对于某些问题,就算采用最复杂的时间复杂度能求得结果,也是一种里程碑式的进步。

回溯算法一个实际的具体例子就是在一套新房子内摆放家具的问题。
开始什么也不拜访,之后是每件家具被摆放到室内的某个部分,如果所有家具都摆好且都满意,则算法结束。
如果摆放了某一个家具之后,但是对于当前拜访的方式不理想,那么我们必须撤销这一步,尝试其他的摆放方式。如果我们一直撤销,直到撤销到第一个摆放的家具,那么不存在满意的家具摆放的方法;否则我们将在满意的摆放位置上结束算法。
在摆放过程中,直接不去考虑某些必然不满意的摆放方法,例如将沙发摆进厨房必然是不满意的摆放方法。这种直接不考虑不合理子集的方式就叫做 剪枝

二、回溯法基本逻辑

回溯的原理就是采用递归的方式 ,将问题看作为解空间树的形式。注意是看作空间树的形式,具体的数据结构可能是列表、树、字符串等等
在解空间树中,按照深度优先的方式,从根结点出发进行搜索,搜索至任意结点时,先判断该结点是否包含问题的解①如果不包含,则向祖先节点回溯(即退出该层递归)②如果包含,则进入该子树,继续进行深度优先。

需要去重的题目有
子集Ⅱ、组合总和Ⅱ、全排列Ⅱ
去重的逻辑在子集Ⅱ中说明。

三、回溯法代码模板

// 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 组合总和Ⅰ

力扣题库序号39

逻辑
代码
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 组合总和Ⅱ

力扣题库序号40

逻辑

本题同样需要去重,去重的逻辑与子集Ⅱ相同。
需要注意的是当目标值为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 组合总和Ⅲ

力扣题库序号216

逻辑
代码
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 全排列Ⅰ

力扣题库序号46

逻辑

选择没有选取过的元素作为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 全排列Ⅱ

力扣题库序号47

逻辑

因为本题包含重复数字,不同于全排列Ⅰ的是需要进行去重。因此就不能采用交换的方式进行优化,只能通过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皇后Ⅰ

力扣题库序号51

逻辑

以4皇后举例
1、
如果第一行选取的是第一个格子,
那么第二行必然不能选取的,由两部分构成 ①第一个格子 (同一列)②第二个格子(第一行对角线上的);假设第二行选取第三个格子
那么第三行必然不能选取的,由四部分构成 ①第一个格子(与第一行同一列)②第三个格子(与第二行同一列)③第三个格子(第一行产生的对角线)④第二个格子、第四个格子(第二行产生的对角线)。从中可以知道1、2、3、4都不能选择,直接进行剪枝操作。
在这里插入图片描述

2、
如果第一行选取的是第二个格子,
那么第二行必然不能选取的,由两部分构成 ①第二个格子(同一列)②第一个格子、第三个格子(第一行对角线上的);第二行只能选取第四个格子
那么第三行必然不能选取得,由四部分构成 ①第二个格子(与第一行同一列)②第四个格子(与第二行同一列)③第四个格子(第一行产生的对角线)④第三个格子(第二行产生的对角线);那么第二行只能选取第一个格子
同样可以推断出,第四行只能选取第三个格子
在这里插入图片描述
最重要的就是如何判断某一行中的某一列是否可选,其实不可选的部分就是两部分 ①已经选区的列 ②已经选区的列产生的对角线。

  1. 第①个很容易,通过col数组记录已经选取的列,就像之前的used数组
  2. 第②个,也就是如何找到棋子的对角线,可以知道第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皇后Ⅱ

力扣题库序号52

逻辑

与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];
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值