| 章 | 状态是什么 | 依赖谁 | 于是顺序是 |
|---|---|---|---|
| 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,一个不多一个不少。
点「运行 ▶」看结果
① 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 暴力:全排列枚举访问顺序
点「运行 ▶」看结果
跑出来 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 章那句话的第六次登场。前五次都要动点脑子,这一次不用 —— 但理由和前五次一模一样。
点「运行 ▶」看结果
跑出来 11。想看整张表长什么样,跑这份:
点「运行 ▶」看结果
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 三种「答案有数但是错的」写法
点「运行 ▶」看结果
跑出来 2。求最小值的 DP,初值必须是「不可能达到的大数」。
设成 0 之后,每一个还没算出来的局面都变成了「白送的 0 代价」,
于是 min 会一路取到那些根本到不了的格子上。
check:viz 用 300 组数据钉死了它的样子:输出恒等于 min over i≠0 of d[i][0],
也就是「谁离起点最近」—— 和整条路线毫无关系。
初值不是「随便填个数」,初值是在回答「哪些局面根本不存在」。 (第 23 章
exact.cpp那一节说的是同一件事。)
点「运行 ▶」看结果
跑出来 4。
少写一个 + d[i][0],解出来的就是开放式旅行商 ——
从 0 号出发走遍所有城市,但不用回去(也就是最短哈密顿路径)。
那是一道真实存在、也很常考的题(洛谷 P1433 吃奶酪就是这一类)。
check:viz 用 300 组数据钉死了这条:两份代码的输出一组不差。
⚠ 现实里这个 bug 特别容易犯,因为两道题的题面只差「回到出发点」五个字。 题目对边界的约定要抄进注释(第 19 章那条规矩)—— 这就是个活例子。
点「运行 ▶」看结果
跑出来 20。走遍所有城市之后人可以停在任何一个城市,回程各不相同,
必须把 n-1 种都试一遍。只看一个,等于凭空规定「最后一个必须是 3 号」。
(回头看第 3 步那张表:0→2→1→3→0 正好是 20 —— 那是所有「以 3 号结尾」的走法里最好的。)
9 ★ 主角登场:方向写反
点「运行 ▶」看结果
跑出来 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 输出路线:删一个元素也只是一次位运算
点「运行 ▶」看结果
最短总路程 = 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 藏在「对称性」里
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 就算入门了