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

树形 DP:没有上司的舞会

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

需要先学:第 1 章 递归入门:函数怎么调用自己、第 21 章 DP 入门:从记忆化到递推例题:没有上司的舞会(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把邻接表建出来看看
// 邻接表是怎么建起来的 —— 这一章自带的「存图」小节
//
// 树和图在代码里长什么样?这一章只需要最简单的那一种:
//
// vector<vector<int>> son(n + 1);
// son[k].push_back(l); // k 是 l 的上司 → 把 l 挂到 k 的名下
//
// ★ 为什么不用二维数组 `int g[N][N]`:
// n 个点的树只有 n-1 条边,而二维数组要开 n² 个格子。
// n = 100000 时,邻接表存 10 万条边,二维数组要 100 亿个格子 —— 开都开不出来。
// **稀疏的图用表,稠密的图才用矩阵。** 第 29 章会拿实测的内存和耗时把这件事说透,
// 这里够用就行。
//
// ⚠ 这一章的图有两个特殊之处,让它比一般的图好写:
// ① 它是**树**,而且每条边都自带方向(上司 → 下属),所以只存单向就够了;
// 一般的无向图要 push_back 两次(第 29、30 章会讲)。
// ② 根是「**没有上司的那个人**」,得数一下入度才知道 —— 不一定是 1 号。
// wrongRoot.cpp 就是栽在这一句上的。
//
// 用法:./adj (用正文那棵默认的 7 个点的树)
// ./adj < 数据文件(格式同 brute.cpp)
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
vector<long long> r;
vector<pair<int, int>> edges;
if (cin >> n && n > 0) {
r.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> r[i];
for (int i = 0; i < n - 1; i++) {
int l, k;
if (!(cin >> l >> k)) break;
edges.push_back({l, k});
}
} else {
n = 7;
r = {0, 1, 3, -5, -1, 2, 5, 7};
edges = {{1, 5}, {2, 6}, {3, 4}, {4, 1}, {6, 5}, {7, 5}};
}
vector<vector<int>> son(n + 1);
vector<int> hasBoss(n + 1, 0);
for (auto [l, k] : edges) {
son[k].push_back(l); // ← 建表就这一行
hasBoss[l] = 1;
}
cout << "读进来的 " << edges.size() << " 条「l k = k 是 l 的上司」:\n ";
for (auto [l, k] : edges) cout << k << "→" << l << " ";
cout << "\n\n建好的邻接表 son[u](u 的直接下属):\n";
for (int u = 1; u <= n; u++) {
cout << " son[" << u << "] = {";
for (size_t i = 0; i < son[u].size(); i++) cout << (i ? ", " : "") << son[u][i];
cout << "}";
if (son[u].empty()) cout << " <- 叶子,没有下属";
cout << "\n";
}
int root = 1;
for (int i = 1; i <= n; i++) if (!hasBoss[i]) { root = i; break; }
cout << "\n没有上司的那个人 = " << root << " 号,它就是根。\n";
cout << "★ 注意它不是 1 号 —— 这一章有一整个错误版本(wrongRoot.cpp)就栽在这里。\n";
long long cells = (long long)(n + 1) * (n + 1);
cout << "\n顺带算笔账:这棵树 " << n << " 个点、" << edges.size() << " 条边。\n";
cout << " 邻接表存了 " << edges.size() << " 个数;";
cout << "换成二维数组 g[" << n + 1 << "][" << n + 1 << "] 要 " << cells << " 个格子。\n";
cout << " 点数一大,这个差距就是「开得出来」和「开不出来」的差别(第 29 章细算)。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 两件事现在就要说清楚,不然后面会栽跟头

① 为什么不用二维数组 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ⁿ 枚举「谁来」,逐条边检查

brute.cpp2ⁿ 枚举到场名单
// 没有上司的舞会 —— 暴力:2ⁿ 枚举「每个人来不来」,再检查有没有直接上下级同时到场
//
// ★ 它是**完全不同的思路**(第 20 章那条规矩):
// 这份代码里**没有树、没有 DFS、没有状态、没有回溯** ——
// 它甚至不需要知道谁是根,只是把 n 个人的「来 / 不来」全排一遍,
// 然后逐条边检查一下合不合法。
// 正解那边是「在树上一层层往上归」—— 两边连数据结构都不一样,对上了才有说服力。
//
// 复杂度 O(2ⁿ × n):n = 24 是一千七百万种名单,n = 30 是十亿多种 —— 正文第 10 步会实测。
//
// ⚠ 常数很小,因为「逐条边检查」通常在第一条边就 break 了(随机一份名单,
// 多半一上来就有一对上下级同时到场)。但**枚举本身还是实打实的 2ⁿ 次** ——
// 省下的是每次检查的时间,省不掉要检查多少次。
// (第 25 章那条教训:要拿暴力证明「有多慢」,先确认它真的走完了。这里确实走完了。)
//
// 题意:n 个职员组成一棵树(上下级关系),第 i 个人的快乐指数是 r[i]。
// **如果某人的直接上司到场,他就不来**。求到场的人快乐指数之和的最大值。
// 输入:第一行 n
// 第二行 n 个整数 r[1..n](可以是负数)
// 接下来 n-1 行,每行两个数 l k,表示 **k 是 l 的直接上司**
// 输出:最大快乐指数之和
//
// ⚠ 一个人都不来是允许的,所以答案至少是 0(全是负数时就该谁都不来)。
// ⚠ 洛谷 P1352 的输入多一行 `0 0` 结尾,读入时忽略掉就一样了。
#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;
// ⚠ `1 << n` 是 int 移位,n ≥ 31 就是未定义行为(而且那时候它也早就跑不完了)
if (n > 30) { cerr << "n 太大了,2ⁿ 暴力只支持 n <= 30\n"; return 1; }
vector<long long> r(n + 1);
for (int i = 1; i <= n; i++) cin >> r[i];
vector<pair<int, int>> edge; // (下属, 上司)
for (int i = 0; i < n - 1; i++) {
int l, k;
if (!(cin >> l >> k)) break;
edge.push_back({l, k});
}
long long best = 0; // 一个都不选,和为 0
for (int s = 0; s < (1 << n); s++) {
bool ok = true;
for (auto [l, k] : edge) // 上司和下属不能同时到场
if ((s >> (l - 1) & 1) && (s >> (k - 1) & 1)) { ok = false; break; }
if (!ok) continue;
long long sum = 0;
for (int i = 1; i <= n; i++)
if (s >> (i - 1) & 1) sum += r[i];
best = max(best, sum);
}
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出来 13,和手算一致。

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

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

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

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

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

fast.cpp树形 DP 正解:后序累加
// 没有上司的舞会 —— 正解:树形 DP,★ 转移在 DFS **回溯**的时候做
//
// 状态比前面几章多的那一维,是「**当前这个点自己选不选**」:
//
// 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])。
//
// ★ 关键一步:**这两个 Σ 必须在儿子全部算完之后才能加** —— 也就是在递归返回之后。
// 「依赖谁,就先填谁」(第 21 章)第五次登场,而这一次它有个现成的名字:**后序遍历**。
// 你在第 1 章就写过它了(那时候叫「归」),第 11 章归并排序、
// 第 26 章输出合并方案,用的都是同一件东西。
//
// 把累加写在递归**前面**(wrongPre.cpp)会怎样?儿子的 f 还全是 0,
// 于是每个点都只看见自己 —— 不报错、不崩溃,安静地给你一个偏小的答案。
// 这和第 26 章「左端点正序读到还没算好的格子」是同一个病。
//
// 复杂度 O(n):每条边只在回溯时被用一次。空间 O(n)。
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<long long> r;
vector<vector<int>> son; // 邻接表:son[u] = u 的所有直接下属
vector<array<long long, 2>> f;
// ⚠ 这里用递归 DFS。n 很大(比如一条 10 万个点的链)时会爆栈,
// 正文第 12 步会实测这个边界 —— 那是第 21 章 stairsDeep.cpp 那个坑的树上版本。
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];
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n) || n <= 0) return 0;
r.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> r[i];
son.assign(n + 1, {});
vector<int> hasBoss(n + 1, 0);
for (int i = 0; i < n - 1; i++) {
int l, k;
if (!(cin >> l >> k)) break;
son[k].push_back(l); // k 是 l 的上司 → l 挂在 k 下面
hasBoss[l] = 1;
}
// ⚠ 根是「没有上司的那个人」,**不一定是 1 号**。
// 直接从 1 开始 DFS 是这一章最容易犯又最容易蒙混过关的错(wrongRoot.cpp),
// 因为很多人造的数据里 1 恰好就是根。
int root = 1;
for (int i = 1; i <= n; i++) if (!hasBoss[i]) { root = i; break; }
f.assign(n + 1, {0, 0});
dfs(root);
cout << max(f[root][0], f[root][1]) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

trace.cpp打印后序次序和每个点的两个 f 值
// 把 DFS 的**回溯顺序**和每个点的 f[u][0] / f[u][1] 打出来 —— 动画就是照着这个次序播的
//
// 为什么要有它:动画是用 TypeScript 把这个算法重写一遍画出来的。
// 只比最后那个答案是不够的 —— 答案蒙对、中间过程画错,学生一样看不出来。
// 所以这里把**每个点被算完的次序**和它的两个 f 值都打出来,
// check:viz 拿它和动画的每一帧逐个对照。(第 23、26 章的 trace.cpp 是同样的用意。)
//
// ★ 这张表本身就是这一章的 ★ 的样子:
// **每个点都排在它所有儿子的后面。** 那就是后序遍历,也就是「依赖谁,就先填谁」。
//
// 用法:./trace (用正文那棵默认的 7 个点的树)
// ./trace < 数据文件(格式同 brute.cpp)
#include <bits/stdc++.h>
using namespace std;
int n, root;
vector<long long> r;
vector<vector<int>> son;
vector<array<long long, 2>> f;
vector<int> order, depth;
void dfs(int u, int d) {
depth[u] = d;
f[u][0] = 0;
f[u][1] = r[u];
for (int v : son[u]) {
dfs(v, d + 1);
f[u][0] += max(f[v][0], f[v][1]);
f[u][1] += f[v][0];
}
order.push_back(u); // ★ 儿子都归位了,才轮到自己
}
int main() {
// ⚠ cout 和 printf 混用(表格用 printf 好对齐),所以不能关 ios::sync_with_stdio
vector<pair<int, int>> edges;
if (cin >> n && n > 0) {
r.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> r[i];
for (int i = 0; i < n - 1; i++) {
int l, k;
if (!(cin >> l >> k)) break;
edges.push_back({l, k});
}
} else {
n = 7; // 正文那棵默认的树
r = {0, 1, 3, -5, -1, 2, 5, 7};
edges = {{1, 5}, {2, 6}, {3, 4}, {4, 1}, {6, 5}, {7, 5}};
}
son.assign(n + 1, {});
vector<int> hasBoss(n + 1, 0);
for (auto [l, k] : edges) { son[k].push_back(l); hasBoss[l] = 1; }
root = 1;
for (int i = 1; i <= n; i++) if (!hasBoss[i]) { root = i; break; }
f.assign(n + 1, {0, 0});
depth.assign(n + 1, 0);
dfs(root, 0);
cout << "根是 " << root << " 号(没有上司的那个人,不一定是 1 号)\n\n";
cout << " 次序 点 深度 快乐 f[u][0] 不来 f[u][1] 来 子树最好 儿子\n";
cout << " ---- -- ---- ---- ------------ ---------- -------- ----------\n";
for (size_t t = 0; t < order.size(); t++) {
int u = order[t];
string kids;
for (int v : son[u]) kids += (kids.empty() ? "" : " ") + to_string(v);
if (kids.empty()) kids = "-";
printf(" %4zu %2d %4d %4lld %12lld %10lld %8lld %s\n",
t + 1, u, depth[u], r[u], f[u][0], f[u][1], max(f[u][0], f[u][1]), kids.c_str());
}
cout << "\n答案 max(f[" << root << "][0], f[" << root << "][1]) = max("
<< f[root][0] << ", " << f[root][1] << ") = " << max(f[root][0], f[root][1]) << "\n";
cout << "每个点都排在它所有儿子的后面 —— 这就是「后序」,也就是这一章的全部内容。\n";
return 0;
}
点一下即可编辑
输入(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](来)。 读某个下属时连线会亮起来:绿色(它算好了) 或 红色(它还没算)。

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

累加写在哪 计数器 答案
递归之后(后序) 0 13
递归之前(前序) 6 2
★ 第七条恒等式:前序版本恒等于 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 来的时候,儿子也能来
// ✗ 错误版本二:u 来的时候,还允许儿子也来 —— 把题目的那条限制整个写没了
//
// 正确: f[u][1] = r[u] + Σ f[v][0] 儿子一个都不能来
// 错误: f[u][1] = r[u] + Σ max(f[v][0], f[v][1]) 儿子随便
//
// 这样一来「直接上司到场,下属就不来」这条唯一的限制就不存在了,
// 于是它解的是另一道题:**把所有快乐指数为正的人全叫来**。
//
// ★ 这又是「写错了就是另一道题」那个系列(第 23~26 章一共六条恒等式)。
// 正文第 12 步用 300 组数据钉死了这一条:
// 它的输出恒等于 Σ max(0, r[i]) —— 一组不差。
//
// ⚠ 注意它是**偏大**的那一类错误(限制少了,答案只会更大)。
// 偏大的错误没法用眼力认出来 —— 一个偏大的答案和一个「数据比较难」的正确答案长得一样。
//
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<long long> r;
vector<vector<int>> son;
vector<array<long long, 2>> f;
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] += max(f[v][0], f[v][1]); // ✗ 应该是 f[v][0]
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n) || n <= 0) return 0;
r.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> r[i];
son.assign(n + 1, {});
vector<int> hasBoss(n + 1, 0);
for (int i = 0; i < n - 1; i++) {
int l, k;
if (!(cin >> l >> k)) break;
son[k].push_back(l);
hasBoss[l] = 1;
}
int root = 1;
for (int i = 1; i <= n; i++) if (!hasBoss[i]) { root = i; break; }
f.assign(n + 1, {0, 0});
dfs(root);
cout << max(f[root][0], f[root][1]) << "\n";
return 0;
}
点一下即可编辑
输入(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 这几章一共钉死了八条这样的恒等式:

章 写错的地方 它其实解了哪道题
23 01 背包写成正序 完全背包
24 完全背包写成倒序 01 背包
25 分组背包组内枚举提到容量外 无视分组的 01 背包
25 分组背包容量写成正序 无视分组的完全背包
25 二维费用外层正序 二维费用的完全背包
26 区间 DP 左端点正序 允许一次合并任意多个连续堆
27 累加写在递归前面 只有根一个人可能来
27 u 来时儿子也能来 把快乐指数为正的人全叫来
wrongMust.cpp✗ 以为「上司不来,下属就必须来」
// ✗ 错误版本三:以为「上司不来,下属就必须来」—— 这一章最像回事的一个误解
//
// 正确: f[u][0] = Σ max(f[v][0], f[v][1]) u 不来 → 儿子来不来都行
// 错误: f[u][0] = Σ f[v][1] u 不来 → 儿子必须来
//
// 题目说的是「**上司来了,下属就不来**」,它**没有**反过来说
// 「上司不来,下属就必须来」。这是一个纯粹的读题错误,和算法一点关系都没有。
//
// ★ 它最值得讲的地方是:**快乐指数全是正数时,它经常恰好是对的。**
// 因为「能来就来」本来就划算 —— 正确写法给儿子取 max(f[v][0], f[v][1]),
// 而值全为正时那个 max 十有八九就是 f[v][1],和错误写法取的是同一个。
//
// 实测(300 组固定种子,见正文第 12 步那张表):
// 生成器只造正数 → 抓住 195 / 300
// 生成器带上负数 → 抓住 268 / 300
//
// 所以对拍数据**必须有负数**。这正是第 20 章那条规矩:
// **让「错误的直觉」在你的数据上必定失败** ——
// 要抓「以为下属必须来」,就得造出「这个下属来了反而亏」的局面。
//
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<long long> r;
vector<vector<int>> son;
vector<array<long long, 2>> f;
void dfs(int u) {
f[u][0] = 0;
f[u][1] = r[u];
for (int v : son[u]) {
dfs(v);
f[u][0] += f[v][1]; // ✗ 应该是 max(f[v][0], f[v][1])
f[u][1] += f[v][0];
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n) || n <= 0) return 0;
r.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> r[i];
son.assign(n + 1, {});
vector<int> hasBoss(n + 1, 0);
for (int i = 0; i < n - 1; i++) {
int l, k;
if (!(cin >> l >> k)) break;
son[k].push_back(l);
hasBoss[l] = 1;
}
int root = 1;
for (int i = 1; i <= n; i++) if (!hasBoss[i]) { root = i; break; }
f.assign(n + 1, {0, 0});
dfs(root);
cout << max(f[root][0], f[root][1]) << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

wrongAns.cpp✗ 最后忘了和 f[root][0] 取 max
// ✗ 错误版本五:最后输出 f[root][1],忘了和 f[root][0] 取 max
//
// 整棵树都算对了,只在最后一行栽了 —— 它默认「根一定要来」。
//
// ★ 这个 bug 只在**根不来更划算**的时候才现形。
// 而只要快乐指数全是正数,根来一趟总不亏,它就一直是对的 ——
// 所以生成器**必须造负数**(尤其是根的快乐指数为负的局面)。
//
// 第 20 章那条规矩的又一次应用:
// **让「错误的直觉」在你的数据上必定失败** ——
// 要抓「忘了取 max」,就必须造出「根宁可不来」的数据。
//
// ⚠ 顺带说一句:题目允许一个人都不来(答案至少是 0),
// 这份代码连这一点也丢了 —— 全是负数时它会输出一个负数。
//
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<long long> r;
vector<vector<int>> son;
vector<array<long long, 2>> f;
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];
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n) || n <= 0) return 0;
r.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> r[i];
son.assign(n + 1, {});
vector<int> hasBoss(n + 1, 0);
for (int i = 0; i < n - 1; i++) {
int l, k;
if (!(cin >> l >> k)) break;
son[k].push_back(l);
hasBoss[l] = 1;
}
int root = 1;
for (int i = 1; i <= n; i++) if (!hasBoss[i]) { root = i; break; }
f.assign(n + 1, {0, 0});
dfs(root);
cout << f[root][1] << "\n"; // ✗ 应该是 max(f[root][0], f[root][1])
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

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

★ 请特别看第三张

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

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

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

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

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

path.cpp回溯出到场名单
// 输出**到底哪些人来了** —— 第 26 章那套回溯,在树上再用一次
//
// 第 23 章说过「要方案就得开二维表」,第 26 章用 from[l][r] 还了这笔账。
// 树形 DP 这里更省事:f[u][0] / f[u][1] 本来就分开存着,
// **回溯的时候只要问一句「这个点当初取的是哪一个状态」**,答案自己就出来了。
//
// 从根开始:取 f[root][0] 和 f[root][1] 里更大的那个;
// · 如果这个点**来**了 → 它的儿子一个都不能来,全部按「不来」往下走;
// · 如果这个点**没来** → 每个儿子各自取更大的那个状态,各走各的。
//
// ⚠ 和第 26 章不同的是,这次回溯是**前序**的(先定自己,再定儿子)——
// 因为「儿子能不能来」取决于「我来没来」。
// 算 f 的时候是后序(要先知道儿子),回溯方案的时候是前序(要先知道自己)。
// **同一棵树,两个方向,各有各的理由** —— 这是这一节最值得带走的一句话。
//
// 用法:./path (用正文那棵默认的 7 个点的树)
// ./path < 数据文件(格式同 brute.cpp)
//
// check:viz 对这份输出做的是**硬验证**:名单里不能有任何一对直接上下级,
// 快乐指数之和必须正好等于第一行那个答案。
#include <bits/stdc++.h>
using namespace std;
int n, root;
vector<long long> r;
vector<vector<int>> son;
vector<array<long long, 2>> f;
vector<int> come;
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];
}
}
// take = 这个点来不来(回溯是前序:先定自己,再定儿子)
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); // 我没来 → 儿子各自挑更好的
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
vector<pair<int, int>> edges;
if (cin >> n && n > 0) {
r.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> r[i];
for (int i = 0; i < n - 1; i++) {
int l, k;
if (!(cin >> l >> k)) break;
edges.push_back({l, k});
}
} else {
n = 7;
r = {0, 1, 3, -5, -1, 2, 5, 7};
edges = {{1, 5}, {2, 6}, {3, 4}, {4, 1}, {6, 5}, {7, 5}};
}
son.assign(n + 1, {});
vector<int> hasBoss(n + 1, 0);
for (auto [l, k] : edges) { son[k].push_back(l); hasBoss[l] = 1; }
root = 1;
for (int i = 1; i <= n; i++) if (!hasBoss[i]) { root = i; break; }
f.assign(n + 1, {0, 0});
dfs(root);
long long best = max(f[root][0], f[root][1]);
come.assign(n + 1, 0);
back(root, f[root][1] > f[root][0] ? 1 : 0);
cout << "最大快乐指数之和 = " << best << "\n\n";
cout << "到场名单:";
long long sum = 0;
bool any = false;
for (int i = 1; i <= n; i++)
if (come[i]) { cout << (any ? " " : "") << i; sum += r[i]; any = true; }
if (!any) cout << "(一个人都不来)";
cout << "\n快乐指数:";
any = false;
for (int i = 1; i <= n; i++)
if (come[i]) { cout << (any ? " + " : "") << r[i]; any = true; }
cout << (any ? " = " : "0 = ") << sum << "\n\n";
cout << "逐个点看(★ 每一对直接上下级里,最多只有一个「来」):\n";
for (int i = 1; i <= n; i++) {
cout << " " << i << " 号(快乐 " << r[i] << "):"
<< (come[i] ? "来" : "不来");
for (int v : son[i])
cout << (v == son[i][0] ? ",下属 " : " ") << v << (come[v] ? "(来)" : "(不来)");
cout << "\n";
}
return 0;
}
点一下即可编辑
输入(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 一轮都抓不到)。
// 没有上司的舞会 —— 正解:树形 DP,★ 转移在 DFS **回溯**的时候做
//
// 状态比前面几章多的那一维,是「**当前这个点自己选不选**」:
//
// 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])。
//
// ★ 关键一步:**这两个 Σ 必须在儿子全部算完之后才能加** —— 也就是在递归返回之后。
// 「依赖谁,就先填谁」(第 21 章)第五次登场,而这一次它有个现成的名字:**后序遍历**。
// 你在第 1 章就写过它了(那时候叫「归」),第 11 章归并排序、
// 第 26 章输出合并方案,用的都是同一件东西。
//
// 把累加写在递归**前面**(wrongPre.cpp)会怎样?儿子的 f 还全是 0,
// 于是每个点都只看见自己 —— 不报错、不崩溃,安静地给你一个偏小的答案。
// 这和第 26 章「左端点正序读到还没算好的格子」是同一个病。
//
// 复杂度 O(n):每条边只在回溯时被用一次。空间 O(n)。
// 输入输出同 brute.cpp。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<long long> r;
vector<vector<int>> son; // 邻接表:son[u] = u 的所有直接下属
vector<array<long long, 2>> f;
// ⚠ 这里用递归 DFS。n 很大(比如一条 10 万个点的链)时会爆栈,
// 正文第 12 步会实测这个边界 —— 那是第 21 章 stairsDeep.cpp 那个坑的树上版本。
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];
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n) || n <= 0) return 0;
r.assign(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> r[i];
son.assign(n + 1, {});
vector<int> hasBoss(n + 1, 0);
for (int i = 0; i < n - 1; i++) {
int l, k;
if (!(cin >> l >> k)) break;
son[k].push_back(l); // k 是 l 的上司 → l 挂在 k 下面
hasBoss[l] = 1;
}
// ⚠ 根是「没有上司的那个人」,**不一定是 1 号**。
// 直接从 1 开始 DFS 是这一章最容易犯又最容易蒙混过关的错(wrongRoot.cpp),
// 因为很多人造的数据里 1 恰好就是根。
int root = 1;
for (int i = 1; i <= n; i++) if (!hasBoss[i]) { root = i; break; }
f.assign(n + 1, {0, 0});
dfs(root);
cout << max(f[root][0], f[root][1]) << "\n";
return 0;
}
点一下即可编辑

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, 而这五个在两个档位下都抓得住。抓获率是重要指标,但不是唯一指标。

gen.cpp(带四个档位的生成器)三次改动都能重跑
★ 反过来验一次:让 1 号永远当根,那个 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」是同一件事 —— 它压根就没错。

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
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)