15.1 给了骨架:解向量、状态空间树、选择-探索-撤销。本节做三次「填空」——每道题都按同一张卡推进,题面 → 解向量 → 约束 → 空间树形态 → 复杂度。这张卡的作用与第 14 章的状态设计卡相同:写代码之前先把每个决定说清楚,代码只是卡的直译。
三道题各占一种树:全排列是排列树,子集和是子集树,N 皇后是「每层取值随约束收缩」的第三种形态。读完应当能独立复述:同一副骨架如何长出三棵不同的树。
完整案例 1 · 全排列:排列树的标准形
建模卡
| 步骤 | 内容 |
|---|---|
| 题面 | 输出 |
| 解向量 | |
| 约束 | 显式: |
| 空间树 | 排列树:第 1 层 |
| 复杂度 | 时间 |
先看树的形状,注意它与子集树的差别:
对比 15.1 的子集树:同样是 3 层决策,排列树每往下一层,取值域就收缩一个元素(3 支 → 2 支 → 1 支),所以叶子是
实现 A:used 标记(显式维护取值域)
#include <iostream>
#include <vector>
std::vector<int> perm; // 解向量前缀
void dfs(const std::vector<int>& a, std::vector<bool>& used) {
if (perm.size() == a.size()) { // 终态:n 个位置都填满
for (int v : perm) std::cout << v;
std::cout << '\n';
return;
}
for (std::size_t i = 0; i < a.size(); ++i) {
if (used[i]) continue; // 隐式约束:互不相同
used[i] = true; // ① 做选择
perm.push_back(a[i]);
dfs(a, used); // ② 探索
perm.pop_back(); // ③ 撤销:两份共享状态各还原一次
used[i] = false;
}
}
int main() {
std::vector<int> a{1, 2, 3};
std::vector<bool> used(a.size(), false);
dfs(a, used); // 按(近似)字典序输出 6 个排列
}permutations-used.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
注意撤销是成对还原:perm 与 used 都是跨分支共享的状态,各需一次还原,少一处就出现 15.1 易错点说的「残留污染」。
实现 B:原地交换(把取值域藏进数组尾部)
#include <algorithm>
#include <iostream>
#include <vector>
void dfs(std::vector<int>& a, int depth) {
if (depth == static_cast<int>(a.size())) {
for (int v : a) std::cout << v;
std::cout << '\n';
return;
}
for (int i = depth; i < static_cast<int>(a.size()); ++i) {
std::swap(a[depth], a[i]); // ① 做选择:把候选换到第 depth 位
dfs(a, depth + 1); // ② 探索(前缀 [0,depth] 已定)
std::swap(a[depth], a[i]); // ③ 撤销:再换一次,数组复原
}
}
int main() {
std::vector<int> a{1, 2, 3};
dfs(a, 0);
}permutations-swap.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
实现 B 没有 used、没有 perm:数组前缀 [0, depth) 就是部分解,后缀 [depth, n) 就是取值域。每次把一个候选交换到 depth 位置,等于「从取值域里取出它」;递归后原样换回,等于撤销。代价是输出顺序不再是字典序。两版时间同为
完整案例 2 · 子集和:子集树与判定问题
建模卡
| 步骤 | 内容 |
|---|---|
| 题面 | 正整数数组 a[0..n-1],问能否选出若干个数使其和恰为 target(变体:输出全部选法) |
| 解向量 | |
| 约束 | 显式: |
| 空间树 | 子集树: |
| 复杂度 | 判定版最坏 |
实现:判定版 + 一行雏形剪枝
#include <iostream>
#include <vector>
bool dfs(const std::vector<int>& a, std::size_t i, int remaining) {
if (remaining == 0) return true; // 已选部分和恰为目标:提前成功
if (i == a.size()) return false; // 元素用尽仍未凑出
if (a[i] <= remaining && dfs(a, i + 1, remaining - a[i])) return true;
return dfs(a, i + 1, remaining); // 不取 a[i]
}
int main() {
std::vector<int> a{3, 34, 4, 12, 5, 2};
std::cout << (dfs(a, 0, 9) ? "yes" : "no") << '\n'; // yes:9 = 4 + 5
}subset-sum-backtracking.cpp2
3
4
5
6
7
8
9
10
11
12
13
14
两个值得停留的细节。其一,remaining == 0 提前返回是到达即成功的终态判断——部分解本身就是答案,不必等叶子(对比全排列必须等满 a[i] <= remaining 排除「装不下」的分支:这一行就是最简单的可行性剪枝,15.3 会把它发展成系统方法。
O(·)复杂度 · 与位运算枚举对照
位运算 for (int mask = 0; mask < (1<<n); ++mask) 同样枚举全部子集,但每个 mask 都要 return true 换成记录即可,代价回到叶子数
完整案例 3 · N 皇后:让约束消失在建模里
建模卡
| 步骤 | 内容 |
|---|---|
| 题面 | |
| 解向量 | |
| 约束 | 显式: |
| 空间树 | 每层至多 |
| 复杂度 | 按行枚举 + |
EX示例 · 按行建模:把行约束变成「结构上不可能」
最直白的解向量是「
约束检查的两个方向
放第 row 行皇后到列 c 时,需检查与前 row 行都不同列、不同对角线。两个实现方向:
- 显式检查:扫描前
row行,判断cols[j] == c || |cols[j] - c| == row - j。单次 ,胜在不用额外数组; - 标记数组:
usedCol[c]记列;同一条副对角线上row + c为定值,同一条主对角线上row - c为定值(平移+n-1避免负下标),三个bool数组各 判定。本质是 15.1 说的「共享状态 + 成对撤销」,用空间换检查时间。
实现:标记数组版(输出 4 皇后第一个解)
#include <iostream>
#include <vector>
int n;
std::vector<int> cols; // cols[i]:第 i 行皇后的列号
std::vector<bool> usedCol, usedD1, usedD2; // 列 / 主对角线(row-c+n-1) / 副对角线(row+c)
bool dfs(int row) {
if (row == n) {
for (int c : cols) std::cout << c + 1;
std::cout << '\n';
return true; // 只要一个解:找到即返回
}
for (int c = 0; c < n; ++c) {
if (usedCol[c] || usedD1[row - c + n - 1] || usedD2[row + c]) continue;
usedCol[c] = usedD1[row - c + n - 1] = usedD2[row + c] = true; // ①
cols.push_back(c);
if (dfs(row + 1)) return true; // ②
cols.pop_back(); // ③ 撤销三份共享状态
usedCol[c] = usedD1[row - c + n - 1] = usedD2[row + c] = false;
}
return false;
}
int main() {
n = 4;
usedCol.assign(n, false);
usedD1.assign(2 * n - 1, false);
usedD2.assign(2 * n - 1, false);
dfs(0); // 输出 2413
}n-queens.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
29
30
31
撤销在这里要还原三份标记加一份 cols——比全排列还多,是检验「成对撤销」纪律的好练习。若把 return true 全部去掉(改为 void 并在 row==n 处打印),同一程序就枚举全部解:4 皇后恰有两个,(2,4,1,3) 与 (3,1,4,2);2、3 皇后无解,程序直接静默结束。
4 皇后的树长什么样
下图画出按列序
读图结论:列 1 的整棵子树没有解(两支都死在 ×),列 2 一路到底命中第一个解 (2,4,1,3),列 3 给出第二个解 (3,1,4,2),列 4 的两支也都死亡。搜索顺序决定哪个解先被找到,完备性保证一个都不漏——两解合计恰好 2 个,与已知结论一致。
棋盘视角:同一过程的两张面孔
上面的树换一个视角,就是棋盘上「放 Q → 死局 → 移除 → 换列」的往复。每块棋盘标注当前已放的行;死局指该行扫完所有列都放不进去。
(一)行1=列1 (二)行2=列3 死局:行3 无列可放
Q . . . Q . . . → 撤销:移除行2,行2 改放列4
. . Q . . . Q .
. . . . . . . .
. . . . . . . .
(三)行2=列4 (四)行3=列2 死局:行4 无列可放
Q . . . Q . . . → 撤销行3、行2;行2 无列可换
. . . Q . . . Q → 继续撤销行1,行1 改放列2
. . . . . Q . .
. . . . . . . .
(五)行1=列2 (六)行2=列4 (七)行3=列1、行4=列3
. Q . . . Q . . . Q . .
. . . . . . . Q . . . Q
. . . . . . . . Q . . .
. . . . . . . . . . Q .把七块棋盘与上图对照:(一)(二)对应 (1) 子树的第一次下探与死亡,(三)(四)是 (1,4) 支的挣扎,(五)到(七)正是加粗路径 (2,4,1,3)。树视角看「访问了哪些结点」,棋盘视角看「每一步为什么放不下去」——两幅图合起来才是完整的过程。
WARN易错点 · 重复元素让排列数重
输入含重复元素(如
建模卡复盘
三张卡并排看,规律立刻浮现:
| 解向量 | 隐式约束 | 空间树 | 终态判断 | |
|---|---|---|---|---|
| 全排列 | 第 | 互不相同 | 排列树, | 叶子(填满 |
| 子集和 | 第 | 和恰为 target | 子集树, | 到达即成功(提前返回) |
| N 皇后 | 第 | 不同列、不同对角线 | 受限排列树 | 叶子(放满 |
三道题里,取值域、约束、终态各不相同,但骨架、撤销纪律和完备性一个字都不用改。下一节处理真正的难点:约束检查不够用时,怎样在树上提前止损。