【递归算法】子集

题目链接

文章摘要:

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

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

相关推荐
lightqjx2 小时前
【算法】二分算法
c++·算法·leetcode·二分算法·二分模板
行百里er3 小时前
优雅应对异常,从“try-catch堆砌”到“设计驱动”
java·后端·代码规范
ms_27_data_develop3 小时前
Java枚举类、异常、常用类
java·开发语言
xiaohe073 小时前
Spring Boot 各种事务操作实战(自动回滚、手动回滚、部分回滚)
java·数据库·spring boot
代码飞天3 小时前
wireshark的高级使用
android·java·wireshark
ic爱吃蓝莓3 小时前
数据结构 | HashMap原理
数据结构·学习·算法·链表·哈希算法
add45a3 小时前
C++编译期数据结构
开发语言·c++·算法
灰色小旋风3 小时前
力扣21 合并两个有序链表(C++)
c++·leetcode·链表
gechunlian883 小时前
Spring Boot中的404错误:原因、影响及处理策略
java·spring boot·后端