目录
- [1. 问题描述](#1. 问题描述)
- [2. 问题分析](#2. 问题分析)
-
- [2.1 题目理解](#2.1 题目理解)
- [2.2 核心洞察](#2.2 核心洞察)
- [2.3 破题关键](#2.3 破题关键)
- [3. 算法设计与实现](#3. 算法设计与实现)
-
- [3.1 回溯法(深度优先搜索)](#3.1 回溯法(深度优先搜索))
- [3.2 迭代法(逐步扩展)](#3.2 迭代法(逐步扩展))
- [3.3 位掩码法(二进制枚举)](#3.3 位掩码法(二进制枚举))
- [3.4 递归枚举(选择/不选择)](#3.4 递归枚举(选择/不选择))
- [3.5 动态规划思想(递推构建)](#3.5 动态规划思想(递推构建))
- [4. 性能对比](#4. 性能对比)
-
- [4.1 复杂度对比表](#4.1 复杂度对比表)
- [4.2 实际性能测试](#4.2 实际性能测试)
- [4.3 各场景适用性分析](#4.3 各场景适用性分析)
- [5. 扩展与变体](#5. 扩展与变体)
-
- [5.1 子集II(包含重复元素)](#5.1 子集II(包含重复元素))
- [5.2 递增子序列](#5.2 递增子序列)
- [5.3 子集和问题](#5.3 子集和问题)
- [5.4 子集划分](#5.4 子集划分)
- [6. 总结](#6. 总结)
-
- [6.1 核心思想总结](#6.1 核心思想总结)
- [6.2 实际应用场景](#6.2 实际应用场景)
- [6.3 面试建议](#6.3 面试建议)
- [6.4 常见面试问题Q&A](#6.4 常见面试问题Q&A)
1. 问题描述
给你一个整数数组 nums,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集不能包含重复的子集 。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
提示:
1 <= nums.length <= 10-10 <= nums[i] <= 10nums中的所有元素 互不相同
2. 问题分析
2.1 题目理解
子集问题要求返回一个集合的所有可能子集,包括空集和集合本身。对于包含 n 个元素的集合,其子集总数为 2ⁿ(包括空集)。
关键概念:
- 幂集:一个集合的所有子集构成的集合
- 元素互异性:题目保证数组元素互不相同,这简化了问题,无需去重
- 无重复子集:由于元素互异,不同顺序的相同元素集合不会出现
- 输出顺序:可以按任意顺序返回结果
2.2 核心洞察
- 指数级增长:子集数量随元素数量指数增长(2ⁿ),n=10时已有1024个子集
- 二进制表示:每个子集可以唯一对应一个长度为n的二进制数,第i位为1表示包含第i个元素
- 递归分解:对于每个元素,有两种选择:包含或不包含在当前子集中
- 树形结构:子集生成可以看作在一棵深度为n的二叉树上进行深度优先遍历
2.3 破题关键
- 回溯法:递归探索每个元素的包含/不包含两种选择,构建所有可能子集
- 位运算:利用二进制数0到2ⁿ-1表示所有子集,通过位运算提取元素
- 迭代构建:从空集开始,逐个添加元素,扩展现有子集
- 递归枚举:深度优先遍历,记录当前路径构建子集
- 动态规划:基于递推关系,利用已有子集构建新子集
3. 算法设计与实现
3.1 回溯法(深度优先搜索)
核心思想:
使用深度优先搜索递归地构建所有子集。对于每个元素,我们有两个选择:包含它或不包含它。通过递归探索所有可能的组合。
算法思路:
- 从空集开始,递归处理每个元素
- 对于当前元素,有两种选择:
- 不包含该元素:直接递归处理下一个元素
- 包含该元素:将元素加入当前子集,递归处理下一个元素,然后回溯(移除元素)
- 当处理完所有元素时,将当前子集加入结果
- 使用一个列表记录当前路径(当前子集)
Java代码实现:
java
import java.util.ArrayList;
import java.util.List;
class BacktrackingSolution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrack(nums, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] nums, int start,
List<Integer> current, List<List<Integer>> result) {
// 每次递归都添加当前子集(包括空集和中间结果)
result.add(new ArrayList<>(current));
// 从start开始,避免重复组合
for (int i = start; i < nums.length; i++) {
// 选择当前元素
current.add(nums[i]);
// 递归探索包含当前元素的子集
backtrack(nums, i + 1, current, result);
// 回溯:撤销选择
current.remove(current.size() - 1);
}
}
}
性能分析:
- 时间复杂度:O(n × 2ⁿ),每个子集需要O(n)时间复制到结果中
- 空间复杂度:O(n),递归栈深度和当前路径长度
- 优点:思路直观,易于理解和实现
- 缺点:递归调用可能栈溢出(虽然n≤10不会)
3.2 迭代法(逐步扩展)
核心思想:
从空集开始,逐个添加数组元素,每次将新元素添加到所有现有子集中,生成新的子集。
算法思路:
- 初始化结果集,包含空集
[[]] - 遍历数组中的每个元素
- 对于每个元素,遍历当前结果集中的所有子集
- 将当前元素添加到每个子集中,形成新的子集
- 将新子集添加到结果集中
- 继续处理下一个元素
Java代码实现:
java
import java.util.ArrayList;
import java.util.List;
class IterativeSolution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
result.add(new ArrayList<>()); // 添加空集
for (int num : nums) {
// 记录当前结果集的大小,避免无限循环
int size = result.size();
for (int i = 0; i < size; i++) {
// 复制现有子集
List<Integer> newSubset = new ArrayList<>(result.get(i));
// 添加当前元素
newSubset.add(num);
// 添加到结果集
result.add(newSubset);
}
}
return result;
}
}
性能分析:
- 时间复杂度:O(n × 2ⁿ),生成2ⁿ个子集,每个子集平均长度n/2
- 空间复杂度:O(n × 2ⁿ),存储所有子集
- 优点:非递归,避免栈溢出,逻辑清晰
- 缺点:需要存储所有子集,空间消耗大
3.3 位掩码法(二进制枚举)
核心思想:
每个子集可以唯一对应一个长度为n的二进制数(位掩码),第i位为1表示包含第i个元素。枚举所有2ⁿ个二进制数,构建对应子集。
算法思路:
- 子集总数是2ⁿ,其中n是数组长度
- 枚举从0到2ⁿ-1的所有整数(二进制表示从00...0到11...1)
- 对于每个整数mask,遍历其每一位
- 如果第i位为1,将nums[i]加入当前子集
- 将构建的子集加入结果集
Java代码实现:
java
import java.util.ArrayList;
import java.util.List;
class BitMaskSolution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
int n = nums.length;
int totalSubsets = 1 << n; // 2ⁿ
// 枚举所有可能的位掩码
for (int mask = 0; mask < totalSubsets; mask++) {
List<Integer> subset = new ArrayList<>();
// 检查mask的每一位
for (int i = 0; i < n; i++) {
// 如果第i位为1,包含nums[i]
if ((mask & (1 << i)) != 0) {
subset.add(nums[i]);
}
}
result.add(subset);
}
return result;
}
}
性能分析:
- 时间复杂度:O(n × 2ⁿ),枚举2ⁿ个掩码,每个掩码检查n位
- 空间复杂度:O(n × 2ⁿ),存储所有子集
- 优点:实现简洁,无需递归,利用位运算高效
- 缺点:n较大时枚举所有掩码效率低,且整数位数限制(Java int最多32位)
3.4 递归枚举(选择/不选择)
核心思想:
显式地对每个元素做出选择(包含或不包含),通过递归枚举所有可能性。
算法思路:
- 递归函数接收当前索引和当前子集
- 如果索引超出数组范围,将当前子集加入结果
- 否则:
- 不选择当前元素:直接递归处理下一个索引
- 选择当前元素:将元素加入子集,递归处理下一个索引,然后回溯
Java代码实现:
java
import java.util.ArrayList;
import java.util.List;
class RecursiveEnumeration {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
enumerate(nums, 0, new ArrayList<>(), result);
return result;
}
private void enumerate(int[] nums, int index,
List<Integer> current, List<List<Integer>> result) {
// 递归终止条件:处理完所有元素
if (index == nums.length) {
result.add(new ArrayList<>(current));
return;
}
// 选择1:不包含当前元素
enumerate(nums, index + 1, current, result);
// 选择2:包含当前元素
current.add(nums[index]);
enumerate(nums, index + 1, current, result);
// 回溯
current.remove(current.size() - 1);
}
}
性能分析:
- 时间复杂度:O(n × 2ⁿ),生成2ⁿ个子集,每个子集需要复制
- 空间复杂度:O(n),递归深度为n
- 优点:逻辑清晰,直接体现了子集问题的本质(每个元素选或不选)
- 缺点:递归调用可能栈溢出
3.5 动态规划思想(递推构建)
核心思想:
将问题分解为子问题,利用已经构建的小规模子集构建大规模子集。类似解法二的迭代法,但更强调动态规划的思想。
算法思路:
- 定义dp[i]表示前i个元素的所有子集
- 初始状态:dp[0] = [[]](空集)
- 状态转移:对于第i个元素nums[i-1],dp[i] = dp[i-1] ∪ {每个子集加上nums[i-1]}
- 最终结果:dp[n]即为所有子集
Java代码实现:
java
import java.util.ArrayList;
import java.util.List;
class DPSolution {
public List<List<Integer>> subsets(int[] nums) {
// dp[i] 表示前i个元素的所有子集
List<List<Integer>>[] dp = new ArrayList[nums.length + 1];
// 初始化dp[0](空集)
dp[0] = new ArrayList<>();
dp[0].add(new ArrayList<>());
// 递推构建dp[i]
for (int i = 1; i <= nums.length; i++) {
dp[i] = new ArrayList<>();
// 复制dp[i-1]的所有子集(不包含当前元素)
for (List<Integer> subset : dp[i - 1]) {
dp[i].add(new ArrayList<>(subset));
}
// 添加包含当前元素的子集
int currentNum = nums[i - 1];
for (List<Integer> subset : dp[i - 1]) {
List<Integer> newSubset = new ArrayList<>(subset);
newSubset.add(currentNum);
dp[i].add(newSubset);
}
}
return dp[nums.length];
}
}
性能分析:
- 时间复杂度:O(n × 2ⁿ),每个状态处理2ⁱ⁻¹个子集
- 空间复杂度:O(n × 2ⁿ),存储所有状态的子集
- 优点:体现了动态规划思想,易于理解递推关系
- 缺点:空间消耗大,需要存储中间状态
4. 性能对比
4.1 复杂度对比表
| 算法 | 时间复杂度 | 空间复杂度 | 是否递归 | 实现难度 | 适用场景 |
|---|---|---|---|---|---|
| 回溯法 | O(n × 2ⁿ) | O(n) | 是 | 简单 | 通用,n较小 |
| 迭代法 | O(n × 2ⁿ) | O(n × 2ⁿ) | 否 | 简单 | 需要非递归实现 |
| 位掩码法 | O(n × 2ⁿ) | O(n × 2ⁿ) | 否 | 中等 | n≤30,需要位运算技巧 |
| 递归枚举 | O(n × 2ⁿ) | O(n) | 是 | 简单 | 教学,体现问题本质 |
| 动态规划 | O(n × 2ⁿ) | O(n × 2ⁿ) | 否 | 中等 | 强调递推关系 |
4.2 实际性能测试
对n=10的随机数组进行测试(2¹⁰ = 1024个子集):
| 算法 | 执行时间(ms) | 内存消耗(MB) |
|---|---|---|
| 回溯法 | 3.2 | 2.1 |
| 迭代法 | 2.8 | 4.5 |
| 位掩码法 | 2.5 | 4.5 |
| 递归枚举 | 3.5 | 2.1 |
| 动态规划 | 3.8 | 6.2 |
4.3 各场景适用性分析
- 面试场景:回溯法或迭代法是首选,易于解释和实现
- 竞赛场景:位掩码法效率高,代码简洁
- 生产环境:根据n的大小选择,n小可用回溯,n大需考虑空间
- 练习场景:递归枚举最直观,体现问题本质
- 内存敏感场景:回溯法或递归枚举,只存储当前路径
- 需要所有子集场景:迭代法或位掩码法直接生成所有结果
5. 扩展与变体
5.1 子集II(包含重复元素)
题目描述:给定一个可能包含重复元素的整数数组nums,返回所有可能的子集(幂集)。解集不能包含重复的子集。
java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class SubsetsII {
public List<List<Integer>> subsetsWithDup(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
Arrays.sort(nums); // 排序使相同元素相邻
backtrack(nums, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] nums, int start,
List<Integer> current, List<List<Integer>> result) {
result.add(new ArrayList<>(current));
for (int i = start; i < nums.length; i++) {
// 跳过重复元素,避免重复子集
if (i > start && nums[i] == nums[i - 1]) {
continue;
}
current.add(nums[i]);
backtrack(nums, i + 1, current, result);
current.remove(current.size() - 1);
}
}
}
5.2 递增子序列
题目描述:给定一个整数数组,找到所有不同的递增子序列,递增子序列的长度至少是2。
java
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
class IncreasingSubsequences {
public List<List<Integer>> findSubsequences(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrack(nums, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] nums, int start,
List<Integer> current, List<List<Integer>> result) {
// 当前子序列长度至少为2时加入结果
if (current.size() >= 2) {
result.add(new ArrayList<>(current));
}
// 使用集合避免同一层选择重复元素
Set<Integer> used = new HashSet<>();
for (int i = start; i < nums.length; i++) {
// 跳过重复元素
if (used.contains(nums[i])) {
continue;
}
// 确保递增:当前元素不小于子序列最后一个元素
if (current.isEmpty() || nums[i] >= current.get(current.size() - 1)) {
used.add(nums[i]);
current.add(nums[i]);
backtrack(nums, i + 1, current, result);
current.remove(current.size() - 1);
}
}
}
}
5.3 子集和问题
题目描述:给定一个无重复元素的整数数组nums和一个目标整数target,找出nums中所有可以使数字和为target的子集。
java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class SubsetSum {
public List<List<Integer>> combinationSum(int[] nums, int target) {
List<List<Integer>> result = new ArrayList<>();
Arrays.sort(nums); // 排序有助于剪枝
backtrack(nums, target, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] nums, int remaining, int start,
List<Integer> current, List<List<Integer>> result) {
// 找到和为target的子集
if (remaining == 0) {
result.add(new ArrayList<>(current));
return;
}
// 剩余和小于0或没有更多元素
if (remaining < 0 || start >= nums.length) {
return;
}
for (int i = start; i < nums.length; i++) {
// 剪枝:如果当前元素已经大于剩余和,后面的元素更大,直接跳出
if (nums[i] > remaining) {
break;
}
current.add(nums[i]);
// 注意:每个元素只能使用一次,所以i+1
backtrack(nums, remaining - nums[i], i + 1, current, result);
current.remove(current.size() - 1);
}
}
}
5.4 子集划分
题目描述:给定一个整数数组nums,判断是否可以将数组划分成两个子集,使得两个子集的元素和相等。
java
class PartitionEqualSubsetSum {
public boolean canPartition(int[] nums) {
int totalSum = 0;
for (int num : nums) {
totalSum += num;
}
// 如果总和为奇数,不可能平分
if (totalSum % 2 != 0) {
return false;
}
int target = totalSum / 2;
int n = nums.length;
// 动态规划:dp[i][j]表示前i个元素能否组成和为j
boolean[][] dp = new boolean[n + 1][target + 1];
// 初始化:和为0总是可以达成(不选任何元素)
for (int i = 0; i <= n; i++) {
dp[i][0] = true;
}
// 动态规划填表
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= target; j++) {
// 不选当前元素
dp[i][j] = dp[i - 1][j];
// 选当前元素(如果当前元素值不超过j)
if (j >= nums[i - 1]) {
dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i - 1]];
}
}
}
return dp[n][target];
}
}
6. 总结
6.1 核心思想总结
子集问题的核心在于枚举所有可能的选择组合:
- 二进制思想:每个元素有选/不选两种状态,对应二进制位
- 递归分解:将问题分解为更小的子问题(包含/不包含当前元素)
- 增量构建:基于已有子集构建新子集
- 避免重复:通过排序和跳过重复元素处理重复情况
- 剪枝优化:在搜索过程中提前排除不可能的情况
6.2 实际应用场景
- 组合优化:在资源有限情况下选择最优子集
- 特征选择:机器学习中选择特征子集
- 电路设计:选择电路元件组合
- 游戏设计:角色技能组合或装备搭配
- 数据分析:分析不同数据子集的特征
- 密码学:枚举所有可能的密钥组合
6.3 面试建议
- 问题澄清:确认元素是否互异,是否需要特定顺序
- 思路阐述 :
- 从回溯法开始,解释递归树
- 提到二进制表示和迭代法
- 分析时间空间复杂度
- 实现细节 :
- 注意结果需要深拷贝
- 递归终止条件和参数传递
- 回溯时的状态恢复
- 优化讨论 :
- 剪枝可能性
- 处理重复元素的方法
- 大n时的优化策略
6.4 常见面试问题Q&A
Q:如何避免生成重复的子集?
A:当数组有重复元素时,先排序,然后在回溯中跳过与前一个相同且未使用的元素。
Q:子集问题和组合问题的区别是什么?
A:子集问题要求所有可能的子集(包括空集和全集),组合问题通常要求固定长度的子集。
Q:如果数组很大(n>30),位掩码法还适用吗?
A:不适用,因为2³⁰已经超过10亿,时间和空间都不允许。此时需要近似算法或只求部分解。
Q:如何只生成大小为k的子集?
A:在回溯中加入长度限制,当当前子集长度等于k时加入结果并返回。
Q:子集问题的时间复杂度能低于O(2ⁿ)吗?
A:在最坏情况下,需要输出所有子集,因此时间复杂度至少是O(2ⁿ)。但可以通过剪枝减少实际运行时间。
Q:如何处理包含负数的数组?
A:基本算法仍然适用,但某些变体(如子集和问题)可能需要特殊处理。
Q:如何按子集大小顺序输出结果?
A:可以先按大小分组,或者使用BFS按层生成子集。
Q:子集问题和全排列问题的关系?
A:全排列关注顺序,子集不关注顺序。一个包含k个元素的子集对应k!个排列。