树的直径与最近公共祖先
0x00 树的遍历问题
0x01 树上简单统计
由树的DFS遍历,我们可以对树上许多东西进行统计。
树上统计一般分为两种:由父亲推算儿子(向下计算),和由儿子推算父亲(向上计算)。
常见的为深度d和子树大小siz,它们可以服务于许多高级树上算法。
深度就是典型的“父亲推儿子”。对于一个点u,其任一子节点为v,则有 \(d[v]=d[u]+1\)。
而子树大小则是“儿子推父亲”。对于一个点u, \(subtree(u)\) 的大小为:
求深度和子树大小的代码:
void Dfs(int fa, int u){
siz[u]=1;// 注意初始化
for(int v:g[u]){
if(v==fa) continue;
d[v]=d[u]+1;// 向下计算放在递归前
Dfs(u,v);
siz[u]+=siz[v];// 向上计算放在回溯时
}
}
事实上,大部分树形DP为向上计算法,但“二次扫描与换根法”例外,它使用向下计算法。
题外话:当年我在考场上读错题导致30pts的暴力挂没,喜提120pts的2=。注:1=线为140。
不多扯闲话,直接看题。给你一棵树和一堆操作,操作一给子树增加,操作二给邻接点增加。操作完后进行一堆询问,询问点的权值。
本题是经典的“先更改再查询”类静态问题。由于直接完全按题意更改太慢,考虑用“懒标记”的思想,把每个点两种操作各“改了多少”记下来,最后遍历一遍求出实际权值,回答询问即可。
具体的,开两个数组tag1、tag2,每进行操作一,就给tag1增加;进行操作二,就给tag2增加。
所有操作进行完后,先考虑如何计算操作二。只需要枚举每个点,再枚举每个点的邻接点,给当前点u及其邻接点直接加 \(tag2[u]\) 即可。
对于操作一,可以跑一遍DFS。增加一个参数sum: \(dfs(fa,u,sum)\) ,表示从根走到u路径上所有点tag1的和,即u实际被增加了多少。
每走到一个u,给u的权值增加sum即可。递归遍历u的子节点v时,调用 \(dfs(u,v,sum+tag1[u])\) ,把tag1[u]累加进sum即可。
点击查看代码
// 省略部分代码的实现
void Dfs(int fa, int u, int sum){
a[u]+=sum;
for(int v:g[u]){
if(v==fa) continue;
Dfs(u,v,sum+tag1[v]);
}
}
signed main(){
fastio();
cin>>n;
for(int i=1; i<=n; i++) cin>>a[i];
int u,v;
for(int i=1; i<n; i++){
cin>>u>>v;
g[u].emplace_back(v);
g[v].emplace_back(u);
}
cin>>q;
int ch,x,y;
while(q--){
cin>>ch>>x>>y;
if(ch==1) tag1[x]+=y;
else tag2[x]+=y;
}
Dfs(0,1,tag1[1]);
for(int i=1; i<=n; i++){
a[i]+=tag2[i];
for(int j:g[i]) a[j]+=tag2[i];
}
cin>>q;
while(q--) cin>>x, cout<<a[x]<<"\n";
return 0;
}
0x02 树的搜索序
对树进行DFS遍历,遍历节点的顺序称为DFS序。
同样的,如果采用BFS遍历,则遍历到的顺序称为BFS序。
一个点u在搜索序中的位置,即u是第几个被遍历到的称为时间戳(dfn/bfn)。
另外,如果把回溯的过程经过的点的顺序也放进DFS序中,得到的称为欧拉序。
树的DFS序有非常好用的性质:以u为根的子树中的节点,在DFS序中是连续的。
以u为根的子树中的点,在DFS序中排在 \(dfn[u]\) ~ \(dfn[u]+siz[u]-1\) 位置
树的BFS序也有好用的性质:u的儿子在BFS序中连续。由于不太好写,一般不怎么用。
用这些性质可以把一些树上问题转化为序列问题。
树的DFS序经常与树剖结合使用,可以把许多树上问题转化为序列问题。
刚才讲了本题最正经的做法。这里讲一个同为正解的、使用搜索序的另类做法。
先对树进行DFS,求出DFS序。
给定一个u,进行第一种修改,等价于在DFS序上,给区间 \([dfn[u],dfn[u]+siz[u]-1]\) 中的位置+1。经典的无询问区间加问题,在DFS序上差分即可。最后跑一遍前缀和。
给定一个u,进行第二种修改。由于BFS序上差分比较麻烦,我们弃之不用,继续沿用前面的方法即可。
最后计算一遍每个点实际的值,回答询问即可。
注意到,任何两个黑点之间的路径上的白点都不可被删除,否则一定有一对黑点不连通。
更进一步,对树进行DFS遍历,假设遍历到u,递归遍历每个子节点v,如果 \(subtree(v)\) 中有黑点就啥也不干,否则整个 \(subtree(v)\) 均可删除,答案加上 \(siz[v]\) 即可。
这里,我们让dfs函数带上返回值,返回 \(1\) 表示有黑点,否则无黑点。设 \(dfs(u)\) 返回值为t,则 \(t=col[u]\) \(|\) \(dfs(v1)\) \(|\) \(dfs(v2)\) \(|\) $... $ 。
事实上,本题还有另一种方法。类似拓扑排序,开一个队列,存处在叶子上的白点,即度数 \(≤1\) 的白点。
每次从队头取出一个白点,删掉一个白点后,给每个邻接点的度数-1,如果又出现了叶子上的白点,则将其加入队列。重复执行直到队列空,剩下的白点就是不应被删掉的白点。
0x03 树的重心
删掉一个点,使得形成的子树中,大小最大的子树最小,则这个点被称为树的重心。
树的重心有许多经典的性质:
- (等价定义)树中所有的点到某一点的距离和中,到重心的距离和是最小的。(仅限无权图)
- 树的重心不一定唯一,但最多有两个。
- 以重心为根时,所有子树的大小都不超过树的大小的一半
- 加一个点或删一个点时,重心最多移动一条边。
- 两棵树相连得到一颗新的树,这棵树的重心在原来两棵树的重心之间的简单路径上。
重心的求解:以一道题为例
P1395 会议
由题及重心的性质,本题需要求的就是树的重心。
进行DFS遍历。假设当前遍历到u,我们考虑假设将u换为全树的根,则u的子树除了当前所有子树外,还多了u的父亲这一支,其大小等于 \(n-siz[u]\)。由此我们可以求出每个点换为根之后所有子树大小的最大值,即可求出树的重心。
对于求距离和,只需要以重心为根,再遍历一遍树,求每个点的深度,即为每个点到重心的距离,求和即可。
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define abs(x) ((x)>0?(x):-(x))
using namespace std;
const int N=1e5+10;
struct Edge{int to,Next;}g[N<<1];
static int h[N],siz[N],d[N];
int n,num,tot,ans=0x3f3f3f3f,mn=0x3f3f3f3f,mn2=0x3f3f3f3f,ans2;
inline int max(int x,int y){return x>y?x:y;}
inline int min(int x,int y){return x<y?x:y;}
void fastio(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
}
void Add(int u,int v){g[++num]={v,h[u]};h[u]=num;}
void Dfs1(int fa,int u){
siz[u]=1;
int mx=-1;
for(int i=h[u];i;i=g[i].Next){
int v=g[i].to;
if(v==fa)continue;
Dfs1(u,v);
siz[u]+=siz[v];
if(siz[v]>mx)mx=siz[v];
}
mx=max(mx,n-siz[u]);
if(mx<mn)mn=mx,ans=u;
if(mx==mn)ans=min(ans,u);
}
void Dfs2(int fa,int u){
for(int i=h[u];i;i=g[i].Next){
int v=g[i].to;
if(v==fa)continue;
d[v]=d[u]+1;
Dfs2(u,v);
}
}
signed main(){
// fastio();
cin>>n;
int u,v;
for(int i=1;i<n;i++)
cin>>u>>v,Add(u,v),Add(v,u);
Dfs1(0,1);Dfs2(0,ans);
int s=0;
for(int i=1;i<=n;i++)s+=d[i];
cout<<ans<<" "<<s;
return 0;
}
0x10 最近公共祖先
给定一棵有根树,若点z既是点x的祖先,也是点y的祖先,则称z为x,y的公共祖先。在所有公共祖先中深度最大的一个称为x,y的最近公共祖先(LCA)。
lca是点x与点y到根节点路径的交汇点,也是它们之间的路径上的关键转折点。对于lca的求解,有一下两种较简单的方法:
0x11 暴力法
对于求最近公共祖先,最简单的方法便是向上标记。
假设x的深度大于y,则我们先把x提升到与y同一深度处,然后令x,y同时向上移动,直到两点相会。
时间复杂度最坏为 \(O(qn)\) 。
0x12 树上倍增
显然,暴力法非常浪费时间,明明很大的距离偏偏用一步步的方法往上走,有没有更简单的方法?
这便用到了树上倍增法。
树上倍增不仅可以用来求lca,在其他各种各样的树上计算中都有广泛应用。
对于求lca,定义 \(f[i,j]\) 表示点 \(j\) 向上走 \(2^i\) 步后走到的点,规定如果没有这个点则 \(f[i,j]=0\) 。
对于 f 数组的求解,我们用类似DP的思想。先将 \(f[0,i]\) 用一遍DFS求出,对于其他的f值,有如下方程:
由此便可通过DP计算出所有f的值
代码如下:
void st(){
for(int i=1; i<=lg2[n]; i++)// 注意不能调换枚举顺序
for(int j=1; j<=n; j++)
f[i][j]=f[i-1][f[i-1][j]];
}
预处理时间为 \(O(n\log n)\)。
查询时,依旧使用暴力法的思想,假设x的深度更大:
- 令 \(k\) 从 \(\log n\) 枚举到 \(0\) 。对于每个 \(k\) ,如果 \(f[k,x]\) 的深度不小于 \(y\),就令 \(x=f[k,x]\)。否则啥也不干。
- 如果此时 $ x=y $ ,则结束,返回 $ x $ 。
- 再令 \(k\) 从 \(\log n\) 枚举到 \(0\) 。对于每个 \(k\) ,如果 \(f[k,x]≠f[k,y]\) ,就令 \(x=f[k,x], y=f[k,y]\)。否则啥也不干。
- 最后 $ x $ 和 $ y $ 只差一步就能汇合了,返回 \(f[0,x]\) 即可。
总时间复杂度:\(O(n\log n+q\log n)\),空间复杂度: \(O(n\log n)\)
代码实现:
inline int lca(int x, int y){
if(d[x]<d[y]) x^=y^=x^=y;
for(int i=lg2[n]; ~i; i--)
if(d[f[i][x]]>=d[y]) x=f[i][x];
if(x==y) return x;
for(int i=lg2[n]; ~i; i--)
if(f[i][x]!=f[i][y])
x=f[i][x], y=f[i][y];
return f[0][x];
}
事实上,普通树上倍增在常数效率、空间上依然不够优秀,有更好用的替代品,我们将在后面介绍。
对于两个点x,y的最短路径,如果不走传送门,直接走x->lca->y即为最短,令 \(d[x]\) 表示x到根节点的路径长度,则这种情况的答案为 \(d[x]+d[y]-(d[lca]<<1)\) 。
如果走传送门,则最短路为x走到离它最近的有传送门的点,传送到离y最近的有传送门的点,再走到y即可。令dmin[x]表示x到任意一个有传送门的点的最短距离,则这种情况的答案为 \(dmin[x]+dmin[y]\) 。
两种情况答案求最小值即可。
对于第二种情况的dmin的求解,可以将所有有传送门的点放进一个队列,作初始队列跑BFS即可。
点击查看代码
// 省略部分代码的实现
void Bfs(){
int u;
while(!q.empty()){
u=q.front(); q.pop();
vis[u]=1;
for(int v:g[u]){
dmin[v]=min(dmin[v],dmin[u]+1);
if(!vis[v]) q.push(v);
}
}
}
signed main(){
fastio();
cin>>n>>k>>Q;
int u,v,l;
for(int i=1; i<n; i++){
cin>>u>>v;
g[u].emplace_back(v);
g[v].emplace_back(u);
}
Dfs(1); st();
memset(dmin,0x3f,sizeof(dmin));
for(int i=1; i<=k; i++) cin>>u, dmin[u]=0, q.push(u);
Bfs();
while(Q--){
cin>>u>>v;
l=lca(u,v);
ans=d[u]+d[v]-(d[l]<<1);
ans=min(ans,dmin[v]+dmin[u]);
cout<<ans<<"\n";
}
return 0;
}
给定一棵树和一堆询问,每次询问两组点(u,v)、(x,y),求u->v路径和x->y路径是否有公共点。
画几个图,经过观察,我们可以发现,若(u,v)、(x,y)有公共点,当且仅当lca(x,y)在u->v路径上,或者lca(u,v)在路径x->y上。
判断点z是否在路径x->y上,等价于判断lca为点z的祖先,且点z为x或y的祖先。
点击查看代码
// 省略部分代码的实现
signed main(){
fastio();
cin>>n>>Q;
int u,v,x,y,luv,lxy;
for(int i=1; i<n; i++){
cin>>u>>v;
g[u].emplace_back(v);
g[v].emplace_back(u);
}
Dfs(1); st();
while(Q--){
cin>>u>>v>>x>>y;
luv=lca(u,v); lxy=lca(x,y);
if(pd(lxy,luv) && (pd(luv,x) || pd(luv,y))){
cout<<"Y\n";continue;
}
if(pd(luv,lxy) && (pd(lxy,u) || pd(lxy,v))){
cout<<"Y\n";continue;
}
cout<<"N\n";
}
return 0;
}
给定一个无向图和一堆询问,每次询问两个点x,y,求x->y所有简单路径上最小边权的最大值。
本题是kruskal重构树的板子。
执行kruskal的过程。先对边排序,对于每条边(u,v,w),如果它们在并查集中不在同一集合,我们可以新建一个点p,点权为w,同时将u,v所在的两个集合的根节点fu,fv分别设为p的左儿子和右儿子。然后我们将两个集合和p合并成一个集合,将p设为新集合的根,即在并查集中令 \(fa[fu]=p, fa[fv]=p\) 。
这样建出来的二叉树称为kruskal重构树。kruskal重构树有许多性质,假设边升序排序:
- kruskal重构树为一个大根堆。
- 原图中两个点之间的所有简单路径上最大边权的最小值 = 最小生成树上两个点之间的简单路径上的最大值 = kruskal重构树上两点之间的lca的权值。
对于本题,直接对边从大到小排序,建出kruskal重构树,然后跑lca即可。
注意跑lca之前,应当利用并查集先判连通性。
点击查看代码
// 省略部分代码的实现
int Find(int x){
if(tf[x]==x) return x;
return tf[x]=Find(tf[x]);
}
void kru(){
tot=n;
for(int i=1; i<=(n<<1); i++) tf[i]=i;
stable_sort(e+1, e+m+1);
int fu,fv;
for(int i=1; i<=m; i++){
fu=Find(e[i].u), fv=Find(e[i].v);
if(fu^fv){
tot++; val[tot]=e[i].w;
g[tot].emplace_back(fu);
g[fu].emplace_back(tot);
g[tot].emplace_back(fv);
g[fv].emplace_back(tot);
tf[fu]=tf[fv]=tot;
if(tot==n+n-1) break;
}
}
}
signed main(){
fastio();
cin>>n>>m;
int u,v;
for(int i=1; i<=m; i++)
cin>>e[i].u>>e[i].v>>e[i].w;
kru();
memset(fa,0,sizeof(fa));
for(int i=1; i<=tot; i++)
if(!vis[i]) Dfs(Find(i));
st();
cin>>q;
while(q--){
cin>>u>>v;
ans=query(u,v);
cout<<ans<<"\n";
}
return 0;
}
0x13 树上差分
对于序列上维护“区间加”的问题,如果要求不高,不必写线段树,我们通常会使用差分法,将“区间加”转化为“单点加”。类似的,在树上处理“链增加”“路径加”时,我们也可以用“差分”,将其转化为单点加以降低复杂度。
注意到,要想让树上两点不连通,必须删除路径上的点。所以,考虑给每个点加一个权值,初始均为0。对每组好点对(u,v),将u->v路径上的每个点权值+1。最后检查一遍坏点对之间的路径上的所有点,统计路径上权值为0的点的个数即可。问题转化为如何快速标记路径上的点。
可以将路径x->y分解成x->lca,lca->y,问题再转化为对链打标记,本题中只需要实现“链增加”。
类比序列上的差分,对于一条链 x->y(y为x的祖先),我们只需将x的权值+1,y的父亲的权值-1。最后跑一遍DFS,向上计算法求每个点子树中所有点的权值和,即可得到每个点的真实权值。
此题运用的树上差分被称为“点差分”。
点击查看代码
// 省略部分代码的实现
void Dfs1(int u){
for(int v:g[u]){
if(v==fa[u]) continue;
f[0][v]=u; d[v]=d[u]+1;
Dfs1(v);
}
}
void Dfs2(int u){
for(int v:g[u]){
if(v==fa[u]) continue;
Dfs2(v);
b[u]+=b[v];
}
}
signed main(){
// 省略读入
Dfs1(1); st();
while(m--){
cin>>u>>v; l=lca(u,v);
b[u]++; b[f[0][l]]--;
b[v]++; b[f[0][l]]--;
}
int x,y;
cin>>x>>y;
Dfs2(1);
if(d[x]<d[y]) swap(x,y);
while(d[x]>d[y]){
if(!b[x]) ans++;
x=f[0][x];
}
while(x^y){
if(!b[x]) ans++;
if(!b[y]) ans++;
x=f[0][x], y=fa[0][y];
}
if(!b[x]) ans++;// 注意最后还要判一下lca
cout<<ans;
return 0;
}
将Dark的无向图边结构,分成一颗树,与另一堆“非树边”。显然,如果只有树边,则干掉任意两个点的路径上任意一条边即可。难点在于,有些非树边,会在原树上形成环,给某些点第二条连接路径。因此,我们考虑非树边对树边的影响。
对于一条非树边(x,y),它以及路径x->y上的所有边,构成一个环,如果想从路径上的任意一条树边作为“突破口”,则在干掉此边的基础上,还需要干掉非树边(x,y),才能分断Dark。
而每增加一条非树边,这样的环便会增加一个。以此类推,切断一条树边后,还需要切断的非树边的条数就是它参与构成的环的个数。
由此,我们称每条非树边(x,y),都把路径x->y上的每一条边都标记了一次。对于任意一条树边,其被标记的次数,便是其参与构成环的个数,也是斩断此边后还需要斩断的非树边的条数。
由于题目限制我们只能切断一条树边、一条非树边,我们对树边的标记次数进行分类:
- 对于标记次数为0的边,切断后随便再切断一条非树边即可。
- 对于标记次数为1的边,切断后只有再切断标记它的非树边这一个选择。
- 对于标记次数≥2的边,切断后都至少需要两次操作,一定无解。
这样,我们便可以求出每条树边被切断后可能的选择个数,利用加法原理即可求出方案数。
剩下的便是对链上的边进行增加,考虑将边权转化为点权,每个点记录它到父亲的边的边权,即可转化为上一题的“点差分”。本题的树上差分方式称为“边差分”。
点击查看代码
// 省略部分代码
void Dfs2(int u){
for(int v:g[u]){
if(v==fa[u]) continue;
Dfs2(v);
b[u]+=b[v];
}
}
signed main(){
fastio();
cin>>n>>m;
int u,v,l;
for(int i=1; i<n; i++){
cin>>u>>v;
g[u].emplace_back(v);
g[v].emplace_back(u);
}
Dfs1(1); st();
for(int i=1; i<=m; i++){
cin>>u>>v;
l=lca(u,v);
b[u]++; b[l]--;
b[v]++; b[l]--;
}
Dfs2(1);
for(int i=2; i<=n; i++){
if(!b[i]) ans+=m;
if(b[i]==1) ans++;
}
cout<<ans;
return 0;
}
0x20 树的直径
给定一棵带权树,树中距离最远的两个点之间的距离便称为树的直径,这两个点之间的路径一般也称为树的直径。
树的直径一般有以下三种方法求解,时间复杂度均为 \(O(n)\) 。
0x21 两遍DFS法
可以通过两遍搜索遍历求直径,并可以顺带求出直径的端点。
方法如下:
- 从任一节点出发,走到离这个点最远的点p。
- 再从p出发,走到离p最远的点q。路径p->q便是树的直径。
事实上,从任意一个节点出发,走到离它最远的节点,则那个节点一定为直径的一端,可以反证法证明。但我不会
点击查看代码
需要注意,本方法不可处理负权边。
0x22 树形DP法
钦定点1为全树的根,则此树变成了一棵有根树。对于有根树上的路径,我们常使用lca进行分析。
对于一条路径x->y,可以用lca将其分成两部分:x->lca,lca->y。为了保证时间复杂度,我们肯定不能枚举两个点x,y,考虑遍历枚举lca。
令 \(f[0,u]\) 表示从u出发向下走,能走到的路径的最大长度。 \(f[1,u]\) 表示从u出发向下走,不走 \(f[0,u]\) 中已经走过的边,能走到的路径的最大长度。
我们常把 \(f[1,u]\) 称为“次优路径”,不过一定要注意,这里的“次优”并非简单的“从u出发向下走,能走到的路径的次大长度”。下文将使用“次优”表示,注意不要混淆“次优”与“次大值”。
显然,将 \(f[0,u],f[1,u]\) 拼起来,就是u做lca时的最长路径,其长度为 \(g[u]=f[0,u]+f[1,u]\) 。对于整棵树的直径,对所有点的g求一遍最大值即可。
转移方程也很好推。在DFS中进行DP,设当前走到的点为u,现在处理到它的子节点为v,边权为w,类似“打擂台”:
- 先递归计算v。
- 如果 \(f[0,v]+w>f[0,u]\) ,则“走 \(subtree(v)\) ”这一方案优于原来的最优方案,原来的最优变成了次优。即先令 \(f[1,u]=f[0,u]\) ,再令 \(f[0,u]=f[0,v]+w\)。
- 否则,如果 \(f[0,v]+w>f[1,u]\),则最优不变,“走 \(subtree(v)\) ”这一方案变成了次优方案。即 \(f[1,u]=f[0,v]+w\)。
- 则 \(g[u]=f[0,u]+f[1,u]\) 。
事实上,我们还可以省掉一个数组。
仍考虑u作lca时的最长路径长度。令 \(f[u]\) 表示从u出发向下走,能走到的路径的最大长度; \(g[u]\) 表示u作lca时的最长路径长度。
仍旧设当前走到的点为u,现在处理到它的子节点为v,边权为w,进行“打擂台”:
- 先递归计算v。
- 此时产生了一种新的路径方案,其长度为 \(f[u]+w+f[v]\),将其与当前 \(g[u]\) 取个max,即 \(g[u]=max(g[u],f[u]+w+f[v])\) 。
- 更新 \(f[u]\) ,即令 \(f[u]=max(f[u],f[v]+w)\)。
代码:
void Dfs(int fa, int u){
for(EDGE to:g[u]){
int v=to.v, w=to.w;
if(v==fa) continue;
Dfs(u,v);
ans=max(ans,f[u]+w+f[v]);// 没必要真的开一个g数组
f[u]=max(f[u],f[v]+w);
}
}
令 \(f[c,u]\) 表示 \(subtree(u)\) 中,从u出发向下走,走到一个颜色为c的点,能走到的路径的最大长度。
沿用DP求直径的方法,设当前走到的点为u,现在处理到它的子节点为v:
- 递归计算v。
- 此时产生了两种新的路径方案。一种长度为 \(f[0,u]+1+f[1,v]\),另一种长度为 \(f[1,u]+1+f[0,v]\) 。将当前答案对两种方案各求一次max即可。
- 更新f数组。即 \(f[0,u]=max(f[0,u],f[0,v]+1) , f[1,u]=max(f[1,u],f[1,v]+1)\)。
点击查看代码
// 只展现关键部分的代码
void Dfs(int fa, int u){
f[a[u]][u]=0; f[!a[u]][u]=-INF;
for(int v:g[u]){
if(v==fa) continue;
Dfs(u,v);
ans=max(ans,f[0][u]+f[1][v]+1);
ans=max(ans,f[1][u]+f[0][v]+1);
f[0][u]=max(f[0][u],f[0][v]+1);
f[1][u]=max(f[1][u],f[1][v]+1);
}
}

浙公网安备 33010602011771号