阶段 5 · 动态规划 · 第 28 章提高组 S

状压 DP 入门:旅行商问题

状态从「一棵子树」换成「一个集合」,而集合在代码里就是一个整数。这一章的填表顺序简单得可疑 —— 就是 0、1、2、3……,而理由还是那一句:依赖谁,就先填谁。

需要先学:第 21 章 DP 入门:从记忆化到递推例题:旅行商问题(TSP)建议用时:120 分钟
「依赖谁,就先填谁」第六次 —— 这次它简单得可疑
章 状态是什么 依赖谁 于是顺序是
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++ 速查的第一组有可运行的最小例子。

bits.cpp集合 ↔ 整数的对应表,以及那条顺序的证明
// 「一个集合就是一个整数」—— 这一章的地基,以及那条填表顺序的**证明**
//
// 第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
① 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暴力:全排列枚举访问顺序

brute.cpp(n-1)! 全排列
// 旅行商问题(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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 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 章那句话的第六次登场。前五次都要动点脑子,这一次不用 —— 但理由和前五次一模一样。

fast.cpp状压 DP 正解:S 从小到大
// 旅行商问题(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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 11。想看整张表长什么样,跑这份:

trace.cpp把 f[S][i] 整张表打出来
// 把整张 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
   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实测:两边都是指数,但指数的底完全不一样

同题对比:(n-1)! 全排列 vs 状压 DP O(2ⁿ × n²)
先跑 11,再改成 12、13。⚠ 变的是城市数 —— 暴力是 (n-1)!,每加一个城市就乘一次。别超过 14。
(n-1)! 全排列
状压 DP O(2ⁿ × n²)

本机实测(./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 秒
★ 状压 DP 没有消灭指数,它换掉了指数的底
暴力    (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动画:一行一行往下填,目标永远在下方

★ 集合就是一个整数,所以「从小到大」就是正确的填表顺序
答案 11
第 1 / 15 步
停在 0
停在 1
停在 2
停在 3
0 0000 {}
-
-
-
-
1 0001 {0}
0
-
-
-
2 0010 {1}
-
-
-
-
3 0011 {0,1}
-
-
-
-
4 0100 {2}
-
-
-
-
5 0101 {0,2}
-
-
-
-
6 0110 {1,2}
-
-
-
-
7 0111 {0,1,2}
-
-
-
-
8 1000 {3}
-
-
-
-
9 1001 {0,3}
-
-
-
-
10 1010 {1,3}
-
-
-
-
11 1011 {0,1,3}
-
-
-
-
12 1100 {2,3}
-
-
-
-
13 1101 {0,2,3}
-
-
-
-
14 1110 {1,2,3}
-
-
-
-
15 1111 {0,1,2,3}
-
-
-
-
更新打在「已经定稿」的状态上
0
这个顺序对不对
✓ 对的
答案
…
每一行是一个集合(编号 / 二进制 / 里面有谁),每一列是「人停在哪」。- 表示这个局面还到不了。浅绿 = 这一行已经处理完、定稿了; 蓝色 = 正在处理的那一格;这一步推出去的目标格会标成绿色(目标还没处理,更新有效)或 红色(目标早就定稿了,这笔更新白写)。 请注意目标行永远在当前行的下方 —— 因为加一个元素,整数一定变大。 把顺序切成「从大到小」再看一遍:满屏红色,最后一行一个数都填不出来。
f[S][i] = 走过的城市集合是 S、人停在 i 时的最短路程。一共 16 个集合 × 4 个落脚点。起点 f[{0}][0] = 0。现在按集合编号从小到大(正确)处理 —— 请盯住每次更新打到的那一格,它是不是已经定稿了。

每一行是一个集合(编号 / 二进制 / 里面有谁),每一列是「人停在哪」。 蓝色是正在处理的那一格,推出去的目标格会标成 绿色(目标还没处理,更新有效) 或 红色(目标早就定稿了,这笔更新白写)。

请注意目标行永远在当前行的下方 —— 那就是「加一个元素,整数一定变大」的画面版。

现在把顺序切成「从大到小」:

集合 S 的枚举顺序 更新打在已定稿状态上的次数 答案
从小到大(0 → 2ⁿ-1) 0 11
从大到小 3 无解
★ 那个「3」比满屏红色更说明问题

倒着枚举时,整个 DP 只推动了 3 次 —— 而且这 3 次全是白写的。

道理很干脆:一开始只有 f[{0}][0] = 0 这一格有值。 轮到 S = 1 的时候,它想往 S = 3、5、9 推, 可这三行早就处理完了 —— 写进去也没人再看一眼。 于是信息卡在起点,一步都传不出去,最后 f[全集] 里一个有效值都没有。

第 26 章「左端点正序」、第 27 章「累加写在递归前面」, 和这里犯的是同一个病:读到 / 写到一个已经定稿的格子上,程序不会有任何反应。

wrongOrder.cpp✗ 集合 S 从大到小枚举

8三种「答案有数但是错的」写法

wrongInit.cpp✗ 初值设成 0 而不是 ∞
// ✗ 错误版本二:初值设成 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 2。求最小值的 DP,初值必须是「不可能达到的大数」。 设成 0 之后,每一个还没算出来的局面都变成了「白送的 0 代价」, 于是 min 会一路取到那些根本到不了的格子上。

check:viz 用 300 组数据钉死了它的样子:输出恒等于 min over i≠0 of d[i][0], 也就是「谁离起点最近」—— 和整条路线毫无关系。

初值不是「随便填个数」,初值是在回答「哪些局面根本不存在」。 (第 23 章 exact.cpp 那一节说的是同一件事。)

wrongEnd.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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 4。

★ 第九条恒等式:它精确地解了「不用回来」的那道题

少写一个 + d[i][0],解出来的就是开放式旅行商 —— 从 0 号出发走遍所有城市,但不用回去(也就是最短哈密顿路径)。

那是一道真实存在、也很常考的题(洛谷 P1433 吃奶酪就是这一类)。

openTsp.cpp(开放式 TSP,另一道题的正确答案)老老实实写的「不用回来」

check:viz 用 300 组数据钉死了这条:两份代码的输出一组不差。

⚠ 现实里这个 bug 特别容易犯,因为两道题的题面只差「回到出发点」五个字。 题目对边界的约定要抄进注释(第 19 章那条规矩)—— 这就是个活例子。

wrongLast.cpp✗ 最后只看了停在 n-1 号那一个
// ✗ 错误版本六:最后只看了「停在 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 20。走遍所有城市之后人可以停在任何一个城市,回程各不相同, 必须把 n-1 种都试一遍。只看一个,等于凭空规定「最后一个必须是 3 号」。

(回头看第 3 步那张表:0→2→1→3→0 正好是 20 —— 那是所有「以 3 号结尾」的走法里最好的。)

9★ 主角登场:方向写反

wrongDir.cpp✗ 转移写成了 d[j][i]
// ✗ 错误版本四:转移里把 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 12。人是从 i 走到 j,代价当然是 d[i][j]; 写成 d[j][i] 就是按回程的价钱付去程的钱。

这个 bug 不难看懂,但它是这一章的主角 —— 原因在第 12 步。先看它错得多离谱:

10动画:把每条路线「真的走一遍」再算一次账

四种写法各自走出来的路线(都用真实距离重新算一遍)
用「下一步 ▶」切换四种写法
第 1 / 4 步
0123起点
✓ 正解
它选的路线
0 → 1 → 3 → 2 → 0
它报出来的答案
11
这条路线真的走一遍(含回程)
11
报的数和实际花费对得上吗
✓ 对得上
城市摆成一圈,蓝色是起点 0 号。实线箭头是这份代码选出来的路线,绿色虚线是最后回起点那一段 (「忘了回起点」那一档没有这一段 —— 它就地解散了)。

右边两个大数字是重点:上面是它自己报的答案,下面是拿真实距离 把它选的路线真的走一遍。正解这两个数相等, 「方向写反」那一档对不上 —— 它连自己选的路线要花多少都算错了。

这就是为什么第 26、27 章一直在做「输出方案」:一份走法能自证清白,一个数字不能。
✓ 正解:报出来 11。它报的数和这条路线实际走下来的花费一致 —— 名副其实。

城市摆成一圈,蓝色是起点。实线是这份代码选出来的路线,绿色虚线是回起点那一段。

★ 右边那两个大数字是重点:上面是它自己报的答案,下面是拿真实距离把它选的路线 真的走一遍要花多少。

哪一种写法 它报的数 这条路线真的走一遍 对得上吗
✓ 正解 11 11 ✓
✗ 忘了回起点 4 11 ✗(它就地解散了)
✗ 方向写反 12 22 ✗ 差了 10
✗ 只看一个结尾 20 20 ✓(路线没毛病,只是不是最优的)
★ 第三行是这一章最好玩的地方

「方向写反」那份报出来 12,比正确答案 11 还大一点点 —— 看着像是「差不多,就是没找到最优」。

可你让它把自己选的那条路线真的走一遍:要 22。

也就是说,它连自己选的路线要花多少钱都算错了。 它报的 12 不对应任何一条真实存在的走法,那是个凭空的数字。

一份走法能自证清白,一个数字不能。

这就是第 26、27 章一直在做「输出方案」的理由,也是第 27 章那句 「答案大不代表答案对」的续集。 (顺带:第四行「只看一个结尾」报的数和实际是对得上的 —— 它的路线合法, 只是被人为限制了终点。同样是错,错法可以完全不同。)

11输出路线:删一个元素也只是一次位运算

path.cpp记 from[S][i],回溯出走法
// 输出**走法本身** —— 第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
最短总路程 = 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 藏在「对称性」里

对拍器
★ 这个生成器的灵魂是「距离矩阵不对称」。一旦 d[i][j] == d[j][i],「方向写反」和「方向写对」就是同一件事,那个 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 章「堆数太少,错误贪心就隐身」是同一类现象: 规模小 = 可能性少 = 蒙对的概率大。

gen.cpp(带三个档位的生成器)两次改动都能重跑
★ 反过来验一次:矩阵永远对称,那个 bug 就彻底隐身

我另写了一份 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] 是同一个数 —— 它压根就没错。

wrongVisit.cpp✗「已经去过就跳过」写反了
genSym.cpp(故意造得很温柔的生成器)演示用:反面教材

⚠ 最阴险的地方在这里:现实里的距离常常真的是对称的(欧氏距离就是), 所以这个 bug 在很多题上确实无害。 直到你遇到一道给有向图的题,它立刻就错 —— 而你之前所有的对拍都是绿的。

「在我的数据上没错」和「对」,是两件完全不同的事。

⚠ 顺带记一条:两个不同的 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 号永远当根」,两次的根因是同一个: 生成器里那个不假思索的「顺手」写法,悄悄给数据加了一条题目里没有的性质。

阶段 5 到这里就结束了 —— 回头看一眼你拿到了什么

八章 DP 走完,真正需要背的东西其实只有一句话:

依赖谁,就先填谁。

它换了七次形状(从下往上 / 容量倒序 / 容量正序 / 组内在最里层 / 长度从小到大 / 后序遍历 / 集合编号从小到大),每一次的理由都是同一个。

而这八章一共钉死了九条「写错了就是另一道题」的恒等式:

章 写错的地方 它其实解了哪道题
23 01 背包写成正序 完全背包
24 完全背包写成倒序 01 背包
25 分组背包组内枚举提到容量外 无视分组的 01 背包
25 分组背包容量写成正序 无视分组的完全背包
25 二维费用外层正序 二维费用的完全背包
26 区间 DP 左端点正序 允许一次合并任意多个连续堆
27 累加写在递归前面 只有根一个人可能来
27 u 来时儿子也能来 把快乐指数为正的人全叫来
28 忘了加回起点 开放式 TSP(不用回来)

九条都在说同一件事:DP 写错了不会崩溃、不会报警,它只是安静地去解另一道题。 唯一能发现这件事的,是对拍。

下一章开始进入阶段 6 · 图论(第 29 章:图的存储)。 好消息是你已经用过邻接表了 —— 第 27 章那一小节。 那一章埋的伏笔现在要收:稀疏用表、稠密用矩阵,到底差多少?下一章拿实测的数字说。

14自测

自测清单0 / 10
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)