LeetCode第78题_子集
LeetCode第78题:子集
题目描述
给你一个整数数组 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] <= 10
nums
中的所有元素 互不相同
解题思路
这道题目是经典的子集问题,可以使用多种方法来解决,包括回溯法、迭代法和位运算法。
方法一:回溯法
回溯法是一种通过探索所有可能的候选解来找出所有解的算法。对于子集问题,我们可以考虑每个元素是否被选择,从而生成所有可能的子集。
- 定义一个递归函数
backtrack(start, path)
,其中start
表示当前考虑的元素位置,path
表示当前已选择的元素集合 - 在每一步中,我们都有两个选择:选择当前元素或不选择当前元素
- 对于每个元素,我们都将当前路径添加到结果集中(因为每个路径都是一个有效的子集)
- 然后继续递归处理下一个元素
方法二:迭代法
迭代法的思路是,从空集开始,每次将当前所有子集与新元素组合,生成新的子集。
- 初始化结果集为空集
[[]]
- 遍历数组中的每个元素
num
- 对于每个元素,将当前结果集中的每个子集都复制一份,并在复制的子集中添加当前元素
num
- 将这些新生成的子集添加到结果集中
方法三:位运算法
位运算法利用了子集的一个重要特性:对于长度为 n 的数组,共有 2^n 个子集,每个子集可以用一个 n 位的二进制数表示,其中第 i 位为 1 表示选择第 i 个元素,为 0 表示不选择。
- 对于长度为 n 的数组,共有 2^n 个子集
- 我们可以用 0 到 2^n - 1 的二进制表示来表示所有可能的子集
- 对于每个二进制表示,我们检查每一位是否为 1,如果是,则将对应位置的元素添加到当前子集中
关键点
- 理解子集的性质:长度为 n 的数组共有 2^n 个子集
- 正确处理递归的终止条件和状态恢复(对于回溯法)
- 理解位运算在子集生成中的应用(对于位运算法)
算法步骤分析
以回溯法为例:
步骤 | 操作 | 说明 |
---|---|---|
1 | 初始化 | 创建结果集 result 和当前路径 path |
2 | 定义回溯函数 | backtrack(start, path) 用于生成所有可能的子集 |
3 | 添加当前路径 | 将当前路径添加到结果集中 |
4 | 选择元素 | 从 start 开始,考虑每个元素是否选择 |
5 | 递归调用 | 选择当前元素后,递归处理下一个元素 |
6 | 撤销选择 | 将最后添加的元素从路径中移除,尝试其他可能的组合 |
7 | 返回结果 | 返回所有可能的子集 |
算法可视化
以示例 1 为例,nums = [1,2,3]
,回溯过程如下:
- 初始状态:
path = []
,将空集添加到结果集,结果集变为[[]]
- 考虑元素 1:
- 选择元素 1:
path = [1]
,将[1]
添加到结果集,结果集变为[[], [1]]
- 考虑元素 2:
- 选择元素 2:
path = [1, 2]
,将[1, 2]
添加到结果集,结果集变为[[], [1], [1, 2]]
- 考虑元素 3:
- 选择元素 3:
path = [1, 2, 3]
,将[1, 2, 3]
添加到结果集,结果集变为[[], [1], [1, 2], [1, 2, 3]]
- 撤销选择 3:
path = [1, 2]
- 选择元素 3:
- 撤销选择 2:
path = [1]
- 选择元素 2:
- 考虑元素 3:
- 选择元素 3:
path = [1, 3]
,将[1, 3]
添加到结果集,结果集变为[[], [1], [1, 2], [1, 2, 3], [1, 3]]
- 撤销选择 3:
path = [1]
- 选择元素 3:
- 撤销选择 1:
path = []
- 选择元素 1:
- 考虑元素 2:
- 选择元素 2:
path = [2]
,将[2]
添加到结果集,结果集变为[[], [1], [1, 2], [1, 2, 3], [1, 3], [2]]
- 考虑元素 3:
- 选择元素 3:
path = [2, 3]
,将[2, 3]
添加到结果集,结果集变为[[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3]]
- 撤销选择 3:
path = [2]
- 选择元素 3:
- 撤销选择 2:
path = []
- 选择元素 2:
- 考虑元素 3:
- 选择元素 3:
path = [3]
,将[3]
添加到结果集,结果集变为[[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
- 撤销选择 3:
path = []
- 选择元素 3:
- 最终结果集为
[[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
代码实现
C# 实现
public class Solution {
private IList<IList<int>> result = new List<IList<int>>();
public IList<IList<int>> Subsets(int[] nums) {
Backtrack(nums, 0, new List<int>());
return result;
}
private void Backtrack(int[] nums, int start, IList<int> path) {
// 将当前路径添加到结果集中
result.Add(new List<int>(path));
// 从start开始,考虑每个元素是否选择
for (int i = start; i < nums.Length; i++) {
// 选择当前元素
path.Add(nums[i]);
// 递归处理下一个元素
Backtrack(nums, i + 1, path);
// 撤销选择,回溯
path.RemoveAt(path.Count - 1);
}
}
}
Python 实现
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
result = []
def backtrack(start, path):
# 将当前路径添加到结果集中
result.append(path[:])
# 从start开始,考虑每个元素是否选择
for i in range(start, len(nums)):
# 选择当前元素
path.append(nums[i])
# 递归处理下一个元素
backtrack(i + 1, path)
# 撤销选择,回溯
path.pop()
backtrack(0, [])
return result
C++ 实现
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> result;
vector<int> path;
backtrack(nums, 0, path, result);
return result;
}
private:
void backtrack(vector<int>& nums, int start, vector<int>& path, vector<vector<int>>& result) {
// 将当前路径添加到结果集中
result.push_back(path);
// 从start开始,考虑每个元素是否选择
for (int i = start; i < nums.size(); i++) {
// 选择当前元素
path.push_back(nums[i]);
// 递归处理下一个元素
backtrack(nums, i + 1, path, result);
// 撤销选择,回溯
path.pop_back();
}
}
};
执行结果
C# 执行结果
- 执行用时:108 ms,击败了 95.24% 的 C# 提交
- 内存消耗:41.2 MB,击败了 88.10% 的 C# 提交
Python 执行结果
- 执行用时:32 ms,击败了 94.56% 的 Python3 提交
- 内存消耗:16.2 MB,击败了 87.89% 的 Python3 提交
C++ 执行结果
- 执行用时:0 ms,击败了 100.00% 的 C++ 提交
- 内存消耗:7.1 MB,击败了 91.23% 的 C++ 提交
代码亮点
- 回溯算法的应用:使用回溯算法解决子集问题,代码结构清晰。
- 状态恢复:在递归返回后,正确地恢复状态,确保不影响其他分支的探索。
- 深拷贝处理:在添加结果时,创建当前路径的副本,避免后续修改影响已添加的结果。
- 递归终止条件:不需要显式的终止条件,因为循环会自然终止。
- 空间优化:只需要一个路径变量和结果集,空间复杂度为 O(n)。
常见错误分析
- 忘记创建路径副本:在将当前路径添加到结果集时,如果不创建副本,后续的修改会影响已添加的结果。
- 递归终止条件设置不当:对于子集问题,每个路径都是一个有效的子集,不需要等到路径长度达到某个值才添加到结果集。
- 状态恢复不完全:在递归返回后,需要完全恢复状态,否则会影响其他分支的探索。
- 循环范围设置错误:循环的起始和结束位置需要正确设置,否则可能漏掉某些子集或产生重复子集。
- 没有考虑空集:空集也是一个有效的子集,需要确保结果集中包含空集。
解法比较
解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
---|---|---|---|---|
回溯法 | O(n * 2^n) | O(n) | 实现简单,适用于所有子集问题 | 递归调用可能导致栈溢出(对于非常大的输入) |
迭代法 | O(n * 2^n) | O(n * 2^n) | 避免递归调用的开销 | 需要额外的空间来存储中间结果 |
位运算法 | O(n * 2^n) | O(n) | 实现简洁,直观表示子集 | 对于不熟悉位运算的人可能难以理解 |
相关题目
- LeetCode 77. 组合
- LeetCode 90. 子集 II
- LeetCode 46. 全排列
- LeetCode 39. 组合总和