按照改进方案,为以下6个二叉树题目增强了解题思路的详细程度: 1. 二叉树的中序遍历 - 增加"思路推导"部分,解释递归到迭代的转换 - 详细说明迭代法的每个步骤 - 增加执行过程演示和多种解法 2. 二叉树的最大深度 - 增加"思路推导",对比DFS和BFS - 详细解释递归的基准情况 - 增加多种解法和变体问题 3. 从前序与中序遍历序列构造二叉树 - 详细解释前序和中序的特点 - 增加"思路推导",说明如何分治 - 详细说明切片边界计算 4. 对称二叉树 - 解释镜像对称的定义 - 详细说明递归比较的逻辑 - 增加迭代解法和变体问题 5. 翻转二叉树 - 解释翻转的定义和过程 - 详细说明多值赋值的执行顺序 - 增加多种解法和有趣的故事 6. 路径总和 - 详细解释路径和叶子节点的定义 - 说明为什么使用递减而非累加 - 增加多种解法和变体问题 每个文件都包含: - 完整的示例和边界条件分析 - 详细的算法流程和图解 - 关键细节说明 - 常见错误分析 - 复杂度分析(详细版) - 执行过程演示 - 多种解法 - 变体问题 - 总结 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
17 KiB
子集 (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] <= 10nums中的所有元素 互不相同
思路推导
暴力解法分析
第一步:直观思路 - 枚举所有可能的子集
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)
问题:
- 虽然时间复杂度已经是最优的,但位运算不易理解
- 代码可读性较差
- 难以扩展到带约束条件的子集问题
优化思考 - 如何更直观地生成子集?
核心观察:
- 子集的本质:对每个元素,都有"选"或"不选"两种选择
- 决策树视角:n 个元素构成一个 n 层的决策树
- 回溯法:自然地表达这种"选择-撤销"的过程
为什么用回溯?
- 更直观地表达选择过程
- 易于剪枝(如有约束条件)
- 可以生成子集的同时进行处理
为什么这样思考?
1. 二叉选择视角
对于 [1,2,3]:
[]
/ \
不选1 选1
[] [1]
/ \ / \
不选2 选2 不选2 选2
[] [2] [1] [1,2]
/ \ / \ / \ / \
3 [] 3 [2] ... (继续展开)
叶子节点就是所有子集
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=[],加入 []
- 选择 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))
}
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. 深度理解:为什么子集问题适合用回溯法?
回溯法的本质:
- 在解空间树中进行深度优先搜索
- 每个节点代表一个决策(包含或不包含当前元素)
- 通过撤销选择(回溯)来探索所有可能
为什么适合子集问题:
- **决策清晰:**每个元素只有两种选择(包含或不包含)
- **无后效性:**当前选择不影响之前的选择
- **边界明确:**子集大小从 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