78. 子集 (Medium)

专题归类: 08-回溯算法 LeetCode 链接: https://leetcode.cn/problems/subsets/


在线做题: 写 Python 代码并运行 · Java 跳转 LeetCode

题目描述

给你一个整数数组 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 中的所有元素互不相同

题目详细分析

数据范围含义:

  • nums.length <= 10:规模很小,2^10 = 1024,全部枚举完全可行。这暗示可以使用回溯或位运算。
  • 所有元素互不相同:简化了问题,不需要考虑重复元素带来的去重问题(如有重复参见 90-子集 II)。

核心约束:

  • 子集不关心元素顺序,[1,2][2,1] 被视为同一个子集。
  • 空集是任何集合的子集,必须包含在结果中。
  • 每个元素有”选”和”不选”两种状态。

与排列的区别:

  • 排列关注顺序,子集不关注顺序。
  • 排列选满 n 个元素才记录,子集每一步都可以记录。
  • 排列用 visited 控制,子集用 start 控制。

小白版直白理解

就像你面前有 n 种水果(苹果、香蕉、橘子),你要做成水果拼盘。你可以不放任何水果(空盘),也可以只放苹果,或只放香蕉,或放苹果+香蕉……总之,每种水果只有”放”或”不放”两种选择。所有可能的拼盘组合就是子集。

换个比喻:你要从衣橱里挑衣服出门,每件衣服都可以选择”穿”或”不穿”,所有可能的穿衣搭配方案就是子集。


解题思路

思路一:回溯 + start 参数(推荐)

核心思想: 与全排列不同,子集不关心顺序。使用 start 参数控制每次只能从当前索引之后的元素中选择,避免产生重复组合。

关键洞察: 排列在叶子节点记录,而子集在每个节点都记录。因为 [1][2][1,2] 都是子集,中间状态也要加入结果。

决策树可视化(以 [1,2,3] 为例):

                   []
         /         |         \
      [1]         [2]        [3]
     /   \         |
  [1,2] [1,3]   [2,3]
   |
 [1,2,3]

每个节点(包括根节点)都加入结果。

def subsets(nums):
    res = []
    n = len(nums)
 
    def backtrack(start, path):
        # 每个节点都加入结果(包括空集)
        res.append(path[:])
 
        # 从 start 开始,避免重复
        for i in range(start, n):
            path.append(nums[i])      # 做选择
            backtrack(i + 1, path)    # 递归:只能选 i 之后的元素
            path.pop()                # 撤销选择
 
    backtrack(0, [])
    return res

思路二:位运算法

核心思想: n 个元素,每个元素可选可不选,恰好对应二进制位 0/1。2^n 种状态映射所有子集。

以 [1,2,3] 为例,3 位二进制数:

000 → []        100 → [1]
001 → [3]       101 → [1,3]
010 → [2]       110 → [1,2]
011 → [2,3]     111 → [1,2,3]
def subsets(nums):
    n = len(nums)
    res = []
    # 从 0 到 2^n - 1 枚举所有状态
    for mask in range(1 << n):
        subset = []
        for i in range(n):
            if mask & (1 << i):  # 第 i 位为 1 表示选中 nums[i]
                subset.append(nums[i])
        res.append(subset)
    return res

时间复杂度: O(n x 2

思路三:迭代法(动态增量)

核心思想: 每处理一个新元素,就在已有所有子集的基础上追加这个元素,形成新子集。

初始:[[]]
处理 1:[[], [1]]
处理 2:[[], [1], [2], [1,2]]
处理 3:[[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]
def subsets(nums):
    res = [[]]
    for num in nums:
        # 对当前所有子集,追加新元素
        for i in range(len(res)):
            res.append(res[i] + [num])
    return res

易错点

  1. start 参数传递错误:递归时应该传 i + 1 而不是 start + 1start + 1 会导致元素被跳过或重复访问。
  2. 忘记创建副本res.append(path[:]) 而不是 res.append(path)path 随后会被修改,不创建副本会导致结果被覆盖。
  3. 空集遗漏:空集是任何集合的子集,必须加入结果。回溯法在首次调用时就加入空集。
  4. 与排列混淆:子集用 start 控制方向避免重复,排列用 visited 从全部元素中选择,这两者容易混淆。

框架提炼

子集/组合问题的回溯模板:

def backtrack(start, path):
    # 每个节点都记录结果(区别于排列只在叶子节点记录)
    res.append(path[:])
 
    for i in range(start, n):
        path.append(nums[i])         # 做选择
        backtrack(i + 1, path)       # 递归:只能向后选
        path.pop()                   # 撤销选择

“选或不选”的思维模型:

  • 回溯视角:从空集开始,逐个决定每个元素”加入/不加入”。
  • 位运算视角:n 位二进制数,0 表示不选,1 表示选。
  • 迭代视角:新元素加入已有所有子集,规模翻倍。

注意事项:

  • 组合问题(如 77-组合)只需在特定长度时记录,其他与子集完全一致。

关联题目

  • 46-全排列 — 排列与子集的对比学习:排列关心顺序(visited),子集不关心顺序(start);排列只在叶子记录,子集每个节点都记录。
  • 39-组合总和 — 组合问题的变体,加了目标和约束,且元素可重复选取(递归传 i 而非 i+1)。
  • 90-子集II — 包含重复元素的子集问题,需要排序 + 同层去重剪枝。
  • 77-组合 — 限定大小为 k 的子集,在子集模板上加上长度约束即可。