题单 · 习题解析

洛谷 P1352 没有上司的舞会

本章原题:★★★ 「有没有负数」这把旋钮,把正文那个 bug 和本页的贪心推向相反方向;★★ 那句「末尾多一行 0 0」量完之后要反过来说

原题:洛谷 P1352出自 第 27 章 树形 DP:没有上司的舞会 的题单题面本地存档:2026-08-30
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1352,日期见页头。两边不一致时信原站。

题目描述

某大学有 n 个职员,编号为 1 … n

他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。

现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 rᵢ, 但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。

所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。

输入格式

输入的第一行是一个整数 n

2 到第 (n + 1) 行,每行一个整数,第 (i + 1) 行的整数表示 i 号职员的快乐指数 rᵢ

(n + 2) 到第 2n 行,每行输入一对整数 l, k,代表 kl 的直接上司。

输出格式

输出一行一个整数代表最大的快乐指数。

说明/提示

数据规模与约定

对于 100% 的数据,保证 1 ≤ n ≤ 6 × 10³−128 ≤ rᵢ ≤ 1271 ≤ l, k ≤ n, 且给出的关系一定是一棵树。

输入输出样例

输入

7
1
1
1
1
1
1
1
1 3
2 3
6 4
7 4
4 5
3 5

输出

5

七个人,每个人的快乐指数都是 1。5 号是校长(他从没在左边那一列出现过), 34 是他的直接下属,1 2 挂在 3 下面、6 7 挂在 4 下面。 请 5 1 2 6 7 五个人,快乐指数 5

★ 这一组样例把本页两个错法都挡住了(贪心 3、从 0 号找根 0)。 ⚠ 而它顺带是本书那条规律(「样例是个一测就死的过滤器:挡住每组都错的,放过偶尔才错的」) 的一个反例 —— 「从 0 号找根」确实每组都错,可贪心 300 轮里只错 55 轮,样例照样一测就死。

1★ 第一版:谁的快乐指数高就先请谁

这道题的第一反应几乎是同一个:把所有人按快乐指数从大到小排队,轮着看, 只要他的上司和下属都还没请,就把他请来(负数的当然不请)。

p1352Greedy.cpp✗ 第一版:按快乐指数从大到小,能选就选
// ✗ P1352 第一版:按快乐指数从大到小,能选就选
//
// 大多数人第一眼的想法:谁带来的快乐多就先请谁,只要他的上司和下属都还没请。
// 它给出的**是一个真能办成的名单**(没有任何一对上下级同时在场)
// ⇒ 所以它恒 ≤ 正解,永远不会「多报」。
// 错在哪:请了一个大的,可能挡住两个更大的(经典的独立集反例:一个 100 卡住两个 90)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 6005;
int n, r[MAXN];
vector<int> adj[MAXN]; // 无向:上司和下属都算相邻
bool taken[MAXN];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n) || n <= 0) return 0;
for (int i = 1; i <= n; i++) cin >> r[i];
int l, k;
while (cin >> l >> k) {
if (l == 0 && k == 0) break;
adj[l].push_back(k);
adj[k].push_back(l);
}
vector<int> ord(n);
for (int i = 0; i < n; i++) ord[i] = i + 1;
sort(ord.begin(), ord.end(), [](int a, int b) { return r[a] > r[b]; });
long long sum = 0;
for (int u : ord) {
if (r[u] <= 0) continue; // 负数请来只会更差
bool blocked = false;
for (int v : adj[u]) if (taken[v]) { blocked = true; break; }
if (blocked) continue;
taken[u] = true;
sum += r[u];
}
cout << sum << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 它错在哪 —— 而「往哪边错」不用跑就知道

它排出来的名单是真能办成的(没有任何一对上下级同时在场)⇒ 它恒 ≤ 正解,永远不会多报。这就是第 26 章 P1220 那条判据的又一次: 看你给出的是「一个合法方案」还是「一个放宽了的问题的解」,答案偏大还是偏小不用跑就能判。

≤ 正解 300 / 300
300 轮被抓 55
错的时候平均少拿 13.30%
最多少拿 56.07%

最小的反例只要三个人:上司 100、两个下属各 90。 贪心先抓住那个 100,于是两个 90 都进不来(拿 100);正解是把上司晾着,请两个下属(拿 180)。

2★★ 正解:状态里多一维「他自己来不来」

★ f[u][0] / f[u][1]
    f[u][0] = u 不来时,u 这棵子树里最大的快乐指数和
    f[u][1] = u   来时,同上

    f[u][0] = Σ max(f[v][0], f[v][1])      下属来不来都行,取大的那个
    f[u][1] = r[u] + Σ f[v][0]             u 来了,下属一个都不能来

答案是 max(f[root][0], f[root][1])

为什么这一维非有不可:一个人来不来,只影响他的直接下属。 把「他自己来没来」记进状态,父亲那一层就只需要看这两个数, 而不必回头去问整棵子树到底请了谁 —— 这正是本章正文第 ⑥ 步在讲的事。

推导、动画、以及另外五种错法(前序累加、儿子也能来、下属必须来、没找根、忘了取 max) 正文里都有,这一页不重复,只补正文没量过的那几件事。

p1352.cpp★ 这一版就能 AC
// P1352 没有上司的舞会 —— 正解:树形 DP,O(n)
//
// f[u][0] = u 不来时,u 的子树里最大的快乐指数和
// f[u][1] = u 来时,同上
// f[u][0] = Σ max(f[v][0], f[v][1]) 下属来不来都行,取大的那个
// f[u][1] = r[u] + Σ f[v][0] u 来了,下属一个都不能来
// 答案 = max(f[root][0], f[root][1])
//
// ★ 两件和算法无关、却真会挂人的事,都挤在读入这几行里:
// ① 根要自己找 —— 输入给的是「l 的上司是 k」,从没当过 l 的那个点才是根;
// ② 读到 (0, 0) 就停 —— 题面写的是 n-1 行,而这道题的数据常被人提到
// 「末尾多一行 0 0」。挡一句一分钱不吃亏;不挡的写法里有一种会自环。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 6005;
int n, r[MAXN], f[MAXN][2];
vector<int> son[MAXN];
bool hasFa[MAXN];
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]; // u 来了,下属只能不来
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n) || n <= 0) return 0;
for (int i = 1; i <= n; i++) cin >> r[i];
int l, k;
while (cin >> l >> k) {
if (l == 0 && k == 0) break; // ← ② 挡住那对 (0, 0)
son[k].push_back(l);
hasFa[l] = true;
}
int root = 1; // ← ① 自己找根
for (int i = 1; i <= n; i++) if (!hasFa[i]) { root = i; break; }
dfs(root);
cout << max(f[root][0], f[root][1]) << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3★★★ 「快乐指数有没有负数」这把旋钮,把两个 bug 推向相反方向

正文第 ⑫ 步量过:生成器从「全是正数」改成「−50 ~ 100」之后, 「以为下属必须来」那个 bug 从 195 涨到 268。理由很直白 —— 快乐指数全是正数时,「能来就来」本来就划算,那个错误理解十有八九恰好取到同一个数。

同一把旋钮,对本页这个贪心的作用正好相反:

快乐指数 全是正数 −50 ~ 100(有负数) 全是负数
「以为下属必须来」被抓(正文) 195 / 300 268 / 300 ——
本页的贪心被抓 119 / 300 55 / 300 精确的 0
「从 0 号找根」被抓(第 ④ 步) —— 300 / 300 精确的 0

⇒ ★★★ 同一个旋钮把两个 bug 推向相反方向 —— 本书量到第五次了 (前四次是第 23 章 P1049P2925第 24 章 P5365)—— ⚠ 而这一次两条曲线还分别写在两处:一条在正文,一条在这一页。 「加了负数数据就更强」是没有主语的。

⚠ 而最右边那两个「精确的 0」是同一句话造成的

rᵢ 全是负数时,正解恒等于 0(谁都不请)—— 300 / 300 轮如此。 于是「只会少拿」的贪心和「恒输出 0」的错法同时隐身,两个都是结构性的 0,加多少轮都没用。

★ 而 −128 ≤ rᵢ题面写着的:全负数据完全合法,只是顺手写的生成器造不出来。 ⇒ 又一次「生成器最自然的默认值,正好是某个 bug 的藏身处」。

4★★★ 第二个错法:从 0 号点开始找根 —— 它的来处就在同一张题单里

这道题的根要自己找:输入给的是「l 的上司是 k」,从没在左边那一列出现过的那个点才是根。 正文第 ⑫ 步演示的是「干脆从 1 号点开始 DFS」。这一页换一个更隐蔽的:循环从 0 写起。

p1352Zero.cpp✗ 找根的循环从 0 号点写起
// ✗ P1352:把「编号从 0 开始」的习惯带了过来 —— 找根时从 0 号点找起
//
// ⚠ 这个错法有一个非常具体的来处:**同一章题单里的下一道题 [P2016 战略游戏]
// 的结点编号真的是 0 ~ n-1**。先写完那道再回头写这道,很容易顺手写成 `for (i = 0; ...)`。
//
// 后果:0 号点从来没当过谁的下属 ⇒ 它「没有上司」⇒ 被当成根。
// 而 0 号点是个空点(r[0] = 0、没有下属),于是这份程序**恒输出 0**。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 6005;
int n, r[MAXN], f[MAXN][2];
vector<int> son[MAXN];
bool hasFa[MAXN];
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;
for (int i = 1; i <= n; i++) cin >> r[i];
int l, k;
while (cin >> l >> k) {
if (l == 0 && k == 0) break;
son[k].push_back(l);
hasFa[l] = true;
}
int root = 1;
for (int i = 0; i <= n; i++) if (!hasFa[i]) { root = i; break; } // ← 从 0 找起
dfs(root);
cout << max(f[root][0], f[root][1]) << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 说清楚它算了什么,它的所有表现都是白送的推论

0 号点从来没当过谁的下属 ⇒ 它「没有上司」⇒ 它被当成根。 而 0 号点是个空点(r[0] = 0、没有下属)⇒ 这份程序恒输出 0

于是:被抓的轮数 ≡ 正解 > 0 的轮数,一个不差(默认档 300 ≡ 300、全负档 0 ≡ 0)。

⚠ 它的来处非常具体:同一章题单的下一道题 P2016 战略游戏, 结点编号真的是 0 ~ n−1。 先写完那道再回头写这道,for (int i = 0; ...) 是顺手就打出来的。 ⇒ 第 52 章那条「上一章的正确写法可能就是这一章的 bug」, 这次发生在同一张题单的两道题之间。

5★★ 那句「数据末尾多一行 0 0」—— 量完之后要反过来说

这道题流传很广的一句提醒是:它的输入末尾多一行 0 0 题面写的是「第 n+2 到第 2n 行」,正好 n−1 行,样例里也没有那一行。 我们没法去查洛谷的测试数据,但能查的是另一半: 真有那一行的话,哪几种读法会挂?没有那一行呢?

读法 没有 0 0 0 0
① 照题面读 n−1 300 / 300 300 / 300
② 读到 EOF,不挡 300 / 300 300 / 300
③ 读到 EOF,不挡 + 找根从 0 写起 0 / 300 300 / 300
④ 读到 EOF,挡住 (0, 0) ← 正解 300 / 300 300 / 300
★★★ 八个格子里唯一那个 0,落在「没有那一行」那一列

② 为什么不挡也没事:多读到的 (0, 0) 只会往 son[0] 里塞一条边, 而根在 1 … n 里,DFS 一次也走不到 0 号点。

★ 真正扎人的是 ③,而它恰恰是「照顾那句提醒」照顾出来的 —— 数据里真有 0 0 时它反而对了(hasFa[0] 被置上,0 号点不再像根), 没有那一行时它满盘皆输。

结论不是「那句提醒是错的」,是「按它去改代码可能改出 bug」: 写成第 ④ 行那样(while (cin >> l >> k) { if (l == 0 && k == 0) break; ... }), 两种数据下都对,一分钱不吃亏。

6★ 参照物、规模和上界

参照物就是最朴素的 2ⁿ 枚举「谁到场」,逐条边检查有没有上下级同时在场 —— 第 23 章 P1060 那条「顶格对拍的参照物有时就是暴力本身」在这儿用不上 (n ≤ 6000),但小数据对拍它足够了。

300 轮:正解 vs 2ⁿ 枚举 不一致 0 轮
答案恒 ≥ 0(一个都不请) 300 / 300
顶格 n = 6000−128 ≤ rᵢ ≤ 127 ⇒ 答案上界 762 000int
递归深度 顶格是一条 6000 层的链 —— 默认栈放得下(⚠ 到了 P347810⁶ 就放不下了)
p1352Brute.cpp参照物:2ⁿ 枚举到场名单(300 轮不一致 0 轮)

7度量程序和生成器

p1352Count.cpp度量程序(本页所有数字都出自它)
p1352Gen.cpp数据生成器

8一页纸

★★ 关键的一步 f[u][0/1] —— 把「他自己来没来」记进状态(推导见正文
★ 第一版 按快乐指数从大到小能选就选 —— 恒 ≤ 正解,被抓 55/300,错时平均少拿 13.30%
★★★ 一把旋钮,两个方向 有没有负数:「下属必须来」195 → 268(更容易抓),贪心 119 → 55(更难抓)
⚠ 全负那一档 正解恒为 0 ⇒ 贪心和「从 0 找根」同时变成精确的 0
★★★ 从 0 号找根 恒输出 0 ⇒ 被抓轮数 ≡ 正解 > 0 的轮数(一个不差);来处是同题单的 P2016
★★ 那句「多一行 0 0」 四种读法 × 两种数据,八格里唯一的 0 是照顾它照顾反了的那一格
参照物 2ⁿ 枚举到场名单;300 轮不一致 0 轮
规模 n ≤ 6000 ⇒ 答案上界 762 000,int 够;6000 层递归默认栈放得下