阶段 5 · 动态规划 · 第 28 章

状压 DP 入门:旅行商问题

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

例题:旅行商问题(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 在不在集合 SS >> i & 1
i 加进 SS | (1 << i)
iS 去掉S & ~(1 << i)
S 里有几个元素__builtin_popcount(S)
全集(n 个元素)(1 << n) - 1

n 个元素一共 1 << n 个集合,编号 0 到 2ⁿ-1一个不多一个不少

bits.cpp集合 ↔ 整数的对应表,以及那条顺序的证明
输入(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] = 1d[1][0] = 2 —— 这就是「不对称」。)

从 0 出发,剩下 3 个城市有 3! = 6 种排法,全列出来:

走法逐段总长
0→1→2→3→01 + 9 + 7 + 623
0→1→3→2→01 + 1 + 2 + 711 ← 最优
0→2→1→3→04 + 9 + 1 + 620
0→2→3→1→04 + 7 + 9 + 222
0→3→1→2→05 + 9 + 9 + 730
0→3→2→1→05 + 2 + 9 + 218

正确答案 11。 后面五个数字都是写错的代码跑出来的:

数字谁跑出来的
2初值设成 0 而不是 ∞
4忘了加回起点那一段
12转移写成了 d[j][i](方向反)
20最后只看了「停在 3 号」那一个
−1集合倒着枚举 / 「已经去过」判断写反(都无解)

11 / 2 / 4 / 12 / 20 / −1 —— 这六个数后面每一步都会回来验。

4 暴力:全排列枚举访问顺序

brute.cpp(n-1)! 全排列
输入(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 对 「SS 加一个新元素」都检查了一遍,全部满足 S < S|(1<<j)

这就是第 21 章那句话的第六次登场。前五次都要动点脑子,这一次不用 —— 但理由和前五次一模一样

fast.cpp状压 DP 正解:S 从小到大
输入(stdin)
输出
点「运行 ▶」看结果

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

trace.cpp把 f[S][i] 整张表打出来
输入(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 = 184+7 = 1114+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
100.004 秒0.001 秒
110.022 秒0.003 秒
120.237 秒0.004 秒
132.959 秒0.003 秒
1443.844 秒0.005 秒

n = 14 那一行差了将近一万倍。而状压 DP 还远没到极限:

城市数 n状压 DP
180.076 秒
200.365 秒
221.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)011
从大到小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 而不是 ∞
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

wrongEnd.cpp✗ 忘了加回起点那一段
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 4

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

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

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

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

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

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

wrongLast.cpp✗ 最后只看了停在 n-1 号那一个
输入(stdin)
输出
点「运行 ▶」看结果

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

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

9 ★ 主角登场:方向写反

wrongDir.cpp✗ 转移写成了 d[j][i]
输入(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。它报的数和这条路线实际走下来的花费一致 —— 名副其实。

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

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

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

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

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

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

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

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

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

path.cpp记 from[S][i],回溯出走法
输入(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 一轮都抓不到。

300 轮实测,六个错误版本:

故意写错的地方被抓第几轮它其实解了哪道题
集合 S 从大到小枚举300 / 300第 1 轮—(信息传不出起点,无解)
初值设成 0300 / 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 / 300164 / 300
1矩阵改成不对称(每个方向各随机一次)277 / 300228 / 300
2(在用)城市数下界从 4 提到 5277 / 300239 / 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 / 3000 / 300
只看一个结尾239 / 300180 / 300
忘了加回起点300 / 300300 / 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 走完,真正需要背的东西其实只有一句话

依赖谁,就先填谁。

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

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

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

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

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

14 自测

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