24.回溯(4) | 复原IP地址(h)、子集、子集II

        今天的第1题对分割用比较复杂的形式进行回顾,第2、3题开始涉及子集问题。其中第1道题做的不容易,要注意很多细节。后面2道题要注意分清楚排列、组合、分割、子集的区别。要特别注意在递归函数循环遍历时不要把下一层递归的begin实参i + 1错写成begin + 1。


        第1题(93. 复原IP地址)自己的实现思路是与day 27中的131. 分割回文串类似,在已知上一个分割点的情况下,在递归函数中循环遍历下一个可能的分割点。这样一来,两个分割点,或者说起始和末尾下标,就决定了当前段的内容。将符合要求的当前段内容加入path,再将当前段末尾的下标 + 1传入递归函数作为新的起始下标,就实现了回溯。不过这道题需要注意的细节很多:

  • 如果当前begin对应数字是0,要做特殊处理,将其放入path后进行回溯后直接return,因为以0开始的数字段只能是0本身,其他的不符合题目要求;
  • 在对path回溯时,因为path中还添加了一个或多个小数点,所以不能用.substr(0, begin)取,否则会错误地取太少;
  • 对当前段数值是否大于255判断时,应当提前一轮循环就进行判断,否则有可能出现上一轮值符合要求,但这一轮就大于255的情况;而提前一轮计算要注意避免越界;
  • 递归出口则是段数达到4,或者起始下标已经超过数组s范围。但只要前面两个条件同时满足的情况下,才是一种符合要求的分割方案,才能将path放入到res中;
  • 上面一条属于纵向的剪枝,而横向也是可以剪枝的。当s的剩余部分与当前path中段数量无法凑够4段,或凑成的段数至少大于4段时,就没有必要再进行;此外,由于符合要求的最大值是255,是3位数,所以在数字超过3位时必定是大于255的,也可剪枝,所以循环最多进行3次,具体进行的次数应该从3,和当前begin与末尾的距离中取较小值,避免越界。
class Solution {
public:
    string path;
    vector<string> res;
    void back(string& s, int begin, int cnt) {
        if (begin == s.size() || cnt == 4) {
            if (begin == s.size() && cnt == 4) {
                res.push_back(path.substr(0, path.size() - 1));
            }
            return;
        }
        if (s[begin] == '0') {
            path += "0.";
            back(s, begin + 1, cnt + 1);
            path = path.substr(0, path.size() - 2);
            return;
        }
        int num = s[begin] - '0';
        int endMax = min(begin + 3, (int)s.size());
        int lenRemain = s.size() - begin;
        for (int i = begin; 
            i < endMax && 
            num <= 255 && 
            cnt + lenRemain >= 4 && 
            cnt + ceil(lenRemain / 3) <= 4; 
            ++i) {
            int len = i - begin + 1;
            path += s.substr(begin, len);
            path += ".";
            back(s, i + 1, cnt + 1);
            path = path.substr(0, path.size() - (len + 1));
            if (i < endMax - 1) {
                num = 10 * num + s[i + 1] - '0';
                lenRemain = s.size() - (i + 1);
            }
        }
        return;
    }
    vector<string> restoreIpAddresses(string s) {
        path.clear();
        res.clear();
        back(s, 0, 0);
        return res;
    }
};

代码在计算上面最后一条中,凑成的段数是否至少大于4段的过程中,需要用到对计算结果向上取整的ceil()函数。ceil()与向下取整的floor()相对应。

        题解的解法整体上与自己的思路一致,实现上有几处不同:

  • 没有在回溯函数中判断当前段数字是否合法,而是抽象为了函数。这样做相对自己的方法少了横向剪枝,但对其他非数字的异常符号有了判断,并且将0开头的情况整合在其中。这一题中题目描述只会有数字,所以不对非数字内容进行判断也能AC;
  • 在对path添加或删除元素时,没有用“+”或string.substr(),而是用string.insert()string.erase(),且是对s直接进行操作,没有设立path;
  • 在主函数中,用
    if (s.size() < 4 || s.size() > 12) return res;

    进行了剪枝。

         结合了题解代码的优点后,重新写出的代码如下:

class Solution {
public:
    vector<string> res;
    void back(string& s, int begin, int cnt) {
        if (begin == s.size() || cnt == 4) {
            if (begin == s.size() && cnt == 4) {
                res.push_back(s.substr(0, s.size() - 1));
            }
            return;
        }
        if (s[begin] == '0') {
            s.insert(s.begin() + begin + 1, '.');
            back(s, begin + 2, cnt + 1);
            s.erase(s.begin() + begin + 1);
            return;
        }
        int num = s[begin] - '0';
        int endMax = min(begin + 3, (int)s.size());
        int lenRemain = s.size() - begin;
        for (int i = begin; 
            i < endMax && 
            num <= 255 && 
            cnt + lenRemain >= 4 && 
            cnt + ceil(lenRemain / 3) <= 4; 
            ++i) {
            s.insert(s.begin() + i + 1, '.');
            back(s, i + 2, cnt + 1);
            s.erase(s.begin() + i + 1);
            if (i < endMax - 1) {
                num = 10 * num + s[i + 1] - '0';
                lenRemain = s.size() - (i + 1);
            }
        }
        return;
    }
    vector<string> restoreIpAddresses(string s) {
        res.clear();
        if (s.size() < 4 || s.size() > 12) {
            return res;
        }
        back(s, 0, 0);
        return res;
    }
};

实现过程中还是遇到了些细节问题:

  • 第7行,在向res中添加s时,不能在这里对s进行erase(),否则向上回溯时后错误地多删;
  •  string.insert()string.erase()的用法还不熟悉;
  • 回溯时传入的新begin应该时i + 2,而不是i + 1,因为还插入了点符号;
  • 对于0的情况,传入的参数应该是begin + 2,而不是2。

        二刷:stoi()对应头文件是<string>,isdigit()和isalaph()对应头文件是<cctype>,floor()和ceil()对应头文件是<cmath>。二刷自己实现的代码方法更简单和容易理解:

#include<string>
#include<cctype>
class Solution {
public:
    vector<string> res;
    bool isOK(string s) {
        for (char c : s) {
            if (!isdigit(c)) {
                return false;
            }
        }
        if (s.size() > 1 && s[0] == '0') {
            return false;
        }
        int num = stoi(s);
        if (num < 0 || num > 255) {
            return false;
        }
        return true;
    }
    void back(string s, string path, int begin, int level) {
        if (level == 4) {
            if (begin == s.size()) {
                res.push_back(path.substr(0, path.size() - 1));
            }
            return;
        }
        for (int i = begin; i < s.size(); i++) {
            if (i >= begin + 3) {
                break;
            }
            string sub = s.substr(begin, i - begin + 1);
            if (!isOK(sub)) {
                break;
            }
            back(s, path + sub + ".", i + 1, level + 1);
        }
        return;
    }
    vector<string> restoreIpAddresses(string s) {
        res.clear();
        back(s, "", 0, 0);
        return res;
    }
};

        第2题(78. 子集)尝试按照模板写却没有成功,得到的结果总是会少一些本应空缺一些数字的答案,或又会有重复。于是改用77. 组合中自己的方法,才AC了。分析原因,是模板写法总是在取元素,只在取某个元素后对其继续进行递归。而自己的写法是不论取或不取,都进行递归,相比前者会对不取元素的情况进行处理,所以像这种取子集的题就很适合。

class Solution {
public:
    vector<int> path;
    vector<vector<int>> res;
    void back(vector<int>& nums, int begin) {
        if (begin == nums.size()) {
            res.push_back(path);
            return;
        }
        back(nums, begin + 1);
        path.push_back(nums[begin]);
        back(nums, begin + 1);
        path.pop_back();
        return;
    }
    vector<vector<int>> subsets(vector<int>& nums) {
        path.clear();
        res.clear();
        back(nums, 0);
        return res;
    }
};

        题解还是使用原来的模板。思路是排列、组合、分割都是针对回溯树的叶子节点,取满足要求的叶子节点。其中排列与组合的不同是:

  • 顺序对于组合不重要,所以元素和个数相同的话就被认定为同一个组合,所以实现时,在循环遍历中的起始下标应该从begin开始,而非0;
  • 顺序对于排列重要,所以即便元素和个数相同,只要顺序不一致就不被认定为同一个排列,所以实现时,在循环遍历中的起始下标应该从0开始,而非begin;

而这一题要取的是所有节点,不只是叶子节点。所有节点恰好满足不重复且是子集的要求,所以在实现时也只需要套用模板,但需要把res.push_back(path)从递归出口移到函数开始处,让每次递归,即每个节点都能运行这一句,使每个节点都被放入到结果当中。

class Solution {
public:
    vector<int> path;
    vector<vector<int>> res;
    void back(vector<int>& nums, int begin) {
        res.push_back(path);
        if (begin == nums.size()) {
            return;
        }
        for (int i = begin; i < nums.size(); ++i) {
            path.push_back(nums[i]);
            back(nums, i + 1);
            path.pop_back();
        }
        return;
    }
    vector<vector<int>> subsets(vector<int>& nums) {
        path.clear();
        res.clear();
        back(nums, 0);
        return res;
    }
};

其中第7~9行的递归出口是不必要的,因为即便不加,当begin到达数组末尾时也会因无法开始循环而return。另外,自己在实现时,又又一次因为把back()中的i + 1错写成了begin + 1。

        二刷:把res.push_back(path)放到了回溯函数的循环中,也AC了但需要手动添加空集,题解的放在回溯函数一开始处的方式更简洁。


        第3题(90. 子(集II)是day 27中40. 组合总和II和上一题(78. 子集)的组合。首先在待选数组中有重复元素,在答案的一个集合中也可以包含数量不超限的重复元素,但不同集合不能相等。与40. 组合总和II一样要求在回溯树的同一条树枝上可以重复,但在同一层内不能选已经被选过的重复元素。所以要像40. 组合总和II一样对待选数组排序。然后在递归函数循环遍历时,将每段重复区间的第一个放进path,以第一个元素的下一个位置作为下一个begin进行递归。

        这里选用40. 组合总和II里面最简便的第3种实现方式,即添加每个重复区间的第一个元素并递归后,对当前重复区间的其他元素continue。类似这种情况有2种解决方式,第1种是用num[i]和nums[i - 1]判断,第2种是用num[i]和nums[i + 1]判断。前者适用于要取重复区间第一个元素的情况,也就是这一题的情况;后者则适用于要取重复区间最后一个元素的情况。所以进行continue的条件要写成if (i > begin && nums[i] == nums[i - 1])。

        然后,这一题也是子集问题,所以要像上一题(78. 子集)一样把“path添加进res的时机”从递归出口移动到函数开始,记录所有叶子和非叶子节点。

class Solution {
public:
    vector<int> path;
    vector<vector<int>> res;
    void back(vector<int>& nums, int begin) {
        res.push_back(path);
        if (begin == nums.size()) {
            return;
        }
        for (int i = begin; i < nums.size(); ++i) {
            if (i > begin && nums[i] == nums[i - 1]) {
                continue;
            }
            path.push_back(nums[i]);
            back(nums, i + 1);
            path.pop_back();
        }
    }
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        path.clear();
        res.clear();
        sort(nums.begin(), nums.end());
        back(nums, 0);
        return res;
    }
};

第7~9行的递归出口同样可以去掉。因为去掉的话,当begin超范围,也还是会因为进行不了之后的for循环而return。

        如果使用usedInBranch表的方式,则代码为:

class Solution {
public:
    vector<int> path;
    vector<vector<int>> res;
    vector<bool> usedInBranch;
    void back(vector<int>& nums, int begin) {
        res.push_back(path);
        if (begin == nums.size()) {
            return;
        }
        for (int i = begin; i < nums.size(); ++i) {
            if (i > 0 && nums[i] == nums[i - 1] && usedInBranch[i - 1] == false) {
                continue;
            }
            path.push_back(nums[i]);
            usedInBranch[i] = true;
            back(nums, i + 1);
            usedInBranch[i] = false;
            path.pop_back();
        }
    }
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        usedInBranch.resize(nums.size(), false);
        path.clear();
        res.clear();
        sort(nums.begin(), nums.end());
        back(nums, 0);
        return res;
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值