0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P3478,日期见页头。两边不一致时信原站。
题目描述
给定一个 n 个点的树,请求出一个结点,使得以这个结点为根时,所有结点的深度之和最大。
一个结点的深度定义为该节点到根的简单路径上边的数量。
输入格式
第一行有一个整数,表示树的结点个数 n。
接下来 (n − 1) 行,每行两个整数 u, v,表示存在一条连接 u, v 的边。
输出格式
本题存在 Special Judge。
输出一行一个整数表示你选择的结点编号。如果有多个结点符合要求,输出任意一个即可。
说明/提示
样例 1 解释
输出 7 和 8 都是正确答案。
数据规模与约定
对于全部的测试点,保证 1 ≤ n ≤ 10⁶,1 ≤ u, v ≤ n,给出的是一棵树。
输入输出样例
输入
8 1 4 5 6 4 5 6 7 6 8 2 4 3 4
输出
7
八个点。以 7(或 8)为根时深度和最大,是 20。
⚠⚠ 这一组样例本页四个错法一个都没挡住(暴力、少减一次、int、递归版全都输出 7)——
这是「样例是个一测就死的过滤器」那条规律的极端一头,理由见第 ⑤ 步。
1★ 第一版:每个点都当一次根,各跑一次 BFS
// ✗ P3478 第一版:每个点都当一次根,各跑一次 BFS 把深度加起来//// ★★★ 它的**答案永远是对的** —— 它错在跑不完:O(n²),而题面 n ≤ 10⁶。// ⇒ 官方样例挡不住它,对拍也挡不住它(两版答案逐组相同)。// 这类错法只有一种发现办法:**数次数 + 算规模**(见 [P5019] 那一页)。
#include <bits/stdc++.h>using namespace std;typedef long long ll;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0; vector<vector<int>> adj(n + 1); for (int i = 1; i < n; i++) { int u, v; cin >> u >> v; adj[u].push_back(v); adj[v].push_back(u); }
ll best = -1; int who = 1; vector<int> dep(n + 1), st; vector<char> vis(n + 1); for (int r = 1; r <= n; r++) { fill(vis.begin(), vis.end(), 0); st.clear(); st.push_back(r); vis[r] = 1; dep[r] = 0; ll sum = 0; while (!st.empty()) { int u = st.back(); st.pop_back(); sum += dep[u]; for (int v : adj[u]) if (!vis[v]) { vis[v] = 1; dep[v] = dep[u] + 1; st.push_back(v); } } if (sum > best) { best = sum; who = r; } } cout << who << '\n'; return 0;}点「运行 ▶」看结果
| 300 轮:它 vs 正解(按深度和比) | ★ 不一致 0 轮 |
⇒ 这就是第 20 章 P5019 立过的那条规律: 「样例是个一测就死的过滤器」筛的是「答案错」,对「答案对但跑不完」完全无能为力 —— 对拍也一样。 发现它只有一条路:数次数。
| 入队次数 | n = 1000 |
n = 4000 |
n = 16000 |
|---|---|---|---|
| 每点一次 BFS | 10⁶ |
1.6 × 10⁷ |
2.56 × 10⁸ |
n 每翻 4 倍 |
—— | ★ ×16.0 | ★ ×16.0 |
×16 就是 O(n²) 的签名(4² = 16)。顶格 n = 10⁶ ⇒ 10¹² 次,一秒钟连零头都跑不完。
2★★ 关键的一步:换根 —— 把根挪一格,只有两拨点的深度变了
先以 1 为根跑一遍,拿到每棵子树的大小 size[u] 和总深度和 f[1]。
现在把根从 u 挪到它的儿子 v,谁的深度变了?
u (旧根) u
/ \ / \
v * 挪一格 ==> v * ← 这些点各多走一条边
/ \ / \
. . . . ← v 的子树各少走一条边v的子树里那size[v]个点,到根的路各短一条 ⇒ 减size[v];- 其余
n − size[v]个点,各长一条 ⇒ 加n − size[v]。
f[v] = f[u] − size[v] + (n − size[v]) = f[u] + n − 2 × size[v]⇒ 一次 O(1) 就推到了下一个点 ⇒ 两遍遍历、总共 O(n)。
★ 这就是「换根 DP」这个名字的全部内容:不是重算,是改账。
// P3478 STA-Station —— 正解:换根 DP,两遍 O(n)//// 第一遍(以 1 为根):算出每棵子树的大小 size[u],以及「以 1 为根时所有点的深度和」f[1]。// 第二遍:把根从 u 挪到它的儿子 v,谁的深度变了?//// · v 的子树里那 size[v] 个点,各**少**走一条边 ⇒ 减 size[v]// · 其余 n - size[v] 个点,各**多**走一条边 ⇒ 加 n - size[v]//// ⇒ f[v] = f[u] + n - 2 × size[v] ← 一次 O(1) 就推到了下一个点//// ⚠ 两件在这道题上会真的挂人的事:// ① **必须 long long**:n = 10⁶ 排成一条链时深度和约 5 × 10¹¹,int 装不下;// ② **不能用递归**:10⁶ 层 DFS 直接爆栈(同题单的 [P1122] n ≤ 16000 就没事 —— 见那一页)。
#include <bits/stdc++.h>using namespace std;typedef long long ll;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0;
vector<int> head(n + 1, -1), nxt(2 * (n > 1 ? n - 1 : 1)), to(2 * (n > 1 ? n - 1 : 1)); int cnt = 0; auto addEdge = [&](int u, int v) { to[cnt] = v; nxt[cnt] = head[u]; head[u] = cnt++; }; for (int i = 1; i < n; i++) { int u, v; cin >> u >> v; addEdge(u, v); addEdge(v, u); }
// ① 迭代式 DFS:求出 BFS/DFS 序、父亲、子树大小、深度和 vector<int> par(n + 1, 0), ord; vector<ll> sz(n + 1, 1), f(n + 1, 0); ord.reserve(n); { vector<int> st{1}; vector<char> vis(n + 1, 0); vis[1] = 1; vector<int> dep(n + 1, 0); while (!st.empty()) { int u = st.back(); st.pop_back(); ord.push_back(u); for (int e = head[u]; e != -1; e = nxt[e]) { int v = to[e]; if (vis[v]) continue; vis[v] = 1; par[v] = u; dep[v] = dep[u] + 1; st.push_back(v); } } for (int u : ord) f[1] += dep[u]; for (int i = (int)ord.size() - 1; i >= 1; i--) sz[par[ord[i]]] += sz[ord[i]]; }
// ② 换根:按 DFS 序从上往下推 for (size_t i = 1; i < ord.size(); i++) { int v = ord[i]; f[v] = f[par[v]] + n - 2 * sz[v]; }
int best = 1; for (int u = 2; u <= n; u++) if (f[u] > f[best]) best = u; cout << best << '\n'; return 0;}点「运行 ▶」看结果
3★ 第二个错法:只算了涨的那一半
// ✗ P3478:换根公式少减了一次//// 写成 f[v] = f[u] + n - size[v] (正确的是 n - 2 × size[v])//// ★ 它算了什么:它只记得「子树外面那 n - size[v] 个点各多走一步」,// 忘了「子树里面那 size[v] 个点各少走一步」——**只算了涨的那一半,没算落的那一半**。
#include <bits/stdc++.h>using namespace std;typedef long long ll;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n) || n <= 0) return 0; vector<vector<int>> adj(n + 1); for (int i = 1; i < n; i++) { int u, v; cin >> u >> v; adj[u].push_back(v); adj[v].push_back(u); }
vector<int> par(n + 1, 0), ord, dep(n + 1, 0); vector<ll> sz(n + 1, 1), f(n + 1, 0); ord.reserve(n); vector<int> st{1}; vector<char> vis(n + 1, 0); vis[1] = 1; while (!st.empty()) { int u = st.back(); st.pop_back(); ord.push_back(u); for (int v : adj[u]) if (!vis[v]) { vis[v] = 1; par[v] = u; dep[v] = dep[u] + 1; st.push_back(v); } } for (int u : ord) f[1] += dep[u]; for (int i = (int)ord.size() - 1; i >= 1; i--) sz[par[ord[i]]] += sz[ord[i]]; for (size_t i = 1; i < ord.size(); i++) { int v = ord[i]; f[v] = f[par[v]] + n - sz[v]; } // ← 这里
int best = 1; for (int u = 2; u <= n; u++) if (f[u] > f[best]) best = u; cout << best << '\n'; return 0;}点「运行 ▶」看结果
它只记得「外面那 n − size[v] 个点各多走一步」,忘了「里面那 size[v] 个点各少走一步」。
⚠ 而官方样例放过了它。
| 300 轮里它被抓 | |
|---|---|
| 按深度和比(题目真正要的) | 89 |
| 按输出的编号逐字节比 | 111 |
⇒ 多出来的 22 轮,是它选了另一个同样最优的点 —— 那 22 轮它根本没错, 是「逐字节比」错了。
★ 这一处红灯是我自己撞上的:断言先按「逐字节」写了 89,一跑就红, 真值是 111。⇒ ★★ 这道题的对拍从头到尾都得比深度和, 而这正是下一步要说的事。
4★★★ 两个只在顶格才现形的坑:long long 和递归
深度和最大的形状是一条链,此时 f = n(n−1)/2。
顶格 n = 10⁶ 的链,深度和 |
499 999 500 000 |
int 上限 |
2 147 483 647 |
| 差 | ★ 232.8 倍 |
★ 链上第一个装不下的 n |
65537 |
⚠ 而对拍永远抓不到它 —— 参照物是那个 O(n²) 暴力,它在 n = 65537 上就已经跑不动了。
⇒ 第 11 章 P1908 那条:「对拍查不出溢出」真正的死结是「参照物是暴力」;
这类 bug 算比测又快又充分。
★ 巧的是那一页算出来的临界线也正好是 65537 —— 两道题都卡在 n(n−1)/2 > 2³¹ 这条式子上。
实测(A 机 · WSL2 · ulimit -s = 8192 KB,2026-08-30):
| 链的长度 | 递归版 |
|---|---|
n = 16000(P1122 的顶格) |
✓ 跑通 |
n = 10⁵ |
✓ 跑通 |
n ≈ 1.42 × 10⁵ |
✓ 还活着 |
n ≈ 1.56 × 10⁵ |
✗ 段错误 |
n = 10⁶(本题顶格) |
✗ 段错误(退出码 139) |
⇒ ★★ 「递归会不会爆栈」不是一个写法的属性,是「写法 × 这道题的 n」的属性。
P1122 那一页量到「16000 层完全安全」,而这道题的顶格是它的 62.5 倍 ——
同一份写法,一边是最自然的选择,一边是零分。每道题都要重算一次。
⚠ 而这条线跟着机器走(栈上限是 ulimit -s 定的)⇒ 顶格题一律写迭代,别赌。
5★★★ 题面开了 SPJ ⇒ 对拍不能逐字节比 —— 而这一页的 0 是运气
题面明写着「如果有多个结点符合要求,输出任意一个即可」。300 轮实测:
| 真的有多个点并列最优的轮数 | 80 / 300 |
| 本页两版(暴力 / 换根)逐字节不同的轮数 | 0 |
| ⚠ 换成「并列时取编号最大的」(同样正确) | ★ 80 —— 一个不差 |
| ⚠ 而上一步那个错法,逐字节比多报的假阳性 | ★ 22 轮(111 vs 89) |
⇒ 那个 0 不是「这道题不会出多解」,是「我这两份实现的并列处理恰好一模一样」。 换一个同样正确的写法,80 轮全都对不上。
⇒ ★★ 所以这道题的对拍必须比深度和,不比输出的编号
(和第 26 章 P1040 那条一样:最优方案不唯一时,逐字节比会报假阳性)。
★ 而它也顺手解释了第 ⓪ 步那件事:官方样例四个错法一个都没挡住 ——
n = 8 太小,int 和递归离出事还差五个数量级,而暴力和递归版的答案本来就是对的。
6★ 参照物、规模
| 300 轮:换根 DP vs 每点一次 BFS(按深度和比) | ★ 不一致 0 轮 |
顶格 n = 10⁶ |
正解两遍 O(n),本机瞬间出结果 |
| 深度和上界 | 5 × 10¹¹ ⇒ 必须 long long |
| 递归深度 | 10⁶ ⇒ 必须写成迭代 |
7度量程序和生成器
8一页纸
| ★★ 关键的一步 | 换根不是重算,是改账:f[v] = f[u] + n − 2 × size[v] |
| ★★★ 第一版 | 每点一次 BFS ⇒ 答案永远对、就是跑不完;样例和对拍都无能为力,只能数次数(×16 是 O(n²) 的签名) |
| ★ 第二个错法 | 少减一次(只算涨的那一半)⇒ 被抓 89/300,样例放过 |
★★ long long |
链上 n(n−1)/2 ⇒ 顶格 5 × 10¹¹,是 int 上限的 232.8 倍;临界 n = 65537 |
| ⚠⚠ 递归 | 10⁶ 层段错误;而 P1122 的 16000 层完全安全 ⇒ 「会不会爆栈」是写法 × n |
| ★★★ SPJ | 多解 80/300;本页两版逐字节 0 不同是运气 —— 换个 tie-break 就是 80 ≡ 80 |
| ⚠ 而它真的咬了一口 | 那个错法逐字节比被抓 111、按深度和只有 89 ⇒ 22 轮假阳性 |
| ⚠ 对拍 | 比深度和,别比输出的编号 |