Files
interview/16-LeetCode Hot 100/子集.md
yasinshaw 5c1c974e88 docs: 改进LeetCode二叉树题目解题思路
按照改进方案,为以下6个二叉树题目增强了解题思路的详细程度:

1. 二叉树的中序遍历
   - 增加"思路推导"部分,解释递归到迭代的转换
   - 详细说明迭代法的每个步骤
   - 增加执行过程演示和多种解法

2. 二叉树的最大深度
   - 增加"思路推导",对比DFS和BFS
   - 详细解释递归的基准情况
   - 增加多种解法和变体问题

3. 从前序与中序遍历序列构造二叉树
   - 详细解释前序和中序的特点
   - 增加"思路推导",说明如何分治
   - 详细说明切片边界计算

4. 对称二叉树
   - 解释镜像对称的定义
   - 详细说明递归比较的逻辑
   - 增加迭代解法和变体问题

5. 翻转二叉树
   - 解释翻转的定义和过程
   - 详细说明多值赋值的执行顺序
   - 增加多种解法和有趣的故事

6. 路径总和
   - 详细解释路径和叶子节点的定义
   - 说明为什么使用递减而非累加
   - 增加多种解法和变体问题

每个文件都包含:
- 完整的示例和边界条件分析
- 详细的算法流程和图解
- 关键细节说明
- 常见错误分析
- 复杂度分析(详细版)
- 执行过程演示
- 多种解法
- 变体问题
- 总结

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 21:33:57 +08:00

17 KiB
Raw Blame History

子集 (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 中的所有元素 互不相同

思路推导

暴力解法分析

第一步:直观思路 - 枚举所有可能的子集

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理解回溯框架

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理解为何要加入空集

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理解回溯的撤销

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

# 错误写法
result.append(current)  # 添加引用

# 正确写法
result.append(current[:])  # 添加副本

为什么?

  • current 是可变列表,后续修改会影响已加入结果的数据
  • current[:] 创建副本,保证结果不被修改

细节2为什么循环从 start 开始?

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=[],加入 []
  - 选择 1current=[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))
}
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<<i) != 0 {
				subset = append(subset, nums[i])
			}
		}
		result = append(result, subset)
	}

	return result
}
func subsetsCascade(nums []int) [][]int {
	result := [][]int{{}} // 初始化为空集

	for _, num := range nums {
		// 对于每个已有子集,添加当前元素生成新子集
		newSubsets := make([][]int, 0, len(result))
		for _, subset := range result {
			newSubset := make([]int, len(subset)+1)
			copy(newSubset, subset)
			newSubset[len(subset)] = num
			newSubsets = append(newSubsets, newSubset)
		}
		result = append(result, newSubsets...)
	}

	return result
}

复杂度分析

回溯法

  • 时间复杂度: O(n × 2^n)

    • 共有 2^n 个子集
    • 每个子集的复制需要 O(n) 时间
  • 空间复杂度: O(n)

    • 递归栈深度最大为 n
    • 不包括存储结果的空间

迭代法(位掩码)

  • 时间复杂度: O(n × 2^n)

    • 需要生成 2^n 个子集
    • 每个子集需要 O(n) 时间构建
  • 空间复杂度: O(1)

    • 只使用了常数级别的额外空间(不包括结果)

级联法

  • 时间复杂度: O(n × 2^n)

    • 每次迭代都会将子集数量翻倍
    • 总共需要处理 n 次
  • 空间复杂度: O(n × 2^n)

    • 需要存储所有子集

执行过程演示

nums = [1, 2, 3] 为例:

初始状态result=[], current=[], start=0

第1层递归 (start=0):
  result=[[]]  # 加入空集
  循环i 从 0 到 2

  i=0, nums[0]=1:
    current=[1]
    递归 backtrack(1)
      ├─ result=[[], [1]]  # 加入 [1]
      ├─ i=1, nums[1]=2:
      │   current=[1,2]
      │   递归 backtrack(2)
      │     ├─ result=[[], [1], [1,2]]  # 加入 [1,2]
      │     ├─ i=2, nums[2]=3:
      │     │   current=[1,2,3]
      │     │   递归 backtrack(3)
      │     │     ├─ result=[[], [1], [1,2], [1,2,3]]  # 加入 [1,2,3]
      │     │     └─ 返回
      │     │   current=[1,2]  # 撤销 3
      │     └─ 返回
      │   current=[1]  # 撤销 2
      ├─ i=2, nums[2]=3:
      │   current=[1,3]
      │   递归 backtrack(3)
      │     ├─ result=[[], [1], [1,2], [1,2,3], [1,3]]  # 加入 [1,3]
      │     └─ 返回
      │   current=[1]  # 撤销 3
      └─ 返回
    current=[]  # 撤销 1

  i=1, nums[1]=2:
    current=[2]
    递归 backtrack(2)
      ├─ result=[[], [1], [1,2], [1,2,3], [1,3], [2]]  # 加入 [2]
      ├─ i=2, nums[2]=3:
      │   current=[2,3]
      │   递归 backtrack(3)
      │     ├─ result=[[], [1], [1,2], [1,2,3], [1,3], [2], [2,3]]  # 加入 [2,3]
      │     └─ 返回
      │   current=[2]  # 撤销 3
      └─ 返回
    current=[]  # 撤销 2

  i=2, nums[2]=3:
    current=[3]
    递归 backtrack(3)
      ├─ result=[[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]  # 加入 [3]
      └─ 返回
    current=[]  # 撤销 3

最终结果:[[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

常见错误

错误1忘记复制 current

错误写法:

func subsets(nums []int) [][]int {
    result := [][]int{}
    current := []int{}

    var backtrack func(start int)
    backtrack = func(start int) {
        result = append(result, current)  // 错误!添加引用
        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 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)

        for i := start; i < len(nums); i++ {
            current = append(current, nums[i])
            backtrack(i + 1)
            current = current[:len(current)-1]
        }
    }

    backtrack(0)
    return result
}

**原因:**Go 中切片是引用类型,直接添加会导致所有结果都是同一个切片的引用。

错误2循环从 0 开始而不是 start

错误写法:

for i := 0; i < len(nums); i++ {  // 错误:从 0 开始
    current = append(current, nums[i])
    backtrack(i + 1)
    current = current[:len(current)-1]
}

正确写法:

for i := start; i < len(nums); i++ {  // 正确:从 start 开始
    current = append(current, nums[i])
    backtrack(i + 1)
    current = current[:len(current)-1]
}

**原因:**会导致重复生成相同的子集。

错误3忘记撤销选择

错误写法:

for i := start; i < len(nums); i++ {
    current = append(current, nums[i])
    backtrack(i + 1)
    // 忘记撤销
}

正确写法:

for i := start; i < len(nums); i++ {
    current = append(current, nums[i])
    backtrack(i + 1)
    current = current[:len(current)-1]  // 必须撤销
}

**原因:**不撤销会导致后续递归使用错误的 current。

进阶问题

Q1: 如果数组中有重复元素,应该如何处理?

A: 需要先排序,然后在回溯时跳过重复元素。

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
}

Q2: 如果要求子集的大小恰好为 k应该如何修改

A: 在回溯时添加终止条件。

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 个,考虑顺序

// 组合
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返回该数组所有可能的子集幂集

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