| 章 | 状态是什么 | 依赖谁 | 于是顺序是 |
|---|---|---|---|
| 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])。
// 邻接表是怎么建起来的 —— 这一章自带的「存图」小节//// 树和图在代码里长什么样?这一章只需要最简单的那一种://// 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;}点「运行 ▶」看结果
① 为什么不用二维数组 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ⁿ 枚举「谁来」,逐条边检查
// 没有上司的舞会 —— 暴力: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;}点「运行 ▶」看结果
跑出来 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 步说过同样的话)。 你唯一要做的,就是别把累加写到递归前面去。
// 没有上司的舞会 —— 正解:树形 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;}点「运行 ▶」看结果
跑出来 13。想看它是按什么次序算完的,跑这份:
// 把 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;}点「运行 ▶」看结果
根是 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另外三种错法:一个算错,一个连名单都是违规的
// ✗ 错误版本二: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;}点「运行 ▶」看结果
跑出来 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 来时儿子也能来 | 把快乐指数为正的人全叫来 |
// ✗ 错误版本三:以为「上司不来,下属就必须来」—— 这一章最像回事的一个误解//// 正确: 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;}点「运行 ▶」看结果
跑出来 8。题目说的是「上司来了,下属就不来」,它没有反过来说 「上司不来,下属就必须来」。这是纯粹的读题错误,和算法一点关系都没有。
// ✗ 错误版本五:最后输出 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;}点「运行 ▶」看结果
跑出来 5。整棵树都算对了,只在最后一行栽了 —— 它默认「根一定要来」。
10动画:同一棵树,四种理解各自请了谁
用「下一步 ▶」切换四种理解,绿色 = 这个人来了。
前面几章的错误版本都只是「答案不对」。这一章的第三张不一样 —— 它的名单本身就是违规的:会出现一对直接上下级同时到场(画面上是红色虚线)。
| 哪一种理解 | 算出来 | 名单快乐和 | 名单合法吗 |
|---|---|---|---|
| ✓ 正解 | 13 | 13 | 合法 |
| ✗ 以为下属必须来 | 8 | 8 | 合法(只是不划算) |
| ✗ u 来时儿子也能来 | 18 | 18 | ✗ 违规 |
| ✗ 忘了取 max | 5 | 5 | 合法(只是把根绑死了) |
它算出来的数最大,可它根本没在解这道题。
答案大不代表答案对。 这也是为什么「输出方案」比「输出一个数」值钱:一个数没法自证清白,一份名单可以。
11输出方案:算 f 是后序,回溯是前序
// 输出**到底哪些人来了** —— 第 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;}点「运行 ▶」看结果
最大快乐指数之和 = 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 藏在「编号」里,和数值毫无关系
// 没有上司的舞会 —— 正解:树形 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, 而这五个在两个档位下都抓得住。抓获率是重要指标,但不是唯一指标。
我另写了一份 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 的下一站,值得提前看一眼