78. 子集

8 阅读3分钟

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

这道 LeetCode 第 78 题 “子集” 是回溯算法中非常基础且重要的一道题。它要求找出一个集合的所有可能子集,这在数学上被称为求“幂集”。


1. 生活案例:挑选零食组合

想象你面前有 3 种零食:苹果(1)、香蕉(2)、糖果(3)

  • 任务:请列出你带走零食的所有可能组合方案。

  • 过程

    1. 方案一:什么都不拿。组合:[](空集)。

    2. 方案二:只拿苹果。组合:[1]

    3. 方案三:拿了苹果后,再拿香蕉。组合:[1, 2]

    4. 方案四:拿了苹果、香蕉后,再拿糖果。组合:[1, 2, 3]

    5. 回溯:把糖果放下,换一种尝试;再把香蕉放下,只拿苹果和糖果。组合:[1, 3]

      ...以此类推。

核心逻辑:每到一个零食面前,我们都面临“拿”或者“不拿”的选择,并且我们要记录下每一个过程中的状态


2. 代码实现与详细注释

这是你图片中的代码,我为你添加了详细的“组合探索”注释:

JavaScript

/**
 * @param {number[]} nums - 给定的整数数组
 * @return {number[][]} - 所有可能的子集
 */
var subsets = function(nums) {
    let res = []; // 【总仓库】:存放所有可能的子集组合

    /**
     * @param {number} start - 当前从数组的哪个位置开始挑选
     * @param {number[]} path - 当前已经挑选好的零食组合
     */
    let backtrack = (start, path) => {
        // 【关键点】:不同于其他回溯,这里不需要终止条件。
        // 因为“每一个”组合(无论长短)都是一个合法的子集,所以进门就直接存入仓库。
        res.push([...path]); // 复制当前组合存入结果

        // 从 start 开始遍历,保证不走回头路(避免出现 [1,2][2,1] 这种重复组合)
        for (let i = start; i < nums.length; i++) {
            // 1. 【做选择】:把当前的数放进组合
            path.push(nums[i]); 

            // 2. 【递归】:从下一个数 (i + 1) 开始继续挑选
            backtrack(i + 1, path); 

            // 3. 【回溯】:把刚才放进去的数拿出来,尝试挑选后面的数
            path.pop(); 
        }
    }

    backtrack(0, []); // 从第 0 个数开始,空组合起步
    return res;
};

3. 核心原理解析

为什么结果里会有空集 []

因为在递归的第一层,path 还是空的。由于我们是“先存入结果再进行循环”,所以 res.push([...path]) 在第一次运行时就把 [] 存进去了。这符合子集包含空集的数学定义。

如何保证不重复?

通过 start 参数实现。

  • 当我们处理过 1 之后,下一层递归从 i + 1 开始,这意味着我们永远只看当前元素后面的元素。
  • 这样就只会产生 [1, 2],而不会产生 [2, 1],有效地去除了重复组合。

复杂度分析

  • 时间复杂度O(N2N)O(N \cdot 2^N)。一个长度为 NN 的集合共有 2N2^N 个子集,每个子集的构建需要 O(N)O(N) 的时间(复制数组)。
  • 空间复杂度O(N)O(N)。主要是递归栈的深度。

总结

这道题是理解回溯中**“状态记录”的极佳案例。与“组合总和”或“括号生成”不同,它不需要在特定条件下才记录结果,而是记录探索过程中的每一个足迹**。