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

树形 DP:没有上司的舞会

状态从「一段区间」换成「一棵子树」。而这一章你其实一行新代码都不用学 —— 因为「依赖谁,就先填谁」在树上有个你第 1 章就写过的名字:后序遍历。

例题:没有上司的舞会(P1352) 建议用时:110 分钟
「依赖谁,就先填谁」第五次登场 —— 这次它有个现成的名字
状态是什么依赖谁于是顺序是
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])

adj.cpp把邻接表建出来看看
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 两件事现在就要说清楚,不然后面会栽跟头

① 为什么不用二维数组 int g[N][N] n 个点的树只有 n-1 条边,而二维数组要开 个格子。 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ⁿ 枚举「谁来」,逐条边检查

brute.cpp2ⁿ 枚举到场名单
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 13,和手算一致。

这份代码里没有树、没有 DFS、没有状态、没有回溯 —— 它甚至不需要知道谁是根,只是把 n 个人的「来 / 不来」全排一遍,再逐条边检查合不合法。

这正是它当标准答案的资格(第 20 章那条规矩):正解那边是「在树上一层层往上归」, 两边连数据结构都不一样,对上了才有说服力。

5 实测:每多一个人,暴力翻一倍

同题对比:2ⁿ 枚举到场名单 vs 树形 DP O(n)
先跑 26,再改成 28、30。⚠ 变的是人数 —— 暴力是 2ⁿ,每加一个人就翻一倍。别超过 30(再大 1<<n 就溢出了)。
2ⁿ 枚举到场名单
树形 DP O(n)

本机实测(./genBig n,固定种子):

人数 n2ⁿ 暴力树形 DP
240.066 秒0.001 秒
260.250 秒0.001 秒
280.959 秒0.001 秒
303.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 来  时的最大快乐和

转移就自己掉出来了(vu 的儿子):

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 步说过同样的话)。 你唯一要做的,就是别把累加写到递归前面去。

fast.cpp树形 DP 正解:后序累加
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 13。想看它是按什么次序算完的,跑这份:

trace.cpp打印后序次序和每个点的两个 f 值
输入(stdin)
输出
点「运行 ▶」看结果
根是 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 动画:儿子全部归位之后,才轮到父亲

★ 树形 DP:儿子全部归位之后,才轮到父亲
答案 13
第 1 / 22 步
1+1· / ·2+3· / ·3-5· / ·4-1· / ·5+2· / ·6+5· / ·7+7· / ·
读到「还没算好的下属」
0
这个顺序对不对
✓ 对的
答案
每个圆圈是一个人,圈里是编号和快乐指数,圈下面那两个数是 f[u][0](不来) / f[u][1](来)。 浅绿 = 这棵子树已经算完了,蓝色 = 正在处理的点, 连线在读某个下属时会亮起来:绿色(它算好了)红色(它还没算)。 切到「递归之前」再看一遍:每条线都是红的, 所有 f[u][0] 恒为 0、f[u][1] 恒等于这个人自己的快乐指数 —— 整棵树的信息一点都没往上传。
f[u][0] = u 不来时这棵子树的最大快乐和,f[u][1] = u 来时的。根是 5 号(没有上司的那个人)。现在按后序(正确)走一遍 —— 请盯住每次读儿子的时候,那个儿子算好了没有。

圈里是编号和快乐指数,圈下面那两个数是 f[u][0](不来) / f[u][1](来)。 读某个下属时连线会亮起来:绿色(它算好了)红色(它还没算)

盯住计数器「读到还没算好的下属」,然后把下拉框切到「递归之前」:

累加写在哪计数器答案
递归之后(后序)013
递归之前(前序)62
★ 第七条恒等式:前序版本恒等于 max(0, 根的快乐指数)

切到前序你会看到一个很整齐的画面:每一条线都是红的, 所有 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[根]),一组不差。

wrongPre.cpp✗ 累加写在了递归前面

9 另外三种错法:一个算错,一个连名单都是违规的

wrongBoth.cpp✗ u 来的时候,儿子也能来
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 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 这几章一共钉死了八条这样的恒等式:

写错的地方它其实解了哪道题
2301 背包写成正序完全背包
24完全背包写成倒序01 背包
25分组背包组内枚举提到容量外无视分组的 01 背包
25分组背包容量写成正序无视分组的完全背包
25二维费用外层正序二维费用的完全背包
26区间 DP 左端点正序允许一次合并任意多个连续堆
27累加写在递归前面只有根一个人可能来
27u 来时儿子也能来把快乐指数为正的人全叫来
wrongMust.cpp✗ 以为「上司不来,下属就必须来」
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 8。题目说的是「上司来了,下属就不来」,它没有反过来说 「上司不来,下属就必须来」。这是纯粹的读题错误,和算法一点关系都没有。

wrongAns.cpp✗ 最后忘了和 f[root][0] 取 max
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 5。整棵树都算对了,只在最后一行栽了 —— 它默认「根一定要来」。

10 动画:同一棵树,四种理解各自请了谁

同一棵树,四种理解各自请了谁
用「下一步 ▶」切换四种理解
第 1 / 4 步
1+12+3不来3-5不来4-1不来5+2不来6+57+7
这份名单的快乐和
13
名单合法吗
✓ 合法
它算出来的答案
13
绿色 = 这个人来了,空心 = 没来。红色的虚线表示一对直接上下级同时到场 —— 那是题目明确禁止的,只要出现一条,这份名单就根本不是这道题的解。 请特别看第三张(「u 来时儿子也能来」):它算出来的数最大, 可它的名单是违规的。答案大不代表答案对。
✓ 正解:后序,回溯时累加:算出来 13,名单的快乐和 13,名单合法。这份名单是合法的,而且快乐和正好等于算出来的答案。

用「下一步 ▶」切换四种理解,绿色 = 这个人来了。

★ 请特别看第三张

前面几章的错误版本都只是「答案不对」。这一章的第三张不一样 —— 它的名单本身就是违规的:会出现一对直接上下级同时到场(画面上是红色虚线)。

哪一种理解算出来名单快乐和名单合法吗
✓ 正解1313合法
✗ 以为下属必须来88合法(只是不划算)
✗ u 来时儿子也能来1818✗ 违规
✗ 忘了取 max55合法(只是把根绑死了)

它算出来的数最大,可它根本没在解这道题。

答案大不代表答案对。 这也是为什么「输出方案」比「输出一个数」值钱:一个数没法自证清白,一份名单可以。

11 输出方案:算 f 是后序,回溯是前序

path.cpp回溯出到场名单
输入(stdin)
输出
点「运行 ▶」看结果
最大快乐指数之和 = 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 藏在「编号」里,和数值毫无关系

对拍器
★ 这个生成器有两个痛点,都不在数值大小上:快乐指数必须有负数,而且点的编号必须打乱(否则根永远是 1 号,「没找根」那个 bug 一轮都抓不到)。

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

故意写错的地方被抓第几轮它其实解了哪道题
累加写在递归前面(前序)298 / 300第 1 轮只有根一个人可能来
以为下属必须来267 / 300第 1 轮—(读题错误)
没找根,从 1 号开始 DFS251 / 300第 1 轮—(只算了 1 号那棵子树)
u 来时儿子也能来242 / 300第 1 轮把快乐指数为正的人全叫来
忘了和 f[root][0] 取 max219 / 300第 1 轮—(强制根到场)
★ 生成器改了三次,而第三次改的东西和「数值」一点关系都没有

gen.cpp 带了四个档位,你可以把当初那几次修改一次一次重跑 (./gen 种子 档位)。种子固定 1..300:

档位改了什么前序儿子也能来下属必须来没找根忘了取 max
0(最初)随机树,根固定 1 号,快乐指数全是正数3003001950184
1快乐指数改成 −50 ~ 100(有负数)3002782680179
2点的编号随机打乱(根不再是 1 号)299275268257208
3(在用)形状在「随机树 / 链 / 菊花」里轮着造298242267251219

① 加负数(档位 0 → 1):「以为下属必须来」从 195 涨到 268。 道理很直白 —— 快乐指数全是正数时,「能来就来」本来就划算, 那个错误的理解十有八九恰好取到同一个数。 要抓它,就得造出「这个下属来了反而亏」的局面(第 20 章那条规矩)。

② 打乱编号(档位 1 → 2):「没找根」从 0 / 300 直接跳到 257 / 300。 这一处改动没有动任何一个数值 —— 没改值域、没改点数、没改形状, 只是把点的编号重新分配了一遍。

★ 这是这一章最值得带走的一条:

数据的随机性不能只在数值上。结构、编号、谁扮演什么角色,同样要随机。

前面几章调的都是数值(第 24 章的 k、第 25 章的容量松紧、第 26 章的堆数), 这一次那个旋钮根本不在数值里。 写树 / 图的生成器时都要问一句: 我是不是无意中给某个点安排了特殊身份(根、起点、编号 1)?

③ 档位 3 的账要老实算。 它加了「链」和「菊花」两种退化形状,图的是覆盖(第 24 章那条: 参数取到极端时题目会退化成什么样子,那一端也得造)。 但代价是「儿子也能来」的抓获率从 275 掉到 242 —— 如果只看这五个已知 bug,档位 2 更划算。 我还是留了档位 3,因为退化形状防的是还没写出来的那些 bug, 而这五个在两个档位下都抓得住。抓获率是重要指标,但不是唯一指标。

gen.cpp(带四个档位的生成器)三次改动都能重跑
★ 反过来验一次:让 1 号永远当根,那个 bug 就彻底隐身

我另写了一份 genRoot1.cpp,和最终档比只改了一处:不打乱编号。 快乐指数照样有负数、形状照样轮着造。同样跑 300 轮:

故意写错的地方正常数据1 号永远当根的数据
没找根251 / 3000 / 300
累加写在递归前面298 / 300299 / 300
u 来时儿子也能来242 / 300255 / 300
以为下属必须来267 / 300261 / 300
忘了取 max219 / 300226 / 300

一个 bug 完全隐身,另外四个纹丝不动。 而且这次原因不用猜: 1 号点确实是根的时候,「找根」和「直接用 1」是同一件事 —— 它压根就没错。

wrongRoot.cpp✗ 直接从 1 号点开始 DFS
genRoot1.cpp(故意造得很温柔的生成器)演示用:反面教材

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 自测

自测清单0 / 10
配套练习
  • 洛谷 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 的下一站,值得提前看一眼
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)