求
本节把这种直觉固定成三个可复用的对象:解向量(决定序列本身)、状态空间树(所有决定序列的组织方式)和选择-探索-撤销模板(遍历这棵树的固定骨架)。后两节的全部例题都只是往这三件东西里填内容。
解向量与结点的三种状态
DEF定义 · 解向量与约束
一个问题的解被写成 解向量
全排列里
DEF定义 · 活结点、扩展结点与死结点
在状态空间树上:已被生成、但还没被探索完的结点叫 活结点;当前正在生成孩子结点的活结点叫 扩展结点;不再会被扩展的结点(孩子已全部处理完、或已被约束排除)叫 死结点。
回溯做深度优先遍历,任一时刻只有一个扩展结点,活结点构成一条从根到它的路径(恰好存在调用栈里);15.4 的分支限界做广度或优先级遍历,同时持有整批活结点。这是两种方法在存储上的分水岭,术语先在此统一。
两棵基本的树:子集树与排列树
DEF定义 · 子集树与排列树
子集树:第
多数约束问题的空间树都是这两类之一或其变体(N 皇后按行放置:每层
两个读图要点:其一,树按路径区分结点,不按标签区分——图中出现三个
选择-探索-撤销:一个骨架,三步纪律
EX示例 · 回溯模板(以子集枚举为例)
#include <iostream>
#include <vector>
void dfs(int depth, int n, std::vector<char>& chosen) {
if (depth == n) { // 终态:解向量完整
std::cout << "{";
for (std::size_t i = 0; i < chosen.size(); ++i) {
if (i) std::cout << ",";
std::cout << chosen[i];
}
std::cout << "}\n";
return;
}
for (int take = 1; take >= 0; --take) { // 先「取」后「不取」:对应图中左、右分支
if (take == 1) {
chosen.push_back(static_cast<char>('a' + depth)); // ① 做选择
}
dfs(depth + 1, n, chosen); // ② 探索
if (take == 1) {
chosen.pop_back(); // ③ 撤销
}
}
}
int main() {
std::vector<char> chosen;
dfs(0, 3, chosen); // 输出 {a,b,c} {a,b} {a,c} {a} {b,c} {b} {c} {} 共 8 行
}backtracking-skeleton.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
所有回溯程序都是这个形状:if (终态) 记录,然后 for (取值 : 取值域) { 做选择 → dfs → 撤销 }。区别只在三处——终态怎么判、取值域怎么生成、约束怎么查。
注意「不取」这个分支:它也是一个选择,只是没有需要还原的共享状态,所以 ③ 对它是空操作。只有真正修改了共享状态的选择才需要撤销——这半句话是下面执行的钥匙。
执行轨迹:把代码跑成图上的一次行走
把上面的程序执行到第二个叶子为止,用缩进表示递归深度,可以逐行对应到下图中的结点与虚线边。左列注释里的「①②③」即代码中的三步。
进入 dfs(depth=0) 部分解 ∅ ← 根结点
① 做选择:取 a chosen={a}
进入 dfs(depth=1) 部分解 {a}
① 做选择:取 b chosen={a,b}
进入 dfs(depth=2) 部分解 {a,b}
① 做选择:取 c chosen={a,b,c}
进入 dfs(depth=3) 叶子:记录子集 {a,b,c}
③ 撤销:弹出 c chosen={a,b}
① 做选择:不取 c
进入 dfs(depth=3) 叶子:记录子集 {a,b}
返回(不取 c,无需还原)
返回 → ③ 撤销:弹出 b chosen={a} ← dfs(depth=2) 的取值域耗尽
① 做选择:不取 b ……
(后续按同样模式继续,直到枚举完 8 个子集)为什么不会漏:完备性
下图把上面的轨迹画回树里:深色实线是前进(做选择),虚线是撤销回跳。仔细看两条虚线——它们分别对应轨迹中「弹出 c」与「弹出 b」两处撤销。
撤销之后算法换到了「不取 b」的分支,
THM定理 · 回溯的完备性
设解向量长度为
PROOF证明
对已确定的分量个数
IDEA直觉 · 为什么「撤销」不能省
递归返回只负责把「执行位置」退回父结点;chosen、used 这类共享状态不会自己退回去。如果不撤销,下一个分支会在被上一个分支污染的状态上做判断——完备性证明中的前提 (c) 被破坏,定理不再适用。这也是为什么模板把「做选择」与「撤销」写成一对照对出现。对照第 7 章的 DFS:显式图遍历只需一个 visited 集合,因为结点身份由顶点编号决定;这里的状态由决策路径决定,每深入一层都可能新增若干份共享状态,撤销纪律必须逐一兑现。
复杂度:从树的规模数出来
O(·)复杂度 · 无剪枝回溯的成本
前提:约束检查与每结点扩展为
WARN易错点 · 撤销不完整与终态判断错位
撤销不完整:忘记还原与解向量同步修改的共享状态(used 标记、行列占用数组、当前重量),后续分支读到上一分支的残留,解被悄悄污染且往往不崩溃、只出错。自查方法:每个修改共享状态的「做选择」必须有且仅有一个配对「撤销」,且从函数的任何出口离开前都已执行——在循环中途 return 而绕过撤销是最常见的写法来源。终态判断错位:「到达即记录」与「叶子才记录」是两种语义,若问题要求解向量满 if (满足当前约束) 记录,会把部分解当完整解输出;反过来,子集类问题的部分解本身可能就是合法答案(如「是否存在和为 target 的子集」中前缀和已等于 target 时可提前成功),记录位置要跟题目语义走,不跟手感走。
下一步
骨架就位后,剩下的全是「填内容」:终态、取值域、约束。下一节子集树与排列树实战用全排列、子集和与 N 皇后三道题各填一遍,并把「题面 → 解向量 → 约束 → 空间树 → 复杂度」固定成一张五步建模卡;填完你会发现约束检查的位置,正是 15.3 剪枝的落点。