← 返回题目列表

子集问题如何用回溯法求解?(LeetCode 78)

高频 中等 第 11 / 30 题 更新于 2026/07/28
回溯子集组合DFS

简化版

求一个数组的所有子集(幂集)。用回溯:从 start 下标往后逐个选,决策树上的每一个节点(不只是叶子)都是一个子集,所以进入递归时就先把当前路径收集下来。关键是用 start 下标保证只往后选、不回头,从而不产生「顺序不同的重复子集」(子集不看顺序)。n 个元素共 2^n 个子集,复杂度 O(n × 2^n)

详细版

List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> res = new ArrayList<>();
    backtrack(nums, 0, new ArrayList<>(), res);
    return res;
}
void backtrack(int[] nums, int start, List<Integer> path, List<List<Integer>> res) {
    res.add(new ArrayList<>(path));            // 每个节点都是一个子集,直接收集
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);                     // 做选择
        backtrack(nums, i + 1, path, res);     // 从 i+1 往后,不回头
        path.remove(path.size() - 1);          // 撤销
    }
}
  • 收集时机不同于排列:排列只在叶子(填满时)收集;子集是每进一个节点就收集[]、[1]、[1,2]… 全要)。
  • start 下标:只能选 start 及之后,避免 [1,2][2,1] 这种重复(子集不看顺序)。
  • 递归传 i+1:同一个元素在一条路径里不重复用。
  • 复杂度 O(n × 2^n)2^n 个子集,每个收集拷贝 O(n)。

完整版教学

一、子集问题的决策树

子集(幂集):[1,2,3] 的所有子集是 []、[1]、[2]、[3]、[1,2]、[1,3]、[2,3]、[1,2,3],共 2^3 = 8 个。

它的决策树和排列不同:每个元素只有「选」或「不选」两种状态,或者换个视角——从 start 开始,依次决定「下一个加谁」。决策树的每一个节点(含根的空集、各中间节点)都对应一个合法子集,而不像排列那样只有叶子才是解。

二、关键:用 start 避免重复(组合型)

子集是组合型问题——[1,2][2,1] 是同一个子集,不区分顺序。若像排列那样每层从头扫,就会既生成 [1,2] 又生成 [2,1],重复了。

解决办法是维护一个 start 下标:每层只从 start 及之后的元素里选,选了下标 i 后,递归时把 start 推进到 i+1。这样元素永远按下标递增加入路径,[1,2] 只会以「先 1 后 2」的唯一顺序出现,[2,1] 根本不会被生成。

start 是所有「组合 / 子集」类回溯的标志,作用是只往后选、消除顺序造成的重复。这与排列用 used[] 恰好相对。

三、为什么每个节点都收集

这是子集区别于组合、排列的独特点。组合(如「选 k 个数」)只在路径长度达到 k 时收集;排列只在填满时收集;而子集要所有长度的组合(从空集到全集),决策树上从根到每个节点的路径都是一个合法子集。

所以代码里 res.add(...) 放在函数最开头、for 循环之前——一进入某个节点就把当前路径存下来,无需任何长度判断。空路径 [](根节点)也因此被收进结果,正好对应空集。

四、复杂度 O(n × 2^n)

  • n 个元素的子集共 2^n(每个元素选或不选)。
  • 每个子集收集时要拷贝路径,最长 O(n)。
  • 合计 O(n × 2^n)。这是子集问题不可避免的下界——因为输出本身就有 2^n 个子集、总规模 O(n·2^n)

五、扩展:子集 II 去重,及「选/不选」写法

子集 II(含重复元素,LeetCode 90):数组有重复时,先 Arrays.sort,再在 for 里加同层去重 if (i > start && nums[i]==nums[i-1]) continue;(注意是 i > start,跳过同一层里重复的值),逻辑和全排列去重同理。

另一种等价写法——「选或不选」二叉决策:不用 for+start,而是对每个下标 idx 递归两次:一次「选 nums[idx]」、一次「不选」,idx 到末尾时收集。它更直白地体现了「每个元素选/不选」的 2^n 本质,和 start 写法等价,二选一即可。

void dfs(int[] nums, int idx, List<Integer> path, List<List<Integer>> res) {
    if (idx == nums.length) { res.add(new ArrayList<>(path)); return; }
    dfs(nums, idx + 1, path, res);          // 不选 nums[idx]
    path.add(nums[idx]);
    dfs(nums, idx + 1, path, res);          // 选 nums[idx]
    path.remove(path.size() - 1);
}

六、把状态、选择与撤销画成决策树

本题递归状态的精确定义是:每个递归节点代表一个唯一子集,start 保证后续只选更大的原下标,因此每个组合只生成一次。状态含义越清楚,结束条件、候选范围和去重位置越容易从题意推出。

进入节点:检查当前状态与剩余目标
枚举候选:先判断约束和剪枝条件
做选择:同步修改 path / used / 约束集合
递归下一层
撤销选择:恢复到进入本节点前的状态

带数字推演:n=3 时决策树共有 2^3=8 个节点/子集,复制总元素量为 n·2^(n-1)。画树时只需展开能体现“同层选择”和“纵向递归”区别的两三层,并标出被剪掉的分支,便能解释去重或剪枝为什么不会漏解。

记忆钩子:回溯不是“递归试一试”,而是维护状态不变量;做选择与撤销必须镜像,剪枝必须证明被删分支不可能产生答案。

七、复杂度、剪枝代价与实现边界

关键实现边界是:元素重复版本需排序并在同层跳过相等值;若用选/不选二叉树,收集时机在叶子而非每个节点。回溯复杂度不能只写一个固定模板,应说明树深、每层分支数、合法叶子数以及复制答案的成本。

维度自检问题
状态参数能否唯一描述当前节点
候选是否遗漏合法选择或重复枚举
剪枝条件是必要条件还是拍脑袋
撤销path、标记和计数是否全部恢复
输出保存的是快照还是共享可变引用

测试应包含无解、唯一解、多解、最小规模、全部候选相同或冲突密集的输入。对可变字符串、棋盘和标记数组,还应在递归返回后断言状态与进入前一致;这类断言比只比较最终答案更容易定位撤销错误。

八、常见误区与追问

  • 误区:子集只在 path 长度为 n 时收集。 每个中间节点本身就是一个合法子集。
  • 误区:组合型回溯也需要 used。 start 已保证下标递增,通常不需要 used。
  • 误区:复杂度是 O(2^n) 且忽略输出。 复制所有子集的总元素数为 Θ(n·2^n) 的同阶上界。
  • 追问:为什么不会出现 [2,1]? 递归只向更大下标推进。
  • 追问:子集 II 怎么去重? 排序后同一层跳过与前一个相等的候选。
  • 追问:迭代怎样做? 每读一个元素,把现有所有子集复制并追加该元素。

九、加强记忆

子集回溯:用 start 下标只往后选(组合型去重,[1,2] 不再生成 [2,1]),且每进一个节点就收集一次res.add 放在 for 之前,从空集到全集所有长度都要)。递归传 i+1 防重复用元素。复杂度 O(n × 2^n)。含重复元素用「排序 + if (i>start && nums[i]==nums[i-1]) continue」去重;也可用「每个元素选/不选」的二叉递归等价实现。