《代码随想录》专题:回溯算法


  • 母题清单

    • 77. 组合(使用回溯算法,考虑使用剪枝操作)
    • 17.电话号码的字母组合(把数字换成了字母,原理是一样的,但这道题无法进行剪枝操作,这道题目可以讲回溯算法理解更加透彻)
    • 39. 组合总和(可使用多个重复的元素,注意如果要进行剪枝操作则需先进行先对原始数组进行排序)
    • 40.组合总和II(去重是本道题的难点,需要结合题解勤加思考)
    • 131. 分割回文串(分割可以看做组合的变体,注意回文串的判断方法)
      • 93. 复原 IP 地址(子题,注意条件限制,题解里写的不好,采用自己的写法)
    • 78. 子集(很好写,不赘述)
      • 90. 子集 II(子题,比母题多了个去重操作,会去重那么这道题会很简单)
    • 491.递增子序列(使用了新的去重逻辑)
    • 46. 全排列(注意树层遍历范围以及树枝去重方式)
  • 解题时可以🧠里把题目想象成一个树形结构,题目就很好做了,并且不容易出错。


0、金口玉言

  • 回溯法抽象为树形结构后,其遍历过程就是:for循环横向遍历,递归纵向遍历,回溯不断调整结果集

1、组合问题

  • 题目链接:77. 组合

  • 题解

    • 把组合问题抽象为如下树形结构
      在这里插入图片描述
      图中每次搜索到了叶子节点,我们就找到了一个结果。相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
    • 最难理解的是单层搜索的过程
      回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出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 {
    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) {
            backtracking(n, k, 1);
            return result;
        }
    };
    
    • 时间复杂度: O(n * 2^n)
    • 空间复杂度: O(n)
  • 给出回溯算法模板

    void backtracking(参数) {
        if (终止条件) {
            存放结果;
            return;
        }
    
        for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
            处理节点;
            backtracking(路径,选择列表); // 递归
            回溯,撤销处理结果
        }
    }
    
  • 这道题目还可以在进行优化,也就是剪枝操作。举一个例子,当n = 4k = 4时,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了。
    在这里插入图片描述所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。接下来看一下优化过程如下:

    • 已经选择的元素个数:path.size();
    • 所需要的元素个数为: k - path.size();
    • 列表中可提供的剩余元素个数:n-i+1; 为什么要+1?举个例子,n=4i=2,此时列表剩余的元素为[2,3,4],也就是4-2+1 = 3
    • 我们需要满足 列表中可提供的剩余元素个数>=所需要的元素个数 ,也就是 n-i+1 >= k - path.size(),最后化简得到 i <= n - (k - path.size()) + 1

    最后,优化后的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) {
            backtracking(n, k, 1);
            return result;
        }
    };
    

2、组合问题II

  • 题目链接:40.组合总和II

  • 题解:

    • 此题需要加一个bool型数组used,用来记录同一树枝上的元素是否使用过。如果candidates[i] == candidates[i - 1] 并且used[i - 1] == false,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]。注意在递归之前需要先对数组进行排序操作,注意vector的初始化方法。
    • 图解
      在这里插入图片描述
  • 代码如下,注意如果不去重则直接出现错误结果。

    class Solution {
    public:
        vector<vector<int>> result;
        vector<int> path;
        int sum = 0;
        void backTracking(vector<int> candidates,int target,int startIndex,vector<bool> used){
            if(sum > target){
                return;
            }
    
            if(sum == target){
                result.push_back(path);
                return;
            }
            
            for(int i=startIndex;i<candidates.size();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,i+1,used);
                used[i] = false;
                path.pop_back();
                sum -= candidates[i];
            }
        }
    
        vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
            vector<bool> used(candidates.size(),false);
            // 首先把给candidates排序,让其相同的元素都挨在一起。
            sort(candidates.begin(),candidates.end());
            backTracking(candidates,target,0,used);
            return result;
    
        }
    };
    

3、分割问题

  • 题目链接:131. 分割回文串

  • 题解

    • 分割问题其实类似于组合问题,[startIndex,i]其实就是这里的子串范围,注意学习string类型的子串生成方法,以及回文串判断方法。
    • 图解
      在这里插入图片描述
  • 代码如下

    class Solution {
    public:
        vector<vector<string>> result;
        vector<string> path;
        bool isPalindorme(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;
        }
        void backTracking(string s,int startIndex){
            if(startIndex >= s.size()){
                result.push_back(path);
                return;
            }
    
            for(int i=startIndex;i<s.size();i++){
                //如果是回文串
                if(isPalindorme(s,startIndex,i)){
                    //添加子串到结果集
                    path.push_back(s.substr(startIndex,i-startIndex+1));
                }else{
                    continue;
                }
                backTracking(s,i+1);
                path.pop_back();
            }
    
        }    
    
        vector<vector<string>> partition(string s) {
            backTracking(s,0);
            //cout << isPalindorme(s,0,0) << endl;
            return result;
        }
    };
    

4、复原 IP 地址

  • 题目链接:93. 复原 IP 地址
  • 题解
    • 这道题目有很多限制条件,但大体上和上面讲的分割问题是差不多的,注意采用自己的书写方式,这道题的题解不适合自己。下面的图解没有列举出前导0的情况,需要自行添加判断条件。
    • 图解
      在这里插入图片描述
  • 代码如下
    class Solution {
    public:
        vector<string> path;
        vector<string> result;
        void backTracking(string s,int startIndex){
            if(path.size()==4){
                //还未搜索玩整个数字字符串,直接退出
                if(startIndex < s.size()){
                    return;
                }
                //符合规定的字符串整合成一个IP地址,加入到结果集中
                string temp = path[0]+"."+path[1]+"."+path[2]+"."+path[3];
                result.push_back(temp);
                return;
            }
            //每次最多挑选三位数字
            for(int i=startIndex;i<s.size() && i-startIndex<3;i++){
                //如果二位数或三位数的首位为0 或者 三位数加起来超过255 则不符合规定,直接结束递归
                if(i-startIndex>=1 && s[startIndex]=='0' || (i-startIndex==2 && (s[startIndex]-'0')*100+(s[startIndex+1]-'0')*10+(s[startIndex+2]-'0')>255)  ){
                    break;
                }
                path.push_back(s.substr(startIndex,i-startIndex+1));
                backTracking(s,i+1);
                path.pop_back();
            }
        }
    
        vector<string> restoreIpAddresses(string s) {
            //减去枝叶
            if (s.size() < 4 || s.size() > 12) return result;
            backTracking(s,0);
            return result;
        }
    };
    

5、子集

  • 题目链接:78. 子集

  • 题解:

    • 题目很简单,跟组合非常类似,不赘述。
    • 图解:
      在这里插入图片描述
  • 代码如下:

    class Solution {
    public:
        vector<vector<int>> result;
        vector<int> path;
        void backTracking(vector<int> nums,int startIndex){
            // 题解中直接把收集子集的操作放这样,这样就不用在主函数开头添加空元素了
            if(startIndex >= nums.size()){
                return;
            }
    
            for(int i=startIndex;i<nums.size();i++){
                path.push_back(nums[i]);
                result.push_back(path);// 收集子集
                backTracking(nums,i+1);
                path.pop_back();
            }
        }
    
        vector<vector<int>> subsets(vector<int>& nums) {
            result.push_back(path);// 把空元素先加入到结果集中去
            backTracking(nums,0);
            return result;
        }
    };
    

6、递增子序列

  • 题目链接:491.递增子序列
  • 题解:
    • 这道题目难在去重逻辑上,之前在做去重之前需要先对数组进行排序,所以重合的元素一定会挨着,也就是说可以用candidates[i] == candidates[i - 1] && used[i - 1] == false这个逻辑即可,但是现在这个题目中是不能进行排序的,所以需要使用其他的去重逻辑,也就是unordered_set<int> uset;,下文会提及到。
    • 图解:
      在这里插入图片描述
  • 代码如下:
    class Solution {
    private:
        vector<vector<int>> result;
        vector<int> path;
        void backtracking(vector<int>& nums, int startIndex) {
            if (path.size() > 1) {
                result.push_back(path);
                // 注意这里不要加return,要取树上的节点
            }
            unordered_set<int> uset; // 使用set对本层元素进行去重
            for (int i = startIndex; i < nums.size(); i++) {
                if ((!path.empty() && nums[i] < path.back())
                        || uset.find(nums[i]) != uset.end()) {
                        continue;
                }
                uset.insert(nums[i]); // 记录这个元素在本层用过了,本层后面不能再用了
                path.push_back(nums[i]);
                backtracking(nums, i + 1);
                path.pop_back();
            }
        }
    public:
        vector<vector<int>> findSubsequences(vector<int>& nums) {
            result.clear();
            path.clear();
            backtracking(nums, 0);
            return result;
        }
    };
    
    • 其实也可以使用数组来存放重复元素,这样效率会比unordered_set高,因为unordered_set底层实现是哈希表。

7、全排列

  • 题目链接:46. 全排列

  • 题解:

    • 每次遍历的范围和之前不一样,之前都有startIndex,而现在每次都是从下标0开始遍历,遇到重复的元素就暂时跳过。注意这里的去重逻辑是used[i]==true,这样某个祖先节点中遍历过的话就能发现并且避免掉。
    • 图解:
      在这里插入图片描述
  • 代码如下:

    class Solution {
    public:
        vector<vector<int>> result;
        vector<int> path;
        void backtracking (vector<int>& nums, vector<bool>& used) {
            // 此时说明找到了一组
            if (path.size() == nums.size()) {
                result.push_back(path);
                return;
            }
            for (int i = 0; i < nums.size(); i++) {
                if (used[i] == true) continue; // path里已经收录的元素,直接跳过
                used[i] = true;
                path.push_back(nums[i]);
                backtracking(nums, used);
                path.pop_back();
                used[i] = false;
            }
        }
        vector<vector<int>> permute(vector<int>& nums) {
            result.clear();
            path.clear();
            vector<bool> used(nums.size(), false);
            backtracking(nums, used);
            return result;
        }
    };
    
  • 我个人也有不一样的代码,就是每次遍历过了直接将元素删除掉,这样下一次遍历肯定不会再有上一次已经遍历过的元素。代码如下:

    class Solution {
    public:
        vector<vector<int> > result;
        vector<int> path;
        void backTracking(vector<int> nums,int size){
            if(path.size() == size){
                result.push_back(path);
                return;
            }
            for(auto it = nums.begin();it != nums.end();it++){
                int num = *it;
                path.push_back(num);
                nums.erase(it);
                backTracking(nums,size);
                nums.insert(it,num);
                path.pop_back();
            }
    
        }
        vector<vector<int>> permute(vector<int>& nums) {
            int size = nums.size();
            backTracking(nums,size);
            return result;
        }
    };
    
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Elec Liu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值