树上算法

树的直径与重心

最长路径、距离汇聚与平衡分割

11个章节
查看本篇目录一、直径:不是从根出发的最长路二、物理推导:两遍遍历的神奇魔法1. 把七点树走两遍2. 为什么这不是碰巧?三、完整程序一:非负权树的直径四、危险边界:当树里藏着负权边五、重心:把最大的一块压到最小1. 七点树里的重心是谁?六、完整程序二:求出全部重心七、灵魂拷问:重心和中心到底差在哪儿?八、选学一:利用直径端点求每个点的最远距离九、选学二:两棵树连桥后的新直径生成十、选学三:重心的距离汇聚与带权中位点十一、把两种工具真正用起来1. 实战演练指引

上一讲我们一直在问局部问题:某个点的子树在哪里,两点的路径在哪儿拐弯。现在把视线拉远一点:这棵树最长能走多远?删掉哪个点,剩下的几块最均匀?

这两个问题分别引出直径和重心。一个关心路径长度,一个关心连通块大小。名字听起来都像在找树的“中间”,但千万别在做题时把它们混在一起。

(先修要求:无向树存储、树的遍历、父亲与子树大小。本篇继续用显式栈或队列,长链也不用担心深层递归爆栈。)

一、直径:不是从根出发的最长路

树上任意两点之间只有一条简单路径。把所有这样的路径放在一起,长度最大的那条就是树的一条直径,它的长度叫直径长度。

物理意义防坑:无权树把每条边看成长度 11;带权树把路径上的边权相加。经过 55 个点的无权路径只有 44 条边,别把点数当成长度。直径可能不唯一,但所有直径的长度肯定相同。

直观例子:一棵七点树,边是 1-2、1-3、2-4、2-5、3-6、5-7。(所有边权均为 11)

如果从根节点 11 出发,最远走到 77,只有 33 条边。可是如果你从 77 出发,经 55、22、11、33 走到 66,一共经过了 55 条边。这才是真正的直径。

所以“树的高度”和“树的直径”完全不是一回事。高度需要先选根,换根以后高度可能会变;直径只看树本身的客观结构,画图时你把哪个点拎在最上面,并不会改变答案。

如果最直接地暴力做,我们可以把每个点都当一次起点,遍历整棵树取最大值,时间复杂度 O(N2)O(N^2)。小数据对拍很适合这么写,但面对十几万的数据,我们有降维打击的方法。

二、物理推导:两遍遍历的神奇魔法

对于无权树或边权非负的树,找直径的过程极其简单,只有两步:

  1. 第一遍:任取一个点 ss,找到距离它最远的点 aa。
  2. 第二遍:再从 aa 出发,找到距离它最远的点 bb。那么 aa 到 bb 就是一条直径。

第一遍不是直接求答案,而是在找一个合适的出发位置。随便从树的内部开走,可能往哪边都差一点;先走到一个最远端,再横穿整棵树,就能把长度充分展开。

注意:这里“最远”比较的是累计距离,不是最后出栈的节点,也不是编号最大的节点。如果有多个点并列最远,任选一个即可,不需要特意把所有候选留下再搜一遍。

1. 把七点树走两遍

节点 从 1 出发的距离 从 7 出发的距离
1 0 3
2 1 2
3 1 4
4 2 3
5 2 1
6 2 5
7 3 0

第一遍从 11 走,最远点是 77;第二遍从 77 走,最远点是 66,直径长度 55。注意第二遍必须把距离重新计算,绝对不能在第一遍的 dis 数组上继续累加。

2. 为什么这不是碰巧?

可以把一条直径画成主干,其余节点挂在主干的某些位置上。任意起点要么位于主干上,要么通过一条支路接到主干。因为树没有环,这些路径怎样相交、怎样分叉,是被结构死死固定住的。

把“起点到最远点”的路径与这条主干比较,在它们相接的位置拆开。如果这个最远点不能作为任何一条直径的端点,那么把它的分支换成主干中更长的那一端,要么得到离起点更远的点,要么拼出比原直径更长的路径。前者违背“最远”,后者违背“直径”。

这个比较用到了边权非负的前提:多保留一段路径,不会反而把长度变小。于是第一遍找到的点必然可以作为某条直径的端点;既然已经站在了端点上,第二遍再找最远点,自然就把整条直径找全了。学习时先会手推这条逻辑,再去记代码,而不是只死记“跑两遍就行”。

三、完整程序一:非负权树的直径

输入协议:第一行 nn,随后 n−1n-1 行 u v w,表示无向边。范围为 1≤n≤5000001 \le n \le 500000、0≤w≤1090 \le w \le 10^9。单点树输出 00。(无权树把输入边权都填成 11 即可)

下面使用迭代的 DFS(显式栈)来防爆栈。树上从起点到每个点只有一条路径,走到孩子时直接累加边权就够了,不存在像最短路那样“有另一条路线回来把距离改小”的问题。遍历时间是纯线性的 O(N)O(N)。

C++
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=500005;

struct Edge { int v, w; };
vector<Edge> node[N];
int dis[N], par[N];
int n;

// 核心模块:从起点 s 找最远点,返回 {最远点编号, 最大距离}
pair<int, int> farthest(int s) {
	// 每次遍历前必须清空状态
	for(int i=1; i<=n; i++) {
		dis[i] = 0;
		par[i] = 0;
	}
	
	stack<int> st;
	st.push(s);
	int best = s;
	
	while(!st.empty()){
		int u = st.top(); 
		st.pop();
		
		// 动态打擂台更新最远点
		if(dis[u] > dis[best]) best = u;
		
		for(int i=0; i<node[u].size(); i++){
			int v = node[u][i].v;
			int w = node[u][i].w;
			if(v == par[u]) continue; // 不走回头路
			
			par[v] = u;
			dis[v] = dis[u] + w;
			st.push(v);
		}
	}
	return {best, dis[best]};
}

void solve() {
	cin >> n;
	for(int i=1; i<n; i++){
		int u, v, w;
		cin >> u >> v >> w;
		node[u].push_back({v, w});
		node[v].push_back({u, w});
	}
	// 第一遍:任选节点 1 作为起点,找到端点 a
	int a = farthest(1).first;
	// 第二遍:从 a 出发找最远点,其距离即为直径长度
	int ans = farthest(a).second;
	
	cout << ans << '\n';
}

signed main() {
	ios::sync_with_stdio(0), cin.tie(0);
	solve();
	return 0;
}

两次遍历,每次访问全部节点和边,总时间和空间都是 O(N)O(N)。

💡 进阶提示:如果题目还要输出直径经过的具体节点,就保留第二遍的父亲数组,从终点 bb 顺着 par 走回 aa,把路径存下来再反转即可。千万别沿第一遍的父亲回溯,那份父亲关系服务的是错误的起点。

四、危险边界:当树里藏着负权边

如果树里有负权边,千万别把两遍遍历硬套上去!

看一条链,依次是 1—2—3—4,边权为 -100、10、10。 从 11 出发,到其他点的距离分别是 −100-100、−90-90、−80-80。本篇的 farthest 把起点也作为候选,最远的反而就是 11 自己,距离为 00;两遍遍历根本无法找到 22 到 44(长度为 2020)这条真正的直径。

此时必须换成树形 DP:定义 down[u] 表示从 u 往子树内延伸,能拿到的最大贡献。在遍历孩子 vv 时,将两条最大的非负分支拼在一起更新全局答案,再把最大的一份向父亲交上去。

这段只说明方法的分界,看到“树”并不意味着所有树题都能两遍遍历;真正决定能不能用的是边权条件。

五、重心:把最大的一块压到最小

换个问题:删掉节点 uu 和它连着的边,原来的树会碎裂成若干个连通块。我们关心其中最大的一块有多少个点,记作 mx[u]mx[u]。能让这个 mx[u]mx[u] 最小的节点,就是树的重心。

关键词是“先取最大,再让它最小”。不是让最小块最大,不是找度数最大的点,也不是看画出来的位置最居中。删掉一棵星形树的叶子,会留下几乎整棵树;但如果删掉中心点,剩下的每一块却都只有一个点,显然重心更为均匀。

为什么求重心也要先任选一个根?不是因为重心依赖根,而是我们想借“子树大小”把这些连通块的规模快速算出来。根只是脚手架,最后删点形成的那些客观连通块不会因为换根而改变。

假设已经知道了子树大小 sz[u]sz[u]。删除 uu 后,剩下的块严格分为两类:

  1. 孩子方向:每个孩子的整棵子树,大小就是 sz[v]sz[v]。
  2. 父亲方向:整棵树减掉 uu 的子树剩下的所有部分,大小为 n−sz[u]n-sz[u]。

因此,节点 uu 的最大连通块大小公式为:

mx[u]=max⁡(n−sz[u],max⁡v 是 u 的孩子sz[v])mx[u] = \max\left(n-sz[u], \max_{v\text{ 是 }u\text{ 的孩子}}sz[v]\right)

根的父亲方向大小是 00,叶子没有孩子块,单点树删完后没有非空块(最大块按 00 处理)。这样公式就能自然兼容所有边界。

1. 七点树里的重心是谁?

删除的点 剩余连通块大小 最大块
1 4、2 4
2 1、2、3 3
3 1、5 5
4 6 6
5 1、5 5
6 6 6
7 6 6

删除 22 时,一边是 44,一边是 55 和 77,另一边是 11、33、66,最大的块只有 33 个点。其他选择产生的最大块都比 33 大,所以 22 是这棵树的唯一重心。

删除七点树的节点2后,三个连通块分别为{4}、{5,7}、{1,3,6},父亲方向大小7−4=3,最大块为3。

💡 易错警告:最常见的漏项就在父亲方向。如果只看孩子子树,会把叶子的最大块误算成 00,仿佛每个叶子都特别均匀。其实它身后还有 n−1n-1 个点,这必须作为一个整体块一起算进去!

六、完整程序二:求出全部重心

输入协议:第一行 nn,随后 n−1n-1 条无向边 u v(无权),n≤500000n \le 500000。第一行输出删掉重心后最大块的大小,第二行按编号升序输出全部重心。

这里我们利用一个“灵魂数组” ord:先用类似 BFS 的队列方式建立父亲关系,这顺便留下了一个父亲永远在孩子前面的拓扑访问顺序;接着只要倒着扫 ord 数组,就能保证在处理父亲时,孩子的 szsz 绝对已经算好了,彻底避免了 DFS 的递归爆栈。

C++
#include<bits/stdc++.h>
using namespace std;
const int N=500005;

vector<int> node[N];
int par[N], sz[N], mx[N], ord[N];
int n;

void solve(){
	cin >> n;
	for(int i=1; i<n; i++){
		int u, v;
		cin >> u >> v;
		node[u].push_back(v);
		node[v].push_back(u);
	}
	
	// 1. 用类似 BFS 的方式获取遍历序列 ord,建立严格的父子关系
	int head = 1, tail = 0;
	ord[++tail] = 1;
	while(head <= tail){
		int u = ord[head++];
		for(int i=0; i<node[u].size(); i++){
			int v = node[u][i];
			if(v == par[u]) continue;
			par[v] = u;
			ord[++tail] = v;
		}
	}
	
	// 2. 倒序遍历 ord 数组:相当于剥洋葱式地从叶子向上推导
	for(int i=n; i>=1; i--){
		int u = ord[i];
		sz[u] = 1; // 自己算 1 个点
		mx[u] = 0;
		for(int j=0; j<node[u].size(); j++){
			int v = node[u][j];
			if(v == par[u]) continue; // 只看孩子
			sz[u] += sz[v];
			mx[u] = max(mx[u], sz[v]); // 收集孩子方向的最大块
		}
		// 灵魂补刀:千万别忘了父亲方向剩下的那一块!
		mx[u] = max(mx[u], n - sz[u]); 
	}
	
	// 3. 找出所有重心
	int best = 1e9;
	for(int i=1; i<=n; i++) best = min(best, mx[i]);
	
	cout << best << '\n';
	
	bool first = true;
	for(int i=1; i<=n; i++){
		if(mx[i] == best){ // 树可能有两个重心,都要输出
			if(!first) cout << " ";
			cout << i;
			first = false;
		}
	}
	cout << '\n';
}

int main(){
	ios::sync_with_stdio(0), cin.tie(0);
	solve();
	return 0;
}

性质推论:重心有一个很实用的判断——删掉它后,每一块大小都不会超过总点数的一半。如果某一块超过了一半,你只要向那一块走一步,最大块还能继续缩小,所以当前点不可能已经是最优的。另外,一棵树最多只有两个重心(如果有两个,必然相邻)。

七、灵魂拷问:重心和中心到底差在哪儿?

  • 树的中心关心的是距离:选一个位置,让它到最远节点的距离尽量小。无权树在直径中间找中心点。
  • 树的重心关心的是连通块大小:选一个位置,让删掉它后最大的碎块尽量小。

想象节点 11 连着四个叶子 22、33、44、55,同时还连着一条长尾巴 1—6—7—8。这棵树有 88 个点。删掉 11 以后,留下四个单点和三点长尾,最大块为 33,所以 11 是重心。

可是这棵树的直径是 2—1—6—7—8,正中间的节点是 66,66 才是无权的中心。

人多的一侧会影响重心,路长的一侧会影响中心,两种截然不同的物理目标自然可能选出不同的位置。不要一遇到“想在树上找个中间点”就乱套模板。

八、选学一:利用直径端点求每个点的最远距离

(先修要求:掌握树的直径两遍遍历求法。)

问题模型:如果我们要在这棵树上建一个消防站,我们需要评估每个候选位置的“最坏响应距离”。也就是给定一棵无向正权树,求出对于每个节点 uu,树中距离它最远的节点到它的距离。

如果对每个点都当一次起点去跑遍历,时间复杂度会达到 O(N2)O(N^2)。其实直径在这里能发挥巨大的威力。

关键推导: 不管你站在树上的哪个节点 uu,离你最远的那个节点 vv,一定可以是一条直径的某个端点。

为什么?假设存在一条全树直径,两端点为 AA 和 BB。再假设离 uu 最远的节点是 xx。从 uu 到 xx 的路径必定会和直径 A−BA-B 产生某种联系(要么相交,要么可以通过另一条路连过去)。如果 xx 离 uu 极远,那把 uu 到 xx 的这段路径拆下来,接到直径的某一段上,就有可能拼出一条比 A−BA-B 更长的路径,从而打破“A−BA-B 是直径”的前提。这是由树形结构决定的绝对极限。

实现要点: 我们只需要在原来的基础上多跑一遍遍历,总共跑三遍,即可在 O(N)O(N) 时间内解决:

  1. 第一遍:从随便一个点出发,找到距离最远的点 AA。这必定是直径的一个端点。
  2. 第二遍:从 AA 出发找最远点,得到另一个端点 BB。在此过程中,顺便记录下所有节点到 AA 的距离,存进 disA 数组。
  3. 第三遍:从 BB 出发跑一遍遍历。这遍不需要再找最远点了,只需要记录下所有节点到 BB 的距离,存进 disB 数组。

最终,任意节点 uu 的最远距离,直接 O(1)O(1) 查表即可:max(disA[u], disB[u])。

同一个问题也可以用换根 DP 解决,见《树形 DP》第六节;这里借直径端点,把状态压缩成了两份距离。

C++
// 独立验证:求树上每个点到其余节点的最远距离
// 输入:第一行 n,随后 n-1 行 u v w (无向正权边),1 <= n <= 100000
// 输出:n 行,第 i 行表示节点 i 到全树的最远距离
/*
样例输入:
4
1 2 5
2 3 2
2 4 3
样例输出:
8
5
7
8
*/
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=100005;

struct Edge { int v, w; };
vector<Edge> node[N];
int disA[N], disB[N];
int n;

// 返回 {最远点编号, 距离},并将遍历距离存入传入的 dis 数组
pair<int, int> farthest(int s, int dis[]) {
	for(int i=1; i<=n; i++) dis[i] = -1;
	
	queue<int> q;
	q.push(s);
	dis[s] = 0;
	int best = s;
	
	while(!q.empty()){
		int u = q.front(); 
		q.pop();
		
		if(dis[u] > dis[best]) best = u;
		
		for(int i=0; i<node[u].size(); i++){
			int v = node[u][i].v;
			int w = node[u][i].w;
			if(dis[v] != -1) continue; // 已访问过
			
			dis[v] = dis[u] + w;
			q.push(v);
		}
	}
	return {best, dis[best]};
}

void solve() {
	cin >> n;
	for(int i=1; i<n; i++){
		int u, v, w;
		cin >> u >> v >> w;
		node[u].push_back({v, w});
		node[v].push_back({u, w});
	}
	
	// 借用 disA 作为第一遍寻找端点 A 的临时数组
	int a = farthest(1, disA).first;
	// 第二遍:从 A 找最远点 B,并永久留下所有点到 A 的距离 disA
	int b = farthest(a, disA).first;
	// 第三遍:从 B 出发跑距离,留下 disB
	farthest(b, disB);
	
	for(int i=1; i<=n; i++){
		cout << max(disA[i], disB[i]) << '\n';
	}
}

signed main() {
	ios::sync_with_stdio(0), cin.tie(0);
	solve();
	return 0;
}

九、选学二:两棵树连桥后的新直径生成

(先修要求:掌握选学一的最远距离模型。)

问题模型:有两棵互相独立的树,内部直径分别是 D1D_1 和 D2D_2。现在我们在第一棵树的节点 uu 和第二棵树的节点 vv 之间,修建一条长度为 ww 的无向边(桥),把它们连成一棵新树。这棵新树的直径会是多少?

关键推导: 新树的最长路径只有三种可能的生存空间:

  1. 路径完全没有经过新桥,老老实实待在第一棵树内部,长度必定被 D1D_1 限制。
  2. 路径完全待在第二棵树内部,长度被 D2D_2 限制。
  3. 路径跨越了新桥。这条跨界路径要想最长,必定是在第一棵树里从最远的地方走到 uu,过桥,再在第二棵树里一直走到最远的地方。

此时,第一棵树里走到 uu 的最长距离,其实就是 uu 在自己树内的最远距离(记为 max_dis1[u]\text{max\_dis}_1[u]);同样,第二棵树里走到 vv 的最远距离记为 max_dis2[v]\text{max\_dis}_2[v]。

跨桥的最大路径长度直接就是三段拼图:

Cross=max_dis1[u]+w+max_dis2[v] \text{Cross} = \text{max\_dis}_1[u] + w + \text{max\_dis}_2[v]

所以,连边后不需要重新跑遍历,新树的直径直接在三者中取最大值即可:

Dnew=max⁡(D1,D2,Cross) D_{new} = \max(D_1, D_2, \text{Cross})

手算小例子: 假设两棵树都只有一个节点(自己到自己的最远距离是 00,原直径也是 00),在它们之间连了一条长度为 55 的边。 三种情况分别是 D1=0D_1=0, D2=0D_2=0, 跨桥长度 =0+5+0=5= 0 + 5 + 0 = 5。新直径就是 max⁡(0,0,5)=5\max(0, 0, 5) = 5。

十、选学三:重心的距离汇聚与带权中位点

(先修要求:掌握树的重心定义与 O(N)O(N) 推导逻辑。)

问题模型:如果要把树上所有人都聚集到一个节点开会,选哪个点能让大家“走过的总距离之和”最小?

关键推导: 在无权点模型下(把每个节点看作 11 个人,本节边长按正数处理),距离和最小的聚集点,正是树的重心。

想象一开始聚会点选在节点 uu,暂时以 uu 为根看这一步。现在尝试把聚会点往它的某个邻居 vv 挪动一步(假设边长为 ww)。这一挪,会发生什么?

  • vv 这一侧子树里的所有人(数量为 sz[v]sz[v]),距离聚会点都近了 ww。
  • 树上剩下的所有人(数量为 n−sz[v]n - sz[v]),距离聚会点都远了 ww。

所以,总距离的变化量是:w×(n−sz[v])−w×sz[v]=w×(n−2×sz[v])w \times (n - sz[v]) - w \times sz[v] = w \times (n - 2 \times sz[v])。 只要 vv 这一侧的人数超过总人数的一半(也就是 sz[v]>n/2sz[v] > n/2),不管边长 ww 是多少,括号里都是负数,把聚会点往 vv 移动就是稳赚不赔的,总距离必然减小!

回想重心的核心性质:删去重心后,任何一块连通块的大小都不会超过 n/2n/2。这就意味着,当你站在重心上时,往任何一个邻居方向挪动,那一侧的人数都不会超过一半,总距离都不可能变得更小。这就从物理上证明了,重心就是“距离和最小”的天然汇聚点。

带权中位点的区别: 如果这不仅是一棵树,每个节点代表的村庄人数还不一样(给节点加上了点权 WiW_i),我们要最小化的是“所有人走过的加权距离和”。这时该找什么?

此时就不再是寻找普通重心了,而是寻找带权中位点(带权重心)。 判断挪动是否划算的物理逻辑依然有效,只是比较的筹码变了:vv 这一侧的“总人数”,是否大于全树“总人数”的一半。 即判断 ∑Wv一侧>Total_W/2\sum W_{v\text{一侧}} > \text{Total\_W} / 2。

实现要点: 求带权中位点不用重写遍历逻辑。在原来的重心模板里,把初始化 sz[u] = 1 改成 sz[u] = W[u],再把 n - sz[u] 改为 Total_W - sz[u]。此时 sz、mx 统计的是人数而非节点数,要按总人数选用类型(必要时用 long long),best 也要用足够大的初值,如 Total_W。选点取决于点权分布,而不取决于正边长的具体大小。

想把每个聚集点的总花费都算出来,可以接着看《树形 DP》第四节的 P2986:同一条“挪一步”的式子,会变成换根转移。

十一、把两种工具真正用起来

直径端点怎样帮助求每个点的最远距离,见第八节。

而重心常用于把树拆得比较均匀。后续如果递归处理删点后的各块,每块都不会超过原来的一半(保证递归深度不超过 log⁡N\log N),这才是它适合做点分治的核心原因。点分治本套未展开。

1. 实战演练指引

课后可以按三步进行思维刻意练习:

  1. 先在链、星形图、七点树上手算两遍距离;
  2. 再逐点删除小树,手算连通块,与第二份程序打印出的数组作比较;
  3. 自我挑战:构造一个重心和中心不同的新例子。能自己构造反例,比只会复述定义更能说明你彻底吃透了这两个概念。

想继续做直径应用的实战,推荐看 洛谷 P1099 [NOIP2007 提高组] 树网的核。它要求在直径上选一段受长度限制的路径,使最远点到这段路径的距离最小。它不是只输出直径长度,也不是直接套重心程序,需要你先把题意和本节的模型精准对接,再做区间处理。

最后留三个极端边界自测,确保模板不翻车:

  • 单点树的直径和最大块都是 00。
  • 全零边权树的直径长度仍是 00。
  • 偶数点链必定有两个重心。 只有跑过长链(检查防爆栈)和星形树(检查遍历孩子),才能放心把模板带到更复杂的赛场题目里。
搜索全部54篇讲义的标题、目录与正文
点击结果进入讲义Esc 关闭