[NOI2003] 逃学的小孩

GitHub跳转原题关系图返回列表

答案 = 直径长 + max_u min(d(u,A), d(u,B)); 三臂分解得主干与支链折返,配合极简树上上界与无需证明直径的换根 DP 双解法.

OJ: luogu

题目 ID: P4408

难度:提高

标签:树的直径BFS贪心树形DP换根DP

创建: 2026-07-17 02:00

更新: 2026-10-26 21:37

形式化题目

给定一棵带非负边权的树. 取三个互不相同的节点: 家 CC、朋友 AA、朋友 BB. 父母的路线由规则唯一确定, 先走到离 CC 较近的那个朋友家, 再沿唯一道路走向另一个. 所以花的时间是

f(C,A,B)=min⁡(d(C,A), d(C,B))+d(A,B)f(C, A, B) = \min\big(d(C,A),\ d(C,B)\big) + d(A,B)

求所有三点选取中 ff 的最大值. 这里 dd 是树上距离, 边权可以是 00.

样例

样例 in1 是一条链 1−2−3−41-2-3-4, 每条边权 11. 链上有 33 条边, 直径就是两端点 11 和 44 之间长 33 的路径. 下面算出每个位置作为家时的花费, 表格里的 h(u)=min⁡(d(u,1),d(u,4))h(u)=\min\big(d(u,1), d(u,4)\big):

家 CC d(C,1)d(C,1) d(C,4)d(C,4) h(C)h(C) 时间 =3+h(C)= 3 + h(C)
1 0 3 0 3
2 1 2 1 4
3 2 1 1 4
4 3 0 0 3

朋友取直径两端 A=1,B=4A=1, B=4 时, 家在 22 或 33 都会花 44 分钟. 以 C=2C=2 为例: 先去近的 11 号点用 11 分钟, 再走 33 分钟去 44 号点, 共 44 分钟, 与样例输出一致. 表中也出现了后面要证的形状: 时间恒等于"直径长"加"到两端较近的距离".

暴力解法

思路

最直接的做法是把定义照抄一遍: 枚举所有三点组合 (C,A,B)(C, A, B), 分别求出三段距离, 代入 ff 后取最大值.

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-08-13 08:02
 * update_at: 2026-10-26 20:33
 */
// brute.cpp:小数据暴力解,枚举所有三点组合 (C, A, B),按定义直接算最坏时间。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 15;

struct Edge {
    int to; // 邻居节点
    ll w;   // 边权
};

int n, m;
vector<Edge> g[MAXN]; // 邻接表:g[u] 是节点 u 连出的所有边

// 从 start 出发 BFS,返回到 target 的距离(树连通,一定可达)。
// 图为树,路径唯一,首次到达即最短距离,因此不需要 Dijkstra。
ll bfs_dist(int start, int target) {
    ll dist[MAXN];
    for (int i = 1; i <= n; i++)
        dist[i] = -1; // 用 -1 当"未访问"标记:边权可以为 0
    dist[start] = 0;
    queue<int> q;
    q.push(start);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        if (u == target)
            return dist[u];
        for (auto &e : g[u]) {
            if (dist[e.to] == -1) {
                dist[e.to] = dist[u] + e.w;
                q.push(e.to);
            }
        }
    }
    return -1;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int u, v;
        ll w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});
        g[v].push_back({u, w});
    }

    ll ans = 0;
    // 枚举家 C 与两个朋友 A、B,三者互不相同。
    // 规则 1:先到离 C 较近的朋友,再去另一个,所以时间 = min(d(C,A), d(C,B)) + d(A,B)。
    for (int C = 1; C <= n; C++) {
        for (int A = 1; A <= n; A++) {
            if (A == C)
                continue;
            for (int B = A + 1; B <= n; B++) {
                if (B == C)
                    continue;
                ll dCA = bfs_dist(C, A);
                ll dCB = bfs_dist(C, B);
                ll dAB = bfs_dist(A, B);
                ll cost = min(dCA, dCB) + dAB;
                if (cost > ans)
                    ans = cost;
            }
        }
    }
    cout << ans << '\n';
    return 0;
}

brute.cpp 对每组三点各做一次遍历求距离, 完全按定义结算, 不依赖任何还没证明的公式. 它的价值在于给出可信的小数据基准.

复杂度

枚举 O(N3)O(N^3) 组三点, 每组遍历 O(N)O(N), 总 O(N4)O(N^4). N=2×105N=2\times 10^5 时完全不可行.

瓶颈

三点组合太多. 如果能把某两个点固定成与 CC 无关的特定节点, 枚举量就能从 O(N3)O(N^3) 掉到 O(N)O(N). 自然的候选是直径的两个端点, 但这必须证明, 而这一步正是本题最容易写错的地方.

解法总览

两条路线都是 O(N)O(N),差别在于要不要证明「朋友取直径端点」这条几何结论。解法一把答案化简成「直径长 + 到两端较近距离的最大值」,代价是先证一条关于直径的上界;解法二直接枚举三叉臂的汇合中心点,用换根 DP 汇总每个点向外的前三条长链,完全不需要直径性质。

解法 核心观察 复杂度 适用场景 对应代码
一、直径端点 先证最优解的两个朋友可以取直径两端,答案化简为 D+max⁡uh(u)D + \max_u h(u) O(N)O(N) 时间,O(N)O(N) 空间 需要一次几何上界证明;代码最短,三次遍历加一次扫描 main.cpp / main.py
二、树形换根 DP 不碰直径,在每个节点汇总向外的前三长链 l1≥l2≥l3l_1 \ge l_2 \ge l_3,取 l1+2l2+l3l_1 + 2l_2 + l_3 O(N)O(N) 时间,O(N)O(N) 空间 不想证贪心结论,只需树形 DP 基本功 main2.cpp

正式主解是解法一(main.cpp,附同算法的 Python 版 main.py):证明虽多一步,但它直接回答了「为什么是直径端点」,并且把求解压成三次遍历加一次扫描,常数最小、最好写。

如果不想碰任何几何论证,解法二是纯机械路线:枚举汇合中心点之后,题目要求的时间恰好是「一条主干道走一次 + 一条支链折返走两次」,于是每个点只需知道向外伸出的前三条独立长链。两条路线互不依赖,不构成前后优化关系。

解法一:直径端点贪心(主解)

思路

这道解法最核心的问题是:为什么最优解里的两个朋友,可以固定为树的某条直径的两个端点? 只要这个结论成立,两个朋友 A,BA, B 之间的距离就直接固定为树的直径 D=d(A,B)D = d(A, B),剩下只需枚举家 CC,找到让 min⁡(d(C,A),d(C,B))\min(d(C,A), d(C,B)) 最大的点,一次 O(N)O(N) 扫描就能得到全局最优解。

注意:我们不能靠「一步步贪心拉伸」来论证(因为向最远点替换某个朋友时,汇合中心点可能漂移,两朋友甚至可能折叠变近,单步替换并不保序)。正确的思路是为任意三点计算一个全树统一的天花板上界,并证明选直径端点正好能顶满这个天花板。

整个论证分为清晰的三步:

第一步:三叉臂展开(物理意义)

无论选哪三点 C,X,YC, X, Y,在树结构中它们必然交汇于唯一的中心分叉点 mm,形成一个「三叉臂」结构。 设三条臂长分别为 a=d(m,C),b=d(m,X),c=d(m,Y)a = d(m, C), b = d(m, X), c = d(m, Y)。不妨设 b≤cb \le c,即朋友 XX 是离家更近的那个。

三臂分解: 汇合点 m 引出三条臂 a、b、c, 三点两两距离分别是两臂之和。绿色两条是 f 的两个加数, 灰色那条不可能是最小值

按题意,父母先去近的朋友 XX,再去远的朋友 YY:

f(C,X,Y)=d(C,X)+d(X,Y)=(a+b)+(b+c)=(a+c)+2b=(a+b+c)+bf(C, X, Y) = d(C, X) + d(X, Y) = (a + b) + (b + c) = (a + c) + 2b = (a + b + c) + b

仔细观察这个形式 (a+c)+2b(a+c) + 2b 的物理意义:

  • a+c=d(C,Y)a+c = d(C,Y):这是从家 CC 一路直达朋友 YY 的主干路径(只走一次);
  • 2b2b:是在途经汇合点 mm 时,向旁边伸出去看望朋友 XX,然后原路退回 mm 的侧向支链(走了一个来回折返,即走两次)。

第二步:树上任意三链的天然上界

设整棵树的直径长度为 DD(即全树任意两点间距离的最大值),A→BA \to B 为一条直径。记 h(u)=min⁡(d(u,A),d(u,B))h(u) = \min(d(u, A), d(u, B)),即点 uu 到直径两端较近者的距离。

三叉臂中的两条链与单条链有天然的长度封顶:

  1. 主干道长度:a+c=d(C,Y)a + c = d(C, Y) 是树上的一条简单路径,因此显然:
    a+c⩽Da + c \leqslant D
  2. 折返短臂 bb 的长度: 汇合中心点 mm 无论在树的哪个位置,它向外伸出的任意分支长度 bb,都不可能超过 mm 到直径两端较近者的距离(即 b⩽h(m)b \leqslant h(m))。 证明:若不然,假设 b>h(m)=min⁡(d(m,A),d(m,B))b > h(m) = \min(d(m, A), d(m, B))。那么不妨设 d(m,B)≥d(m,A)d(m, B) \ge d(m, A)(即 BB 是较远端),此时 m→Bm \to B 的长度至少为 D/2D/2。由于直径是树上最长路径,从 mm 沿着伸出 bb 的分支走到底,与 m→Bm \to B 拼成的新路径长度为 b+d(m,B)>d(m,A)+d(m,B)≥d(A,B)=Db + d(m, B) > d(m, A) + d(m, B) \ge d(A, B) = D,这与 DD 是全树直径矛盾! 因此必有:
    b⩽h(m)⩽max⁡uh(u)b \leqslant h(m) \leqslant \max_{u} h(u)

第三步:合并结论(全树天花板)

先把第二步的两条封顶直接相加,得到一个很松的上界:

f(C,X,Y)=(a+c)+2b⩽D+2×max⁡uh(u)f(C, X, Y) = (a + c) + 2b \leqslant D + 2 \times \max_{u} h(u)

这个界比真实上界多出了一个 max⁡h\max h。要补上这个差距,得换一个角度:真正该控制的是「三个两两距离中最小的那个」。

因为 b⩽cb \leqslant c,有 d(C,X)=a+b⩽a+c=d(C,Y)d(C, X) = a + b \leqslant a + c = d(C, Y),所以 ff 的两个加数中,第一个正是三点两两距离的最小值 Pmin⁡P_{\min}:

f(C,X,Y)=Pmin⁡+(两个加数中较大的那个)⩽Pmin⁡+Pmax⁡f(C, X, Y) = P_{\min} + (\text{两个加数中较大的那个}) \leqslant P_{\min} + P_{\max}

式中 Pmin⁡P_{\min}、Pmax⁡P_{\max} 分别是 d(C,X)d(C,X)、d(C,Y)d(C,Y)、d(X,Y)d(X,Y) 三个距离的最小值与最大值。换成这个写法,是因为这两个量各自有天然封顶:

  1. 最大值不会超过直径:Pmax⁡⩽DP_{\max} \leqslant D。
  2. 最小值不会超过 max⁡uh(u)\max_u h(u),这一条需要单独证。

Pmin⁡P_{\min} 的封顶(同侧 + 抽屉原理)

把每个点按它离直径哪一端更近分成两类:d(u,A)⩽d(u,B)d(u, A) \leqslant d(u, B) 的算 AA 侧,否则算 BB 侧。三个点 C,X,YC, X, Y 只有两类可选,必有两点落在同一侧,记为 y,zy, z。

同侧引理:若 y,zy, z 同侧,则 d(y,z)⩽max⁡uh(u)d(y, z) \leqslant \max_u h(u)。

证明:把 y,zy, z 投影到直径 A→BA \to B 上,投影点记为 py,pzp_y, p_z,并记

xy=d(A,py),ty=d(y,py)x_y = d(A, p_y), \qquad t_y = d(y, p_y)

这两个数分别描述「yy 落在直径的哪个位置」和「yy 离开直径伸出去多远」(zz 的同名量是 xzx_z、tzt_z)。交换 y,zy, z 的名字,总可以令 xy⩽xzx_y \leqslant x_z。

树中两点之间的唯一路径必经过各自的投影点,而 py→pzp_y \to p_z 这一段本身就在直径上,所以

d(y,z)⩽ty+(xz−xy)+tzd(y, z) \leqslant t_y + (x_z - x_y) + t_z

另外,离开直径的分支深度不会超过投影点到端点的距离,即 ty⩽xyt_y \leqslant x_y:若 ty>xyt_y > x_y,则从 yy 走到直径另一端 BB 的路径长为 ty+(D−xy)>xy+(D−xy)=Dt_y + (D - x_y) > x_y + (D - x_y) = D,与 DD 是直径矛盾。代入上式:

d(y,z)⩽xy+(xz−xy)+tz=xz+tz=d(z,A)d(y, z) \leqslant x_y + (x_z - x_y) + t_z = x_z + t_z = d(z, A)

又因为 zz 在 AA 侧意味着 xz⩽D/2x_z \leqslant D/2,此时 d(z,B)=(D−xz)+tz⩾xz+tz=d(z,A)d(z, B) = (D - x_z) + t_z \geqslant x_z + t_z = d(z, A),即 h(z)=d(z,A)h(z) = d(z, A)。于是

d(y,z)⩽h(z)⩽max⁡uh(u)d(y, z) \leqslant h(z) \leqslant \max_u h(u)

抽屉原理挑出的 y,zy, z 是三个点中的一对,所以

Pmin⁡⩽d(y,z)⩽max⁡uh(u)P_{\min} \leqslant d(y, z) \leqslant \max_u h(u)

把它和 Pmax⁡⩽DP_{\max} \leqslant D 相加,就得到全树统一的上界:

f(C,X,Y)⩽max⁡uh(u)+Df(C, X, Y) \leqslant \max_u h(u) + D

这个上界对任意三点组合 (C,X,Y)(C, X, Y) 都成立! 而当我们把两个朋友直接选在直径两端 A,BA, B 时,d(A,B)=Dd(A, B) = D 直接拉满,家选在让 h(u)h(u) 取得最大的点 u0u_0:

f(u0,A,B)=min⁡(d(u0,A),d(u0,B))+d(A,B)=max⁡uh(u)+Df(u_0, A, B) = \min(d(u_0, A), d(u_0, B)) + d(A, B) = \max_u h(u) + D
刚好能够完全取满这个理论天花板!

由此严谨证明:在全局最优解中,两个朋友一定可以取为直径的两个端点 AA 和 BB。

警示:一个流传极广的“伪直觉证明”及其致命反例

许多人在初学此题时,常会产生一个看似极其简洁、顺理成章的“直觉证明”:

⚠️

「固定家 CC 点不动。路线是先从 CC 走到朋友 BB,再从 BB 走到朋友 AA。

  • 为了让第一步尽可能长,从 CC 出发显然应该走到离 CC 最远的点,由两次 DFS 求直径的定理,这个最远点 BB 一定是某条直径的端点;
  • 接下来从 BB 走到 AA,为了让第二步尽可能长,显然应该走到离 BB 最远的点,也就是直径的另一端点 AA。
  • 综上,两朋友 A,BA, B 必然是直径的两端。」

这个证明非常符合人的直觉,甚至很多博客和讨论区都在使用,但它在逻辑上是完全错误的!主要有两大漏洞:

  1. 违背规则定义: 题目的规则是先去较近的朋友(式中为 min⁡(d(C,A),d(C,B))\min(d(C, A), d(C, B)))。如果让 BB 成为离 CC 最远的点,那只要另一个朋友 AA 离 CC 稍微近一点,父母根本不会先去 BB,而是会先去 AA。此时走的是 C→A→BC \to A \to B,你以为被算入折返的第一步根本没生效。
  2. 目标不是单步最大化,局部贪心不保序: 总路程是两段的和,为了追求第一步大,可能会导致两朋友之间的距离 d(A,B)d(A, B) 剧烈缩水,导致总和变小。

精确反例(固定 CC 时,选直径端点反而更差):

text
树的边权:
1-2(28)  1-3(45)  2-4(18)  2-5(38)  1-6(21)  1-7(2)  1-8(42)  1-9(25)  4-10(28)  1-11(34)
整棵树的唯一直径是 10 到 3,长 D = 119。两端点为 A = 10, B = 3。

若我们固定家在 C=4C = 4:

  • 离 C=4C = 4 最远的点确实是直径端点 B=3B = 3(d(4,3)=91d(4, 3) = 91);
  • 离 B=3B = 3 最远的点也确实是直径端点 A=10A = 10(d(3,10)=119d(3, 10) = 119);
  • 按直觉伪证选直径端点 A=10,B=3A=10, B=3:
    f(C=4,A=10,B=3)=min⁡(d(4,10),d(4,3))+d(10,3)=min⁡(28,91)+119=28+119=147f(C=4, A=10, B=3) = \min(d(4,10), d(4,3)) + d(10,3) = \min(28, 91) + 119 = 28 + 119 = \mathbf{147}
  • 但如果不选直径端点,而是选 X=3,Y=5X = 3, Y = 5:
    f(C=4,X=3,Y=5)=min⁡(d(4,3),d(4,5))+d(3,5)=min⁡(91,56)+111=56+111=167f(C=4, X=3, Y=5) = \min(d(4,3), d(4,5)) + d(3,5) = \min(91, 56) + 111 = 56 + 111 = \mathbf{167}

167>147167 > 147!选直径端点反而比非直径端点少了整整 20!

核心警示: 本题成立的命题是**「全树全局最大值可以由直径端点配上某个特定的 CC 取到」,而不是“对每个固定的 CC,把朋友换成直径端点都不亏”。任何试图通过“固定 CC 单步找最远点”的贪心推导都是伪证,必须通过前文的全树上界放缩法**(或解法二的换根 DP)来证明。

算法步骤

  1. 从任意节点(如节点 11)出发跑一次 BFS/DFS,找到距离最远的点,记为直径端点 AA。
  2. 从 AA 出发跑一次 BFS/DFS,记录每个点到 AA 的距离 distA[],距离 AA 最远的点即为直径另一端点 BB(此时直径长 D=distA[B]D = distA[B])。
  3. 从 BB 出发再跑一次 BFS/DFS,记录每个点到 BB 的距离 distB[]。
  4. 扫描所有节点 u∈[1,N]u \in [1, N],计算 min⁡(distA[u],distB[u])\min(distA[u], distB[u]) 的最大值,加上 DD 即为答案。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-08-13 08:02
 * update_at: 2026-10-26 20:33
 */
// main.cpp:三次 BFS 求直径端点与两端距离数组,再 O(n) 扫描答案。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 200005;

struct Edge {
    int to; // 邻居节点
    ll w;   // 边权
};

int n, m;
vector<Edge> g[MAXN]; // 邻接表:g[u] 是节点 u 连出的所有边
ll distA[MAXN];       // 各点到直径端点 A 的距离
ll distB[MAXN];       // 各点到直径端点 B 的距离

// 从 start 出发 BFS,把到各点的距离写入 dist,返回距离最远的节点编号。
// 因为是树, 所以用 BFS 求路径长度即可(路径唯一,不需要 Dijkstra)。
int bfs_farthest(int start, ll dist[]) {
    queue<int> q;
    for (int i = 1; i <= n; i++)
        dist[i] = -1; // 用 -1 当"未访问"标记:边权可以为 0,不能用 0 标记
    dist[start] = 0;
    q.push(start);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (auto &e : g[u]) {
            if (dist[e.to] == -1) {
                dist[e.to] = dist[u] + e.w;
                q.push(e.to);
            }
        }
    }
    int far = start;
    for (int i = 1; i <= n; i++) {
        if (dist[i] > dist[far])
            far = i;
    }
    return far;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int u, v;
        ll w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});
        g[v].push_back({u, w});
    }

    // 两次最远点搜索求出一对直径端点 A、B。
    int A = bfs_farthest(1, distA);
    int B = bfs_farthest(A, distA); // 这次的距离以 A 为源,正好是 distA[]
    bfs_farthest(B, distB);

    // 答案是「直径长 + 到两端较近距离的最大值」。
    // 注意直径端点这一对朋友只保证全局最优,对每个固定的家未必最优。
    ll best_extra = 0;
    for (int i = 1; i <= n; i++) {
        ll mn = min(distA[i], distB[i]);
        if (mn > best_extra)
            best_extra = mn;
    }
    cout << distA[B] + best_extra << '\n';
    return 0;
}

同一算法的 Python 版, 便于对照阅读:

python
"""P4408 逃学的小孩:与 main.cpp 同一算法的 Python 版。

三次遍历求出一对直径端点与两个距离数组,再扫描一遍取答案:

    ans = 直径长 + max_u min(d(u, A), d(u, B))

复杂度和 C++ 版一致,都是 O(N),但 Python 常数较大,在 N = 2e5 的随机树上
约 1.4 s,会超出本题时限,因此只作为阅读对照,正式主解是 main.cpp。
"""

import sys


def main():
    data = sys.stdin.buffer
    n, edges = map(int, data.readline().split())

    graph = [[] for _ in range(n + 1)]  # graph[u] 是 (邻居, 边权) 的列表
    for _ in range(edges):
        u, v, weight = map(int, data.readline().split())
        graph[u].append((v, weight))
        graph[v].append((u, weight))

    def distances(start):
        """从 start 出发遍历整棵树,返回到各点的距离数组。"""
        distance = [-1] * (n + 1)  # 用 -1 当"未访问"标记:边权可以为 0
        distance[start] = 0
        order = [start]
        for node in order:  # order 一边当队列用,一边记录遍历顺序
            for neighbor, weight in graph[node]:
                if distance[neighbor] == -1:
                    distance[neighbor] = distance[node] + weight
                    order.append(neighbor)
        return distance

    from_one = distances(1)
    # 两次最远点搜索求出一对直径端点 first 和 second。
    first = max(range(1, n + 1), key=from_one.__getitem__)
    from_first = distances(first)
    second = max(range(1, n + 1), key=from_first.__getitem__)
    from_second = distances(second)

    # 答案是「直径长 + 到两端较近距离的最大值」。
    best_extra = max(
        min(da, db) for da, db in zip(from_first[1:], from_second[1:])
    )
    print(from_first[second] + best_extra)


if __name__ == "__main__":
    main()

Python 版在本机 N=2×105N=2\times 10^5 的随机树上约 1.41.4 s, 会超出本题时限; 正式主解是 main.cpp.

复杂度

  • 时间: 三次遍历各 O(N)O(N), 最后一次扫描 O(N)O(N), 共 O(N)O(N).
  • 空间: 邻接表与两个距离数组, O(N)O(N).

解法二:树形换根 DP(枚举汇合中心)

思路

如果不想证明“两朋友必为直径端点”,有没有更直接暴力的树形结构做法? 答案是:直接枚举三叉臂的汇合中心点 mm。

回忆第一步中的物理拆解: 无论三点选在哪里,它们必定交汇于唯一的中心点 mm,且总路程为:

f=最长臂+2×次长臂+第三长臂f = \text{最长臂} + 2 \times \text{次长臂} + \text{第三长臂}
(即一条主干道走一次,一条支链折返走两次)。

因此,如果我们将 mm 作为根,从 mm 出发向不同的子树延伸,我们要找的就是:在 mm 的所有相邻分支中,选出长度排名前三的分支 l1≥l2≥l3l_1 \ge l_2 \ge l_3,此时以 mm 为中心的最大贡献即为 l1+2l2+l3l_1 + 2l_2 + l_3。

由于我们需要考察每个节点作为中心点 mm 时的答案,这是一个标准而纯粹的 树形 DP + 二次扫描换根法(Up-and-Down Tree DP):

  1. 第一次 DFS(自底向上): 维护每个节点 uu 在其子树内部向下走出的前三长链:down[u][0..2],并记录取到这些最值时的直接子节点编号 best_child[u][0..2]。
  2. 第二次 DFS(自顶向下换根): 将从父节点往上走能走到的最长链 up[u] 传递给各个子节点。在每个节点 uu 处,汇总向下延伸的前三长链和向上延伸的 up[u] 链,挑出全局前三长的独立分支 l1≥l2≥l3l_1 \ge l_2 \ge l_3,用 l1+2l2+l3l_1 + 2l_2 + l_3 更新全局最大值。

这种做法的好处是:完全不需要树的直径性质,也不需要证明任何贪心结论,完全由树的拓扑结构自然驱动。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-26 20:37
 * update_at: 2026-10-26 20:37
 */
// main2.cpp:换根 DP(二次扫描与换根)解法
// 核心思路:枚举三叉臂的汇合中心点 m,在 m 处向不同子树拉出前三长链 l1 >= l2 >= l3
// 最大路程为一条主干道(走一次)加一条支链折返(走两次):l1 + 2 * l2 + l3
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

typedef long long ll;

const int MAXN = 200005;

struct Edge {
    int to;
    ll w;
};

int n, m;
vector<Edge> g[MAXN];

// down[u][0..2]:节点 u 在其以 1 为根的子树内,向不同子树走出的最长、次长、第三长链长度
ll down[MAXN][3];
// best_child[u][0..2]:对应链走入的具体子节点编号
int best_child[MAXN][3];
// up[u]:从 u 沿着父亲方向向外能走的最长链长度
ll up_chain[MAXN];

ll ans = 0;

void insert_down(int u, ll val, int child) {
    if (val > down[u][0]) {
        down[u][2] = down[u][1];
        best_child[u][2] = best_child[u][1];
        down[u][1] = down[u][0];
        best_child[u][1] = best_child[u][0];
        down[u][0] = val;
        best_child[u][0] = child;
    } else if (val > down[u][1]) {
        down[u][2] = down[u][1];
        best_child[u][2] = best_child[u][1];
        down[u][1] = val;
        best_child[u][1] = child;
    } else if (val > down[u][2]) {
        down[u][2] = val;
        best_child[u][2] = child;
    }
}

// 第一次 DFS:自底向上统计子树内的最长、次长、第三长链
void dfs1(int u, int fa) {
    for (auto &e : g[u]) {
        int v = e.to;
        if (v == fa) continue;
        dfs1(v, u);
        insert_down(u, down[v][0] + e.w, v);
    }
}

// 第二次 DFS:自顶向下传递 up 链,并在每个点汇聚所有方向的前三长链更新全局答案
void dfs2(int u, int fa) {
    // 收集节点 u 向外延伸的所有独立分支(向下最多 3 条,向上 1 条)
    vector<ll> chains;
    for (int i = 0; i < 3; i++) {
        if (down[u][i] > 0) chains.push_back(down[u][i]);
    }
    if (up_chain[u] > 0) chains.push_back(up_chain[u]);
    sort(chains.begin(), chains.end(), greater<ll>());

    // 若至少有三条分支,第二长链翻倍:l1 + 2*l2 + l3
    // 若只有两条分支,第三条视作长度 0:l1 + 2*l2
    // 若只有一条分支,翻倍也是合法的退化情况
    if (chains.size() >= 3) {
        ans = max(ans, chains[0] + 2 * chains[1] + chains[2]);
    } else if (chains.size() == 2) {
        ans = max(ans, chains[0] + 2 * chains[1]);
    } else if (chains.size() == 1) {
        ans = max(ans, chains[0]);
    }

    for (auto &e : g[u]) {
        int v = e.to;
        if (v == fa) continue;
        // 计算给子节点 v 传递的 up 链:u 的除 v 以外的最长分支 + e.w
        ll max_other = up_chain[u];
        if (best_child[u][0] != v) {
            max_other = max(max_other, down[u][0]);
        } else {
            max_other = max(max_other, down[u][1]);
        }
        up_chain[v] = max_other + e.w;
        dfs2(v, u);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    if (!(cin >> n >> m)) return 0;
    for (int i = 0; i < m; i++) {
        int u, v;
        ll w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});
        g[v].push_back({u, w});
    }

    dfs1(1, 0);
    dfs2(1, 0);

    cout << ans << "\n";
    return 0;
}

复杂度

  • 时间复杂度:两次 DFS 各遍历整棵树一次,每个节点只做常数大小的前三链合并与排序,总时间复杂度为严格的 O(N)O(N)。
  • 空间复杂度:存储邻接表与前三长链数组,总空间复杂度为 O(N)O(N)。

复杂度对比

方案 维护结构 时间 空间 相对特点
main.cpp / main.py(解法一) 三次 BFS + 两个距离数组 O(N)O(N) O(N)O(N) 依赖直径性质,代码最短
main2.cpp(解法二) 两次 DFS + 每个节点前三长链 O(N)O(N) O(N)O(N) 不依赖直径,常数略大

两者的复杂度同阶,差别在常数与证明负担:解法一只做三次遍历,但必须先证明「朋友取直径端点」;解法二要做两次 DFS 并在每个节点维护前三长链(含换根时的比较与更新),但跳过了全部几何论证。因此追求代码简短选解法一,追求「不需要任何结论」选解法二。

总结

本题提供了两种完全不同视角的 O(N)O(N) 优秀解法:

  1. 解法一(主解:直径端点贪心): 通过三叉臂展开发现物理本质是「主干道直达走一次 + 侧向支链折返走两次」,即 f=(a+c)+2bf = (a+c) + 2b。再把 ff 改写成「三个两两距离中的最小值 + 最大值」,分别封顶:Pmax⁡⩽DP_{\max} \leqslant D(直径最长),Pmin⁡⩽max⁡uh(u)P_{\min} \leqslant \max_u h(u)(三点必有两点同侧,同侧两点的距离不超过较远者到直径端点的距离)。两个上界相加即 D+max⁡uh(u)D + \max_u h(u),选两个朋友为直径两极正好取满。代码仅需三次简单的 BFS/DFS,非常简洁。
  2. 解法二(进阶:树形换根 DP): 完全跳过对“直径端点”的推导证明,直接枚举三叉臂的汇合中心点 mm。通过树形二次扫描与换根 DP 维护向外延伸的前三长链 l1≥l2≥l3l_1 \ge l_2 \ge l_3,以 l1+2l2+l3l_1 + 2l_2 + l_3 结算局部最优。思维直接、不依赖特殊图论引理。

实现细节注意点:

  • 边权可以为 00,解法一的距离数组要用 −1-1 而不是 00 当未访问标记;
  • 答案量级最大可达 2×105×109=2×10142 \times 10^5 \times 10^9 = 2 \times 10^{14},必须用 64 位整数(C++ 中使用 long long)。

图示解析

这张图把整道题的推导与解法结构串起来:

text
暴力枚举 (brute.cpp)
  枚举三点 (C, A, B), 每组跑遍历求距离
  f = min(d(C,A), d(C,B)) + d(A,B)             O(N^4)
        |
        | 瓶颈: 三点组合太多,如何降维?
        v
物理拆解: 三叉臂中心视角
  三点必在唯一的中心点 m 汇合,臂长分别为 a, b, c (设 b <= c)
  总路程 = (a + c) [主干道走一次] + 2b [侧向支链走折返]
        |
        +-----------------------------------+
        |                                   |
        v                                   v
【路线 A】全树极值放缩 (解法一: main.cpp)       【路线 B】换根 DP (解法二: main2.cpp)
  f = P_min + (另一加数) <= P_min + P_max    以 m 为根,找向外前三长链:
  1. P_max <= 直径长 D                       l1 >= l2 >= l3
  2. P_min <= max_u h(u)  (三点必有两点同侧)  Ans_m = l1 + 2 * l2 + l3
  => 上界 D + max_u h(u)
  => 朋友直接选直径两端 A, B 取满天花板!        树形 DP + 二次扫描换根全局更新
  => 3 次 BFS 线性扫描 (O(N))                 => 两次 DFS 遍历即可 (O(N))