树的直径
树的直径的原理与实现:两次 DFS 或树形 DP 求树中最长路径。
一句话算法
- 两次搜索:先找到一个最远端点,再从它找到另一个最远端点。
- 树形 DP:枚举路径的最高点,把两条最长向下链拼起来。
问题模型
给定一棵树,边可以带非负权。
- 两点距离:树上唯一简单路径的边权和。
- 树的直径:任意两点距离的最大值。
- 目标:求直径长度,有时还需要求直径的两个端点。
树的直径
树上最长简单路径叫做树的直径。
边权范围
本文只讨论非负边权。长度为
方法一:两次最远点搜索
核心直觉
树没有环,所以任意两点之间只有一条路。
如果从任意点 s 出发,找到最远点 u,这个 u 一定已经被推到了树的某个“边界”。直径一定从一个边界走到另一个边界,所以再从 u 出发找最远点 v,路径 u -> v 就是最长路径。
可以把第一次搜索理解成“找到一端”,第二次搜索理解成“从这一端拉到最远”。
算法步骤
- 从任意点开始搜索,通常选
1。 - 计算所有点到
1的距离,找到最远点u。 - 从
u再搜索一次,计算所有点到u的距离。 - 找到最远点
v。 dist(u, v)就是树的直径长度。
搜索可以使用 DFS 或 BFS。因为树上路径唯一,即使边有权,也不需要 Dijkstra;沿树遍历时累加边权即可。
正确性证明
核心结论:从任意点
先固定一条直径
反设
直觉模型:如果把直径的一端换成
情况一: 与直径没有公共点
两条路径之间有一条唯一的连接路径。设它在
因为
于是
但
因此,
情况二: 与直径相交
取公共部分中离
而
同样因为
综上,从任意点
第二次从
复杂度分析
设点数为
- 时间复杂度:
。两次遍历整棵树。 - 空间复杂度:
。邻接表和距离数组。
代码模板
farthest 做一次最远点搜索;solve 连续调用两次后,直径长度存在 ans,端点是 a、b。
队列写法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <queue>
#include <vector>
const int maxn = 1e6 + 5;
using ll = long long;
using Edge = struct { int to; ll w; };
using Graph = std::vector<Edge>;
Graph tree[maxn]; // 全局邻接表数组:直接向 tree[u] 加带权边
// 如何使用:
// 1. 读入带权边:tree[u].push_back({v, w}); tree[v].push_back({u, w});
// 2. TreeDiameter td(n);
// 3. td.solve(); cout << td.ans; // 直径长度在 td.ans,端点是 td.a、td.b
// 求树的直径:任意点出发找最远点 u,再从 u 找最远点 v,u -> v 就是直径。
struct TreeDiameter {
int n;
int a, b; // 直径的两个端点
ll ans; // 直径长度
std::vector<ll> dis; // 到当前起点的距离
explicit TreeDiameter(int n) : n(n), a(0), b(0), ans(0), dis(n + 1) {}
// 从 s 出发找最远点,返回最远点的编号
int farthest(int s) {
dis.assign(n + 1, -1);
std::queue<int> q;
q.push(s);
dis[s] = 0;
int far = s;
while (!q.empty()) {
int u = q.front();
q.pop();
if (dis[u] > dis[far]) far = u;
for (Edge e : tree[u]) {
int v = e.to;
ll w = e.w;
if (dis[v] != -1) continue;
dis[v] = dis[u] + w;
q.push(v);
}
}
return far;
}
// 两次最远点搜索:端点存入 a、b,长度存入 ans
void solve() {
a = farthest(1);
b = farthest(a);
ans = dis[b];
}
};
DFS 写法
如果题目还需要输出直径上的节点,可以使用 DFS 版本。第二次 DFS 会记录 parent,从右端点一路跳父节点即可还原整条直径路径。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <algorithm>
#include <vector>
const int maxn = 1e6 + 5;
using ll = long long;
using Edge = struct { int to; ll w; };
using Graph = std::vector<Edge>;
Graph tree[maxn]; // 全局邻接表数组:直接向 tree[u] 加带权边
// 如何使用:
// 1. 读入带权边:tree[u].push_back({v, w}); tree[v].push_back({u, w});
// 2. TreeDiameterDFS td(n);
// 3. td.solve();
// 直径长度在 td.ans,端点是 td.a、td.b,路径是 td.path。
// 两次 DFS 最远点搜索求树的直径,并记录直径路径。
struct TreeDiameterDFS {
int n;
int a, b; // 直径的两个端点
ll ans; // 直径长度
std::vector<ll> dis; // dis[u] = 当前 DFS 起点到 u 的距离
std::vector<int> parent; // parent[u] = 当前 DFS 中 u 的父节点,用于还原路径
std::vector<int> path; // 从 a 到 b 的直径路径
explicit TreeDiameterDFS(int n)
: n(n), a(0), b(0), ans(0), dis(n + 1), parent(n + 1) {}
void dfs(int u, int fa, ll d) {
dis[u] = d;
parent[u] = fa;
for (Edge e : tree[u]) {
int v = e.to;
if (v == fa) continue;
dfs(v, u, d + e.w);
}
}
int farthest(int s) {
dfs(s, 0, 0);
int far = s;
for (int u = 1; u <= n; u++) {
if (dis[u] > dis[far]) far = u;
}
return far;
}
void build_path() {
path.clear();
for (int u = b; u != 0; u = parent[u]) {
path.push_back(u);
if (u == a) break;
}
std::reverse(path.begin(), path.end());
}
// 两次最远点搜索:端点存入 a、b,长度存入 ans,路径存入 path。
void solve() {
a = farthest(1);
b = farthest(a);
ans = dis[b];
build_path();
}
};
方法二:树形 DP
核心直觉
把树以 1 为根。节点越靠近根,深度越小。一条简单路径上深度最小的节点是唯一的,称为这条路径的最高点。
如果一条路径的最高点是
- 从
沿一个儿子方向向下走,此时另一条链的长度视为 ; - 从一个儿子子树走到
,再进入另一个儿子子树。
因此,只要知道每个儿子方向提供的最长链,就可以在
状态定义
定义 dfs(u) 结束后,从
对于
叶子没有儿子,所以
从二重枚举到一次扫描
若
这样在节点
现在按顺序扫描儿子。处理第
代码直接复用 f[u] 保存这个 mx。处理当前儿子
再把当前链并入已经处理的链:
当还没有处理任何儿子时,f[u]=0。这个
更新顺序不能交换
必须先更新 ans,再更新 f[u]。如果顺序反过来,f[u] 可能已经包含当前儿子
算法步骤
- 把树以
1为根,执行后序 DFS。 - 处理
时,先递归算出每个儿子 的 。 - 用
f[u] + f[v] + w(u,v)更新全局答案。 - 用
f[v] + w(u,v)更新f[u]。 - DFS 结束后,全局答案就是直径长度。
正确性证明
证明只需要抓住两个不变量,再说明它们覆盖了所有路径。
1. 子树状态
叶子的 f[u] 取到了这些链的最大值,所以 dfs(u) 结束时状态定义成立。
2. 儿子扫描
处理当前儿子 f[u] 始终是已处理儿子提供的最长链。因此
就是“此前最佳方向”和“当前方向”在 ans 后,再把当前链加入 f[u],这个不变量继续成立。扫描结束时,每个当前方向都已经和此前的最佳方向配对。
最后考虑整棵树中的任意简单路径。它有唯一的最高点
- 若一个端点就是
,路径对应一条向下链和长度为 的空链; - 否则,两个端点位于
的不同儿子子树中,路径由两个方向的向下链拼成。
DFS 处理了每个节点,也就枚举了每个可能的最高点。算法得到的候选都是合法路径,并覆盖了每个最高点处的最长路径,因此全局最大值 ans 就是树的直径。
另一种等价写法
也可以先找出所有儿子链中的最长值 best1 和次长值 best2。扫描每条链 len 时,核心更新为:
1
2
3
4
5
6
if (len > best1) {
best2 = best1;
best1 = len;
} else if (len > best2) {
best2 = len;
}
扫描结束后,f[u]=best1,经过 best1 + best2。这与模板中的在线写法完全等价:模板里的 f[u] 就是“已处理儿子中的 best1”,当前链是本次与它配对的另一条链。
复杂度分析
- 时间复杂度:
。每个儿子只合并一次,整棵树共处理 条父子边。 - 空间复杂度:
。邻接表、 数组和递归栈都可能达到线性规模。
代码模板
solve 从节点 1 开始后序遍历,直径长度保存在 ans 中。这个模板只统计长度,不记录直径端点。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <algorithm>
#include <vector>
const int maxn = 1e6 + 5;
using ll = long long;
using Edge = struct { int to; ll w; };
using Graph = std::vector<Edge>;
Graph tree[maxn]; // 全局邻接表数组:直接向 tree[u] 加带权边
// 如何使用:
// 1. 读入带权边:tree[u].push_back({v, w}); tree[v].push_back({u, w});
// 2. TreeDiameterDP td(n);
// 3. td.solve(); cout << td.ans; // 直径长度在 td.ans
// 树上 DP 求直径:对每个点统计"往下走的最长链"。
// 扫描 u 的儿子时,f[u] 表示已处理儿子提供的最长链;当前儿子 v
// 提供 f[v] + w。先用这两条链更新直径,再把当前链并入 f[u]。
struct TreeDiameterDP {
int n;
ll ans; // 直径长度
std::vector<ll> f; // f[u] = 从 u 出发往下走的最长链长度
explicit TreeDiameterDP(int n) : n(n), ans(0), f(n + 1) {}
void solve() { dfs(1, 0); }
// 后序遍历:处理儿子 v 时,f[u] 还是"已处理儿子的最长链",
// 先 ans = max(ans, f[u] + f[v] + w) 用两条链在 u 拼接,
// 再 f[u] = max(f[u], f[v] + w) 把当前儿子的链并入 f[u]。
void dfs(int u, int parent) {
for (Edge e : tree[u]) {
int v = e.to;
ll w = e.w;
if (v == parent) continue;
dfs(v, u);
ans = std::max(ans, f[u] + f[v] + w); // 过 u 的两条链拼接(f[u] 为 0 时就是单链)
f[u] = std::max(f[u], f[v] + w); // 更新过 u 的最长向下链
}
}
};
递归深度
当树退化成长链时,递归深度会达到
两种方法怎么选
| 对比项 | 两次最远点搜索 | 树形 DP |
|---|---|---|
| 遍历方式 | 两次完整遍历 | 一次后序遍历 |
| 直接得到 | 直径长度和两个端点 | 直径长度 |
| 深树风险 | 队列实现不依赖递归栈 | 递归实现可能栈溢出 |
| 适用场景 | 普通树直径问题 | 已在进行树形 DP,需顺便统计最长路径 |
两种方法的时间、空间复杂度都是
模板二选一
两份模板共用了 tree、Edge 等全局名称,应根据题目需要选择其中一份,不要同时复制到同一份源码中。
完整代码实现
以下两份程序使用相同的输入格式:
n
u1 v1 w1
u2 v2 w2
...
u(n-1) v(n-1) w(n-1)
输出直径长度。
两次最远点搜索
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e6 + 5;
using ll = long long;
using Edge = struct { int to; ll w; };
using Graph = vector<Edge>;
Graph tree[maxn]; // 全局邻接表数组:直接向 tree[u] 加带权边
// 求树的直径:任意点出发找最远点 u,再从 u 找最远点 v,u -> v 就是直径。
struct TreeDiameter {
int n;
int a, b; // 直径的两个端点
ll ans; // 直径长度
vector<ll> dis; // 到当前起点的距离
explicit TreeDiameter(int n) : n(n), a(0), b(0), ans(0), dis(n + 1) {}
// 从 s 出发找最远点,返回最远点的编号
int farthest(int s) {
dis.assign(n + 1, -1);
queue<int> q;
q.push(s);
dis[s] = 0;
int far = s;
while (!q.empty()) {
int u = q.front();
q.pop();
if (dis[u] > dis[far]) far = u;
for (Edge e : tree[u]) {
int v = e.to;
ll w = e.w;
if (dis[v] != -1) continue;
dis[v] = dis[u] + w;
q.push(v);
}
}
return far;
}
// 两次最远点搜索:端点存入 a、b,长度存入 ans
void solve() {
a = farthest(1);
b = farthest(a);
ans = dis[b];
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 1; i < n; ++i) {
int u, v;
ll w;
cin >> u >> v >> w;
tree[u].push_back({v, w});
tree[v].push_back({u, w});
}
TreeDiameter td(n);
td.solve();
cout << td.ans << '\n';
return 0;
}
树形 DP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <algorithm>
#include <iostream>
#include <vector>
const int maxn = 1e6 + 5;
using ll = long long;
using Edge = struct { int to; ll w; };
using Graph = std::vector<Edge>;
Graph tree[maxn];
// 树上 DP 求直径:扫描 u 的儿子时,用 f[u] 维护此前最长链,
// 再把当前儿子的链与它拼起来更新直径。
struct TreeDiameterDP {
int n;
ll ans;
std::vector<ll> f; // f[u] = 从 u 出发往下走的最长链长度
explicit TreeDiameterDP(int n) : n(n), ans(0), f(n + 1) {}
void solve() { dfs(1, 0); }
void dfs(int u, int parent) {
for (Edge e : tree[u]) {
int v = e.to;
ll w = e.w;
if (v == parent) continue;
dfs(v, u);
// 必须先更新答案,确保 f[u] 还不包含当前儿子 v。
ans = std::max(ans, f[u] + f[v] + w);
f[u] = std::max(f[u], f[v] + w);
}
}
};
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n;
std::cin >> n;
for (int i = 1; i < n; ++i) {
int u, v;
ll w;
std::cin >> u >> v >> w;
tree[u].push_back({v, w});
tree[v].push_back({u, w});
}
TreeDiameterDP td(n);
td.solve();
std::cout << td.ans << '\n';
return 0;
}
测试用例
输入:
6
1 2 3
2 3 2
2 4 4
3 5 1
4 6 2
输出:
9
解释:最长路径是 5 -> 3 -> 2 -> 4 -> 6,长度为 1+2+4+2=9。
树形 DP 状态演算
以 1 为根。递归处理完节点 3、4 后,已有 2 如何依次合并两个儿子:
| 当前儿子 |
当前链 |
合并前 |
候选路径 | 合并后 |
|---|---|---|---|---|
3 |
||||
4 |
处理儿子 4 时,f[2]=3 仍表示此前经过儿子 3 的最长链;它与当前长度为 f[2] 更新为
应用分类详解
树的直径本质上是在树中寻找“最远的两个边界点”。只要题目出现树上最远距离、最长路径、中心点,通常都要先想到直径。
一、直接求最长路径
典型模式: 给一棵树,问任意两点之间最远距离。
识别信号: 出现“树上最长路”“最远两点”“最大距离”。
核心建模: 边权就是路径代价,两次最远点搜索直接求直径。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 树直径模板 | 树上最长路模板题 | 两次 DFS/BFS 找最长路 |
| 无权树最长路 | 树上基础题 | 每条边权视为 1 |
二、树的中心与最小最大距离
典型模式: 在树上选一个点,使它到所有点的最大距离尽量小。
识别信号: 出现“选址”“到最远点距离最小”“中心”。
核心建模: 树的最优中心一定在直径中点附近。
三、树上路径覆盖与删边问题
典型模式: 先找出最长路径,再围绕这条路径处理分支。
识别信号: 出现“所有点到某条主干的距离”“保留一条链”“走遍整棵树”。
核心建模: 直径提供树中最极端的主路径,其余部分是挂在直径上的子树。
经典例题
1. 树上最长路模板题
树直径模板题。适合练习两次 DFS/BFS 的基本写法。
2. 树的中心类题目
重点是把“最小化到最远点的距离”转成直径中点模型。
3. luogu-P3304
SDOI2013 直径。除了求直径,还要分析哪些边一定在所有直径上,适合进一步理解直径结构。
参考
- 本书树的中心章节:
tree-algo/center_of_tree/index.md