一、当没有多项式算法时
第 28 讲的分治、第 30 讲的贪心、第 31 讲的动态规划,都要求问题有某种结构——子问题独立、贪心选择性质、或最优子结构。
当这些结构都不存在时,剩下的办法只有一个:把所有可能都试一遍。
⭐ 但"试一遍"不等于"笨"。回溯(backtracking)是有组织的穷举:它把解空间组织成一棵树,深度优先地走,并且在确定某条分支不可能产生解时立刻掉头。这个"立刻掉头"就是剪枝,它常常把 2ⁿ 的理论规模削减几个数量级。
这一讲的位置很重要。把三种范式放在一起看,会发现它们是同一件事的三个切面:
解空间树(所有可能的选择序列)
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
回溯:走遍整棵树 贪心:只走一条枝 DP:走遍但记住重复的子树
(+剪枝) (靠交换论证保证对) (靠重叠子问题省下指数)
⭐ DP 就是"回溯 + 记忆化"——第 31 讲会看到,几乎每个 DP 都可以先写成回溯,再加一个 memo 表。先写回溯往往是设计 DP 最可靠的路径。
二、通用框架
回溯的三要素:
| 要素 | 含义 |
|---|---|
| 状态(path) | 当前已经做出的选择序列 |
| 选择列表 | 当前状态下还能做哪些选择 |
| 撤销(undo) | 返回上层前,把这一层的修改还原 |
func backtrack(path []T, state *State) {
if isSolution(path) {
record(path) // ⚠️ 若要保存,必须拷贝(见下)
return
}
for _, choice := range candidates(state) {
if !isValid(choice, state) {
continue // ⭐ 剪枝:不合法就根本不进入
}
apply(choice, &path, state) // 做选择
backtrack(path, state) // 递归
undo(choice, &path, state) // ⭐ 撤销
}
}
⚠️ Go 里最容易踩的坑:切片共享底层数组
res = append(res, path) // ✗ path 是切片,后续修改会污染已保存的结果
res = append(res, slices.Clone(path)) // ✓ 必须深拷贝
res = append(res, append([]int(nil), path...)) // ✓ 等价写法
这是回溯题里最高频的 bug,而且症状极具迷惑性:结果数量正确,但内容全是最后一次的状态。根因是第 5 讲讲过的切片共享底层数组——path 在撤销时被就地修改,而 res 里存的是指向同一块内存的切片头。
三、子集与排列:两种基本形态
子集(组合):每个元素选或不选
func Subsets(nums []int) [][]int {
var res [][]int
var path []int
var dfs func(start int)
dfs = func(start int) {
res = append(res, slices.Clone(path)) // 每个节点都是一个解
for i := start; i < len(nums); i++ {
path = append(path, nums[i])
dfs(i + 1) // ⭐ i+1:每个元素最多用一次,且不回头 ⟹ 天然去重
path = path[:len(path)-1] // 撤销
}
}
dfs(0)
return res
}
⭐ start 参数是组合类问题的核心:它保证选出的下标严格递增,从而 {1,2} 和 {2,1} 只会产生一次。组合不关心顺序,用 start 消除顺序;排列关心顺序,就不能用 start。
排列:每个元素用一次,但顺序重要
func Permutations(nums []int) [][]int {
var res [][]int
path := make([]int, 0, len(nums))
used := make([]bool, len(nums))
var dfs func()
dfs = func() {
if len(path) == len(nums) {
res = append(res, slices.Clone(path))
return
}
for i := range nums {
if used[i] {
continue // 排列没有 start,改用 used 标记
}
used[i] = true
path = append(path, nums[i])
dfs()
path = path[:len(path)-1]
used[i] = false // ⭐ 两个撤销都不能漏
}
}
dfs()
return res
}
⭐ 含重复元素时的去重
这是回溯里最容易写错的一处。 先排序,然后在同一层跳过重复值:
func PermutationsUnique(nums []int) [][]int {
slices.Sort(nums) // 前提:相同元素相邻
var res [][]int
var path []int
used := make([]bool, len(nums))
var dfs func()
dfs = func() {
if len(path) == len(nums) {
res = append(res, slices.Clone(path))
return
}
for i := range nums {
if used[i] {
continue
}
// ⭐ 关键:同一层里,若前一个相同元素还没被用,说明我们正处在
// "跳过前一个、直接用后一个" 的分支上 —— 它与之前的分支重复
if i > 0 && nums[i] == nums[i-1] && !used[i-1] {
continue
}
used[i] = true
path = append(path, nums[i])
dfs()
path = path[:len(path)-1]
used[i] = false
}
}
dfs()
return res
}
为什么条件是 !used[i-1] 而不是 used[i-1]?
考虑 [1, 1'](两个相同的 1)。我们希望只允许按下标顺序使用相同元素,即只有 1 已被用时才允许用 1':
!used[i-1] ⟹ 前一个 1 没被用,当前却要用 1' ⟹ 这是"跳过 1 用 1'"的分支
这与"用 1 跳过 1'"的分支产生完全相同的排列 ⟹ 剪掉 ✓
used[i-1] ⟹ 前一个 1 已在路径中,现在用 1' 是合法的"两个都用" ⟹ 保留
⚠️ 写成 used[i-1] 会剪掉合法分支,得到偏少的结果;不写这个判断会得到重复的结果。两种错误都不会报错,只会给出错误答案——所以必须理解它,而不是背它。
复杂度
| 形态 | 解的数量 | 复杂度 |
|---|---|---|
| 子集 | 2ⁿ | Θ(n·2ⁿ)(每个解要拷贝 O(n)) |
| 排列 | n! | Θ(n·n!) |
| 组合 C(n,k) | C(n,k) | Θ(k·C(n,k)) |
⭐ 输出规模本身就是指数级的,所以这个复杂度不可能改进——除非题目只要求计数而非枚举,那往往可以用 DP 降到多项式。“要枚举"和"要计数"是完全不同难度的两个问题,看清楚题目问的是哪个。
四、N 皇后
在 n×n 棋盘上放 n 个皇后,使任意两个不在同一行、列或对角线上。
关键建模:每行恰好放一个皇后 ⟹ 解就是一个排列,状态只需记录"每列/每条对角线是否被占”。
func SolveNQueens(n int) int {
cols := make([]bool, n)
diag1 := make([]bool, 2*n) // ↘ 方向:row - col + n 恒定
diag2 := make([]bool, 2*n) // ↙ 方向:row + col 恒定
count := 0
var dfs func(row int)
dfs = func(row int) {
if row == n {
count++
return
}
for col := 0; col < n; col++ {
d1, d2 := row-col+n, row+col
if cols[col] || diag1[d1] || diag2[d2] {
continue // ⭐ 剪枝:这一格被攻击
}
cols[col], diag1[d1], diag2[d2] = true, true, true
dfs(row + 1)
cols[col], diag1[d1], diag2[d2] = false, false, false // 撤销
}
}
dfs(0)
return count
}
⭐ 两条对角线的编号方式值得记住:↘ 上 row - col 恒定(加 n 避免负数),↙ 上 row + col 恒定。把二维约束降成一维数组查表,是把 O(n) 的冲突检测变成 O(1) 的标准手法。
位运算版本
n ≤ 32 时,用三个整数代替三个数组,快一个数量级:
func SolveNQueensBits(n int) int {
count := 0
full := (1 << n) - 1
var dfs func(cols, d1, d2 int)
dfs = func(cols, d1, d2 int) {
if cols == full {
count++
return
}
// ⭐ 一次算出所有可放位置:取反后的空位
avail := full &^ (cols | d1 | d2)
for avail != 0 {
p := avail & (-avail) // 取最低位的 1(第 20 讲的 lowbit)
avail ^= p // 用掉它
dfs(cols|p, (d1|p)<<1&full, (d2|p)>>1)
// ↑ 对角线随行数下移而平移
}
}
dfs(0, 0, 0)
return count
}
这段代码是位运算枚举的范本:avail & (-avail) 取最低位的 1、avail ^= p 消掉它——第 32 讲状压 DP 用的是同一套技巧。
规模感:n = 8 有 92 个解,n = 15 有 2 279 184 个,n = 27 至今仍是被完整求解的最大规模(2016 年,用了超算集群)。⭐ 这说明剪枝再好,指数增长仍然是指数增长——剪枝改变的是常数和可行规模,不是复杂度类。
五、剪枝:把指数削小的四种手段
剪枝是回溯的全部价值所在。 没有剪枝,回溯就只是朴素穷举。
① 可行性剪枝(constraint propagation)
一旦发现当前部分解已经违反约束,立即返回。N 皇后里的 cols[col] || diag1 || diag2 就是。
更强的版本是约束传播:数独求解中,填入一个数后立即更新所有相关格子的候选集,若某格候选集为空则立刻回溯。⭐ 这比"填完再检查"能提前几十层剪掉整棵子树。
② 最优性剪枝(bound pruning)
求最优解时,若"当前代价 + 剩余部分的乐观估计" 已经不优于已找到的最优解,剪掉。
if curCost + optimisticBound(remaining) >= best {
return // 这条分支不可能更优
}
⚠️ optimisticBound 必须是真正的下界(不能高估),否则会剪掉最优解。这与 A* 的可采纳启发式是同一个条件。
③ 搜索顺序启发(ordering heuristics)
⭐ 这一条常常是效果最好的,也最容易被忽略。
最受约束变量优先(MRV):先处理候选最少的位置 ⟹ 尽早触发冲突,尽早剪枝
最少约束值优先(LCV): 优先尝试对其他变量限制最小的取值
数独求解中,“总是先填候选数最少的格子"通常带来 10–100 倍加速——比任何代码微优化都有效。直觉:尽早失败比晚失败好,因为失败得越早,剪掉的子树越大。
④ 对称性破除(symmetry breaking)
若解空间存在对称,只搜索其中一个代表。N 皇后中第一行只需搜索左半边(右半边的解由镜像得到),直接减半。
剪枝的效果
| 手段 | 典型收益 |
|---|---|
| 可行性剪枝 | 数量级 |
| 约束传播 | 数量级 |
| 搜索顺序启发 | 常常是最大的一项 |
| 最优性剪枝(界越紧越好) | 数量级 |
| 对称性破除 | 常数倍(2×、4×、8×) |
⚠️ 剪枝本身也有代价。一个需要 O(n²) 计算的"更聪明的界”,可能不如一个 O(1) 的粗糙界跑得快。必须实测,不能靠推理。
六、分支限界
分支限界(branch and bound)= 回溯 + 最优性剪枝 + 更聪明的搜索顺序。
| 回溯 | 分支限界 | |
|---|---|---|
| 搜索顺序 | 深度优先(栈) | 最优优先(优先队列,按界排序) |
| 目标 | 找所有解 / 任一解 | 找最优解 |
| 内存 | O(深度) | 可能 O(指数) |
// 0-1 背包的分支限界:优先展开"乐观估计最大"的节点
type node struct {
level, value, weight int
bound float64 // 乐观上界:用分数背包松弛计算
}
func knapsackBB(items []Item, W int) int {
// 按单位价值降序排列,使分数背包松弛成为有效上界
slices.SortFunc(items, func(a, b Item) int {
return cmp.Compare(b.Value*a.Weight, a.Value*b.Weight)
})
pq := &nodeHeap{} // 最大堆,按 bound 排序
heap.Push(pq, &node{level: -1, bound: fracBound(items, W, -1, 0, 0)})
best := 0
for pq.Len() > 0 {
u := heap.Pop(pq).(*node)
if u.bound <= float64(best) {
continue // ⭐ 上界已不优于现有解,整棵子树剪掉
}
i := u.level + 1
if i >= len(items) {
continue
}
// 分支 1:取第 i 件
if w := u.weight + items[i].Weight; w <= W {
v := u.value + items[i].Value
best = max(best, v)
if b := fracBound(items, W, i, v, w); b > float64(best) {
heap.Push(pq, &node{i, v, w, b})
}
}
// 分支 2:不取第 i 件
if b := fracBound(items, W, i, u.value, u.weight); b > float64(best) {
heap.Push(pq, &node{i, u.value, u.weight, b})
}
}
return best
}
⭐ 这里的上界用第 30 讲的分数背包贪心计算——分数背包是 0-1 背包的松弛(relaxation),它的最优解必然 ≥ 0-1 背包的最优解,因此是一个合法的乐观界。
“松弛出一个界,用界来剪枝"是整个组合优化领域的核心套路。 现代 ILP 求解器(Gurobi、CPLEX)的骨架就是分支限界 + 线性规划松弛,它们能求解百万变量的实例,靠的正是这个。这也回答了第 34 讲的问题:为什么 NP-难问题在实践中常常可解。
与 A* 的关系
⭐ A*(第 25 讲)就是分支限界在最短路问题上的特例:
分支限界的 "界" ≙ A* 的 f(v) = g(v) + h(v)
"界必须乐观(不高估)" ≙ h 必须可采纳(admissible)
"按界排序展开" ≙ 优先队列按 f 排序
两者是同一个思想在不同问题上的化身。
七、三种范式的统一视角
回到开头那张图,现在可以说得更精确:
| 回溯 | 贪心 | DP | |
|---|---|---|---|
| 走解空间树的方式 | 全走,靠剪枝 | 只走一条枝 | 全走,靠记忆化 |
| 正确性靠什么 | 穷举的完备性 | 交换论证(第 30 讲) | 最优子结构 + 重叠子问题 |
| 复杂度 | 指数(剪枝后常可用) | 通常 O(n log n) | 状态数 × 转移代价 |
| 失败的表现 | 太慢 | 给出错误答案 | 状态设计不出来 |
⭐ 注意最后一行:贪心失败时会静悄悄地给出错误答案,而回溯失败只是慢。 这就是为什么没有证明的贪心是危险的,而回溯至少是安全的。
实用的解题顺序:
① 先写回溯(保证正确)
② 看子问题是否重叠 ──▶ 是:加 memo,变成 DP
└─▶ 否:找剪枝,或找贪心策略(但必须证明)
⭐ 这条路径在工程和面试中都极其有用:先得到一个正确但慢的版本,再有依据地优化——而不是一上来猜一个贪心策略然后祈祷它对。
八、什么时候该用回溯
| 场景 | 说明 |
|---|---|
| 要枚举所有解 | 子集、排列、组合、划分 |
| 约束满足问题(CSP) | 数独、图着色、八皇后、时间表排课 |
| 搜索空间小或剪枝强 | n ≤ 20 左右的精确求解 |
| NP-难问题的精确解 | 配合分支限界,第 34 讲的出路之一 |
| 没有更好的办法 | 诚实地承认这一点也是一种能力 |
⚠️ 不该用的场景:子问题明显重叠(该用 DP)、有可证明的贪心策略、或规模大到剪枝也救不了(该转向近似或启发式)。
一个现实的提醒:真正的工业级 CSP 求解不会手写回溯,而是把问题编码成 SAT 或 ILP,交给成熟求解器。手写回溯的价值在于理解机制,以及处理那些不方便编码成标准形式的问题。
随堂自测
- 回溯的三要素是什么?为什么"撤销"这一步不能省略?
- Go 中保存回溯结果时为什么必须
slices.Clone(path)?不拷贝会出现什么症状? - 子集枚举用
start参数、排列枚举用used数组,为什么不能反过来? - 含重复元素的排列去重条件是
!used[i-1],为什么不是used[i-1]?分别会导致什么错误? - N 皇后中两条对角线如何编号成一维数组下标?这把什么复杂度降成了什么?
- 位运算版 N 皇后中,
avail & (-avail)和(d1|p)<<1各是什么含义? - 剪枝的四种手段是什么?哪一种在实践中收益往往最大,为什么?
- 最优性剪枝的界为什么必须是"乐观"的?这与 A* 的哪个条件对应?
- 分支限界与回溯的两个区别是什么?0-1 背包的上界为什么可以用分数背包算?
- 说明"DP 是回溯 + 记忆化”。给出一个先写回溯再转 DP 的例子。
- 贪心失败和回溯失败的表现有什么本质不同?这对解题顺序有什么启示?