羊羊刷题笔记Day28/60 | 第七章 回溯算法P4 | 491. 递增子序列、46. 全排列、47. 全排列II

142 阅读6分钟

491 递增子序列

虽然都是去重,但和之前的题不太一样!小心掉坑 本题对养成思维定式或者套模板套嗨了的,起到了很好的警醒作用。更重要的是拓展了思路!

思路

这又是子集,又是去重,是不是不由自主的想起了之前的题目。
40 组合总和Ⅱ中我们是首先通过排序,再加一个标记数组来达到去重的目的。
而本题求自增子序列,是不能对原数组进行排序,因为排序后对原有数组下标进行了改变而题目是在原有坐标下求自增子序列,因此结果会发生变化。

特别是本题给出的示例,本身就是一个有序数组 [4, 6, 7, 7],更容易误导去用之前的去重思路去做。

以下用[4, 7, 6, 7]这个数组来举例,抽象为树形结构如图:
image.png

回溯三部曲

  • 递归函数参数

本题求子序列,很明显一个元素不能重复使用,所以需要startIndex,调整下一层递归的起始位置。
代码如下:

private LinkedList<Integer> path = new LinkedList<>();
private List<List<Integer>> result = new ArrayList<>();
private void backtracking(int[] nums, int startIndex) {
  • 终止条件

由上图树层结构可看出,遍历树形结构找到叶子节点即为终止,可以不加终止条件,因为startIndex每次都会加1,并不会无限递归。
但也要注意的是,递增序列至少有两位,因此代码如下:

// 终止条件 - 每一次递归结果都是答案(判断是否是递增的逻辑在递归体现)
if (path.size() >= 2) result.add(new ArrayList<>(path));
  • 单层搜索逻辑

image.png 在图中可以看出,和之前去重方向一样:树层去重,树枝可以重复
那么单层搜索代码如下:

HashSet<Integer> hs = new HashSet<>();
// 单层遍历逻辑
for(int i = startIndex;i <= nums.length - 1;i++){
    // 下一个元素比已有最后一个元素要小(不是递增序列) 或 之前已出现元素 则结束本次循环
    if (!path.isEmpty() && path.getLast() > nums[i] || hs.contains(nums[i]))
        continue;

    // 节点处理
    hs.add(nums[i]);
    path.add(nums[i]);
    // 递归
    backtracking(nums,i + 1);
    // 回溯
    path.removeLast();
    // 注意:这里不用处理hs,因为每次递归都会创建一个新的HashSet出来
}

这里会发现:

  • 为什么HashSet没有回溯?回溯逻辑不是递归前做了什么,递归后就撤回操作吗?

对的,但由于我们的HashSet每次进入递归都会重新创建一次,一个HashSet对应一个树层,因此不需要回溯。

  • 那为什么这里使用HashSet在for循环里而不是在全局变量中呢?

因为HashSet只管树层有没有重复元素,不需要继承父树的信息

  • 为什么这里不使用之前的used数组判断[i - 1] ?= false呢

正是本题没有了排列,因此重复元素不一定相邻,因此用不了[i - 1]判断。换个想法,可以用其他映射方法。由于题目本身长度有限,因此可以对201个元素在数组中进行映射。(详细在优化部分


最后整体代码如下:

class Solution {
    private LinkedList<Integer> path = new LinkedList<>();
    private List<List<Integer>> result = new ArrayList<>();
    public List<List<Integer>> findSubsequences(int[] nums) {
        int startIndex = 0;
        backtracking(nums,startIndex);
        return result;
    }

    private void backtracking(int[] nums, int startIndex) {
        // 终止条件 - 每一次递归结果都是答案(判断是否是递增的逻辑在递归体现)
        if (path.size() >= 2) result.add(new ArrayList<>(path));
        HashSet<Integer> hs = new HashSet<>();
        // 单层遍历逻辑
        for(int i = startIndex;i <= nums.length - 1;i++){
            // 下一个元素比已有最后一个元素要小(不是递增序列) 或 之前已出现元素 则结束本次循环
            if (!path.isEmpty() && path.getLast() > nums[i] || hs.contains(nums[i]))
                continue;

            // 节点处理
            hs.add(nums[i]);
            path.add(nums[i]);
            // 递归
            backtracking(nums,i + 1);
            // 回溯
            path.removeLast();
            // 注意:这里不用处理hs,因为每次递归都会创建一个新的HashSet出来
        }
    }
}
  • 时间复杂度: O(n * 2^n)
  • 空间复杂度: O(n)

优化

以上代码用我用了HashSet来记录本层元素是否重复使用。
其实用数组来做哈希,效率就高了很多
注意题目中说了,数值范围[-100,100]为可控少量元素,所以完全可以用数组来做哈希。
程序运行的时候对HashSet 频繁的add,HashSet需要做哈希映射(也就是把key通过HashCode映射为唯一的哈希值)相对费时间,而且每次重新定义set,也是费事的。
那么优化后的代码如下:
注意[-100,100] 映射到长度为201的数组的逻辑

class Solution {
    private LinkedList<Integer> path = new LinkedList<>();
    private List<List<Integer>> result = new ArrayList<>();
    public List<List<Integer>> findSubsequences(int[] nums) {
        int startIndex = 0;
        backtracking(nums,startIndex);
        return result;
    }

    private void backtracking(int[] nums, int startIndex) {
        // 终止条件 - 每一次递归结果都是答案(判断是否是递增的逻辑在递归体现)
        if (path.size() >= 2) result.add(new ArrayList<>(path));
        boolean[] used = new boolean[201];

        // 单层遍历逻辑
        for(int i = startIndex;i <= nums.length - 1;i++){
            // 映射used坐标
            int usedIndex = nums[i] + 100;
            // 下一个元素比已有最后一个元素要小(不是递增序列) 或 之前已出现元素 则结束本次循环
            if (!path.isEmpty() && path.getLast() > nums[i] || used[usedIndex] == true)
                continue;

            // 节点处理
            used[usedIndex] = true;
            path.add(nums[i]);
            // 递归
            backtracking(nums,i + 1);
            // 回溯
            path.removeLast();
            // 注意:这里不用处理hs,因为每次递归都会创建一个新的HashSet出来
        }
    }
}

优化后,由于使用了数组,效率相较快了
哈希表章节中提过,数组,set,map都可以做哈希表,而且数组干的活,map和set都能干,但如果数值范围小的话能用数组尽量用数组,效率会更快

46 全排列

组合问题后,再来排列问题

思路

我以[1,2,3]为例,抽象成树形结构如下:
image.png

回溯三部曲

  • 递归函数参数

首先排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了
但排列问题不用startIndex,而需要一个used数组标记已经选择的元素,下一层不会选择到。如图橘黄色所示:
image.png
代码如下:

private LinkedList<Integer> path = new LinkedList<>();
private List<List<Integer>> res = new ArrayList<>();
private void backtracking(int[] nums, boolean[] used) {}
  • 递归终止条件

image.png
可以看出叶子节点,就是收割结果的地方。
那么什么时候,算是到达叶子节点呢?
当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
代码如下:

// 终止条件 - 收集完了
if (path.size() == nums.length) res.add(new ArrayList<>(path));
  • 单层搜索的逻辑

这题最大的不同就是for循环里不用startIndex了,每次循环都是从头开始(只要用过的不用)
而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次
代码如下:

// 单层递归逻辑
for (int i = 0;i <= nums.length - 1;i++){
    if (used[i] == true) continue;

    // 节点处理
    path.add(nums[i]);
    used[i] = true;
    backtracking(nums,used);
    used[i] = false;
    path.removeLast();
}

整体代码如下:

class Solution {
    private LinkedList<Integer> path = new LinkedList<>();
    private List<List<Integer>> res = new ArrayList<>();
    public List<List<Integer>> permute(int[] nums) {
        boolean[] used = new boolean[nums.length];
        Arrays.fill(used,false);
        backtracking(nums,used);
        return res;
    }

    private void backtracking(int[] nums, boolean[] used) {
        // 终止条件
        if (path.size() == nums.length) res.add(new ArrayList<>(path));

        // 单层递归逻辑
        for (int i = 0;i <= nums.length - 1;i++){
            if (used[i] == true) continue;

            // 节点处理
            path.add(nums[i]);
            used[i] = true;
            backtracking(nums,used);
            used[i] = false;
            path.removeLast();
        }
    }
}
  • 时间复杂度: O(n!)
  • 空间复杂度: O(n)

总结

大家此时可以感受出排列问题的不同:

  • 不用startIndex记录开始坐标,每层都是从0开始搜索
  • 取而代之需要used数组记录path里都放了哪些元素,用过的就不能再用了

排列问题与组合问题一样,是回溯算法解决的经典题目了。

47 全排列Ⅱ

和上一题类似,加了条件:可包含重复数字。与之前组合去重逻辑相同

思路

这道题目和和上一题46 全排列的区别在与给定一个可包含重复数字的序列,要返回所有不重复的全排列
这里又涉及到去重了。
40. 组合总和II也有涉及去重问题,排列问题其实也是一样的套路。
还要强调的是去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了
我以示例中的 [1,1,2]为例 (为了方便举例,已经排序)抽象为一棵树,去重过程如图:
image.png
图中我们对同一树层,前一位(也就是nums[i-1])如果使用过,那么就进行去重。
一般来说:组合问题和排列问题是在树形结构的叶子节点上收集结果,而子集问题就是取树上所有节点的结果
在上一题中已经详细讲解了排列问题的写法,所以这次我就不用回溯三部曲分析了,本题较上一题难点就是去重,代码如下

class Solution {
    private LinkedList path = new LinkedList();
    private List<List<Integer>> result = new ArrayList<>();
    public List<List<Integer>> permuteUnique(int[] nums) {
        boolean[] used = new boolean[nums.length];
        Arrays.fill(used,false);
        Arrays.sort(nums);
        backtracking(nums,used);
        return result;
    }

    private void backtracking(int[] nums, boolean[] used) {
        // 终止条件 - path的大小满了
        if (path.size() == nums.length) {
            result.add(new ArrayList<>(path));
            return;
        }

        // 单层递归逻辑
        for (int i = 0;i <= nums.length - 1;i++){
            // 树层去重 - 注意需要先排序后去重(这里不像491 递增子序列没有要求原始坐标 可以排序)
            if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) continue;

            // 跳过已选元素
            if (used[i] == true) continue;
            path.add(nums[i]);
            used[i] = true;
            backtracking(nums, used);
            used[i] = false;
            path.removeLast();
        }
    }
}
  • 时间复杂度: O(n! * n)
  • 空间复杂度: O(n)

拓展:用true也可以👈

学习资料:

491. 递增子序列

46. 全排列

47. 全排列 II