Problem: 78. 子集
题目:给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
文章目录
- 整体思路
- 完整代码
- 时空复杂度
-
- [时间复杂度:O(N * 2^N)](#时间复杂度:O(N * 2^N))
- 空间复杂度:O(N)
整体思路
这段代码同样旨在解决 "子集 (Subsets)" 问题,即找出给定数组的所有可能子集。与上一个"选或不选"的实现方式不同,此版本的回溯法思路可以理解为 构建组合 的模型。
算法的整体思路可以分解为以下步骤:
-
决策模型:构建组合
- 该算法将生成子集的过程,看作是从原数组
nums
中挑选 0 个、1 个、2 个......直到n
个元素来组成不同长度的子集。 - 它定义了一个
dfs(i, ...)
函数,其核心任务是:从nums
数组的第i
个元素开始 ,向后选择元素,加入到当前正在构建的子集path
中。
- 该算法将生成子集的过程,看作是从原数组
-
递归与回溯的核心逻辑:
dfs
函数被调用时,首先要做的一件事是 将当前path
的状态加入结果集 。ans.add(new ArrayList<>(path))
:这意味着,无论是空集(第一次调用时)、只含一个元素的子集、还是包含多个元素的子集,只要进入dfs
函数,path
的当前状态就是一个合法的子集。这与"选或不选"模型只在叶子节点收集结果的方式不同。
- 接下来,通过一个
for
循环来做选择。这个循环从当前位置i
开始,而不是从 0 开始。for (int j = i; j < nums.length; j++)
: 这个循环的作用是,在当前层级的决策中,我们可以选择nums[i]
,nums[i+1]
,nums[i+2]
等等中的任意一个,作为下一个要加入path
的元素。
- 选择 (Choose) :
path.add(nums[j])
。将选中的元素nums[j]
加入路径。 - 探索 (Explore) :
dfs(j + 1, nums, ...)
。做出选择后,递归地进入下一层。注意,下一层的搜索起点是j + 1
,而不是i + 1
。这确保了我们不会重复选择同一个元素,并且生成的子集中的元素顺序是按照原数组的顺序来的(例如,不会出现[3, 1]
这样的组合,只会是[1, 3]
)。 - 撤销选择 (Unchoose / Backtrack) :
path.removeLast()
。当对j + 1
的探索返回后,撤销刚才的选择,将nums[j]
从path
中移除。这样,在下一次for
循环中,就可以尝试选择nums[j+1]
作为当前层级的选择了。
-
递归的启动
- 初始调用是
dfs(0, ...)
,这意味着第一次我们可以在nums[0...n-1]
中任意选择一个元素作为子集的第一个元素。
- 初始调用是
通过这种方式,DFS的每一条路径都代表了子集的构建过程,并且在进入每个递归节点时都收集一次结果,从而不重不漏地生成了所有子集。
完整代码
java
class Solution {
/**
* 计算给定数组的所有子集(幂集)。
* (组合模型的回溯实现)
* @param nums 不含重复元素的整数数组
* @return 包含所有子集的列表
*/
public List<List<Integer>> subsets(int[] nums) {
// ans: 最终的结果列表,用于存储所有子集
List<List<Integer>> ans = new ArrayList<>();
// path: 用于存储当前正在构建的单个子集路径
List<Integer> path = new ArrayList<>();
// 从索引 0 开始进行深度优先搜索
dfs(0, nums, ans, path);
return ans;
}
/**
* 深度优先搜索(回溯)辅助函数。
* @param i 当前选择的起点索引。表示在这一层,我们可以从 nums[i] 开始向后选择元素。
* @param nums 原始输入数组
* @param ans 结果列表
* @param path 当前构建的子集
*/
private void dfs(int i, int[] nums, List<List<Integer>> ans, List<Integer> path) {
// 关键步骤:在每个递归节点,都将当前 path 的一个深拷贝加入结果集。
// 这代表了以当前 path 为前缀的所有子集中的一个。
// 第一次调用时,path为空,加入的是空集。
ans.add(new ArrayList<>(path));
// 从当前起点 i 开始,向后遍历,尝试将每个元素加入 path
for (int j = i; j < nums.length; j++) {
// 选择 (Choose): 将 nums[j] 加入当前子集
path.add(nums[j]);
// 探索 (Explore): 递归地去构建更长的子集。
// 注意:下一层的起点是 j + 1,确保每个元素只用一次,且组合不重复。
dfs(j + 1, nums, ans, path);
// 撤销选择 (Unchoose / Backtrack): 将刚刚加入的 nums[j] 移除,
// 恢复到上一层状态,以便 for 循环可以尝试下一个 j。
path.remove(path.size() - 1); // removeLast() is more efficient if path is a LinkedList
}
}
}
时空复杂度
时间复杂度:O(N * 2^N)
- 子集数量 :此算法同样会生成
2^N
个子集。 - 构建每个子集的成本 :
- 该算法的DFS树结构与前一个版本不同,但最终生成的子集数量和总的计算量级是相同的。
- 在每次
dfs
调用时(总共有 2^N 次调用),都会执行一次ans.add(new ArrayList<>(path))
。 path
的长度从 0 到 N 不等。所有path
的总长度加起来,可以证明是 O(N * 2^N)。- 因此,所有复制操作的总时间消耗是 O(N * 2^N)。
综合分析 :
尽管实现方式不同,但解决问题的根本计算量没有改变。总时间复杂度仍然是 O(N * 2^N)。
空间复杂度:O(N)
- 主要存储开销 :我们分析的是额外辅助空间 ,不包括存储最终结果的
ans
列表。List<Integer> path
: 用于存储当前路径。path
的最大长度为N
。空间复杂度为 O(N)。- 递归调用栈 :
dfs
函数的最大递归深度为N
(当构建包含所有N
个元素的子集时,路径为0 -> 1 -> ... -> N-1
)。因此,递归栈所占用的空间为 O(N)。
综合分析 :
算法所需的额外辅助空间由 path
和递归栈深度共同决定。它们都是 O(N) 级别的。因此,总的辅助空间复杂度为 O(N)。
参考灵神