文章摘要:
- 题目要求返回不含重复数字数组的所有子集,包括空集。采用递归法解决,有两种思路:一是基于每个数字的"选与不选"构建决策树,递归遍历所有可能;二是按子集元素个数分类,通过循环和回溯生成所有组合。两种方法均使用全局变量记录结果和路径,并通过回溯恢复现场。代码实现简洁,时间复杂度为O(2ⁿ),空间复杂度为O(n)。
一、题目解析
题目给出一个不含重复数字的数组,要求我们返回该数组所有的子集。
这里的子集概念是高中知识,我们知道,集合是具有互异性和无序性的,即每个元素不相同、每个元素的位置可以互换。简单来说,就是必须不重复出现,虽数字的顺序不同但其实是同一个集合(如 [ 1, 2 ] 和 [ 2, 1 ] 是同一个集合),因此我们返回的结果也必须是不重复的。
注意,空集也是一个子集。

二、算法原理 + 代码实现
我们知道,一个集合的子集个数是 2ⁿ ,如果采用暴力穷举法那铁定超时了。
因此我们选择使用 递归法
递归解法一
决策树
我们使用递归来遍历数组nums,针对每一个数字,有 " 选择 " 和 " 不选择 "两个选项。
第一次递归,针对数字1,我们如果选择这个数字,那么就有一个结果 [ 1 ],如果不选择这个数字,那么结果就是空集 ∅。再基于 " 选择了数字1 " 继续递归,若选择2,得到 [ 1, 2 ],若不选择2,得到 [ 1 ]...
以此类推,我们可以得到决策树如下:

可以看到,结果都在叶子节点。
接下来我们根据决策树设计代码。
全局变量
这里需要两个全局变量,一个用于记录结果 ret,一个用于记录路径 path 。
由于不涉及到状态判断等操作,两个全局变量就足够了。
dfs 函数
函数头
我们这里思考一下每一次递归都会做的事情是什么?那就是 "是否选择当前数字",而数字从哪里来?从题目所给数组里来,因此将 int[ ] nums 作为参数传递。
由于是递归形式的遍历,我们需要让每一次的操作知道当前的数字是谁,因此要将下标设计成参数之一。
函数体
每一次递归都要具体做哪些事呢?是否选择当前数字:
- 若选择当前数字,就将数字添加到 path 当中,然后基于这个情况,继续递归,将所有情况都遍历到(注意回溯时要恢复现场)
- 若不选择当前数字,就直接递归去拿到下一个数字
细节问题
回溯
刚刚我们已经谈到,在 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); // 回溯时恢复现场
}
}
}
文章到这里就结束啦,若有错误请尽管指出~
完