| 章 | 状态是什么 | 依赖谁 | 于是顺序是 |
|---|---|---|---|
| 21 | 数字三角形的一个格子 | 下面一行 | 从下往上 |
| 23 | 前 i 件物品 + 剩多少容量 | 上一轮的 f[j-w] |
容量倒序 |
| 24 | 同上 | 这一轮的 f[j-w] |
容量正序 |
| 25 | 同上(多一维 / 分组) | 上一组的 f[j-w] |
容量倒序、组内在最里层 |
| 26 | 一段区间 f[l][r] |
更短的区间 | 长度从小到大 |
| 27 | 一棵子树 f[u][*] |
所有儿子的子树 | 后序遍历 |
| 28 | 一个集合 f[S][*] |
少一个元素的集合 | S 从 0 数到 2ⁿ-1 |
最后一行看着像在偷懒 —— 「就按整数顺序数一遍」,这也算填表顺序?
算。 而且它是这七行里唯一一个可以一行证完的:
S 加上一个新元素之后,作为整数一定变大。所以小的先算,依赖自动就绪。
第 5 步会把这条用程序暴力验一遍。
这一章真正的新东西只有一件:一个集合,就是一个整数。
1一句话问题
n个城市,给出距离矩阵d[i][j](从i走到j的距离)。 从 0 号城市出发,每个城市恰好经过一次,最后回到 0 号。求最短总路程。
这就是大名鼎鼎的旅行商问题(TSP)。
d[i][j] 不一定等于 d[j][i] —— 想想单行道、上坡下坡、单程机票。
这不是我为了出难题加的,它是这一章对拍那一节的主角: 一个只会造对称矩阵的生成器,会让一个真实存在的 bug 一轮都抓不到。第 12 步见。
2★ 关键一步(一):一个集合就是一个整数
这件事你在第 3 章(二进制枚举子集)就见过了,只不过那时候它是「枚举手段」。 这一章它要升级成状态。
n 个元素的集合,用一个 n 位二进制数表示:第 i 位是 1 = 第 i 个元素在集合里。
| 想干什么 | 怎么写 |
|---|---|
判断 i 在不在集合 S 里 |
S >> i & 1 |
把 i 加进 S |
S | (1 << i) |
把 i 从 S 去掉 |
S & ~(1 << i) |
S 里有几个元素 |
__builtin_popcount(S) |
全集(n 个元素) |
(1 << n) - 1 |
n 个元素一共 1 << n 个集合,编号 0 到 2ⁿ-1,一个不多一个不少。
⚠ 这五个写法本身(<<、>>、&、|、~)要到第 46 章才讲。
现在只当成五句口诀用就行;想看它们逐个跑一遍,C++ 速查的第一组有可运行的最小例子。
// 「一个集合就是一个整数」—— 这一章的地基,以及那条填表顺序的**证明**//// 第 3 章用二进制枚举过子集,那时候它只是个「枚举手段」。// 这一章它要变成**状态**,所以值得把对应关系摊开看一遍。//// 这份代码做三件事:// ① 把 n 个元素的全部 2ⁿ 个集合按整数从小到大列出来(编号 / 二进制 / 里面有谁);// ② 把常用的几个位运算写成一张对照表;// ③ ★ **暴力验证那条填表顺序的理由**:// 对每一个集合 S 和每一个 j ∉ S,检查 `S < (S | 1<<j)` 是不是永远成立。// 成立,所以「S 从 0 数到 2ⁿ-1」这个自然顺序,天然满足「依赖先算好」。//// ★ 第 ③ 件事才是重点。第 21、23、24、25、26、27 章每一次都要专门想一想填表顺序,// 这一章的顺序简单得可疑 —— 简单是有理由的,而理由可以**跑出来给你看**。//// 用法:./bits [元素个数=4]
#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { // ⚠ cout 和 printf 混用(表格用 printf 好对齐),所以不关 ios::sync_with_stdio int n = (argc > 1) ? atoi(argv[1]) : 4; if (n < 1) n = 1; if (n > 5) n = 5; // 再多就刷屏了
cout << "① " << n << " 个元素,一共 " << (1 << n) << " 个集合:\n\n"; cout << " 整数 二进制 集合里有谁\n"; cout << " ---- ------ --------------------\n"; for (int S = 0; S < (1 << n); S++) { string bin; for (int i = n - 1; i >= 0; i--) bin += (S >> i & 1) ? '1' : '0'; string who; for (int i = 0; i < n; i++) if (S >> i & 1) who += (who.empty() ? "" : ", ") + to_string(i); if (who.empty()) who = "空集"; printf(" %4d %6s {%s}\n", S, bin.c_str(), who.c_str()); }
cout << "\n② 常用的几个位运算(拿元素 2 和集合 S 举例):\n\n"; cout << " 判断 2 在不在 S 里 S >> 2 & 1\n"; cout << " 把 2 加进 S S | (1 << 2)\n"; cout << " 把 2 从 S 去掉 S & ~(1 << 2)\n"; cout << " S 里一共有几个元素 __builtin_popcount(S)\n"; cout << " 全集 (1 << n) - 1\n"; cout << " 枚举 S 的所有子集 for (int T = S; ; T = (T - 1) & S) { … if (!T) break; }\n";
cout << "\n③ ★ 验证那条填表顺序的理由:加一个元素,整数一定变大\n\n"; long long checked = 0; bool ok = true; int worst = -1; for (int S = 0; S < (1 << n); S++) for (int j = 0; j < n; j++) { if (S >> j & 1) continue; // j 已经在里面了,不算「加进去」 int T = S | (1 << j); checked++; if (!(S < T)) { ok = false; if (worst < 0) worst = S; } } printf(" 检查了 %lld 对「S 和 S 加上一个新元素」,", checked); if (ok) cout << "全部都满足 S < S|(1<<j)。\n"; else cout << "居然有反例(S = " << worst << ")—— 那这一章就白写了。\n";
cout << "\n 所以:任何一个集合的编号,都严格小于「它再加一个元素」之后的编号。\n"; cout << " 于是 for (S = 0; S <= full; S++) 这个最朴素的顺序,\n"; cout << " 自动保证了「转移要用到的那个 S,早就算好了」——\n"; cout << " 「依赖谁,就先填谁」第六次登场,而这一次它不用你动脑子。\n"; return 0;}点「运行 ▶」看结果
① 4 个元素,一共 16 个集合:
整数 二进制 集合里有谁
---- ------ --------------------
0 0000 {空集}
1 0001 {0}
2 0010 {1}
3 0011 {0, 1}
…
15 1111 {0, 1, 2, 3}
3手算一遍:4 个城市,六个数字贯穿全章
到 0 到 1 到 2 到 3
从 0 0 1 4 5
从 1 2 0 9 1
从 2 7 9 0 7
从 3 6 9 2 0
(注意 d[0][1] = 1 而 d[1][0] = 2 —— 这就是「不对称」。)
从 0 出发,剩下 3 个城市有 3! = 6 种排法,全列出来:
| 走法 | 逐段 | 总长 |
|---|---|---|
| 0→1→2→3→0 | 1 + 9 + 7 + 6 | 23 |
| 0→1→3→2→0 | 1 + 1 + 2 + 7 | 11 ← 最优 |
| 0→2→1→3→0 | 4 + 9 + 1 + 6 | 20 |
| 0→2→3→1→0 | 4 + 7 + 9 + 2 | 22 |
| 0→3→1→2→0 | 5 + 9 + 9 + 7 | 30 |
| 0→3→2→1→0 | 5 + 2 + 9 + 2 | 18 |
正确答案 11。 后面五个数字都是写错的代码跑出来的:
| 数字 | 谁跑出来的 |
|---|---|
| 2 | 初值设成 0 而不是 ∞ |
| 4 | 忘了加回起点那一段 |
| 12 | 转移写成了 d[j][i](方向反) |
| 20 | 最后只看了「停在 3 号」那一个 |
| −1 | 集合倒着枚举 / 「已经去过」判断写反(都无解) |
11 / 2 / 4 / 12 / 20 / −1 —— 这六个数后面每一步都会回来验。
4暴力:全排列枚举访问顺序
// 旅行商问题(TSP)—— 暴力:next_permutation 枚举「访问顺序」的全排列//// ★ 它是**完全不同的思路**(第 20 章那条规矩):// 这份代码里**没有集合、没有位运算、没有 f 表** ——// 它就是把「先去哪、再去哪」的所有排法列一遍,各自加一加,取最小。// 正解那边是「按集合从小到大填表」,两边连状态的概念都没有,对上了才有说服力。//// 复杂度 (n-1)!:起点固定是 0 号,剩下 n-1 个城市全排列。// n = 10 是 362 880 种走法,n = 12 是 3990 万种 —— 正文第 6 步会实测。//// 题意:n 个城市,给出**有向**距离矩阵 d[i][j](从 i 走到 j 的距离,可能 ≠ d[j][i])。// 从 0 号城市出发,**每个城市恰好经过一次**,最后回到 0 号,求最短总路程。// 输入:第一行 n// 接下来 n 行、每行 n 个整数,第 i 行第 j 列是 d[i][j](对角线是 0)// 输出:最短总路程//// ⚠ 距离矩阵**不一定对称**,这一点是特意的 —— 正文第 12 步会看到,// 一个只造对称矩阵的生成器,会让「方向写反」那个 bug 一轮都抓不到。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0; vector<vector<long long>> d(n, vector<long long>(n)); for (auto& row : d) for (auto& x : row) cin >> x; if (n == 1) { cout << 0 << "\n"; return 0; }
vector<int> order(n - 1); for (int i = 0; i < n - 1; i++) order[i] = i + 1; // 要全排列的是 1 ~ n-1
long long best = LLONG_MAX; do { long long sum = 0; int cur = 0; for (int v : order) { sum += d[cur][v]; cur = v; } sum += d[cur][0]; // ★ 别忘了回起点这一段 best = min(best, sum); } while (next_permutation(order.begin(), order.end()));
cout << best << "\n"; return 0;}点「运行 ▶」看结果
跑出来 11,和手算一致。
这份代码里没有集合、没有位运算、没有 f 表 —— 它就是把「先去哪、再去哪」的所有排法 列一遍,各自加一加。正解那边是「按集合编号填表」,两边连状态的概念都没有 —— 这正是它当标准答案的资格(第 20 章那条规矩)。
5★ 关键一步(二):状态里要带上「人现在在哪」
先想清楚为什么状态不能只写「去过哪些城市」。
假设 g[S] = 走完集合 S 的最短路程。现在想往下走一步 —— 可你走不了:
下一步要走多远,取决于你现在站在哪,而 g[S] 里没说。
★ 所以状态得是两维:
f[S][i] = 已经走过的城市集合恰好是 S(且 i ∈ S),此刻人停在 i,
从 0 号出发走到这里的最短路程这和第 22 章「以 i 结尾」、第 27 章「这个点选不选」是同一条道理:
状态里必须带上「后面还会用到的那一点信息」。
转移(往前推一步,走到还没去过的 j):
f[S | 1<<j][j] = min( f[S][i] + d[i][j] ) j ∉ S答案:min over i≠0 of ( f[全集][i] + d[i][0] ) —— ★ 最后还要回起点。
转移的目标是 S | (1 << j),而 j ∉ S,所以它比 S 多一个 1 ——
作为整数一定严格大于 S。
于是最朴素的 for (int S = 0; S <= full; S++) 就够了:
轮到 S 的时候,它要用的那些更小的集合早就算好了。
不用信我,跑 bits.cpp 的第 ③ 节 —— 它把 n = 4 时全部 32 对
「S 和 S 加一个新元素」都检查了一遍,全部满足 S < S|(1<<j)。
这就是第 21 章那句话的第六次登场。前五次都要动点脑子,这一次不用 —— 但理由和前五次一模一样。
// 旅行商问题(TSP)—— 正解:状态压缩 DP//// ★ 关键一步(一):**一个集合就是一个整数。**// 第 i 位是 1,就表示第 i 个城市已经去过了。n 个城市一共 `1 << n` 个集合。// 这件事你在**第 3 章**(二进制枚举子集)就见过了,只不过那时候它是「枚举手段」,// 这一章它要变成「**状态**」。//// 判断 i 在不在集合里 S >> i & 1// 把 i 加进集合 S | (1 << i)// 全集(n 个元素) (1 << n) - 1//// 状态:f[S][i] = 已经走过的城市集合恰好是 S(且 i ∈ S),此刻**人停在 i**,// 从 0 号出发走到这里的最短路程。//// ⚠ 为什么状态里非要带上「停在哪」:因为下一步要走多远,取决于当前在哪 ——// 光知道「去过哪些城市」是接不下去的。// 这和第 22 章「以 i 结尾」、第 27 章「这个点选不选」是**同一条道理**:// **状态要带上后面还会用到的那一点信息。**//// 转移(往前推):从 (S, i) 走到还没去过的 j//// f[S | 1<<j][j] = min( f[S][i] + d[i][j] ) j ∉ S//// 答案:min over i≠0 of f[全集][i] + d[i][0] (★ 最后要回起点)//// ★ 关键一步(二):**填表顺序就是 S 从 0 数到 (1<<n)-1,自然顺序。**// 理由一行就能说清:`S | (1<<j)` 里 j ∉ S,所以它比 S **多一个 1**,// 作为整数**一定严格大于 S**。于是「小的先算」自动满足了依赖。// 「依赖谁,就先填谁」(第 21 章)第六次登场 ——// 这一次那个顺序简单得可疑,但理由和前五次一模一样。// (wrongOrder.cpp 把 S 倒过来枚举,看看会怎样。)//// 复杂度 O(2ⁿ × n²),空间 O(2ⁿ × n)。n = 20 时 2²⁰ × 400 ≈ 4 亿,还能跑;// 而暴力那边 19! 是个 17 位数。**状压 DP 的适用范围就写在指数里:n 通常 ≤ 20。**// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0; vector<vector<long long>> d(n, vector<long long>(n)); for (auto& row : d) for (auto& x : row) cin >> x; if (n == 1) { cout << 0 << "\n"; return 0; }
const long long INF = LLONG_MAX / 4; // ⚠ 除以 4,留出加法的余地 int full = (1 << n) - 1; vector<vector<long long>> f(1 << n, vector<long long>(n, INF)); f[1][0] = 0; // 出发:只去过 0 号,人在 0 号
for (int S = 0; S <= full; S++) // ★ 自然顺序,因为子集的编号一定更小 for (int i = 0; i < n; i++) { if (f[S][i] >= INF) continue; // 这个局面根本到不了 if (!(S >> i & 1)) continue; // i 必须在 S 里,否则「停在 i」无意义 for (int j = 0; j < n; j++) { if (S >> j & 1) continue; // j 已经去过了 int T = S | (1 << j); // T > S,所以它还没被处理到 f[T][j] = min(f[T][j], f[S][i] + d[i][j]); } }
long long best = LLONG_MAX; for (int i = 1; i < n; i++) if (f[full][i] < INF) best = min(best, f[full][i] + d[i][0]); // ★ 回起点
cout << best << "\n"; return 0;}点「运行 ▶」看结果
跑出来 11。想看整张表长什么样,跑这份:
// 把整张 f[S][i] 表按集合编号从小到大打出来 —— 动画就是照着这个次序填的//// 为什么要有它:动画是用 TypeScript 把这个算法重写一遍画出来的。// 只比最后那个答案是不够的 —— 答案蒙对、中间过程画错,学生一样看不出来。// 所以这里把每一个状态的值都打出来,check:viz 拿它和动画**逐格**对照。// (第 23、26、27 章的 trace.cpp 是同样的用意。)//// ★ 这张表本身就是这一章的 ★ 的样子:// 从上往下看,集合编号是 0、1、2、3…… 一路递增;// 而每一行用到的都只是**上面**某一行的值 —— 因为「加一个元素,整数一定变大」。// `-` 表示这个局面根本到不了(比如「集合里没有 0 号,人却停在 0 号」)。//// 用法:./trace (用正文那组默认的 4 个城市)// ./trace < 数据文件(格式同 brute.cpp)
#include <bits/stdc++.h>using namespace std;
int main() { // ⚠ cout 和 printf 混用(表格用 printf 好对齐),所以不关 ios::sync_with_stdio int n; vector<vector<long long>> d; if (cin >> n && n > 0) { d.assign(n, vector<long long>(n)); for (auto& row : d) for (auto& x : row) cin >> x; } else { n = 4; // 正文那组默认数据 d = {{0, 1, 4, 5}, {2, 0, 9, 1}, {7, 9, 0, 7}, {6, 9, 2, 0}}; }
const long long INF = LLONG_MAX / 4; int full = (1 << n) - 1; vector<vector<long long>> f(1 << n, vector<long long>(n, INF)); f[1][0] = 0;
for (int S = 0; S <= full; S++) for (int i = 0; i < n; i++) { if (f[S][i] >= INF) continue; if (!(S >> i & 1)) continue; for (int j = 0; j < n; j++) { if (S >> j & 1) continue; int T = S | (1 << j); f[T][j] = min(f[T][j], f[S][i] + d[i][j]); } }
cout << "城市数 " << n << ",起点 0 号。f[S][i] = 走过的城市集合是 S、人停在 i 的最短路程。\n\n"; // ⚠「集合」是 2 个汉字 = 4 格宽,而数据那一列宽 4n+2;不能靠 %-*s(它数字节) cout << " S 二进制 集合" << string(4 * n + 2 - 4, ' '); for (int i = 0; i < n; i++) printf(" f[S][%d]", i); cout << "\n"; cout << " -- ------ " << string(4 * n + 2, '-'); for (int i = 0; i < n; i++) cout << " -------"; cout << "\n";
for (int S = 0; S <= full; S++) { string bin; for (int i = n - 1; i >= 0; i--) bin += (S >> i & 1) ? '1' : '0'; string who; for (int i = 0; i < n; i++) if (S >> i & 1) who += to_string(i); if (who.empty()) who = ""; printf(" %2d %6s %-*s", S, bin.c_str(), 4 * n + 2, ("{" + who + "}").c_str()); for (int i = 0; i < n; i++) { if (f[S][i] >= INF) printf(" %7s", "-"); else printf(" %7lld", f[S][i]); } cout << "\n"; }
cout << "\n走遍所有城市之后,还要回起点 —— 每个结尾各算一次:\n"; long long best = LLONG_MAX; int bi = -1; for (int i = 1; i < n; i++) { if (f[full][i] >= INF) continue; printf(" 停在 %d 号:f[%d][%d] = %lld,加上回程 d[%d][0] = %lld,一共 %lld\n", i, full, i, f[full][i], i, d[i][0], f[full][i] + d[i][0]); if (f[full][i] + d[i][0] < best) { best = f[full][i] + d[i][0]; bi = i; } } cout << "\n答案 = " << best << "(最后停在 " << bi << " 号那一条)\n"; cout << "每一行用到的都只是上面某一行 —— 因为「加一个元素,整数一定变大」。\n"; return 0;}点「运行 ▶」看结果
S 二进制 集合 f[S][0] f[S][1] f[S][2] f[S][3]
-- ------ ------------------ ------- ------- ------- -------
1 0001 {0} 0 - - -
3 0011 {01} - 1 - -
5 0101 {02} - - 4 -
7 0111 {012} - 13 10 -
9 1001 {03} - - - 5
11 1011 {013} - 14 - 2
13 1101 {023} - - 7 11
15 1111 {0123} - 16 4 14
(- 表示这个局面根本到不了,比如「集合里没有 0 号,人却停在 0 号」。)
每一行用到的都只是上面某一行。 最后一行 S = 15 就是走遍全部城市,
三个落脚点各加一段回程:16+2 = 18、4+7 = 11、14+6 = 20 → 答案 11。
6实测:两边都是指数,但指数的底完全不一样
本机实测(./genBig n,固定种子):
| 城市数 n | (n-1)! 全排列 | 状压 DP |
|---|---|---|
| 10 | 0.004 秒 | 0.001 秒 |
| 11 | 0.022 秒 | 0.003 秒 |
| 12 | 0.237 秒 | 0.004 秒 |
| 13 | 2.959 秒 | 0.003 秒 |
| 14 | 43.844 秒 | 0.005 秒 |
n = 14 那一行差了将近一万倍。而状压 DP 还远没到极限:
| 城市数 n | 状压 DP |
|---|---|
| 18 | 0.076 秒 |
| 20 | 0.365 秒 |
| 22 | 1.608 秒 |
暴力 (n-1)! n = 20 → 19! ≈ 1.2 × 10¹⁷ (宇宙热寂也跑不完)
状压 2ⁿ · n² n = 20 → 2²⁰ × 400 ≈ 4 亿 (零点几秒)n! 涨得比 2ⁿ 快太多了,这就是全部的差距来源。
★ 所以状压 DP 的适用范围直接写在指数里:n 通常 ≤ 20。
看到题面里「n ≤ 20」这种小得离谱的数据范围,
心里就该有数了 —— 出题人是在提示你上状压。
(这和第 25 章「两个上限都只有 200」是同一种信号。)
7动画:一行一行往下填,目标永远在下方
- 表示这个局面还到不了。浅绿 = 这一行已经处理完、定稿了; 蓝色 = 正在处理的那一格;这一步推出去的目标格会标成绿色(目标还没处理,更新有效)或 红色(目标早就定稿了,这笔更新白写)。 请注意目标行永远在当前行的下方 —— 因为加一个元素,整数一定变大。 把顺序切成「从大到小」再看一遍:满屏红色,最后一行一个数都填不出来。每一行是一个集合(编号 / 二进制 / 里面有谁),每一列是「人停在哪」。 蓝色是正在处理的那一格,推出去的目标格会标成 绿色(目标还没处理,更新有效) 或 红色(目标早就定稿了,这笔更新白写)。
请注意目标行永远在当前行的下方 —— 那就是「加一个元素,整数一定变大」的画面版。
现在把顺序切成「从大到小」:
| 集合 S 的枚举顺序 | 更新打在已定稿状态上的次数 | 答案 |
|---|---|---|
| 从小到大(0 → 2ⁿ-1) | 0 | 11 |
| 从大到小 | 3 | 无解 |
倒着枚举时,整个 DP 只推动了 3 次 —— 而且这 3 次全是白写的。
道理很干脆:一开始只有 f[{0}][0] = 0 这一格有值。
轮到 S = 1 的时候,它想往 S = 3、5、9 推,
可这三行早就处理完了 —— 写进去也没人再看一眼。
于是信息卡在起点,一步都传不出去,最后 f[全集] 里一个有效值都没有。
第 26 章「左端点正序」、第 27 章「累加写在递归前面」, 和这里犯的是同一个病:读到 / 写到一个已经定稿的格子上,程序不会有任何反应。
8三种「答案有数但是错的」写法
// ✗ 错误版本二:初值设成 0 而不是 ∞//// 求最小值的 DP,初值必须是「不可能达到的大数」。设成 0 之后,// **每一个还没算出来的局面都被当成了「白送的 0 代价」** ——// 于是 min 会一路取到那些根本到不了的格子上。//// ★ 这个 bug 的答案总是**偏小**,而且小得很有规律:// f[S][i] 恒等于 0(任何格子都能从「0 代价」出发),// 所以最后输出的就是 **min over i≠0 of d[i][0]** —— 一个只和「谁到起点最近」有关的数,// 和整条路线毫无关系。正文第 12 步用 300 组数据把这条钉死了。//// ⚠ 这是第 23 章 `exact.cpp` 那一节的回声:// **初值不是「随便填个数」,初值是在回答「哪些局面根本不存在」。**// 求最大值就填 -∞,求最小值就填 +∞,只有真正的起点才填 0。//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0; vector<vector<long long>> d(n, vector<long long>(n)); for (auto& row : d) for (auto& x : row) cin >> x; if (n == 1) { cout << 0 << "\n"; return 0; }
int full = (1 << n) - 1; vector<vector<long long>> f(1 << n, vector<long long>(n, 0)); // ✗ 应该是 INF f[1][0] = 0;
for (int S = 0; S <= full; S++) for (int i = 0; i < n; i++) { if (!(S >> i & 1)) continue; for (int j = 0; j < n; j++) { if (S >> j & 1) continue; int T = S | (1 << j); f[T][j] = min(f[T][j], f[S][i] + d[i][j]); } }
long long best = LLONG_MAX; for (int i = 1; i < n; i++) best = min(best, f[full][i] + d[i][0]);
cout << best << "\n"; return 0;}点「运行 ▶」看结果
跑出来 2。求最小值的 DP,初值必须是「不可能达到的大数」。
设成 0 之后,每一个还没算出来的局面都变成了「白送的 0 代价」,
于是 min 会一路取到那些根本到不了的格子上。
check:viz 用 300 组数据钉死了它的样子:输出恒等于 min over i≠0 of d[i][0],
也就是「谁离起点最近」—— 和整条路线毫无关系。
初值不是「随便填个数」,初值是在回答「哪些局面根本不存在」。 (第 23 章
exact.cpp那一节说的是同一件事。)
// ✗ 错误版本三:最后忘了加回起点那一段 d[i][0]//// 整张表都填对了,只在最后一行栽了://// 正确: best = min(f[full][i] + d[i][0])// 错误: best = min(f[full][i])//// ★ 它不是随机地错,它**精确地解了另一道题**:// **开放式旅行商 —— 从 0 号出发走遍所有城市,但不用回来**(也就是最短哈密顿路径)。// 那是一道真实存在、也很常考的题(比如洛谷 P1433 吃奶酪就是不用回来的)。//// 所以这一条又是「写错了就是另一道题」那个系列的一员(这是第九条)。// 正文第 12 步用 300 组数据钉死了它:// **wrongEnd.cpp 的输出恒等于 openTsp.cpp(老老实实写的开放式 TSP),一组不差。**//// ⚠ 现实里这个 bug 特别容易犯,因为两道题的题面只差「回到出发点」五个字。// **题目对边界的约定要抄进注释**(第 19 章那条规矩)—— 这就是个活例子。//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0; vector<vector<long long>> d(n, vector<long long>(n)); for (auto& row : d) for (auto& x : row) cin >> x; if (n == 1) { cout << 0 << "\n"; return 0; }
const long long INF = LLONG_MAX / 4; int full = (1 << n) - 1; vector<vector<long long>> f(1 << n, vector<long long>(n, INF)); f[1][0] = 0;
for (int S = 0; S <= full; S++) for (int i = 0; i < n; i++) { if (f[S][i] >= INF) continue; if (!(S >> i & 1)) continue; for (int j = 0; j < n; j++) { if (S >> j & 1) continue; int T = S | (1 << j); f[T][j] = min(f[T][j], f[S][i] + d[i][j]); } }
long long best = LLONG_MAX; for (int i = 1; i < n; i++) if (f[full][i] < INF) best = min(best, f[full][i]); // ✗ 少了 + d[i][0]
cout << best << "\n"; return 0;}点「运行 ▶」看结果
跑出来 4。
少写一个 + d[i][0],解出来的就是开放式旅行商 ——
从 0 号出发走遍所有城市,但不用回去(也就是最短哈密顿路径)。
那是一道真实存在、也很常考的题(洛谷 P1433 吃奶酪就是这一类)。
check:viz 用 300 组数据钉死了这条:两份代码的输出一组不差。
⚠ 现实里这个 bug 特别容易犯,因为两道题的题面只差「回到出发点」五个字。 题目对边界的约定要抄进注释(第 19 章那条规矩)—— 这就是个活例子。
// ✗ 错误版本六:最后只看了「停在 n-1 号」的那一个状态,忘了对所有结尾取 min//// 正确: best = min over i≠0 of ( f[full][i] + d[i][0] )// 错误: best = f[full][n-1] + d[n-1][0]//// 走遍所有城市之后,人可以停在任何一个城市,回程的那一段各不相同 ——// 必须把 n-1 种可能都试一遍。只看一个,等于凭空规定「最后一个必须是 n-1 号」。//// ★ 它的抓获率**跟着城市数变**,这一点很有用:// 最优路线的终点在 n-1 个城市里大致是随机的,所以这份代码大约有 1/(n-1) 的概率蒙对。// n = 4 时四分之一的数据抓不住,n = 9 时只剩八分之一 ——// **城市数越少,它越安全。** 正文第 12 步那张表把这条量出来了。//// 这和第 26 章「堆数太少,错误贪心就隐身」是同一类现象:// **规模小 = 可能性少 = 蒙对的概率大。**//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0; vector<vector<long long>> d(n, vector<long long>(n)); for (auto& row : d) for (auto& x : row) cin >> x; if (n == 1) { cout << 0 << "\n"; return 0; }
const long long INF = LLONG_MAX / 4; int full = (1 << n) - 1; vector<vector<long long>> f(1 << n, vector<long long>(n, INF)); f[1][0] = 0;
for (int S = 0; S <= full; S++) for (int i = 0; i < n; i++) { if (f[S][i] >= INF) continue; if (!(S >> i & 1)) continue; for (int j = 0; j < n; j++) { if (S >> j & 1) continue; int T = S | (1 << j); f[T][j] = min(f[T][j], f[S][i] + d[i][j]); } }
// ✗ 只看了一个结尾,没有对所有 i 取 min cout << f[full][n - 1] + d[n - 1][0] << "\n"; return 0;}点「运行 ▶」看结果
跑出来 20。走遍所有城市之后人可以停在任何一个城市,回程各不相同,
必须把 n-1 种都试一遍。只看一个,等于凭空规定「最后一个必须是 3 号」。
(回头看第 3 步那张表:0→2→1→3→0 正好是 20 —— 那是所有「以 3 号结尾」的走法里最好的。)
9★ 主角登场:方向写反
// ✗ 错误版本四:转移里把 d[i][j] 写成了 d[j][i] —— 方向反了//// 人是从 i 走到 j,代价当然是 d[i][j]。写成 d[j][i] 就是「按回程的价钱付去程的钱」。//// ★ 这个 bug 是这一章的**主角**,但不是因为它难写对,// 而是因为**绝大多数人造的数据根本抓不住它**://// 随手写一个距离矩阵生成器,最自然的写法是// 「随机一个上三角,然后镜像下去」—— 于是 d[i][j] == d[j][i],// **方向写反和写对是同一件事**,这份代码一轮都不会错。//// 正文第 12 步实测:只造对称矩阵的数据上它是 **0 / 300**,// 矩阵改成不对称之后立刻变成 **300 / 300**。//// 第 27 章刚踩过一次同类的坑(「1 号永远当根」让「没找根」隐身),// 这一次换了个维度:**对称性**。两次的教训是同一句话 ——// **数据的随机性不能只在数值上,结构上的「巧合」同样要打破。**//// ⚠ 现实里 TSP 的距离常常真的是对称的(欧氏距离就是),// 所以这个 bug 在很多题上确实无害。但「无害」和「对」是两回事:// 题目一旦给的是有向图(单行道、上坡下坡、机票价格),它立刻就错。//// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0; vector<vector<long long>> d(n, vector<long long>(n)); for (auto& row : d) for (auto& x : row) cin >> x; if (n == 1) { cout << 0 << "\n"; return 0; }
const long long INF = LLONG_MAX / 4; int full = (1 << n) - 1; vector<vector<long long>> f(1 << n, vector<long long>(n, INF)); f[1][0] = 0;
for (int S = 0; S <= full; S++) for (int i = 0; i < n; i++) { if (f[S][i] >= INF) continue; if (!(S >> i & 1)) continue; for (int j = 0; j < n; j++) { if (S >> j & 1) continue; int T = S | (1 << j); f[T][j] = min(f[T][j], f[S][i] + d[j][i]); // ✗ 应该是 d[i][j] } }
long long best = LLONG_MAX; for (int i = 1; i < n; i++) if (f[full][i] < INF) best = min(best, f[full][i] + d[i][0]);
cout << best << "\n"; return 0;}点「运行 ▶」看结果
跑出来 12。人是从 i 走到 j,代价当然是 d[i][j];
写成 d[j][i] 就是按回程的价钱付去程的钱。
这个 bug 不难看懂,但它是这一章的主角 —— 原因在第 12 步。先看它错得多离谱:
10动画:把每条路线「真的走一遍」再算一次账
右边两个大数字是重点:上面是它自己报的答案,下面是拿真实距离 把它选的路线真的走一遍。正解这两个数相等, 「方向写反」那一档对不上 —— 它连自己选的路线要花多少都算错了。
这就是为什么第 26、27 章一直在做「输出方案」:一份走法能自证清白,一个数字不能。
城市摆成一圈,蓝色是起点。实线是这份代码选出来的路线,绿色虚线是回起点那一段。
★ 右边那两个大数字是重点:上面是它自己报的答案,下面是拿真实距离把它选的路线 真的走一遍要花多少。
| 哪一种写法 | 它报的数 | 这条路线真的走一遍 | 对得上吗 |
|---|---|---|---|
| ✓ 正解 | 11 | 11 | ✓ |
| ✗ 忘了回起点 | 4 | 11 | ✗(它就地解散了) |
| ✗ 方向写反 | 12 | 22 | ✗ 差了 10 |
| ✗ 只看一个结尾 | 20 | 20 | ✓(路线没毛病,只是不是最优的) |
「方向写反」那份报出来 12,比正确答案 11 还大一点点 —— 看着像是「差不多,就是没找到最优」。
可你让它把自己选的那条路线真的走一遍:要 22。
也就是说,它连自己选的路线要花多少钱都算错了。 它报的 12 不对应任何一条真实存在的走法,那是个凭空的数字。
一份走法能自证清白,一个数字不能。
这就是第 26、27 章一直在做「输出方案」的理由,也是第 27 章那句 「答案大不代表答案对」的续集。 (顺带:第四行「只看一个结尾」报的数和实际是对得上的 —— 它的路线合法, 只是被人为限制了终点。同样是错,错法可以完全不同。)
11输出路线:删一个元素也只是一次位运算
// 输出**走法本身** —— 第 26、27 章那套回溯,在状压 DP 上再用一次//// 还是那句话(第 23 章欠的账,第 26 章开始还):要方案就得把「这一步是从哪来的」记下来。// 这里记的是 `from[S][i]` = 走到「集合 S、停在 i」这个局面之前,人在哪个城市。//// 回溯时把 i 从集合里去掉(`S & ~(1 << i)`),就退回到上一个局面 ——// ★ 这一步正好把「集合就是整数」用了个彻底:**删一个元素也只是一次位运算。**//// check:viz 对这份输出做的是**硬验证**,不是比字符串:// 路线必须从 0 出发、每个城市恰好经过一次、最后回到 0,// 而且逐段距离加起来必须正好等于第一行那个答案。//// 用法:./path (用正文那组默认的 4 个城市)// ./path < 数据文件(格式同 brute.cpp)
#include <bits/stdc++.h>using namespace std;
int main() { // ⚠ cout 和 printf 混用(表格用 printf 好对齐),所以不能关 ios::sync_with_stdio // —— 关了两边各自缓冲,表格会跑到结语后面去(第 26 章踩过,这次又踩了一遍) int n; vector<vector<long long>> d; if (cin >> n && n > 0) { d.assign(n, vector<long long>(n)); for (auto& row : d) for (auto& x : row) cin >> x; } else { n = 4; d = {{0, 1, 4, 5}, {2, 0, 9, 1}, {7, 9, 0, 7}, {6, 9, 2, 0}}; } if (n == 1) { cout << "最短总路程 = 0\n路线:0 -> 0\n"; return 0; }
const long long INF = LLONG_MAX / 4; int full = (1 << n) - 1; vector<vector<long long>> f(1 << n, vector<long long>(n, INF)); vector<vector<int>> from(1 << n, vector<int>(n, -1)); f[1][0] = 0;
for (int S = 0; S <= full; S++) for (int i = 0; i < n; i++) { if (f[S][i] >= INF) continue; if (!(S >> i & 1)) continue; for (int j = 0; j < n; j++) { if (S >> j & 1) continue; int T = S | (1 << j); if (f[S][i] + d[i][j] < f[T][j]) { f[T][j] = f[S][i] + d[i][j]; from[T][j] = i; // ← 全部的额外开销就是这一行 } } }
long long best = LLONG_MAX; int endCity = -1; for (int i = 1; i < n; i++) if (f[full][i] < INF && f[full][i] + d[i][0] < best) { best = f[full][i] + d[i][0]; endCity = i; }
// 回溯:从「全集、停在 endCity」一路退回起点 vector<int> route; int S = full, cur = endCity; while (cur != -1) { route.push_back(cur); int prev = from[S][cur]; S &= ~(1 << cur); // ★ 把当前城市从集合里去掉 cur = prev; } reverse(route.begin(), route.end());
cout << "最短总路程 = " << best << "\n\n"; cout << "路线:"; for (size_t i = 0; i < route.size(); i++) cout << route[i] << " -> "; cout << "0\n\n";
cout << " 第几段 从 -> 到 这一段 累计\n"; cout << " ------ -------- ------ ----\n"; long long acc = 0; for (size_t i = 0; i + 1 < route.size(); i++) { acc += d[route[i]][route[i + 1]]; printf(" %6zu %3d -> %-2d %6lld %4lld\n", i + 1, route[i], route[i + 1], d[route[i]][route[i + 1]], acc); } acc += d[route.back()][0]; printf(" %6zu %3d -> %-2d %6lld %4lld <- 回起点这一段最容易忘\n", route.size(), route.back(), 0, d[route.back()][0], acc);
cout << "\n 一共经过 " << route.size() << " 个城市各一次,再回到 0 号,累计 " << acc << "。\n"; return 0;}点「运行 ▶」看结果
最短总路程 = 11
路线:0 -> 1 -> 3 -> 2 -> 0
第几段 从 -> 到 这一段 累计
------ -------- ------ ----
1 0 -> 1 1 1
2 1 -> 3 1 2
3 3 -> 2 2 4
4 2 -> 0 7 11 <- 回起点这一段最容易忘
还是第 26、27 章那套:记下 from[S][i] = 「走到这个局面之前人在哪」,然后回溯。
★ 回溯时要「退回上一个局面」,也就是把当前城市从集合里去掉 —— S & ~(1 << i)。
一次位运算。 这正是「集合就是整数」最舒服的地方。
(check:viz 对这份输出做的是硬验证:路线必须从 0 出发、每个城市恰好一次、
最后回到 0,逐段加起来必须正好是 11。)
12★ 对拍:一个 bug 藏在「对称性」里
// 旅行商问题(TSP)—— 正解:状态压缩 DP//// ★ 关键一步(一):**一个集合就是一个整数。**// 第 i 位是 1,就表示第 i 个城市已经去过了。n 个城市一共 `1 << n` 个集合。// 这件事你在**第 3 章**(二进制枚举子集)就见过了,只不过那时候它是「枚举手段」,// 这一章它要变成「**状态**」。//// 判断 i 在不在集合里 S >> i & 1// 把 i 加进集合 S | (1 << i)// 全集(n 个元素) (1 << n) - 1//// 状态:f[S][i] = 已经走过的城市集合恰好是 S(且 i ∈ S),此刻**人停在 i**,// 从 0 号出发走到这里的最短路程。//// ⚠ 为什么状态里非要带上「停在哪」:因为下一步要走多远,取决于当前在哪 ——// 光知道「去过哪些城市」是接不下去的。// 这和第 22 章「以 i 结尾」、第 27 章「这个点选不选」是**同一条道理**:// **状态要带上后面还会用到的那一点信息。**//// 转移(往前推):从 (S, i) 走到还没去过的 j//// f[S | 1<<j][j] = min( f[S][i] + d[i][j] ) j ∉ S//// 答案:min over i≠0 of f[全集][i] + d[i][0] (★ 最后要回起点)//// ★ 关键一步(二):**填表顺序就是 S 从 0 数到 (1<<n)-1,自然顺序。**// 理由一行就能说清:`S | (1<<j)` 里 j ∉ S,所以它比 S **多一个 1**,// 作为整数**一定严格大于 S**。于是「小的先算」自动满足了依赖。// 「依赖谁,就先填谁」(第 21 章)第六次登场 ——// 这一次那个顺序简单得可疑,但理由和前五次一模一样。// (wrongOrder.cpp 把 S 倒过来枚举,看看会怎样。)//// 复杂度 O(2ⁿ × n²),空间 O(2ⁿ × n)。n = 20 时 2²⁰ × 400 ≈ 4 亿,还能跑;// 而暴力那边 19! 是个 17 位数。**状压 DP 的适用范围就写在指数里:n 通常 ≤ 20。**// 输入输出同 brute.cpp。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0; vector<vector<long long>> d(n, vector<long long>(n)); for (auto& row : d) for (auto& x : row) cin >> x; if (n == 1) { cout << 0 << "\n"; return 0; }
const long long INF = LLONG_MAX / 4; // ⚠ 除以 4,留出加法的余地 int full = (1 << n) - 1; vector<vector<long long>> f(1 << n, vector<long long>(n, INF)); f[1][0] = 0; // 出发:只去过 0 号,人在 0 号
for (int S = 0; S <= full; S++) // ★ 自然顺序,因为子集的编号一定更小 for (int i = 0; i < n; i++) { if (f[S][i] >= INF) continue; // 这个局面根本到不了 if (!(S >> i & 1)) continue; // i 必须在 S 里,否则「停在 i」无意义 for (int j = 0; j < n; j++) { if (S >> j & 1) continue; // j 已经去过了 int T = S | (1 << j); // T > S,所以它还没被处理到 f[T][j] = min(f[T][j], f[S][i] + d[i][j]); } }
long long best = LLONG_MAX; for (int i = 1; i < n; i++) if (f[full][i] < INF) best = min(best, f[full][i] + d[i][0]); // ★ 回起点
cout << best << "\n"; return 0;}300 轮实测,六个错误版本:
| 故意写错的地方 | 被抓 | 第几轮 | 它其实解了哪道题 |
|---|---|---|---|
| 集合 S 从大到小枚举 | 300 / 300 | 第 1 轮 | —(信息传不出起点,无解) |
| 初值设成 0 | 300 / 300 | 第 1 轮 | 「谁离起点最近」 |
| 忘了加回起点 | 300 / 300 | 第 1 轮 | 开放式 TSP |
| 「已经去过」判断写反 | 300 / 300 | 第 1 轮 | —(集合永远长不大,无解) |
方向写反(d[j][i]) |
277 / 300 | 第 1 轮 | —(按回程价付去程钱) |
| 只看一个结尾 | 239 / 300 | 第 1 轮 | —(强制在 n-1 号收尾) |
gen.cpp 带了三个档位,你可以把当初那两次修改一次一次重跑(./gen 种子 档位)。
种子固定 1..300:
| 档位 | 改了什么 | 方向写反 | 只看一个结尾 |
|---|---|---|---|
| 0(最初) | 4 ~ 8 个城市,对称矩阵(随机上三角再镜像) | 0 / 300 | 164 / 300 |
| 1 | 矩阵改成不对称(每个方向各随机一次) | 277 / 300 | 228 / 300 |
| 2(在用) | 城市数下界从 4 提到 5 | 277 / 300 | 239 / 300 |
① 那一处改动,一个数值都没动。 档位 0 → 1 只是把「随机上三角再镜像」改成「每个方向各随机一次」—— 值域没变、城市数没变、分布没变。可「方向写反」从 0 / 300 变成 277 / 300。
★ 第 27 章刚踩过一次同类的坑(「1 号永远当根」让「没找根」隐身),这次换了个维度:
数据的随机性不能只在数值上。结构上的「巧合」—— 对称、编号、谁当起点 —— 同样要打破。
而这两次的根因是一样的:生成器里有一个不假思索的「顺手」写法 (顺手让 1 号当根 / 顺手镜像一下矩阵),它悄悄给数据加了一条题目里没有的性质。
② 同一处改动顺带帮了另一个 bug。 「只看一个结尾」也从 164 涨到 228 —— 而且原因完全不同: 矩阵对称时,一条路线和它倒过来走花费相同, 于是最优解的终点总是成双成对出现,「碰巧就是 n-1 号」的概率翻了一倍。
③ 第二次改动才是常规操作(城市数下界 4 → 5):
「只看一个结尾」大约有 1/(n-1) 的概率蒙对,城市越多它越难藏 —— 228 → 239。
这和第 26 章「堆数太少,错误贪心就隐身」是同一类现象:
规模小 = 可能性少 = 蒙对的概率大。
我另写了一份 genSym.cpp,和最终档比只改了一处:d[j][i] = d[i][j]。
城市数范围、距离值域,一个字没动。同样跑 300 轮:
| 故意写错的地方 | 正常数据 | 矩阵永远对称的数据 |
|---|---|---|
| 方向写反 | 277 / 300 | 0 / 300 |
| 只看一个结尾 | 239 / 300 | 180 / 300 |
| 忘了加回起点 | 300 / 300 | 300 / 300 |
一个 bug 完全隐身,一个变弱,一个纹丝不动。
而且这次原因不用猜:矩阵对称的时候,d[i][j] 和 d[j][i] 是同一个数 ——
它压根就没错。
⚠ 最阴险的地方在这里:现实里的距离常常真的是对称的(欧氏距离就是), 所以这个 bug 在很多题上确实无害。 直到你遇到一道给有向图的题,它立刻就错 —— 而你之前所有的对拍都是绿的。
「在我的数据上没错」和「对」,是两件完全不同的事。
「集合 S 倒着枚举」和「已经去过判断写反」,跑出来都是 −1(无解), 可它们的原因毫无关系(一个是顺序,一个是条件)。
对拍只能告诉你「错了」,不能告诉你「错在哪」。
定位还得靠 trace.cpp 那种把中间过程摊开的东西 —— 这也是每一章都写一份 trace 的理由。
13这一章可以带走的四样东西
【1】一个集合就是一个整数。
第 i 位是 1 就表示第 i 个元素在里面;加元素 S | (1<<i)、删元素 S & ~(1<<i)、
判断 S >> i & 1、全集 (1<<n)-1。这一章其余全部内容都建立在这一句上。
【2】填表顺序还是那一句,而这次一行就能证完。
S | (1<<j) 一定大于 S,所以 for (S = 0; S <= full; S++) 天然满足依赖。
倒着枚举不会报错,它只是把每一笔更新写到已经定稿的格子上,然后交给你一个「无解」。
【3】状压 DP 没有消灭指数,它把指数的底从 n! 换成了 2ⁿ。
所以它的适用范围直接写在数据范围里:看到 n ≤ 20,就该想到状压。
【4】结构上的「巧合」也要随机。 「方向写反」在对称矩阵上 0 / 300,矩阵改成不对称立刻 277 / 300 —— 而那一处改动没有动任何一个数值。 连着第 27 章那个「1 号永远当根」,两次的根因是同一个: 生成器里那个不假思索的「顺手」写法,悄悄给数据加了一条题目里没有的性质。
八章 DP 走完,真正需要背的东西其实只有一句话:
依赖谁,就先填谁。
它换了七次形状(从下往上 / 容量倒序 / 容量正序 / 组内在最里层 / 长度从小到大 / 后序遍历 / 集合编号从小到大),每一次的理由都是同一个。
而这八章一共钉死了九条「写错了就是另一道题」的恒等式:
| 章 | 写错的地方 | 它其实解了哪道题 |
|---|---|---|
| 23 | 01 背包写成正序 | 完全背包 |
| 24 | 完全背包写成倒序 | 01 背包 |
| 25 | 分组背包组内枚举提到容量外 | 无视分组的 01 背包 |
| 25 | 分组背包容量写成正序 | 无视分组的完全背包 |
| 25 | 二维费用外层正序 | 二维费用的完全背包 |
| 26 | 区间 DP 左端点正序 | 允许一次合并任意多个连续堆 |
| 27 | 累加写在递归前面 | 只有根一个人可能来 |
| 27 | u 来时儿子也能来 | 把快乐指数为正的人全叫来 |
| 28 | 忘了加回起点 | 开放式 TSP(不用回来) |
九条都在说同一件事:DP 写错了不会崩溃、不会报警,它只是安静地去解另一道题。 唯一能发现这件事的,是对拍。
下一章开始进入阶段 6 · 图论(第 29 章:图的存储)。 好消息是你已经用过邻接表了 —— 第 27 章那一小节。 那一章埋的伏笔现在要收:稀疏用表、稠密用矩阵,到底差多少?下一章拿实测的数字说。
14自测
- 洛谷 P1171 售货员的难题解析 → —— 本章原题(TSP 模板)。写完直接交,一遍就该过
- 洛谷 P1433 吃奶酪解析 → —— ★ 就是本章「忘了加回起点」解的那道题 —— 不用回来。另外它给的是坐标、距离是浮点数,正好练一下「浮点只能按容差比」(第 20 章那个坑)
- 洛谷 P1896 [SCOI2005] 互不侵犯解析 → —— ★ 另一大类状压:棋盘按行 DP,状态是「这一行的国王摆放方案」。先想清楚「同一行内合法」和「相邻两行合法」怎么用位运算判
- 洛谷 P1879 [USACO06NOV] Corn Fields解析 → —— 棋盘状压的入门版,比 P1896 简单一档。适合先做这道再做上面那道
- 洛谷 P2704 [NOI2001] 炮兵阵地解析 → —— 进阶:影响范围跨两行,所以状态要记「前两行」。经典中的经典
- 洛谷 P3959 [NOIP2017 提高组] 宝藏解析 → —— 进阶:状压 + 分层。做得动这道,状压 DP 就算入门了