【回溯第三篇】用回溯代替暴力破解解决子集问题:用n次递归代替n次for循环(本质)

172 阅读5分钟

文章目录

子集问题(所有节点):第78题. 子集,不需要对结果剪枝去重

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

说明:解集不能包含重复的子集(题目明确给定)

示例: 输入: nums = [1,2,3]
输出:
[
[3],
[1],
[2],
[1,2,3],
[1,3],
[2,3],
[1,2],
[]
]

思路
求子集问题和回溯算法:求组合问题!和回溯算法:分割问题!又不一样了。

如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,「那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!」

其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。

「那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!」

有同学问了,什么时候for可以从0开始呢?

求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题我们后续的文章就会讲到的。

class Solution {
    List<List<Integer>> result = new ArrayList<>();
    List<Integer> path=new ArrayList<>();
    public void backtracking(int[] nums,int startIndex){
        result.add(new ArrayList<>(path));
        if (startIndex >= nums.length){
            return;
        }
        for (int i=startIndex;i<nums.length;i++){  // 从 startIndex 到 nums.length-1 ,第一次进来的时候 startIndex 为0,然后每次+1
            path.add(nums[i]);  // 元素添加进来
            backtracking(nums,i+1);
            path.remove(path.size()-1);
        }
    }
    public List<List<Integer>> subsets(int[] nums) {
        backtracking(nums,0);  // 每次开始,都是从0开始
        return result;
    }
}

子集问题(所有节点):第90题.子集II(不含重复的集合,和上一次组合问题,元素不允许重复使用是一样的,加上used数组)

给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

说明:解集不能包含重复的子集(题目明确给定)

示例:
输入: [1,2,2]
输出:
[
[2],
[1],
[1,2,2],
[2,2],
[1,2],
[]
]

「剧透一下,后期要讲解的排列问题里去重也是这个套路,所以理解“树层去重”和“树枝去重”非常重要」。

用示例中的[1, 2, 2] 来举例,如图所示:(「注意去重需要先对集合排序」)

class Solution {
    List<List<Integer>> result = new ArrayList<>();
    List<Integer> path=new ArrayList<>();
    public void backtracking(int[] nums,int startIndex,boolean[] used){
        result.add(new ArrayList<>(path));
        if (startIndex >= nums.length){   // 最后这就是结束条件
            return;
        }
        for (int i=startIndex;i<nums.length;i++){  // 从 startIndex 到 nums.length-1 ,第一次进来的时候 startIndex 为0,然后每次+1
            if (i>0 && nums[i]==nums[i-1] && used[i-1]==false)
                continue;   // 同一个树层使用了就放弃
            path.add(nums[i]);  // 元素添加进来
            used[i]=true;
            backtracking(nums,i+1,used);
            path.remove(path.size()-1);
            used[i]=false;
        }
    }
    public List<List<Integer>> subsetsWithDup(int[] nums) {
        boolean[] used=new boolean[nums.length];
        for (int i=0;i<used.length;i++)
            used[i]=false;
        Arrays.sort(nums);  // 对nums快速排序
        backtracking(nums,0,used);  // 每次开始,都是从0开始
        return result;
    }
}

核心:对于结果,元素在同一个子组合内是可以重复的,怎么重复都没事,但两子个组合不能相同
大组合中每个元素只能使用一次(非组合的数学特性,题目明确给定条件),且结果中不能包含相同子组合(题目明确给定输出结果要求,子组合中元素个数是无序的):大组合排好顺序之后,相同元素同一个树层中只能使用一次,出现两次,得到的子组合就有重复的了;
大集合中每一个元素只能使用一次(集合的数学特性,因为是集合,所以这样,题目隐含条件),结果中不能包含相同子集合(题目明确给定输出结果要求,子集合中元素个数是无序的):大集合排好顺序之后,相同元素只能使用一次,出现两次,得到的子集合就有重复的了。

递增子序列
大序列中每个元素只能使用一次(序列的数学特性,因为是序列,所以这样,题目隐含条件),结果中不能包含相同子序列(题目明确给定输出结果要求,子序列中元素个数要求是递增的,所以要剪枝if):大序列给定后(不能排序),同一树层相同元素只能使用一次,出现两次,得到的子序列就有重复的了。还有,子序列子序列中元素个数要求是递增的,所以要剪枝if。所以两个剪枝

子序列问题(所有节点):491.递增子序列(子序列问题和子集问题两个不同)

子序列问题和子集问题两个不同

  1. 第一个剪枝if不同,即去重逻辑不同,子集执行前需要排序,子序列执行前不需要排序;
  2. 第二个剪枝if不同,子序列多一个剪枝if,就是当前元素不能大于子序列最后一个元素。

给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是 2 。

示例:

输入:[4, 6, 7, 7]
输出:[[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]

思想:本题求自增子序列,是不能对原数组进行排序的,排完序的数组都是自增子序列了。

class Solution {
    List<List<Integer>> result = new ArrayList<>();
    List<Integer> path = new ArrayList<>();   // result和path的java类型,要根据输出的格式来定义
    public void backtracking(int[] nums,int startIndex){
        if (path.size() > 1){
            result.add(new ArrayList<>(path));  // 这就是结束条件,也是成功结束条件,因为没有失败且结束条件,失败条件就是result为[],初始化结果
            // 这里不要加return 要取出树上的节点
            // 加上return 永远取到的是两个元素的子序列
        }
        Set set=new HashSet(); // set自带去重功能   在递归的时候去重,不是在for循环中去重
        // 在for循环中去重,
        // 从startIndex到nums.length-1,i不能从0开始,否则 i+1 没有意义
        for(int i=startIndex;i<nums.length;i++){
            // 第一个剪枝,不能加入小于的
            if (!path.isEmpty() && nums[i]<path.get(path.size()-1))
                continue;
            // 第二个剪枝,不能包含同一个树层使用过的
            if (set.contains(nums[i])==true)   // set的定义一定要在for循环外面
                continue;
            path.add(nums[i]);
            set.add(nums[i]);
            backtracking(nums,i+1);
            path.remove(path.size()-1);
        }
    }
    public List<List<Integer>> findSubsequences(int[] nums) {
        backtracking(nums,0);
        return result;
    }
}

为什么结束条件中不能加上return;
回答:这样的话,只要满足 if (path.size() > 1),就会return到上一个函数,执行path.remove(path.size()-1); 永远只能输出包含两个元素的子序列,
如果不加上return; 一定要startIndex == nums.length,才会执行 path.remove(path.size()-1);
核心:不能加return; 因为即使满足 if (path.size() > 1),仅仅是两个元素的,不能停止,后面还有多个元素的,如果return停止了,就只能取到两个元素的了