# 子集 (Subsets) ## 题目描述 给你一个整数数组 `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` 中的所有元素 **互不相同** ## 思路推导 ### 暴力解法分析 **第一步:直观思路 - 枚举所有可能的子集** ```python def subsets_brute(nums): n = len(nums) result = [] # 遍历所有可能的子集掩码 for mask in range(1 << n): # 0 到 2^n - 1 subset = [] for i in range(n): if mask & (1 << i): # 检查第 i 位是否为 1 subset.append(nums[i]) result.append(subset) return result ``` **时间复杂度分析:** - 有 2^n 个子集 - 每个子集需要 O(n) 时间构建 - **总时间复杂度:O(n × 2^n)** **问题:** - 虽然时间复杂度已经是最优的,但位运算不易理解 - 代码可读性较差 - 难以扩展到带约束条件的子集问题 ### 优化思考 - 如何更直观地生成子集? **核心观察:** 1. **子集的本质**:对每个元素,都有"选"或"不选"两种选择 2. **决策树视角**:n 个元素构成一个 n 层的决策树 3. **回溯法**:自然地表达这种"选择-撤销"的过程 **为什么用回溯?** - 更直观地表达选择过程 - 易于剪枝(如有约束条件) - 可以生成子集的同时进行处理 ### 为什么这样思考? **1. 二叉选择视角** ``` 对于 [1,2,3]: [] / \ 不选1 选1 [] [1] / \ / \ 不选2 选2 不选2 选2 [] [2] [1] [1,2] / \ / \ / \ / \ 3 [] 3 [2] ... (继续展开) 叶子节点就是所有子集 ``` **2. 回溯法的优势** ``` - 每个节点代表一个决策点 - 自然地表达"选"或"不选" - 可以在任意时刻处理当前子集 - 易于添加约束条件(如子集和限制) ``` ## 解题思路 ### 方法一:回溯法(推荐) **核心思想:**对于每个元素,可以选择包含或不包含。使用回溯法生成所有可能的组合。 **算法步骤:** 1. 初始化结果数组和当前子集 2. 定义回溯函数 `backtrack(start)`: - 将当前子集加入结果 - 从 `start` 开始遍历,依次尝试包含每个元素 - 递归调用后撤销选择(回溯) ### 详细算法流程 **步骤1:理解回溯框架** ```python def backtrack(start): # 将当前子集加入结果 result.append(current[:]) # 从 start 开始尝试包含每个元素 for i in range(start, len(nums)): # 选择当前元素 current.append(nums[i]) # 递归处理下一个元素 backtrack(i + 1) # 撤销选择(回溯) current.pop() ``` **Q: 为什么从 start 而不是从 0 开始?** A: 避免重复生成相同的子集。举例: ``` nums = [1, 2] 如果每次都从 0 开始: - 选 1: current=[1] - 选 2: current=[1,2] ✓ - 选 1: current=[1,1] ✗ 重复! 如果从 start 开始: - i=0: 选 1: current=[1] - i=1: 选 2: current=[1,2] ✓ - i=1: 选 2: current=[2] ✓ ``` **步骤2:理解为何要加入空集** ```python result.append(current[:]) # 在循环前就加入 ``` **Q: 为什么在循环前就加入结果?** A: 因为每个中间状态都是一个有效的子集。举例: ``` nums = [1, 2, 3] 执行过程: 1. current=[] → 加入 [] 2. 选择 1: current=[1] → 加入 [1] 3. 选择 2: current=[1,2] → 加入 [1,2] 4. 选择 3: current=[1,2,3] → 加入 [1,2,3] 5. 回溯:current=[1,2] 6. 回溯:current=[1] 7. 选择 3: current=[1,3] → 加入 [1,3] ... ``` **步骤3:理解回溯的撤销** ```python current.append(nums[i]) # 做选择 backtrack(i + 1) current.pop() # 撤销选择 ``` **Q: 为什么必须撤销?** A: 因为 `current` 是共享的列表,不撤销会影响后续递归。 举例说明撤销的重要性: ``` 不撤销的情况: - 选择 1: current=[1] - 选择 2: current=[1,2],加入结果 - 回溯(但没有撤销) - 选择 3: current=[1,2,3] ✗ 应该是 [1,3] 正确撤销: - 选择 1: current=[1] - 选择 2: current=[1,2],加入结果 - 回溯并撤销:current=[1] - 选择 3: current=[1,3] ✓ ``` ### 关键细节说明 **细节1:为什么用 current[:] 而不是 current?** ```python # 错误写法 result.append(current) # 添加引用 # 正确写法 result.append(current[:]) # 添加副本 ``` **为什么?** - `current` 是可变列表,后续修改会影响已加入结果的数据 - `current[:]` 创建副本,保证结果不被修改 **细节2:为什么循环从 start 开始?** ```python for i in range(start, len(nums)): # 从 start 开始 current.append(nums[i]) backtrack(i + 1) # 下次从 i+1 开始 current.pop() ``` **为什么?** - 避免重复:确保子集中的元素按原数组顺序出现 - 例如:[1,2] 会出现,但 [2,1] 不会出现 **细节3:如何理解生成所有子集?** ``` nums = [1, 2, 3] 回溯树: [] / | \ 不选2 选2 选3 (错误理解) | 选3 正确理解(按顺序): [] / | \ [1] [2] [3] / \ | [1,2] [1,3] [2,3] | [1,2,3] 每个节点都是一个有效的子集! ``` ### 边界条件分析 **边界1:空数组** ``` 输入:nums = [] 输出:[[]] 原因:空集是任何集合的子集 ``` **边界2:单个元素** ``` 输入:nums = [1] 输出:[[], [1]] 过程: - 初始:current=[],加入 [] - 选择 1:current=[1],加入 [1] ``` **边界3:所有元素相同** ``` 输入:nums = [1, 1, 1] 输出:[[], [1], [1,1], [1,1,1]] 注意:题目说元素互不相同,所以这种情况不会出现 ``` ### 复杂度分析(详细版) **时间复杂度:** ``` - 子集数量:2^n - 每个子集的构建:O(n)(最坏情况) - **总时间复杂度:O(n × 2^n)** 为什么每个子集是 O(n)? - 虽然子集长度不同,但平均长度是 n/2 - 总元素数 = 0×C(n,0) + 1×C(n,1) + ... + n×C(n,n) = n×2^(n-1) - 平均每个子集的元素数 = n×2^(n-1) / 2^n = n/2 ``` **空间复杂度:** ``` - 递归栈深度:O(n) - 存储结果:O(n × 2^n)(所有子集的总元素数) - **空间复杂度:O(n)**(不计结果存储) ### 方法二:迭代法(位掩码) **核心思想:**子集可以用二进制表示。对于 n 个元素,共有 2^n 个子集。 **算法步骤:** 1. 计算子集总数 `total = 1 << n` 2. 对于每个数字 `i` 从 0 到 `total-1`: - 将 `i` 的二进制表示转换为子集 - 第 `j` 位为 1 表示包含 `nums[j]` ### 方法三:级联法 **核心思想:**对于已有的每个子集,通过添加当前元素生成新的子集。 **算法步骤:** 1. 初始化结果为 `[[]]` 2. 对于每个元素: - 取出所有已有子集 - 将当前元素添加到每个子集 - 将新子集加入结果 ## 代码实现 ### Go 实现(回溯法) ```go package main import "fmt" func subsets(nums []int) [][]int { result := [][]int{} current := []int{} var backtrack func(start int) backtrack = func(start int) { // 将当前子集加入结果(需要复制) temp := make([]int, len(current)) copy(temp, current) result = append(result, temp) // 从 start 开始尝试包含每个元素 for i := start; i < len(nums); i++ { // 选择当前元素 current = append(current, nums[i]) // 递归处理下一个元素 backtrack(i + 1) // 撤销选择(回溯) current = current[:len(current)-1] } } backtrack(0) return result } // 测试用例 func main() { // 测试用例1 nums1 := []int{1, 2, 3} fmt.Printf("输入: %v\n", nums1) fmt.Printf("输出: %v\n", subsets(nums1)) // 测试用例2 nums2 := []int{0} fmt.Printf("\n输入: %v\n", nums2) fmt.Printf("输出: %v\n", subsets(nums2)) // 测试用例3 nums3 := []int{1, 2} fmt.Printf("\n输入: %v\n", nums3) fmt.Printf("输出: %v\n", subsets(nums3)) } ``` ```go func subsetsBitMask(nums []int) [][]int { n := len(nums) total := 1 << n // 2^n 个子集 result := make([][]int, 0, total) for mask := 0; mask < total; mask++ { subset := []int{} for i := 0; i < n; i++ { // 检查第 i 位是否为 1 if mask&(1< start && nums[i] == nums[i-1] { continue } current = append(current, nums[i]) backtrack(i + 1) current = current[:len(current)-1] } } backtrack(0) return result } ``` ### Q2: 如果要求子集的大小恰好为 k,应该如何修改? **A:** 在回溯时添加终止条件。 ```go func subsetsK(nums []int, k int) [][]int { result := [][]int{} current := []int{} var backtrack func(start int) backtrack = func(start int) { if len(current) == k { temp := make([]int, len(current)) copy(temp, current) result = append(result, temp) return } for i := start; i < len(nums); i++ { current = append(current, nums[i]) backtrack(i + 1) current = current[:len(current)-1] } } backtrack(0) return result } ``` ## P7 加分项 ### 1. 深度理解:为什么子集问题适合用回溯法? **回溯法的本质:** - 在解空间树中进行深度优先搜索 - 每个节点代表一个决策(包含或不包含当前元素) - 通过撤销选择(回溯)来探索所有可能 **为什么适合子集问题:** 1. **决策清晰:**每个元素只有两种选择(包含或不包含) 2. **无后效性:**当前选择不影响之前的选择 3. **边界明确:**子集大小从 0 到 n ### 2. 实战扩展:组合与排列 **组合问题:**从 n 个元素中选 k 个,不考虑顺序 **排列问题:**从 n 个元素中选 k 个,考虑顺序 ```go // 组合 func combine(n int, k int) [][]int { result := [][]int{} current := []int{} var backtrack func(start int) backtrack = func(start int) { if len(current) == k { temp := make([]int, len(current)) copy(temp, current) result = append(result, temp) return } for i := start; i <= n; i++ { current = append(current, i) backtrack(i + 1) current = current[:len(current)-1] } } backtrack(1) return result } // 排列 func permute(nums []int) [][]int { result := [][]int{} current := []int{} used := make([]bool, len(nums)) var backtrack func() backtrack = func() { if len(current) == len(nums) { temp := make([]int, len(current)) copy(temp, current) result = append(result, temp) return } for i := 0; i < len(nums); i++ { if used[i] { continue } current = append(current, nums[i]) used[i] = true backtrack() current = current[:len(current)-1] used[i] = false } } backtrack() return result } ``` ### 3. 变形题目 #### 变形1:子集 II(有重复元素) **LeetCode 90:** 给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。 ```go func subsetsWithDup(nums []int) [][]int { sort.Ints(nums) result := [][]int{} current := []int{} var backtrack func(start int) backtrack = func(start int) { temp := make([]int, len(current)) copy(temp, current) result = append(result, temp) for i := start; i < len(nums); i++ { if i > start && nums[i] == nums[i-1] { continue } current = append(current, nums[i]) backtrack(i + 1) current = current[:len(current)-1] } } backtrack(0) return result } ``` ### 4. 相关题目推荐 - LeetCode 78: 子集(本题) - LeetCode 90: 子集 II - LeetCode 77: 组合 - LeetCode 46: 全排列 - LeetCode 47: 全排列 II