目录
🧠 引言
在算法领域中,“求一个数组的所有子集”是一个经典的组合问题。它不仅常见于面试题库,也是理解回溯算法的关键入门案例。
本文将带你深入剖析如何使用 回溯算法(Backtracking) 来生成一个数组的所有子集。以 nums = [1, 2, 3]
为例,通过代码实现、执行流程模拟以及图文并茂的树状图展示,帮助你彻底掌握回溯算法的核心思想和实现技巧。
🔍 回溯算法原理简介
什么是回溯算法?
回溯算法是一种系统性地搜索所有可能解的算法策略。它本质上是 深度优先搜索(DFS) 的一种变体,常用于解决组合、排列、子集等穷举类问题。
核心思想:
尝试所有可能的选择,在每一步做出选择后递归探索后续状态,如果发现当前路径不能得到有效解,则撤销该选择(回溯),回到上一状态,继续尝试其他选择。
这种 “先走再回头” 的方式,使得回溯算法非常适合处理像子集、组合这样的问题。
🧠 “操作完成后撤销”的两种情况
在回溯算法中,“操作完成后撤销”不仅仅发生在遇到死胡同时,还包括已经找到一个可行解后。以下是这两种情况的具体说明:
1. 走到死胡同 ❌
- 描述:当前选择导致无法继续前进。
- 示例:在一个迷宫游戏中,你选择了某条路径,但这条路最终通向了一个死胡同,没有出口。
- 应对策略:撤销这一步选择,返回到上一个决策点,尝试其他方向。
2. 成功找到一个出口 ✅
- 描述:找到了一个正确的解,但任务是找出所有可能的解。
- 示例:在子集生成问题中,当你生成了
[1, 2]
这个子集后,虽然这是一个有效的解,但为了找出所有子集,还需要撤销这条路径的操作,回到之前的岔路口,继续探索其他路线。 - 应对策略:撤销当前路径,回到上一状态,继续尝试其他可能性。
🎯 核心思想总结
✅ 回溯的本质:“探索 + 撤销”
- 探索:每一步都做出一个选择,递归地继续探索后续状态。
- 撤销:无论当前选择是否成功(找到解或遇到死胡同),都要撤销这一步的选择,恢复到之前的状态,以便尝试其他可能性。
✅ 回溯的两种“撤销”情形
- 失败时的回退:当前选择导致无路可走,需要撤销选择,回到上一步尝试其他方向。
- 成功后的回退:即使当前路径已经找到一个解,只要问题要求找出所有可能的解,就需要撤销当前路径,继续探索其他可能性。
💻 代码实现
我们以 Python 实现一个通用的子集生成函数,使用回溯法来遍历所有可能的子集组合。
from typing import List
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
res, path = [], [] # res 存放所有子集,path 存放当前路径
n = len(nums)
def back(start):
res.append(path.copy()) # 每次进入都记录当前路径作为一个子集
for i in range(start, n): # 从 start 开始选择下一个元素
path.append(nums[i]) # 做出选择
back(i + 1) # 递归进入下一层
path.pop() # 回溯,撤销选择
back(0)
return res
# 测试代码
solution = Solution()
nums = [1, 2, 3]
result = solution.subsets(nums)
print(result)
📌 代码解析
✅ 变量说明:
res
: 最终结果列表,保存所有子集。path
: 当前正在构建的子集。n
: 数组长度。back(start)
: 递归函数,参数start
表示当前可以选择的起始索引。
🔄 函数逻辑:
-
每次调用
back()
都会把当前path
的副本加入res
,这样可以保证空集也被包含。 -
循环选择:
- 从
start
到n-1
遍历可选元素; - 将当前元素加入
path
; - 递归调用
back(i+1)
,继续选择下一个元素; - 回溯操作
path.pop()
,撤销本次选择,尝试下一个元素。
- 从
-
递归终止条件:
- 当
i >= n
时循环结束,自动返回上层。
- 当
🌲 执行过程模拟(以 nums = [1, 2, 3] 为例)
下面我们将详细展示整个递归过程,并结合文本形式的树状图,清晰呈现每一步的状态变化。
📋 执行步骤分解:
步骤 | 当前 path | res 内容(逐步更新) | 操作 |
---|---|---|---|
1 | [] | [[]] | 初始调用 back(0) ,添加空集 |
2 | [1] | [[], [1]] | 选择 1 ,递归到 back(1) |
3 | [1, 2] | [[], [1], [1, 2]] | 选择 2 ,递归到 back(2) |
4 | [1, 2, 3] | [..., [1, 2, 3]] | 选择 3 ,递归到 back(3) |
5 | [1, 2] | 回退 3 | 回溯,撤销 3 |
6 | [1] | 回退 2 | 回溯,撤销 2 |
7 | [1, 3] | 添加 [1, 3] | 选择 3 ,递归到 back(3) |
8 | [1] | 回退 3 | 回溯,撤销 3 |
9 | [] | 回退 1 | 回溯,撤销 1 |
10 | [2] | 添加 [2] | 选择 2 ,递归到 back(2) |
11 | [2, 3] | 添加 [2, 3] | 选择 3 ,递归到 back(3) |
12 | [2] | 回退 3 | 回溯,撤销 3 |
13 | [] | 回退 2 | 回溯,撤销 2 |
14 | [3] | 添加 [3] | 选择 3 ,递归到 back(3) |
15 | [] | 回退 3 | 回溯,撤销 3 |
最终 res
包含了以下 8 个子集:
[[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
🎯 图解递归过程(树状结构)
初始状态: []
│
├── back(0)
│ ├── 添加 []
│ │
│ ├── i=0 → path=[1]
│ │ └── back(1)
│ │ ├── 添加 [1]
│ │ ├── i=1 → path=[1,2]
│ │ │ └── back(2)
│ │ │ ├── 添加 [1,2]
│ │ │ ├── i=2 → path=[1,2,3]
│ │ │ │ └── back(3) → 添加 [1,2,3]
│ │ │ └── 回溯 → path=[1,2]
│ │ └── i=2 → path=[1,3]
│ │ └── back(3) → 添加 [1,3]
│ │
│ ├── i=1 → path=[2]
│ │ └── back(2)
│ │ ├── 添加 [2]
│ │ ├── i=2 → path=[2,3]
│ │ │ └── back(3) → 添加 [2,3]
│ │ └── 回溯 → path=[2]
│ │
│ └── i=2 → path=[3]
│ └── back(3) → 添加 [3]
│
└── 返回完整结果 res
更详细的模拟
初始状态: res = [], path = []
|
v
back(0) [[]] (将 [] 添加到 res 中)
|
|-- i = 0: path = [1]
| |
| v
| back(1) [[], [1]] (将 [1] 添加到 res 中)
| |
| |-- i = 1: path = [1, 2]
| | |
| | v
| | back(2) [[], [1], [1, 2]] (将 [1, 2] 添加到 res 中)
| | |
| | |-- i = 2: path = [1, 2, 3]
| | | |
| | | v
| | | back(3) [[], [1], [1, 2], [1, 2, 3]] (将 [1, 2, 3] 添加到 res 中)
| | | |
| | | 回溯 (path.pop(), path = [1, 2])
| | |
| | 循环结束,回溯 (path.pop(), path = [1])
| |
| |-- i = 2: path = [1, 3]
| | |
| | v
| | back(3) [[], [1], [1, 2], [1, 2, 3], [1, 3]] (将 [1, 3] 添加到 res 中)
| | |
| | 回溯 (path.pop(), path = [1])
| |
| 循环结束,回溯 (path.pop(), path = [])
|
|-- i = 1: path = [2]
| |
| v
| back(2) [[], [1], [1, 2], [1, 2, 3], [1, 3], [2]] (将 [2] 添加到 res 中)
| |
| |-- i = 2: path = [2, 3]
| | |
| | v
| | back(3) [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3]] (将 [2, 3] 添加到 res 中)
| | |
| | 回溯 (path.pop(), path = [2])
| |
| 循环结束,回溯 (path.pop(), path = [])
|
|-- i = 2: path = [3]
| |
| v
| back(3) [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]] (将 [3] 添加到 res 中)
| |
| 回溯 (path.pop(), path = [])
|
循环结束,返回 res
🧠 回溯的本质总结
回溯 = DFS + 状态恢复
在每一步做出选择后,要记得在递归返回后把这个选择“还原”,这样才不会影响下一次的选择。
✅ 总结
特点 | 描述 |
---|---|
时间复杂度 | O(n * 2^n),因为每个元素有选或不选两种情况,共 2^n 个子集,每个子集平均长度为 n |
空间复杂度 | O(n),递归栈的最大深度 |
适用场景 | 子集、组合、排列、棋盘问题等需要穷举所有组合的问题 |
关键点 | 每次递归都要做一次选择,递归完成后必须撤销选择,即“先走再回头” |
📢 结语
回溯算法虽然看起来复杂,但只要理解了其核心思想 —— “做出选择 → 递归探索 → 撤销选择”,就能轻松应对各种组合类问题。