目录
前言
2026-03-29,修复了公式小错误,增贴了换根dp代码。
如果您对树的直径与重心确定了解,可以跳转至大大大大大拓展子目来了解更多提升内容。
一,定义
在一棵树上,最大子树大小最小的结点就是树的重心(删除该节点后最大连通块最小)。
在树上所有 条路径中,长度最长的一条路径就是树的直径。
二,常见的定义误区
1,树的直径是不是只有一条?
当然不是!比如说一个菊花图,任意一条端点非花心的路径都是一条直径。
2,树的重心是不是只有一个?
不是的,想象一条节点个数为偶数的链,这棵树的重心就是最中间的两个结点。
而且,一棵树的重心最多有两个,而且必然挨着,咋证?
树的重心数量及位置定理证明
在证明之前,我们可以烧烤(思考)一下删掉重心及其连边后,划分的最大连通分量的大小(设成 m)与整棵树的大小(设成N)有怎样的关系。
易得 ,为什么呢?
我们假设一个重心 u 的 m 是 ,那么现在我们把 u 拎起来。对于那个大小为
子树的根节点 v ,它的 m 就最多是
,所以说 u 绝对不能是重心。
了解完这个不等式后,我们可以开启证明环节了(。^▽^)
我们先假设有两个重心 u 和 v,现在我们把这棵树分为三部分:
:从 u 出发不经过 v 的子树(即 u 的其他分支)。
:从 v 出发不经过 u 的子树。
:公共部分,即路径上的中间节点及其分支(这些节点既可通过 u 到达也可通过 v 到达,但不包含 u 和 v 自身)。
A 和 B 可以分别看作把 u 和 v 分别拎起来除去到对方的路径的其他子树。
我们以两个平平无奇、不是重心的结点 u 和 v 来示意 A、B、C 部分:

蓝色、绿色、红色部分,分别表示A、B、C区域,接下来我们回到两个重心 u 和 v 所划分的A、B、C区域,开始证明操作。
则删除 u 后,包含 v 的连通块大小为 ;
删除 v 后,包含 u 的连通块大小为 ;
总节点数 。
结合图会不会更清楚些呢?~( ̄▽ ̄)~*
由重心定义, 且
,代入得:
;
整理得:
,即
。
同理还有:
;
整理得:
,即
。
这两个不等式成立,必要 C 部分为0,那么不正说明 u 和 v 是挨着的吗?
因此,若有两个重心,则它们相邻。若有三个重心或以上,则它们两两相邻,这在树中会形成环,不可能,故重心最多有两个。
证明完毕,撒花 o(* ̄▽ ̄*)ブ
3,树的重心是不是一定在树的直径上?
这个可以用极限构造法证伪,比如说下面这个图:

像这个图呢,红色的线就是直径,但是 u 才是重心,它可不在直径上耶!
4,是不是找最小子树大小最大的结点也可以找到树的重心?
好图能用千百遍,还是上面那张图。
直径的拐点(设为 v 吧),删掉 v 及其连边后最小连通分量大小是3,但是 u 删掉后最小连通分量大小是1,这不就找到反例啦?
其实,图论学习中的定义误区基本都可以用极限情况来排除一下,比如说特别长的链、菊花图、菊花图与长链结合,做题也是一样滴。
三,树重心的求法
我们回到定义上来,找到重心,也就是要找到 m 最大的那个(或者那两个)结点就行嘛。
话说 m 的定义你们不会忘了吧(@_@;),那就回到上一章的定理证明部分吧!
题外话:谁能告诉我咋把链接设成本文章的一个子目?
那么满足要求的结点,咋……咋找呢?我瞪眼一看么?其实在一遍 dfs 结束之后就可以找到啦!
核心思想是:以任意节点为根,通过一次DFS计算出每个节点的子树大小,然后利用总节点数判断每个节点是否为重心。
算法步骤
一,任选一个节点作为根(比如节点1),进行一次 dfs。
-
在递归过程中,计算每个节点的子树大小。
-
记
表示以 u 为根的子树大小。 -
在每个儿子的递归结束后将所有儿子的 siz 累加,具体看代码。
-
我们已知整棵树的大小 N。
二,遍历每个节点,判断它是否是重心。
-
删除节点 u 后,树会分裂成若干连通块:
-
对于 u 的每一个子节点 v,其对应的连通块大小为
。 -
还有一块位于 u 祖先结点区域的部分(即整棵树除去以u为根的子树的部分),大小为
。
-
-
因此,对于每个节点 u,删除 u 后的所有连通块大小在遍历到 u 时都能获得,这个时候我们找到最大的,判断它是否小于等于
就行了。
-
当然,我们递归时要遍历到每个结点,如果有第二个重心,也能找到。
下面是代码环节( ̄▽ ̄)"
#include<iostream>
#include<vector>
using namespace std;
const int maxn=1005;//大小随意
vector<int> edge[maxn];
vector<int> h;//存储重心编号的向量
int siz[maxn];//以u为根子树大小
int n,u,v;
void dfs(int fa,int id){
siz[id]=1;//一定要初始化,保证叶子节点的siz是1
int m=0;//m的定义还记得咩
for(int i:edge[id]){
if(i!=fa){
dfs(id,i);
siz[id]+=siz[i];
m=max(m,siz[i]);//每个子树的siz
}
}
m=max(m,n-siz[id]);//祖先区域的siz
if(m<=n/2) h.push_back(id);//发现重心,塞入向量h
}
int main(){
cin>>n;//总结点数,就是前文中的N
h.reserve(2);//预分配重心向量大小,最多只有两个重心
for(int i=1;i<n;i++){
cin>>u>>v;//邻接表存储
edge[u].push_back(v);
edge[v].push_back(u);
}
dfs(0,1);//任意一个节点开始dfs,不一定是1
return 0;
}
代码采用邻接表存边,时间复杂度线性。
图解算法

图线所划分的几个区域,表示了删除 u 之后的几个连通分量的情况,这棵树从1开始 dfs,这张图还是比较直观的吧。
小拓展
一个点是不是重心衡量了它周围点的疏密程度,因此与边权没有关系。但是我们在遇到实际问题时,可能还会遇到带权的点,这个时候求得的重心就不是地理位置上的重心了。
如何操作?只需要把 siz 的定义略微改动一下即可。
我们把 的定义修改为以 u 为根子树上的结点权值之和,把 N 的定义修改为所有节点的权值之和。同理, m 的定义也需要修改。
那么最终要求的满足重心的充分必要条件仍是 。
四,树直径求法
方法一:两次 dfs / bfs
核心思想:从任意节点出发,找到离它最远的节点 u;再从 u 出发,找到离 u 最远的节点 vv。则路径 u→v 的长度即为树的直径。
算法步骤
-
任选一个节点 s(通常选 1),通过 dfs 或 bfs 找到距离 s 最远的节点 u。
-
以 u 为起点,再次进行 dfs 或 bfs,找到距离 u 最远的节点 v。
-
路径 u→v 的长度即为树的直径
正确性简述
利用树的性质:从任意点出发的最远点一定是某条直径的端点。因此两次搜索即可确定直径。
方法二:树形dp
核心思想:对于每个节点,计算经过该节点的最长路径,并更新全局答案。用一次 dfs 即可完成。
算法步骤
-
以任意节点为根,进行 dfs。
-
定义
表示从节点 u 向下走(往子树方向)的最长路径长度。 -
在 dfs 回溯时,对于每个节点 u,将其所有子节点 v 的
进行排序,取最大的两个值相加,即可得到经过 u 的最长路径长度。全局答案取所有节点中的最大值。 -
同时,
取子节点中最大的
作为从 u 向下走的最长路径。
小拓展+图解算法
当然了,两次 dfs 也能用于带权树,只需要把路径的长度重新定义一下即可。
对于非负边权的带权树,dp 可以找到两个最大的两个 来相加,但是如果遇到有负权的带权树,比如说下面这张图:

这个时候需要把存储的最大权路径长度和次大权路径长度的初值赋值成负无穷吗?
对于1号点来说,假设我们已经在左侧选定了5→2→1这条路径作为经过1号点路径的半条路径。
但是我们发现在1号点的右侧,一条路也不选比选择最大的7→3→1路径更优秀,这个时候如果把存储的最大权路径长度和次大权路径长度的初值赋值成负无穷,那么在1号点的决策中,我们就会选择7→3→1作为次大路径,答案就错了。
于是呢,就成功地选择了5→2→1作为直径。
问题又来了!!要是整棵树都是负边权咋办呢?那答案不就是0了?
如果直径允许只包含一个点,那0就是对的。如果直径要求至少包含一条边,那答案就是所有负权边里边最大的那条。综上,要是遇上了求直径且有可能是全是负权边的情况,只能留个心眼子,普通情况全设置成0是没有问题的。
其实稍微有点情景的板子99.99%都不会有负权边。
代码放送
方法一的代码(不带权的两次搜索):
#include<iostream>
#include<vector>
using namespace std;
const int maxn=1005;
vector<int> edge[maxn];
int s,maxdep;//最深点和直径
int n,u,v;
void dfs(int fa,int id,int dep){
if(dep>maxdep){//更新最深点
maxdep=dep;
s=id;
}
for(int i:edge[id]){
if(i!=fa){
dfs(id,i,dep+1);
}
}
}
int main(){
cin>>n;
for(int i=1;i<n;i++){
cin>>u>>v;
edge[u].push_back(v);
edge[v].push_back(u);
}
int source=0;
dfs(0,1,0);
source=s;//保留第一次找到的一个端点
maxdep=0;//重置
dfs(0,s,0);//从找到的端点开始
cout<<maxdep;
return 0;
}
方法二的代码(不能处理全是负权边的dp):
#include<iostream>
#include<vector>
using namespace std;
const int maxn=1005;
struct node{
int v,w;
};
vector<node> edge[maxn];
int dp[maxn];
int maxlen;
int n,u,v,w;
void dfs(int fa,int id){
int m1=0,m2=0;
for(auto i:edge[id]){
if(i.v!=fa){
dfs(id,i.v);
int temp=dp[i.v]+i.w;
if(m1<temp) m2=m1,m1=temp;
else if(m2<temp) m2=temp;
}
}
dp[id]=m1;
maxlen=max(maxlen,m1+m2);
}
int main(){
cin>>n;
for(int i=1;i<n;i++){
cin>>u>>v>>w;
edge[u].push_back({v,w});
edge[v].push_back({u,w});
}
dfs(0,1);
cout<<maxlen;
return 0;
}
五,大大大大大拓展(必看)
1,经常考察树的重心的情形
县里面设置联络站,联络站的位置满足四面八方村民来到联络站所在村庄有最小代价,每个村庄都有一些村民(抽象为点权)。
经常变形
每个村庄村民数量相同、走的路难易程度不同(没有点权有边权)或者每个村子村民数量不同且走的路也有难易程度(既有点权也有边权)。
废话不多说,先看题面(自己编的题面,直接复制原题机可能搜不到):
题目背景
春节将至,某县城的
个村庄通过
条道路连成一片(构成一棵树)。每个村庄都有一定数量的村民,他们都要前往同一个村庄参加春节联欢晚会。由于地形不同,每条道路都有一个“艰辛值”,表示通过该道路的难易程度(数值越大越难走)。村民的“疲惫值”定义为从自己村庄到晚会村庄所经过的道路的艰辛值之和。作为组织者,你需要选择一个村庄作为晚会地点,使得所有村民的疲惫值之和最小。若有多个村庄能达到最小值,请选择编号最小的那个。
输入格式
第一行一个整数
,表示村庄数。
第二行
个整数
,表示每个村庄的村民数量。
接下来
行,每行三个整数
,表示村庄
和
之间有一条艰辛值为
的道路。
输出格式
输出一个整数,即最佳晚会地点的村庄编号。
数据范围
这个就是刚才说的既有点权又有边权的树,怎么求形式上的重心呢?换根dp了解一下~
一,第一次 dfs
其实,dp要解决的难点无非就是:如何表示子树上每个点到自己的艰辛值之和?
这个问题隐含着转移方程的公式如何写,我们不妨设 表示 u 子树上每个村庄的每个村民到达 u 的艰辛值之和,仍设
为子树点权和(村民总数)。
那么对于 u 的每个儿子 v,转移方程是:
,
实际上就是把村民从自己所在村庄到 u 的路径拆成两段:第一段到 v,艰辛值之和是 。第二段是 v 到 u,每个村民多加上
的艰辛值,就是到 u 的艰辛值。
二,换根(第二次 dfs)
求完了 dp 数组和 siz 数组,我们就可以计算每个节点作为晚会村庄的代价,记作 。
首先 需要包含子树的艰辛值,即
,然后是祖先区域的村民来到 u 的艰辛值。
仍然利用转移的思想,在求解 时,需要知道 u 的父亲作为晚会村庄的代价,我们以 v 用 u 的代价转移为例(在第一次 dfs 中 v 是 u 的儿子),简单推一下转移方程:
1. 初始时:,
2. 除 v 子树的其他部分到达 u 的代价:
3. 再加上 u 和 v 连边的艰辛值:
三个式子一加,发现 被消掉了,那是不是求 dp 数组就没用呢?不是,因为
得就是
啊,
又要用儿子的 dp 值的回溯才能求。综上我们终于得到了转移方程:
这个公式天衣无缝、冰霜玉洁、肤若凝脂、美若天仙,简直是极品中的极品!
最后比较 最小的那个就行了,时间复杂度线性。完结撒花( ̄︶ ̄)↗
这个板子本身转移方程实在有些扫码,放在洛谷上应该有个绿吧?毕竟树的重心最简单版的也是黄题了。
三,代码放送
#include<iostream>
#include<vector>
#include<climits>
#define int long long
using namespace std;
const int maxn=2e5+999;
struct node{
int v;
int w;
};
vector<node> edge[maxn];
int siz[maxn],dp[maxn],f[maxn],vv[maxn];
int n,u,v,w;
void dfs1(int id,int fa){
siz[id]=vv[id];
for(auto i:edge[id]){
if(i.v!=fa){
dfs1(i.v,id);
siz[id]+=siz[i.v];
dp[id]+=dp[i.v]+siz[i.v]*i.w;
}
}
}
void dfs2(int id,int fa){
for(auto i:edge[id]){
if(i.v!=fa){
f[i.v]=f[id]+(siz[1]-siz[i.v]*2)*i.w;
dfs2(i.v,id);
}
}
}
signed main(){
cin>>n;
for(int i=1;i<=n;i++){
cin>>vv[i];
}
for(int i=1;i<n;i++){
cin>>u>>v>>w;
edge[u].push_back({v,w});
edge[v].push_back({u,w});
}
dfs1(1,0);
f[1]=dp[1];
dfs2(1,0);
int ans=0,maxx=LLONG_MAX;
for(int i=1;i<=n;i++){
if(f[i]<maxx){
maxx=f[i];
ans=i;
}
}
cout<<ans;
return 0;
}
2,经常考察树的直径的情形
这个我倒是没有见过什么经典的题面,好像有个最远点对问题。
最常见的板子就是带边权的树求直径,至于带负边权的树我们已经大大讨论,不必惊慌,一般都难不过刚才的换根操作。
六,结语
直径和重心作为 dp 和 lca 的常客(尤其是树上dp),对建模的能力要求还是有一些的,结合起来比较容易就干到蓝题。最后的最后,还是那句话:
看不懂来私信骂我!
3817

被折叠的 条评论
为什么被折叠?



