【递归算法】子集

题目链接

文章摘要:

  • 题目要求返回不含重复数字数组的所有子集,包括空集。采用递归法解决,有两种思路:一是基于每个数字的"选与不选"构建决策树,递归遍历所有可能;二是按子集元素个数分类,通过循环和回溯生成所有组合。两种方法均使用全局变量记录结果和路径,并通过回溯恢复现场。代码实现简洁,时间复杂度为O(2ⁿ),空间复杂度为O(n)。

一、题目解析

题目给出一个不含重复数字的数组,要求我们返回该数组所有的子集。

这里的子集概念是高中知识,我们知道,集合是具有互异性和无序性的,即每个元素不相同、每个元素的位置可以互换。简单来说,就是必须不重复出现,虽数字的顺序不同但其实是同一个集合(如 1, 2 2, 1 是同一个集合),因此我们返回的结果也必须是不重复的。

注意,空集也是一个子集。

二、算法原理 + 代码实现

我们知道,一个集合的子集个数是 2ⁿ ,如果采用暴力穷举法那铁定超时了。

因此我们选择使用 递归法

递归解法一

决策树

我们使用递归来遍历数组nums,针对每一个数字,有 " 选择 " 和 " 不选择 "两个选项。

第一次递归,针对数字1,我们如果选择这个数字,那么就有一个结果 1 ,如果不选择这个数字,那么结果就是空集 ∅。再基于 " 选择了数字1 " 继续递归,若选择2,得到 1, 2 ,若不选择2,得到 1 ...

以此类推,我们可以得到决策树如下:

可以看到,结果都在叶子节点。

接下来我们根据决策树设计代码。

全局变量

这里需要两个全局变量,一个用于记录结果 ret,一个用于记录路径 path 。

由于不涉及到状态判断等操作,两个全局变量就足够了。

dfs 函数

函数头

我们这里思考一下每一次递归都会做的事情是什么?那就是 "是否选择当前数字",而数字从哪里来?从题目所给数组里来,因此将 int nums 作为参数传递。

由于是递归形式的遍历,我们需要让每一次的操作知道当前的数字是谁,因此要将下标设计成参数之一。

函数体

每一次递归都要具体做哪些事呢?是否选择当前数字:

  1. 若选择当前数字,就将数字添加到 path 当中,然后基于这个情况,继续递归,将所有情况都遍历到(注意回溯时要恢复现场)
  2. 若不选择当前数字,就直接递归去拿到下一个数字

细节问题

回溯

刚刚我们已经谈到,在 dfs 函数中选择了数字,在回溯的时候需要恢复现场------就是将已经添加到 path 中的数字再删掉。

剪枝

我们其实算是穷举了,把所有的情况都遍历到,因此不涉及到剪枝的问题,

递归出口

结果都在决策树的叶子节点,因此当递归时的下标与 nums 的长度一致时,就认为到达了决策树的叶子节点了,此时记录下结果然后回溯。

代码实现

Java 复制代码
class Solution {
    List<List<Integer>> ret = new ArrayList<>();
    List<Integer> path = new ArrayList<>();

    public List<List<Integer>> subsets(int[] nums) {
        dfs(nums, 0);
        return ret;
    }

    private void dfs(int[] nums, int pos) {
        // 递归出口
        if (pos == nums.length) {
            ret.add(new ArrayList<>(path));
            return;
        }

        // 选择当前数字
        path.add(nums[pos]);
        dfs(nums, pos + 1);
        path.remove(path.size() - 1);  // 回溯时恢复现场

        // 不选择当前数字
        dfs(nums, pos + 1);
    }
}

递归解法二

第二个解决思路是 " 按照子集的元素个数 " 来作决策树。

决策树

  • 当子集中的元素个数是 0 ,结果只有一个空集 ∅;
  • 当子集中的元素个数是 1,结果有三个: 1 2 3
  • 当子集中的元素个数是 2,结果有三个: 1, 2 1, 3 2, 3
  • 当子集中的元素个数是 3,结果有一个: 1, 2, 3
    当列举的子集中的元素个数超过了所给数组 nums 的长度时,就说明结束了,此时得到的结果就是: \[ ∅ , 1 , 2 , 1, 2 , 3 , 1, 3 , 2, 3 , 1, 2, 3 ]

基于这个思路画出的决策树的每一个节点都是结果。

并且,每一次的基于某个数字的情况都是往该数字后面遍历。

比如此时子集当中只有一个元素且该元素是数字 1 的情况下,那么它基于数字 1 的所有组合情况都是往数字 1 后面遍历:遍历到 2,就组合成 1, 2 ,然后遍历到 3 ,就组合成 1, 3 ;对于子集当中只有一个元素且该元素是数字 2 的情况,它往后遍历只有数字 3,因此只能组成 2, 3 ;对于子集当中只有一个元素且该元素是数字 3 的情况,它的后面没有数字了,也就不再遍历了。

接下来根据决策树来设计代码。

全局变量

这里我们依然需要两个全局变量:path 记录路径,ret 记录结果。

dfs 函数

函数头

在这个思路中,我们的重复子问题是 " 往当前数字的后面遍历 ",因此首先需要知道当前数字是什么,而且我们是通过递归来实现遍历,自然是用到下标的,所以函数的参数应和解法一是一致的,int nums 和下标。

函数体

对于每一次递归所要做的事情,我们可以使用一个循环来遍历当前数字的后面数字,然后将数字添加到 path 中,接着基于这个再递归。

细节问题

回溯

在函数体中我们会基于当前数字往后遍历,因此会有回溯,也就必须要有恢复现场的操作。具体来说,就是当基于当前数字的情况递归回来之后,需要恢复现场,也就是将 path 当中最后的数字删掉。

剪枝

这里我们也不涉及到剪枝的操作。

递归出口

由于这个解法的决策树的每一个节点都是结果,因此我们一进入dfs函数就更新结果,但是不需要返回。出口主要由函数体中的循环来控制,当第一个递归中的循环结束了,递归也就结束了。

代码实现

Java 复制代码
class Solution {
    List<List<Integer>> ret = new ArrayList<>();
    List<Integer> path = new ArrayList<>();

    public List<List<Integer>> subsets(int[] nums) {
        dfs(nums, 0);
        return ret;
    }

    private void dfs(int[] nums, int pos) {
        // 进入函数后直接更新结果
        ret.add(new ArrayList<>(path));

        // 从当前位置往后遍历
        for (int i = pos; i < nums.length; i++) {
            path.add(nums[i]);
            dfs(nums, i + 1);
            path.remove(path.size() - 1);  // 回溯时恢复现场
        }
    }
}

文章到这里就结束啦,若有错误请尽管指出~

相关推荐
JieE2127 小时前
LeetCode 101. 对称二叉树|JS 递归 + 迭代双解法,彻底搞懂镜像判断
javascript·算法
nanxun88611 小时前
记一次诡异的 Docker 容器"串包"故障排查
java
用户15630681035114 小时前
Day01 | Java 基础(Java SE)
java
行者全栈架构师16 小时前
Maven dependency:tree 的 8 个高级用法
java·后端
行者全栈架构师20 小时前
IDEA 中 Maven 项目的 15 个红色报错快速解决方法
java·后端
令人头秃的代码0_020 小时前
mac(m5)平台编译openjdk
java
JieE2121 天前
LeetCode 56. 合并区间|超清晰 JS 图解思路,面试高频区间题
javascript·算法·面试
Jack202 天前
HarmonyOS开发中错误处理策略:网络异常统一处理
算法
小小杨树2 天前
读懂色彩:拍照调色不再难
算法·计算机视觉·配色
唐青枫2 天前
Java JDBC 实战指南:从 Connection 到事务和连接池
java