【LeetCode Hot100 刷题日记 (56/100)】78. 子集 —— 回溯与位运算法的双重解法🧠

7 阅读5分钟

📌 题目链接:78. 子集 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:数组、回溯、位运算、递归

⏱️ 目标时间复杂度:O(n × 2ⁿ)

💾 空间复杂度:O(n) (不计返回结果)


🔍 题目分析

给定一个不含重复元素的整数数组 nums,要求返回其所有可能的子集(幂集)

  • 子集包括空集 [] 和全集 nums 本身;
  • 解集中不能包含重复子集(因输入无重复,天然满足);
  • 返回顺序任意。

📌 关键点

  • 元素互异 → 无需去重;
  • 所有子集 = 幂集,共 2ⁿ 个
  • 每个元素只有“选”或“不选”两种状态 → 天然适合二进制枚举或递归决策树

⚙️ 核心算法及代码讲解

本题有两种经典解法:位运算法(迭代)回溯法(递归) 。两者时间复杂度相同,但思想迥异,面试中常被交替考察。

✅ 方法一:位运算法(Bitmasking)

📌 核心思想

  • 对于长度为 n 的数组,每个子集可对应一个 n 位二进制数(称为 mask);
  • i 位为 1 表示选择 nums[i],为 0 表示不选;
  • 枚举 mask02ⁿ - 1,共 2ⁿ 种状态,每种状态构造一个子集。

💡 举例说明

nums = [1, 2, 3]n = 3

mask (十进制)mask (二进制)子集
0000[]
1001[3]
2010[2]
3011[2,3]
4100[1]
.........
7111[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),将当前路径(即子集)加入答案;
  • 使用回溯pushpop)维护路径状态。

🌲 决策树示意(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、组合总和等);
面试加分项:体现递归思维与状态管理能力。


🧩 解题思路(分步详解)

🔹 位运算法步骤:

  1. 计算 n = nums.size()
  2. 枚举 mask0(1 << n) - 1
  3. 对每个 mask,遍历 i = 0 ~ n-1,检查 mask 的第 i 位是否为 1;
  4. 若为 1,则将 nums[i] 加入当前子集;
  5. 将子集加入答案列表。

🔹 回溯法步骤:

  1. 定义全局变量 t(当前路径)、ans(答案);

  2. cur = 0 开始递归;

  3. 终止条件cur == n,将 t 加入 ans

  4. 递归分支

    • 选择 nums[cur]push → 递归 cur+1pop(回溯);
    • 不选择 → 直接递归 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() 拷贝!


🎯 面试延伸与变种

  1. 子集 II(含重复元素) → 需排序 + 跳过同层重复;
  2. 组合(固定长度 k) → 在回溯中加 if (t.size() == k) 提前收集;
  3. 子集和问题 → 加入目标值判断;
  4. 生成器优化 → 大数据量下可用 yield 避免内存爆炸(Python/JS)。

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!