代码随想录算法训练营第十二天|今天的回溯味儿有点冲

Leecode 40. 组合总和 II

链接:https://leetcode.cn/problems/combination-sum-ii/description/

刚开始做这道题真的遇到过很多问题:本质上是对递归流程的不熟悉

事实上不熟悉递归流程玩儿技巧都是耍流氓

并且,这道题真正有价值的地方就是我们发现问题,然后去debug的过程。通过这个过程我们能学到很多东西

而不是直接看答案

首先看最基础版本的代码

class Solution {
public:
    vector<vector<int>> res;
    vector<int> vec;
    // 题目规定了每个元素就只能用一次,所以我们一定要加上step
    // 并且每次递归的时候都从当前元素的下一个元素开始搜
    void recursion(vector<int>& candidates,int tar,int sum,int step)
    {
        if(sum > tar) return;
        if(sum == tar) 
        {
            res.push_back(vec);
            return;
        }
        for(int i=step;i<candidates.size();i++) 
        {
            vec.push_back(candidates[i]); 
            recursion(candidates,tar,sum + candidates[i],i+1);
            vec.pop_back();
        }
    }
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(),candidates.end());
        recursion(candidates,target,0,0);
        return res;
    }
};
// candidates = [10,1,2,7,6,1,5]
// sort ->    = [1,1,2,5,6,7,10]
// target = 8
// 预期结果 = [[1,1,6],[1,2,5],[1,7],[2,6]]
// 输出    = [[1,1,6],[1,2,5],[1,7],[1,2,5],[1,7],[2,6]]

第二个[1,2,5]出现的原因显然就是因为我们将vec中的第一个元素1替换成了第二个1,所以也没有再出现[1,1,6]嘛

那么我们为什么不采用set去重呢?这是最直接的想法

然后看set改进后的代码

class Solution {
public:
    set<vector<int>> res;
    vector<int> vec;
    void recursion(vector<int>& candidates,int tar,int sum,int step)
    {
        if(sum > tar) return;
        if(sum == tar) 
        {
            res.insert(vec);
            return;
        }
        for(int i=step;i<candidates.size();i++) 
        {
            vec.push_back(candidates[i]);
            recursion(candidates,tar,sum + candidates[i],i + 1);
            vec.pop_back();
        }
    }
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(),candidates.end());
        recursion(candidates,target,0,0);
        vector<vector<int>> out (res.begin(),res.end());
        return out;
    }
};
// 有超时的情况
// candidates =[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
// target = 30

熟悉递归流程的应该很容易理解为什么超时:当我们递归到第30层的时候,此时的sum就刚好等于target,然后return到29层,29层的for循环+1,递归到31层,31层又return···直到第29层的for循环耗尽,再return到第28层,28层的for循环+1···

所以我们需要想一个办法让后面无效的1都pass

class Solution {
public:
    vector<vector<int>> res;
    vector<int> vec; // 我们重新将set换成vector,因为不需要set去重了
    void recursion(vector<int>& candidates,int tar,int sum,int step)
    {
        if(sum > tar) return;
        if(sum == tar) 
        {
            res.push_back(vec);
            return;
        }
        for(int i=step;i<candidates.size();i++)
        {
            vec.push_back(candidates[i]); 
            recursion(candidates,tar,sum + candidates[i],i + 1);
            while((i + 1)< candidates.size() && candidates[i+1] == vec[vec.size()-1]) i++;
            // 如果pop之前发现下一个要加入的元素和即将pop的元素相同,我们就一直让i++
            vec.pop_back();
        }
    }
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(),candidates.end());
        recursion(candidates,target,0,0);
        return res;
    }
};

实际上,上面加入的这段代码也能处理初代版本中两个[1,2,5]的情况:

加入第一个1之后我们通过for循环遍历了整个数组,得到了三种符合题意的情况[[1,1,6],[1,2,5],[1,7]],那么此时以元素1为开头的数组就再没有符合题意的情况了,for循环遍历完毕之后就要把第一个元素1给pop出去

注意到了吗,此时sort后的target = [1,1,2,5,6,7,10]中第二个元素也是1,我们利用上面加入的while语句让i直接变成3从而跳过了第2个1,从而避免了重复···

Leecode131. 分割回文串

链接:https://leetcode.cn/problems/palindrome-partitioning/

还算是简单,若是回文串就切分,若不是就接着continue

除此之外我们还要写一个判断是否是回文的函数

// 总是有这种比较抽象的题目
// 先写一个判断回文的函数吧
class Solution {
public:
    vector<vector<string>> res;
    vector<string> vec;
    bool isPalindrome(const string &s,int l,int r)
    {
        if(!s.size()) return false;
        if(s.size() == 1) return true;
        while(l < r)
        {
            if(s[l]!=s[r]) return false;
            l++;
            r--;
        }
        return true;
    }
    void recursion(const string &s,int step)
    {
        if(step >= s.size())
        {
            res.push_back(vec);
            return;
        }
        // 我们不是枚举每次子串的长度,而是枚举位置看当前串是不是回文串
        for(int i = step;i < s.size();i++)
        {
            if(isPalindrome(s,step,i))
            {
                string str = s.substr(step,i - step + 1); // 因为substr函数右侧是开区间
                vec.push_back(str);
            }
            else continue;
            // 此时我们让step = i + 1
            recursion(s,i + 1);
            vec.pop_back();
        }
    }
    vector<vector<string>> partition(string s) {
        // 然后我们在这个函数中调用递归函数
        recursion(s,0); // 我们首先从第0个元素开始
        return res;
    }
};

Leecode 93. 复原 IP 地址

一道蛮艰辛的题目,从耗时8ms一直优化到耗时0ms

先来看初代方案:

class Solution {
public:
    // 不能有前导0那么遇见0就只能输出
    // 如果子串没有结束或者是目前的数字不够四个,就不记录
    // 结束条件是什么?子串结束了,或者是vector中已经有超过四个元素了,我们直接return
    vector<string> res;
    vector<string> vec;
    void recursion(string &s,int step)
    {
        if(step > s.size() || vec.size() > 4) return;
        // 如果当前索引已经走到子串末尾了
        if(step == s.size()) 
        {
            // 并且次数子串中刚好存储了4个字符串
            if(vec.size() == 4) 
            {
                // 用res记录当前结果
                string str;
                for(int i=0;i<3;i++) {str += vec[i];str += '.';}
                str += vec[3];
                res.push_back(str);
            }
            return;
        }
        // 接下来我们要去搜索合法的数字
        for(int i=step;i<s.size();i++)
        {
            // 一共定义了两个string
            // start_char表示这层递归循环刚开始时的首字符
            // is_zero表示此次push进vector中的串的首字符
            // 一般而言我们判断is_zero是不是0就OK
            // 但是有一种特殊情况是start_char是1,但是回溯回来由于下一个元素是0,此时is_zero是0,因此下一次回溯会push进0而不是10
            // 所以只有同时满足二者都为0的情况下才push进去0
            string is_zero = s.substr(i,i + 1);
            string start_char; if(step + 1 < s.size()) string start_char = s.substr(step,step+1);

            if(is_zero == "0" && start_char == "0") 
            {
                vec.push_back(is_zero);
                // 我们把0给push进去之后
                recursion(s,i + 1);
                vec.pop_back();
            }
            else 
            {
                string str = s.substr(step,i - step + 1);
                if(str[0] == '0' && str!="0") return; 
                int number = atoi(str.c_str());
                if(number <= 255) 
                {
                    vec.push_back(str);
                    recursion(s,i + 1);
                    vec.pop_back();
                }
                else return;
            }
        }
    }
    vector<string> restoreIpAddresses(string s) 
    {
        recursion(s,0);
        return res;
    }
};

第一种方案我在遇到0之后会直接push进去,但是由于回溯这里的处理逻辑就会比较复杂,必须同时满足“当前step处的值是0”和“当前选定子串的第一个字符是0”才会把单独的0给push进去

这里举一个反例,为什么不能只满足“当前选定子串的第一个字符是0”就把单独的0给push进去

"010010"
输出
["0.10.0.0","0.100.1.0"]
预期结果
["0.10.0.10","0.100.1.0"]

显然输出结果中的子串并不完整。奇怪,我们不是判断了只有在当前step等于子串末尾的时候才会push进去元素吗?其实是在回溯的时候出现了问题:元素1push进去后又把0给push进去,此时vec中的元素个数大于4了,所以pop出去0,然后return到元素1所在的第五层递归中,for 中i + 1,此时的元素是6,如果我们仅仅判断了s.substr(i,i + 1),这是0,所以就会把0给单独push进去,而不是10,错就错在这了

所以我们需要同时满足is_zero和start_char同时为0,才会把单独的0给push到vec中去

然后我们来看第二种写法:

第二种写法就是把判断都集中到一个函数中去,因为边写递归函数边判断实在是有些冗余并且复杂

class Solution {
public:
    vector<string> vec;
    vector<string> res;
    // 换一种写法吧,专门设计一个函数用来判断当前值是不是合法
    bool is_legitimate(string s)
    {
        int l = 0,r = s.size() - 1;
        // 其实不需要只要遇见 0 就将其push进去,只要判断当前切分的子串合不合法就完事了
        // 首先判断当前子串的第一个字符是不是0并且只有0
        if(s[l] == '0' && r > l) return false;
        // 然后判断当前值转化为数字后是不是位于0 - 255之间
        // 注意atoi函数遇到非数字字符会直接停止,所以若是遇见非法字符,是无法判断当前切割是不是合法的,幸好s仅由数字构成
        int num = atoi(s.c_str()); // 错误数据直接爆int了
        if(num > 255 || num < 0) return false;

        return true;
    }
    void recursion(string &s,int step)
    {
        // 若是当前扫描范围大于等于s.size(),那直接就是不合法的情况
        if(step > s.size()) return;
        if(step == s.size())
        {
            if(vec.size() != 4) return;
            string str;
            for(int i=0;i<3;i++) {str += vec[i];str += '.';}
            str += vec[3];
            res.push_back(str);
        }
        for(int i=step;i<s.size();i++)
        {
            string str = s.substr(step,i - step + 1);
            if(is_legitimate(str))
            {
                vec.push_back(str);
                recursion(s,i + 1);
                vec.pop_back();
            }
            // 考虑一下,当前不合法,后序还会有合法的可能性吗
        }
    }
    vector<string> restoreIpAddresses(string s) 
    {
        recursion(s,0);
        return res;
    }
};

有个细节,我们在is_legitimate函数中判断当前字符串是不是合法的时候,直接用atoi将字符串转化为int,但是有可能需要转化的串远远超过int范围,而溢出之后再判断是不是比255大就不合适了,所以我们开始的时候就判断这个串的长度是不是大于等于4,若是,那么一定非法,直接return

但是还是不够快,所以后续我们加上了三个剪枝操作

class Solution {
public:
    vector<string> vec;
    vector<string> res;
    bool is_legitimate(string s)
    {
        if(s.size() > 3) return false; 
        int l = 0,r = s.size() - 1;
        if(s[l] == '0' && r > l) return false;
        long long num = atoi(s.c_str());
        if(num > 255) return false;

        return true;
    }
    void recursion(string &s,int step)
    {
        if(step > s.size()) return;
        // 再加上一个判断来剪枝,因为正确结果肯定是包含全部数字的 -- 剪枝1
        if(vec.size() == 3) if(!is_legitimate(s.substr(step,s.size()))) return; // 判断后面的子串是不是合法,不合法直接return

        if(step == s.size())
        {
            if(vec.size() != 4) return;
            string str;
            for(int i=0;i<3;i++) {str += vec[i];str += '.';}
            str += vec[3];
            res.push_back(str);
        }

        for(int i=step;i<s.size();i++)
        {
            string str = s.substr(step,i - step + 1);
            if(is_legitimate(str))
            {
                vec.push_back(str);
                recursion(s,i + 1);
                vec.pop_back();
            }
            else return; // 剪枝2
            // 考虑一下,当前不合法,后序还会有合法的可能性吗
        }
    }
    vector<string> restoreIpAddresses(string s) 
    {
        if(s.size() < 4 || s.size() > 12) return {}; // 剪枝3
        recursion(s,0);
        return res;
    }
};

剪枝操作解释:

  1. 第一个剪枝是最有效的:因为符合题意的结果一定是包含所有字符的四个串,因此我们在push进去三个子串后直接判断后序的字符组成的串它是不是合法的,若不是合法的直接return
  2. 第二个剪枝是在push的时候发生的,若当前子串不合法,那么一定是有前导0或者是值大于255了,那么接着continue让子串边长还会有合法的可能性吗?显然没有,所以else return。这里一定不要直接写return,因为会有回溯,回溯回来肯定不能再return了吧
  3. 第三个剪枝就是在restoreIpAddresses函数中输入调用递归函数之前,若是判断当前的给的s一定不能组成一个合法的ip地址,就直接return false

Leecode 78. 子集

链接:https://leetcode.cn/problems/subsets/

第一种方法就是枚举当前子串的长度

class Solution {
public:
    // 我的理解其实是for循环中嵌套递归
    // 就是从1一直枚举到字符串的长度,分别去搜索答案
    // 稍微简化一点的写法就是先把空集和全集给push进去,然后push其他长度的子集
    vector<vector<int>> res;
    vector<int> vec;
    void recursion(vector<int>& nums,int k,int step)
    {
        // 元素收集满了,记录当前vec然后return
        if(vec.size() == k)
        {
            res.push_back(vec);
            return;
        }
        for(int i = step;i<nums.size();i++)
        {
            vec.push_back(nums[i]);
            recursion(nums,k,i + 1); // 是取搜下一个元素了
            vec.pop_back();
        }
    }
    vector<vector<int>> subsets(vector<int>& nums) 
    {
        // 首先吧空集和全集都给push进去
        res.push_back({});
        res.push_back(nums);
        // 依次枚举子串的长度
        for(int i = 1; i< nums.size() ;i++) 
        {
            vec.clear();
            recursion(nums,i,0); // 搜i个元素,从第0位开始搜
        }
        return res;
    }
};

其实我们在搜索的过程中,就出现了全部的子集···

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()) { // 终止条件可以不加
            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;
    }
};

真的是需要好好总结的啊

你可能感兴趣的:(每日打卡,算法,leetcode,深度优先)