| 章 | 状态是什么 | 依赖谁 | 于是顺序是 |
|---|---|---|---|
| 21 | 数字三角形的一个格子 | 下面一行 | 从下往上 |
| 23 | 前 i 件物品 + 剩多少容量 | 上一轮的 f[j-w] | 容量倒序 |
| 24 | 同上 | 这一轮的 f[j-w] | 容量正序 |
| 25 | 同上(多一维 / 分组) | 上一组的 f[j-w] | 容量倒序、组内在最里层 |
| 26 | 一段区间 f[l][r] | 更短的区间 | 长度从小到大 |
| 27 | 一棵子树 f[u][*] | 所有儿子的子树 | 儿子全算完,才轮到父亲 |
最后那一行有个名字,你在第 1 章就写过它了 —— 那时候它叫「归」, 第 11 章归并排序、第 26 章输出合并方案,用的都是同一件东西:后序遍历。
所以这一章真正要学的新东西只有两个,而且都不难: ① 状态里多一维「这个点自己选不选」;② 树在代码里长什么样(邻接表)。
1 一句话问题
一家公司有
n个职员,上下级关系构成一棵树。第i个人的快乐指数是r[i](可以是负数)。 现在办舞会,如果某人的直接上司到场,他就不来。 请安排一份到场名单,使快乐指数之和最大。
一个人都不来是允许的,所以答案至少是 0。
把「上下级」看成树上的边,题目就是: 在树上选一批点,任何一条边的两个端点不能同时被选,求最大点权和。
这个问题在一般的图上是出了名的难(最大权独立集,NP 困难)。 但在树上,它是线性的 —— 这一章从头到尾就是在解释这个「但是」从哪来。
2 先把树在代码里摆出来(本章自带的存图小节)
树和图在代码里长什么样?这一章只需要最简单的那一种 —— 邻接表:
vector<vector<int>> son(n + 1);
son[k].push_back(l); // 输入的 l k 表示「k 是 l 的上司」→ 把 l 挂到 k 名下
就这一行。son[u] 就是 u 的所有直接下属,想遍历它们就 for (int v : son[u])。
点「运行 ▶」看结果
① 为什么不用二维数组 int g[N][N]。
n 个点的树只有 n-1 条边,而二维数组要开 n² 个格子。
n = 100000 时,邻接表存 10 万个数,二维数组要 100 亿个格子 —— 开都开不出来。
稀疏的图用表,稠密的图才用矩阵(第 29 章会拿实测的内存和耗时把这件事说透)。
② 根是「没有上司的那个人」,不一定是 1 号。
int root = 1;
for (int i = 1; i <= n; i++) if (!hasBoss[i]) { root = i; break; }这三行看着像废话,但这一章有一整个错误版本就栽在这儿。 更要命的是:它在大多数人自己造的数据上根本不会错 —— 因为随手写树生成器的人几乎都让 1 号当根。第 12 步会把这件事量出来。
3 手算一遍:一棵 7 个人的树,六个数字贯穿全章
5 (+2) ← 根(注意不是 1 号)
┌──────┼──────┐
1 (+1) 6 (+5) 7 (+7)
│ │
4 (−1) 2 (+3)
│
3 (−5)
输入长这样(第一行 n,第二行快乐指数,然后每行 下属 上司):
7
1 3 -5 -1 2 5 7
1 5
2 6
3 4
4 1
6 5
7 5
- 正确答案 13:5 号不来,让 1、6、7 三个人来 →
1 + 5 + 7 = 13。 (1 号来了,所以 4 号不能来;4 号不来,3 号本可以来,但它是 −5,不来更好。)
后面五个数字都是写错的代码跑出来的,每一个对应一类典型错误:
| 数字 | 谁跑出来的 |
|---|---|
| 2 | 累加写在了递归前面(前序) |
| 18 | 以为「u 来了,儿子也能来」 |
| 8 | 以为「上司不来,下属就必须来」 |
| 5 | 最后忘了和 f[root][0] 取 max |
| 1 | 没找根,直接从 1 号点开始 DFS |
13 / 2 / 18 / 8 / 5 / 1 —— 这六个数后面每一步都会回来验。
4 暴力:2ⁿ 枚举「谁来」,逐条边检查
点「运行 ▶」看结果
跑出来 13,和手算一致。
这份代码里没有树、没有 DFS、没有状态、没有回溯 ——
它甚至不需要知道谁是根,只是把 n 个人的「来 / 不来」全排一遍,再逐条边检查合不合法。
这正是它当标准答案的资格(第 20 章那条规矩):正解那边是「在树上一层层往上归」, 两边连数据结构都不一样,对上了才有说服力。
5 实测:每多一个人,暴力翻一倍
本机实测(./genBig n,固定种子):
| 人数 n | 2ⁿ 暴力 | 树形 DP |
|---|---|---|
| 24 | 0.066 秒 | 0.001 秒 |
| 26 | 0.250 秒 | 0.001 秒 |
| 28 | 0.959 秒 | 0.001 秒 |
| 30 | 3.726 秒 | 0.002 秒 |
每加 2 个人,暴力乘以 4(也就是每加 1 个人翻一倍),一行不差。
而树形 DP 那一列压根没动 —— 它是 O(n),30 个点和 30 万个点对它一样快。
它的常数其实很小:逐条边检查时通常在第一条边就 break 了
(随机一份名单,多半一上来就有一对上下级同时到场)。
但省下的是「每次检查花多久」,省不掉「要检查多少次」—— 枚举本身还是实打实的 2ⁿ 次,所以那条曲线该翻倍还是翻倍。 (第 25 章那条教训:要拿暴力证明有多慢,先确认它真的走完了。这里确实走完了。)
6 ★ 关键一步(一):状态里多一维「自己选不选」
先想清楚为什么非要多这一维。
假设状态只写「g[u] = 以 u 为根的子树的最大快乐和」。现在要合并儿子的结果 ——
可你合不上:u 到底能不能来,取决于它的儿子来没来,
而 g[v] 这个数字里根本没说「v 到底来了没有」。
★ 这就是第 22 章那句话的翻版: 状态里必须带上「后面还要用到的那一点信息」(LIS 那五个字是「以 i 结尾」,这里是「u 来没来」)。
于是:
f[u][0] = 以 u 为根的子树里,u 不来时的最大快乐和
f[u][1] = 以 u 为根的子树里,u 来 时的最大快乐和转移就自己掉出来了(v 是 u 的儿子):
f[u][0] = Σ max(f[v][0], f[v][1]) u 不来 → 儿子来不来都行,各挑更大的
f[u][1] = r[u] + Σ f[v][0] u 来 → 儿子一个都不能来答案 = max(f[root][0], f[root][1])。
⚠ 注意 f[u][0] 里是 max,不是 f[v][1]。
「上司来了下属就不来」不等于「上司不来下属就必须来」—— 第 12 步会看到这个误解值多少分。
7 ★ 关键一步(二):那两个 Σ 必须在回溯时做
void dfs(int u) {
f[u][0] = 0;
f[u][1] = r[u];
for (int v : son[u]) {
dfs(v); // ★ 先把儿子整棵子树算完
f[u][0] += max(f[v][0], f[v][1]); // ★ 回来之后才累加
f[u][1] += f[v][0];
}
}★ 那两行累加必须写在 dfs(v) 后面。 写在前面,f[v] 还是初值 0 ——
儿子那棵子树根本还没算。
这就是「依赖谁,就先填谁」在树上的样子。而它有个现成的名字:后序遍历。
好消息是:递归天然帮你把顺序安排好了(第 26 章第 6 步说过同样的话)。 你唯一要做的,就是别把累加写到递归前面去。
点「运行 ▶」看结果
跑出来 13。想看它是按什么次序算完的,跑这份:
点「运行 ▶」看结果
根是 5 号(没有上司的那个人,不一定是 1 号)
次序 点 深度 快乐 f[u][0] 不来 f[u][1] 来 子树最好 儿子
---- -- ---- ---- ------------ ---------- -------- ----------
1 3 3 -5 0 -5 0 -
2 4 2 -1 0 -1 0 3
3 1 1 1 0 1 1 4
4 2 2 3 0 3 3 -
5 6 1 5 3 5 5 2
6 7 1 7 0 7 7 -
7 5 0 2 13 5 13 1 6 7
每个点都排在它所有儿子的后面 —— 这就是后序,也就是这一章的全部内容。
(check:viz 拿这张表和动画逐个对过,包括这个次序本身。)
8 动画:儿子全部归位之后,才轮到父亲
圈里是编号和快乐指数,圈下面那两个数是 f[u][0](不来) / f[u][1](来)。 读某个下属时连线会亮起来:绿色(它算好了) 或 红色(它还没算)。
盯住计数器「读到还没算好的下属」,然后把下拉框切到「递归之前」:
| 累加写在哪 | 计数器 | 答案 |
|---|---|---|
| 递归之后(后序) | 0 | 13 |
| 递归之前(前序) | 6 | 2 |
切到前序你会看到一个很整齐的画面:每一条线都是红的,
所有 f[u][0] 恒为 0、f[u][1] 恒等于这个人自己的快乐指数。
因为累加发生在 dfs(v) 之前,那时 f[v] 全是 [0, 0];
而 dfs(v) 又会把 f[v] 整个重写一遍 —— 父亲加过的那份,之后再也没人回头看。
所以整棵树的信息一点都没往上传,最后输出的就是 max(0, r[根])。
默认数据上根是 5 号、快乐指数 2 → 答案 2。
check:viz 用 300 组数据钉死了这条:输出恒等于 max(0, r[根]),一组不差。
9 另外三种错法:一个算错,一个连名单都是违规的
点「运行 ▶」看结果
跑出来 18。
f[u][1] 里应该是 Σ f[v][0](儿子一个都不能来),写成 Σ max(f[v][0], f[v][1])
之后,「上司来了下属就不来」这条唯一的限制就不存在了。
于是它解的是另一道题:把所有快乐指数为正的人全叫来。
默认数据上 1 + 3 + 2 + 5 + 7 = 18,正好对上。
check:viz 同样用 300 组数据钉死:输出恒等于 Σ max(0, r[i]),一组不差。
连上前面几章,DP 这几章一共钉死了八条这样的恒等式:
| 章 | 写错的地方 | 它其实解了哪道题 |
|---|---|---|
| 23 | 01 背包写成正序 | 完全背包 |
| 24 | 完全背包写成倒序 | 01 背包 |
| 25 | 分组背包组内枚举提到容量外 | 无视分组的 01 背包 |
| 25 | 分组背包容量写成正序 | 无视分组的完全背包 |
| 25 | 二维费用外层正序 | 二维费用的完全背包 |
| 26 | 区间 DP 左端点正序 | 允许一次合并任意多个连续堆 |
| 27 | 累加写在递归前面 | 只有根一个人可能来 |
| 27 | u 来时儿子也能来 | 把快乐指数为正的人全叫来 |
点「运行 ▶」看结果
跑出来 8。题目说的是「上司来了,下属就不来」,它没有反过来说 「上司不来,下属就必须来」。这是纯粹的读题错误,和算法一点关系都没有。
点「运行 ▶」看结果
跑出来 5。整棵树都算对了,只在最后一行栽了 —— 它默认「根一定要来」。
10 动画:同一棵树,四种理解各自请了谁
用「下一步 ▶」切换四种理解,绿色 = 这个人来了。
前面几章的错误版本都只是「答案不对」。这一章的第三张不一样 —— 它的名单本身就是违规的:会出现一对直接上下级同时到场(画面上是红色虚线)。
| 哪一种理解 | 算出来 | 名单快乐和 | 名单合法吗 |
|---|---|---|---|
| ✓ 正解 | 13 | 13 | 合法 |
| ✗ 以为下属必须来 | 8 | 8 | 合法(只是不划算) |
| ✗ u 来时儿子也能来 | 18 | 18 | ✗ 违规 |
| ✗ 忘了取 max | 5 | 5 | 合法(只是把根绑死了) |
它算出来的数最大,可它根本没在解这道题。
答案大不代表答案对。 这也是为什么「输出方案」比「输出一个数」值钱:一个数没法自证清白,一份名单可以。
11 输出方案:算 f 是后序,回溯是前序
点「运行 ▶」看结果
最大快乐指数之和 = 13
到场名单:1 6 7
快乐指数:1 + 5 + 7 = 13
这次比第 26 章还省事:f[u][0] 和 f[u][1] 本来就分开存着,
回溯时只要问一句「这个点当初取的是哪一个状态」:
void back(int u, int take) {
come[u] = take;
for (int v : son[u]) {
if (take) back(v, 0); // 我来了 → 儿子一个都不能来
else back(v, f[v][1] > f[v][0] ? 1 : 0); // 我没来 → 儿子各自挑更好的
}
}
- 算
f的时候是后序(先递归再累加):因为父亲要用儿子的结果; - 回溯方案的时候是前序(先定自己再定儿子):因为儿子能不能来,取决于我来没来。
这两个方向都不是背下来的,都是从「谁依赖谁」推出来的 —— 又是同一句话。
check:viz 对这份名单做的是硬验证:名单里不能有任何一对直接上下级,
快乐指数之和必须正好等于那个答案。
12 ★ 对拍:一个 bug 藏在「编号」里,和数值毫无关系
300 轮实测,五个错误版本:
| 故意写错的地方 | 被抓 | 第几轮 | 它其实解了哪道题 |
|---|---|---|---|
| 累加写在递归前面(前序) | 298 / 300 | 第 1 轮 | 只有根一个人可能来 |
| 以为下属必须来 | 267 / 300 | 第 1 轮 | —(读题错误) |
| 没找根,从 1 号开始 DFS | 251 / 300 | 第 1 轮 | —(只算了 1 号那棵子树) |
| u 来时儿子也能来 | 242 / 300 | 第 1 轮 | 把快乐指数为正的人全叫来 |
| 忘了和 f[root][0] 取 max | 219 / 300 | 第 1 轮 | —(强制根到场) |
gen.cpp 带了四个档位,你可以把当初那几次修改一次一次重跑
(./gen 种子 档位)。种子固定 1..300:
| 档位 | 改了什么 | 前序 | 儿子也能来 | 下属必须来 | 没找根 | 忘了取 max |
|---|---|---|---|---|---|---|
| 0(最初) | 随机树,根固定 1 号,快乐指数全是正数 | 300 | 300 | 195 | 0 | 184 |
| 1 | 快乐指数改成 −50 ~ 100(有负数) | 300 | 278 | 268 | 0 | 179 |
| 2 | 点的编号随机打乱(根不再是 1 号) | 299 | 275 | 268 | 257 | 208 |
| 3(在用) | 形状在「随机树 / 链 / 菊花」里轮着造 | 298 | 242 | 267 | 251 | 219 |
① 加负数(档位 0 → 1):「以为下属必须来」从 195 涨到 268。 道理很直白 —— 快乐指数全是正数时,「能来就来」本来就划算, 那个错误的理解十有八九恰好取到同一个数。 要抓它,就得造出「这个下属来了反而亏」的局面(第 20 章那条规矩)。
② 打乱编号(档位 1 → 2):「没找根」从 0 / 300 直接跳到 257 / 300。 这一处改动没有动任何一个数值 —— 没改值域、没改点数、没改形状, 只是把点的编号重新分配了一遍。
★ 这是这一章最值得带走的一条:
数据的随机性不能只在数值上。结构、编号、谁扮演什么角色,同样要随机。
前面几章调的都是数值(第 24 章的 k、第 25 章的容量松紧、第 26 章的堆数),
这一次那个旋钮根本不在数值里。
写树 / 图的生成器时都要问一句:
我是不是无意中给某个点安排了特殊身份(根、起点、编号 1)?
③ 档位 3 的账要老实算。 它加了「链」和「菊花」两种退化形状,图的是覆盖(第 24 章那条: 参数取到极端时题目会退化成什么样子,那一端也得造)。 但代价是「儿子也能来」的抓获率从 275 掉到 242 —— 如果只看这五个已知 bug,档位 2 更划算。 我还是留了档位 3,因为退化形状防的是还没写出来的那些 bug, 而这五个在两个档位下都抓得住。抓获率是重要指标,但不是唯一指标。
我另写了一份 genRoot1.cpp,和最终档比只改了一处:不打乱编号。
快乐指数照样有负数、形状照样轮着造。同样跑 300 轮:
| 故意写错的地方 | 正常数据 | 1 号永远当根的数据 |
|---|---|---|
| 没找根 | 251 / 300 | 0 / 300 |
| 累加写在递归前面 | 298 / 300 | 299 / 300 |
| u 来时儿子也能来 | 242 / 300 | 255 / 300 |
| 以为下属必须来 | 267 / 300 | 261 / 300 |
| 忘了取 max | 219 / 300 | 226 / 300 |
一个 bug 完全隐身,另外四个纹丝不动。 而且这次原因不用猜: 1 号点确实是根的时候,「找根」和「直接用 1」是同一件事 —— 它压根就没错。
13 这一章可以带走的四样东西
【1】状态里多一维「这个点自己选不选」。 因为父亲能不能选,取决于儿子选没选 —— 而一个光秃秃的「子树最优值」里没有这个信息。 这和第 22 章「以 i 结尾」是同一条道理:状态要带上后面还会用到的那一点信息。
【2】转移在回溯时做,也就是后序遍历。
「依赖谁,就先填谁」第五次登场。好消息是递归天然帮你排好了顺序,
你只要别把累加写到 dfs(v) 前面去 —— 写错了不报错,答案会塌成 max(0, r[根])。
【3】算 f 是后序,回溯方案是前序。 两个方向都是从依赖关系推出来的,不是背的。 而输出方案还有个额外的好处:一份名单可以自证清白,一个数不行 —— 那个「答案 18」的错误版本,名单一画出来就露馅了。
【4】数据的随机性不能只在数值上。 「没找根」这个 bug 在「1 号永远当根」的数据上 0 / 300, 打乱编号之后立刻 257 / 300 —— 而这一处改动没有动任何一个数值。 写树 / 图的生成器时先问:我是不是给某个点安排了特殊身份?
第 28 章:状压 DP 入门。
这一章的状态是「一棵子树」,下一章的状态是 一个集合 ——
而集合在代码里就是一个整数,第 i 位是 1 就表示第 i 个元素在集合里。
你在第 3 章(二进制枚举子集)就见过它了,
这一章第 4 步那份 2ⁿ 暴力用的也正是它 —— 只不过那时候它还只是「枚举」,
下一章它要变成「状态」。
那时候你会发现:1 << n 个状态排成一排,填表顺序又要重新问一遍
「依赖谁,就先填谁」—— 第六次。
14 自测
- 洛谷 P1352 没有上司的舞会 —— 本章原题。注意它的输入多一行 0 0 结尾,而且根同样要自己找
- 洛谷 P2016 战略游戏 —— ★ 最小点覆盖:选最少的点,让每条边至少有一个端点被选。和本章是一对「反着的」题 —— 转移里那个 max 变成 min,f[u][1] 那一项也要跟着变。写完对比一下两份代码,只差几个字
- 洛谷 P1122 最大子树和 —— 状态只有一维(这题不需要「选不选」),但正好练「有负数时该不该要这个儿子」。提示:max(0, f[v])
- 洛谷 P2015 二叉苹果树 —— ★ 树形背包:状态是 f[u][j] = 在 u 的子树里保留 j 条边。它把本章的树形 DP 和第 23 章的背包缝在了一起,是最经典的进阶题
- 洛谷 P1273 有线电视网 —— 进阶的树形背包(分组背包版),正好回收第 25 章。想清楚「每个儿子是一组」这句话
- 洛谷 P3478 [POI2008] STA-Station —— 换根 DP 入门:先求出以 1 为根的答案,再 O(1) 推到每个点当根。它是树形 DP 的下一站,值得提前看一眼