
一、核心思路
子集问题属于回溯组合类题型,数组元素互不相同。 关键两点:
- 每走到递归的一层,当前路径
path就是一个合法子集,直接加入结果集(和全排列只收集叶子节点不同); - 使用
start下标控制遍历起点,下一轮只能选当前下标后面的元素,防止出现[1,2]、[2,1]这种重复子集。
二、完整 Java 代码
csharp
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> subsets(int[] nums) {
backtrack(nums, 0);
return res;
}
private void backtrack(int[] nums, int start) {
// 步骤1:先把当前path存入结果,一定要新建集合拷贝
res.add(new ArrayList<>(path));
// 步骤2:从start开始向后遍历
for(int i = start; i < nums.length; i++){
// 步骤3:选择当前元素
path.add(nums[i]);
// 步骤4:递归,下一轮只能选i之后的元素 i+1
backtrack(nums, i + 1);
// 步骤5:回溯,撤销选择
path.remove(path.size() - 1);
}
}
}
三、实例演示(nums = 1,2,3,表格模拟)
| 递归 start | path | 是否加入 res | 循环 i | 操作 |
|---|---|---|---|---|
| 0 | \[\] | ✅ 加入,res: \[] | i=0 | add 1 → path=1,递归 start=1 |
| 1 | 1 | ✅ 加入,res: \[,1] | i=1 | add 2 → path=1,2,递归 start=2 |
| 2 | 1,2 | ✅ 加入,res: \[,1,1,2] | i=2 | add3 → path=1,2,3,递归 start=3 |
| 3 | 1,2,3 | ✅ 加入,res: \[,1,1,2,1,2,3] | i>=3 循环结束 | return,撤销 3 → path=1,2 |
| 回到 start=2 | 1,2 | - | i 循环结束 | return,撤销 2 → path=1 |
| 回到 start=1 | 1 | - | i=2 | add3 → path=1,3,递归 start=3 |
| 3 | 1,3 | ✅ 加入 | i>=3 循环结束 | return,撤销 3 → path=1 |
| 回到 start=1 | 1 | - | i 循环结束 | return,撤销 1 → path=\[\] |
| 回到 start=0 | \[\] | - | i=1 | add2 → path=2,递归 start=2 |
| 2 | 2 | ✅ 加入 | i=2 | add3 → path=2,3,递归 start=3 |
| 3 | 2,3 | ✅ 加入 | 循环结束 | return,撤销 3 → path=2 |
| 回到 start=2 | 2 | - | i 循环结束 | return,撤销 2 → path=\[\] |
| 回到 start=0 | \[\] | - | i=2 | add3 → path=3,递归 start=3 |
| 3 | 3 | ✅ 加入 | 循环结束 | return,撤销 3 → path=\[\] |
循环全部结束,最终结果:[[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
四、复杂度分析
- 时间复杂度:(O(n* 2^n)),总共有(2^n)个子集,保存每个子集最多拷贝 n 个元素
- 空间复杂度:(O(n)),递归栈最深为 n,path 最多存放 n 个元素
五、易错点总结
res.add(new ArrayList<>(path)),必须 new 新集合,否则 path 后续修改会覆盖 res 里存好的数据;- 递归传参是
i+1,不是start+1,保证只能向后选,避免重复组合; - 子集是每层都收集,不是等循环结束再收集,空集自动被第一层收录,不用手动添加;
- 数组元素互不相同,不需要去重。
六、感悟
子集是回溯组合问题的基础模板,和全排列区分开:
- 子集:start 控制往后选,每层收集 path,不回头;
- 全排列:used 标记是否使用,每次从头遍历,只在 path 长度等于 nums 长度时收集。