题单 · 习题解析

洛谷 P3478 STA-Station

换根 DP:★★★ 暴力那版「答案永远对、就是跑不完」;⚠⚠ 同一份递归在 P1122 的 16000 层安全、在这儿 10⁶ 层段错误;★★★ SPJ 多解 80/300,而逐字节比 0 假阳性纯属运气(换个 tie-break 就是 80 ≡ 80)

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

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

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

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

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

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

题目描述

给定一个 n 个点的树,请求出一个结点,使得以这个结点为根时,所有结点的深度之和最大

一个结点的深度定义为该节点到根的简单路径上边的数量

输入格式

第一行有一个整数,表示树的结点个数 n

接下来 (n − 1) 行,每行两个整数 u, v,表示存在一条连接 u, v 的边。

输出格式

本题存在 Special Judge。

输出一行一个整数表示你选择的结点编号。如果有多个结点符合要求,输出任意一个即可。

说明/提示

样例 1 解释

输出 78 都是正确答案。

数据规模与约定

对于全部的测试点,保证 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

p3478Brute.cpp✗ 第一版:n 次 BFS,O(n²)
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 它的答案永远是对的 —— 样例挡不住它,对拍也挡不住它
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★★ 关键的一步:换根 —— 把根挪一格,只有两拨点的深度变了

★ f[v] = f[u] + n − 2 × size[v]

先以 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.cpp★ 这一版就能 AC
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3★ 第二个错法:只算了涨的那一半

p3478Wrong.cpp✗ f[v] = f[u] + n − size[v](少减一次)
// ✗ 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它只记得「外面那 n − size[v] 个点各多走一步」,忘了「里面那 size[v] 个点各少走一步」。 ⚠ 而官方样例放过了它

★★★ 它被抓多少轮?——这个问题有两个答案,差 22 轮
300 轮里它被抓
深度和比(题目真正要的) 89
输出的编号逐字节比 111

⇒ 多出来的 22 轮,是它选了另一个同样最优的点 —— 那 22 轮它根本没错, 是「逐字节比」错了

★ 这一处红灯是我自己撞上的:断言先按「逐字节」写了 89,一跑就红, 真值是 111。⇒ ★★ 这道题的对拍从头到尾都得比深度和, 而这正是下一步要说的事。

4★★★ 两个只在顶格才现形的坑:long long 和递归

★★ 「要不要开 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³¹ 这条式子上。

p3478Int.cpp✗ 深度和用了 int
⚠⚠ 第二个坑:同一份递归,在上一道题上完全安全,在这道题上直接段错误
p3478Rec.cpp✗ 两遍遍历写成了递归

实测(A 机 · WSL2 · ulimit -s = 8192 KB,2026-08-30):

链的长度 递归版
n = 16000P1122 的顶格) ✓ 跑通
n = 10⁵ ✓ 跑通
n ≈ 1.42 × 10⁵ ✓ 还活着
n ≈ 1.56 × 10⁵ 段错误
n = 10⁶(本题顶格) 段错误(退出码 139)

⇒ ★★ 「递归会不会爆栈」不是一个写法的属性,是「写法 × 这道题的 n」的属性。 P1122 那一页量到「16000 层完全安全」,而这道题的顶格是它的 62.5 倍 —— 同一份写法,一边是最自然的选择,一边是零分。每道题都要重算一次。 ⚠ 而这条线跟着机器走(栈上限是 ulimit -s 定的)⇒ 顶格题一律写迭代,别赌。

5★★★ 题面开了 SPJ ⇒ 对拍不能逐字节比 —— 而这一页的 0 是运气

★★★ 「换一个同样正确的 tie-break,输出不同的轮数」≡ 多解轮数,一个不差

题面明写着「如果有多个结点符合要求,输出任意一个即可」。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度量程序和生成器

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

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、按深度和只有 8922 轮假阳性
⚠ 对拍 比深度和,别比输出的编号