📌 题目链接:78. 子集 - 力扣(LeetCode)
🔍 难度:中等 | 🏷️ 标签:数组、回溯、位运算、递归
⏱️ 目标时间复杂度:O(n × 2ⁿ)
💾 空间复杂度:O(n) (不计返回结果)
🔍 题目分析
给定一个不含重复元素的整数数组
nums,要求返回其所有可能的子集(幂集) 。
- 子集包括空集
[]和全集nums本身;- 解集中不能包含重复子集(因输入无重复,天然满足);
- 返回顺序任意。
📌 关键点:
- 元素互异 → 无需去重;
- 所有子集 = 幂集,共 2ⁿ 个;
- 每个元素只有“选”或“不选”两种状态 → 天然适合二进制枚举或递归决策树。
⚙️ 核心算法及代码讲解
本题有两种经典解法:位运算法(迭代) 与 回溯法(递归) 。两者时间复杂度相同,但思想迥异,面试中常被交替考察。
✅ 方法一:位运算法(Bitmasking)
📌 核心思想
- 对于长度为
n的数组,每个子集可对应一个 n 位二进制数(称为 mask); - 第
i位为1表示选择nums[i],为0表示不选; - 枚举
mask从0到2ⁿ - 1,共2ⁿ种状态,每种状态构造一个子集。
💡 举例说明
nums = [1, 2, 3],n = 3:
| mask (十进制) | mask (二进制) | 子集 |
|---|---|---|
| 0 | 000 | [] |
| 1 | 001 | [3] |
| 2 | 010 | [2] |
| 3 | 011 | [2,3] |
| 4 | 100 | [1] |
| ... | ... | ... |
| 7 | 111 | [1,2,3] |
🧾 C++ 代码(带行注释)
// 枚举所有 mask ∈ [0, 2^n)
for (int mask = 0; mask < (1 << n); ++mask) {
t.clear(); // 清空临时子集
for (int i = 0; i < n; ++i) {
if (mask & (1 << i)) { // 检查第 i 位是否为 1
t.push_back(nums[i]);
}
}
ans.push_back(t); // 将当前子集加入答案
}
✅ 优点:代码简洁,逻辑直观,易于理解。
❌ 缺点:位运算对初学者稍显抽象,且无法自然扩展到“带重复元素”或“限制子集大小”的变种。
✅ 方法二:回溯法(Backtracking)
📌 核心思想
- 构建一棵决策树:每个节点代表是否选择当前元素;
- 从索引
0开始,对每个位置做两种选择:“选” 或 “不选”; - 当遍历完所有元素(
cur == n),将当前路径(即子集)加入答案; - 使用回溯(
push后pop)维护路径状态。
🌲 决策树示意(nums = [1,2,3])
[]
/ \
[1] []
/ \ / \
[1,2] [1] [2] []
/ \ / \ / \ / \
... ...(叶节点即为所有子集)
🧾 C++ 代码(带行注释)
void dfs(int cur, vector<int>& nums) {
if (cur == nums.size()) {
ans.push_back(t); // 到达叶子节点,保存当前子集
return;
}
// 选择当前元素
t.push_back(nums[cur]);
dfs(cur + 1, nums);
t.pop_back(); // 回溯:撤销选择
// 不选择当前元素
dfs(cur + 1, nums);
}
✅ 优点:结构清晰,易于扩展(如子集 II、组合总和等);
✅ 面试加分项:体现递归思维与状态管理能力。
🧩 解题思路(分步详解)
🔹 位运算法步骤:
- 计算
n = nums.size(); - 枚举
mask从0到(1 << n) - 1; - 对每个
mask,遍历i = 0 ~ n-1,检查mask的第i位是否为 1; - 若为 1,则将
nums[i]加入当前子集; - 将子集加入答案列表。
🔹 回溯法步骤:
-
定义全局变量
t(当前路径)、ans(答案); -
从
cur = 0开始递归; -
终止条件:
cur == n,将t加入ans; -
递归分支:
- 选择
nums[cur]→push→ 递归cur+1→pop(回溯); - 不选择 → 直接递归
cur+1。
- 选择
📊 算法分析
| 方法 | 时间复杂度 | 空间复杂度 | 是否易扩展 | 面试推荐度 |
|---|---|---|---|---|
| 位运算 | O(n × 2ⁿ) | O(n) | ❌ | ⭐⭐⭐ |
| 回溯 | O(n × 2ⁿ) | O(n) | ✅✅✅ | ⭐⭐⭐⭐⭐ |
💡 为什么时间是 O(n × 2ⁿ) ?
- 共有
2ⁿ个子集;- 每个子集平均长度为
n/2,但最坏需复制整个n元素 → 每次构造耗时 O(n)。
💡 空间复杂度 O(n) :
- 临时数组
t最多存n个元素;- 回溯法递归栈深度为
n。
💻 代码
✅ C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
class Solution {
public:
vector<int> t;
vector<vector<int>> ans;
// 方法一:位运算法
vector<vector<int>> subsets_bit(vector<int>& nums) {
int n = nums.size();
for (int mask = 0; mask < (1 << n); ++mask) {
t.clear();
for (int i = 0; i < n; ++i) {
if (mask & (1 << i)) {
t.push_back(nums[i]);
}
}
ans.push_back(t);
}
return ans;
}
// 方法二:回溯法
void dfs(int cur, vector<int>& nums) {
if (cur == nums.size()) {
ans.push_back(t);
return;
}
t.push_back(nums[cur]);
dfs(cur + 1, nums);
t.pop_back();
dfs(cur + 1, nums);
}
vector<vector<int>> subsets(vector<int>& nums) {
ans.clear();
t.clear();
dfs(0, nums); // 可替换为 subsets_bit(nums)
return ans;
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
Solution sol;
vector<int> nums = {1, 2, 3};
auto res = sol.subsets(nums);
for (auto& subset : res) {
cout << "[";
for (int i = 0; i < subset.size(); ++i) {
if (i > 0) cout << ",";
cout << subset[i];
}
cout << "]\n";
}
return 0;
}
✅ JavaScript
/**
* @param {number[]} nums
* @return {number[][]}
*/
var subsets = function(nums) {
const t = [];
const ans = [];
const dfs = (cur) => {
if (cur === nums.length) {
ans.push([...t]); // 注意:必须浅拷贝!
return;
}
t.push(nums[cur]);
dfs(cur + 1);
t.pop();
dfs(cur + 1);
};
dfs(0);
return ans;
};
// 测试
console.log(subsets([1, 2, 3]));
// 输出: [[1,2,3],[1,2],[1,3],[1],[2,3],[2],[3],[]]
⚠️ JS 注意:
ans.push(t)是引用传递,必须用[...t]或t.slice()拷贝!
🎯 面试延伸与变种
- 子集 II(含重复元素) → 需排序 + 跳过同层重复;
- 组合(固定长度 k) → 在回溯中加
if (t.size() == k)提前收集; - 子集和问题 → 加入目标值判断;
- 生成器优化 → 大数据量下可用 yield 避免内存爆炸(Python/JS)。
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!