【LeetCode回溯算法#07】子集问题I+II,巩固解题模板并详解回溯算法中的去重问题
子集
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
思路
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点
其实这题就是模板题,与 组合总和、 分割回文串 不同的是,这题需要全部收集遍历到的值,包括最开始path为空也要算进结果数组中
那就套用回溯问题的解题模板即可,真的就是直接套
代码
class Solution {
private:
//定义结果数组
vector<int> path;
vector<vector<int>> res;
//确定回溯函数的参数和返回值
void backtracking(vector<int>& nums, int beginIndex){
res.push_back(path);//放在这里,不会把path为空的情况漏掉
//确定停止条件
if(beginIndex >= nums.size()){//写不写都行,因为当满足条件时for循环也到头了,也会自己停止
return;
}
//确定单层处理逻辑
//直接遍历获取树结构中的所有值
for(int i = beginIndex; i < nums.size(); ++i){
path.push_back(nums[i]);
backtracking(nums, i + 1);//因为子集不能有重复的,因此要跳过本次的值
path.pop_back();
}
}
public:
vector<vector<int>> subsets(vector<int>& nums) {
backtracking(nums, 0);
return res;
}
};
关于backtracking(nums, i + 1)为什么要跳,对比看->组合总和思路部分单层处理逻辑来理解
字母大小写全排列(多次递归的子集)
https://leetcode.cn/problems/letter-case-permutation/
给定一个字符串 s ,通过将字符串 s 中的每个字母转变大小写,我们可以获得一个新的字符串。
返回 所有可能得到的字符串集合 。以 任意顺序 返回输出。
示例 1:
输入:s = "a1b2"
输出:["a1b2", "a1B2", "A1b2", "A1B2"]
示例 2:
输入: s = "3z4"
输出: ["3z4","3Z4"]
提示:
1 <= s.length <= 12
s 由小写英文字母、大写英文字母和数字组成
思路
说是全排列,实际上是子集问题,在处理流程上与子集问题一致
但是因为要将字母大小写的情况都纳入path,所以我们在单层处理逻辑中需要进行多次递归
先判断当前遍历值是否为字母,如果是就先将遍历值转为大写,加入path,触发一次递归,然后回溯
随后再将字母转成小写再次加入path并触发递归,然后回溯
这样我们就得到了同一个排列下字母不同大小写情况的组合
如果判断当前遍历值是数字,则只需要正常进行一次递归将其加入path中即可。
代码
class Solution {
private:
vector<string> res;
string path = "";
void traversal(string s, int beginIndex) {
if (beginIndex == s.size()) {
res.push_back(path);
return;
}
if (isalpha(s[beginIndex])) {//如果当前遍历值是字母
//那么就进行两次递归,一次为path加入大写字母,一次加入小写
// 转换为小写字母
path.push_back(tolower(s[beginIndex])); //#include <cctype>
traversal(s, beginIndex + 1);
path.pop_back();
// 转换为大写字母
path.push_back(toupper(s[beginIndex]));
traversal(s, beginIndex + 1);
path.pop_back();
} else {//如果当前遍历值是数字,就正常进行一次递归即可
path.push_back(s[beginIndex]);
traversal(s, beginIndex + 1);
path.pop_back();
}
}
public:
vector<string> letterCasePermutation(string s) {
traversal(s, 0);
return res;
}
};
子集II
给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例:
- 输入: [1,2,2]
- 输出: [ [2], [1], [1,2,2], [2,2], [1,2], [] ]
思路
相较 子集I ,这题题目加了“可能包含重复元素,但不能包含重复子集”
这意味着什么?又得去重呗
经过 组合总合II 的训练,现在你知道了我们可以使用一个布尔数组used来标记用过的元素,并通过该数组的状态以及相邻相同元素来进行去重操作
本题的其他部分代码与 子集I 区别不大,在其基础之上添加去重逻辑就行了
正好这里可以很明显的体现出去重逻辑应该写在哪些地方
代码
class Solution {
private:
//定义结果数组
vector<int> path;
vector<vector<int>> res;
//确定回溯函数的参数和返回值
void backtracking(vector<int>& nums, int beginIndex, vector<bool>& used){
res.push_back(path);//放在这里,不会把path为空的情况漏掉
//确定停止条件
if(beginIndex >= nums.size()){//写不写都行,因为当满足条件时for循环也到头了,也会自己停止
return;
}
//确定单层处理逻辑
//直接遍历获取树结构中的所有值
for(int i = beginIndex; i < nums.size(); ++i){
if(i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0){//去重逻辑详见 组合总和II
continue;//在单层遍历时有重复值就跳过
}
path.push_back(nums[i]);
used[i] = true;//记录使用的元素
backtracking(nums, i + 1, used);//因为子集不能有重复的,因此要跳过本次的值
used[i] = false;//回溯
path.pop_back();
}
}
public:
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<bool> used(nums.size(), false);
//排序
sort(nums.begin(), nums.end());
backtracking(nums, 0, used);
return res;
}
};
与上一题的代码对比来看不难发现
新增的去重逻辑大部分都在单层处理部分
这也和之前我们在组合总和II中分析的一致,即:在被视为树结构的回溯问题中,“树层”中出现重复是不行的,但是纵向的“树枝”中可以出现重复
下面总结一下目前使用的回溯算法模板中的去重问题
去重操作总结
本题与组合总和II都应用了“去重”这一操作
这两个问题都在题干中给出了类似的要求
(本题)“给定一个可能包含重复元素的整数数组 nums...解集不能包含重复的组合”
(组合)“candidates 中的每个数字在每个组合中只能使用一次...解集不能包含重复的组合”
发现什么了吗?
这类题目都会给一个有重复值(或者暗示有重复值)的数据,然后让你找出所有组合,并且组合不能有重复
以后看见这些字眼要想到去重操作
再回顾一下出现重复值的本质
重复值的本质
当我们对一个已经排序了的数据进行回溯遍历时,当相邻的两个重复值中的前一个已经通过递归遍历完树结构的一个分支后,此时因为回溯我们会回到最开始触发递归的位置,然后继续从相邻重复值的后一个值再次开启递归遍历
那之后开启的递归遍历中的所有组合(或者是需要获取的某种结果)一定都在前一次递归遍历中出现过了
这就是重复值的本质
处理方法
逻辑
为了处理上述问题,我们引入一个布尔数组used来记录当前使用过的元素
used数组的大小会和题目给的数组大小一样(为了一一对应数组中的元素)
当我们使用递归到单层处理部分时,此时我们会记录组合结果,同时我们也在used中记录当前组合使用到的元素
等到下一次再记录组合结果之前,就判断一下used的状态
关键点来了,也就是重复的条件
重复的条件
重复的条件是,判断当前遍历到的元素(也就是要用到组合中的元素)的前一个元素与其本身是否构成相邻重复值
也就是当前这个元素在上次遍历时是否也出现过
如果确实出现过,那么再看当前used的前一个元素位置是否有记录
如果有记录(used[i - 1] != 0),那么意味着本次递归是处于树结构的一条枝干中,可以使用重复值
如果没有记录(used[i - 1] == 0),说明本次递归的上一次递归已经使用过相邻重复值了,那么本次递归再进行下去的结果将全部被包含于上次的结果中,因此要跳过本次for循环,使用下一个值进行递归遍历寻找新的组合
可见,used数组是负责判断当前处于"树枝"还是"树层"的一个辅助工具
而关键点还是得有相邻重复值,这是造成重复值出现的本质核心
步骤总结
1、在回溯函数中引入used布尔数组
2、在单层处理逻辑中加入对于相邻重复值和used状态的判断
3、在主函数中对用来寻找组合的数组进行提前排序(使用sort)
以后有新理解再补充