【8】基环树学习笔记
前言
基环树是一类特殊的树形结构,这一类问题经常在省选阶段考察。这一类题目一般有特定的解决方法,积累经验,即可快速解决这类题目。
基环树
基环树:在一棵树上增加一条边,使树上存在且仅存在一个环,这样的树叫做基环树。
找环算法
遍历一整棵树,将访问到的节点入栈,离开时退栈。如果存在一个点可以到达正在栈中的节点,那么从栈顶这个节点到可以到达的那个在栈中的节点就是环上的节点。这个算法可以通过画图来证明。
Tarjan 算法与这个算法有点像,可以对比复习。
void dfs1(int x,int fa)
{
if(flag)return;
st[++top]=x,b[x]=1;
for(int i=h[x];i;i=e[i].nxt)
if(i!=(fa^1))
{
if(!b[e[i].v])dfs1(e[i].v,i);
else
{
flag=1;
while(st[top]!=e[i].v&&top>0)cir[++num]=st[top],top--;
if(top>0)cir[++num]=st[top],top--;
}
}
if(top>0)top--;
b[x]=0;
}
常用算法
基环树问题的常用处理方法是枚举环上的断边(或枚举一个点),这通常需要 的时间。枚举断边之后,基环树就转化为了树。而通常来讲,在基环树的环上总有一条用不到的边,这条边就可以被视为断边。
如果题目允许 复杂度,那我们通常枚举断边之后对于树使用 的树上算法,包括树的直径,树的重心,DFS,树形 DP,LCA等。这类问题通常只是树上问题的变形,比较简单。
如果题目只允许 以下复杂度,那我们需要考虑预处理和数据结构优化,以保证在 的预处理之后,对于每次断边可以在低于 的复杂度求出。这通常需要深入挖掘题目性质,需要较高的思维能力,可以在例题 中感受一下。
对于其他处理方式,特殊对待。
例题
例题 :
对于 的情况,原图为一棵树。为了使字典序最小,我们需要使第一个点最小,也就是选择编号为 的点作为起点。之后,为了保证字典序最小,我们进行贪心,在树上进行 DFS 时优先访问编号小的节点,这样就可以使靠前的元素尽量小,保证字典序最小。
对于 的情况,原图为一棵基环树。不难发现,访问 个点,会经过 条边,还有一条边是无用的,视作断边。我们枚举这个断边,就转化为了 的情况,使用相同的算法求出字典序之后取最小的一个即可。
#include <bits/stdc++.h>
using namespace std;
struct edge
{
long long v,nxt;
}e[100000];
long long n,m,u,v,h[100000],ans[100000],la[100000],book[100000],cnt=1,dfc=0,ban=0,flag=0;
void add_edge(long long u,long long v)
{
e[++cnt].nxt=h[u];
e[cnt].v=v;
h[u]=cnt;
}
void dfs(long long x,long long fa)
{
ans[++dfc]=x,book[x]=1;
if(ans[dfc]!=la[dfc]&&!flag)
{
if(ans[dfc]>la[dfc])flag=1;
else flag=-1;
}
if(flag==1)return;
vector<long long>to;
for(int i=h[x];i;i=e[i].nxt)
if(e[i].v!=fa&&i!=ban&&i!=(ban^1)&&!book[e[i].v])to.push_back(e[i].v);
sort(to.begin(),to.end());
for(int i=0;i<to.size();i++)
if(!book[to[i]])dfs(to[i],x);
}
int main()
{
scanf("%lld%lld",&n,&m);
for(int i=1;i<=n;i++)la[i]=1e9;
for(int i=1;i<=m;i++)
{
scanf("%lld%lld",&u,&v);
add_edge(u,v),add_edge(v,u);
}
if(m==n-1)
{
dfs(1,0);
for(int i=1;i<=n;i++)printf("%lld ",ans[i]);
}
else
{
for(int i=2;i<=cnt;i+=2)
{
flag=0,dfc=0,ban=i;
for(int j=1;j<=n;j++)book[j]=0;
dfs(1,0);
if(dfc!=n)continue;
for(int j=1;j<=n;j++)
if(la[j]!=ans[j])
{
if(la[j]>ans[j])
for(int k=1;k<=n;k++)la[k]=ans[k];
break;
}
}
for(int i=1;i<=n;i++)printf("%lld ",la[i]);
}
return 0;
}
例题 :
双倍经验
显然是一棵基环树。考虑先忽略这个环,问题就转化为了在树上选择不直接相连的一些点,权值最大,就是 P1352 没有上司的舞会,可以在 【6】树形DP学习笔记 中查看。
接下来,考虑忽略这个环的具体方式。我们发现,忽略环的方式,其实就是枚举环上的断边。这一种常用的处理方式。
接下来,考虑断边的限制,即断边相连的两个点不能同时被选。我们只需要在状态取值中限定边上一个点不选即可,另一个点随便。两个点不选的情况取较大值。
具体来讲,设断边相连的两点为 ,则这一次求出的答案为:
每次枚举的答案取最大值,就是最终的答案。
题目中使用了并查集判环的方式,顺便处理了基环树森林的情况,可以学习一下。
P2607
#include <bits/stdc++.h>
using namespace std;
struct edge
{
int u,v,nxt;
}e[2000010];
int n,k,v,a[2000000],fa[2000000],h[2000000],cnt=1,ban=0;
long long f[2000000][2],ans=0;
void add_edge(int u,int v)
{
e[++cnt].nxt=h[u];
e[cnt].u=u;
e[cnt].v=v;
h[u]=cnt;
}
int getf(int x)
{
if(fa[x]==x)return x;
else return fa[x]=getf(fa[x]);
}
void merge(int x,int y)
{
int p=getf(x),q=getf(y);
if(p==q)return;
else fa[q]=p;
}
void dfs(int x,int fa)
{
f[x][0]=0,f[x][1]=a[x];
for(int i=h[x];i;i=e[i].nxt)
if(i!=ban&&i!=(ban^1)&&i!=(fa^1))
{
dfs(e[i].v,i);
f[x][0]+=max(f[e[i].v][0],f[e[i].v][1]);
f[x][1]+=f[e[i].v][0];
}
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)fa[i]=i;
for(int i=1;i<=n;i++)
{
scanf("%d%d",&a[i],&v);
add_edge(i,v),add_edge(v,i);
}
for(int i=2;i<=cnt;i+=2)
{
long long u=e[i].u,v=e[i].v,mx=0;
if(getf(u)!=getf(v))merge(u,v);
else
{
ban=i;
dfs(u,0),mx=max(mx,f[u][0]);
dfs(v,0),mx=max(mx,f[v][0]);
ans+=mx;
}
}
printf("%lld\n",ans);
return 0;
}
P1453
#include <bits/stdc++.h>
using namespace std;
struct edge
{
int u,v,nxt;
}e[200010];
int n,u,v,a[200000],fa[200000],h[200000],f[200000][2],cnt=1,ban=0,ans=0;
double k;
void add_edge(int u,int v)
{
e[++cnt].nxt=h[u];
e[cnt].u=u;
e[cnt].v=v;
h[u]=cnt;
}
int getf(int x)
{
if(fa[x]==x)return x;
else return fa[x]=getf(fa[x]);
}
void merge(int x,int y)
{
int p=getf(x),q=getf(y);
if(p==q)return;
else fa[q]=p;
}
void dfs(int x,int fa)
{
f[x][0]=0,f[x][1]=a[x];
for(int i=h[x];i;i=e[i].nxt)
if(i!=ban&&i!=(ban^1)&&i!=(fa^1))
{
dfs(e[i].v,i);
f[x][0]+=max(f[e[i].v][0],f[e[i].v][1]);
f[x][1]+=f[e[i].v][0];
}
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)fa[i]=i;
for(int i=1;i<=n;i++)scanf("%d",&a[i]);
for(int i=1;i<=n;i++)
{
scanf("%d%d",&u,&v);
add_edge(u+1,v+1),add_edge(v+1,u+1);
}
scanf("%lf",&k);
for(int i=2;i<=cnt;i+=2)
{
int u=e[i].u,v=e[i].v,mx=0;
if(getf(u)!=getf(v))merge(u,v);
else
{
ban=i;
dfs(u,0),mx=max(mx,f[u][0]);
dfs(v,0),mx=max(mx,f[v][0]);
ans+=mx;
}
}
printf("%.1lf\n",ans*k);
return 0;
}
例题 :
转化题意,给定一棵基环树森林,求每个基环树的直径之和。
显然,对于每个基环树,分开求直径。基环树的直径有两种情况:过环和不过环。对于不过环的情况,我们只需要把环上的点标记不能走,然后对于每棵子树求树的直径即可。
对于过环的情况,我们需要枚举过环上的点。枚举起点和终点,需要 的复杂度,无法接受。考虑断环为链,复制两倍到末尾,这样就只需要枚举一个点,另一个点在这个点前面找,最后取最大值,转化为了序列问题。事实上,枚举这个点相当于枚举断边。
记环上点 不过环子树内最深节点深度为 ,则点 作为起点或终点时,对直径的贡献为 。由于我们需要快速求出两点之间的环上距离,考虑断环为链后求边权前缀和 ,点 和点 的距离就能表示为 。显然,不能把环走一遍再走一遍,所以枚举一个点 后,在其之前可以取的点的范围为 。也就是说,对于点 ,我们要求下列式子:
我们发现可取值区间长度固定,随着 的枚举整体向后移动,使用单调队列维护。具体的,以 为权值,枚举完 后加入单调队列,排除队尾权值小于等于 的选项。计算点 前把队首位置小于 的元素排除,直接取队首即可。
总体时间复杂为 ,非常优秀。
#include <bits/stdc++.h>
using namespace std;
struct edge
{
long long v,nxt,d;
}e[3000000];
long long n,v,d,h[3000000],st[3000000],cir[3000000],dep[3000000],s[3000000],dis[3000000],cnt=1,top=0,num=0,mx=0,ans=0,now=0,y=0,id=0;
long long qv[3000000],qp[3000000],q=1,t=0;
bool b[3000000],ic[3000000],vis[3000000],flag=0;
inline long long read()
{
long long x=0,f=1;char ch=getchar();
while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();}
while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();}
return x*f;
}
void add_edge(long long u,long long v,long long d)
{
e[++cnt].nxt=h[u];
e[cnt].v=v;
e[cnt].d=d;
h[u]=cnt;
}
void dfs1(long long x,long long fa)
{
if(flag)return;
st[++top]=x,b[x]=1;
for(int i=h[x];i;i=e[i].nxt)
if(i!=(fa^1))
{
if(!b[e[i].v])dfs1(e[i].v,i);
else
{
flag=1;
while(st[top]!=e[i].v&&top>0)cir[++num]=st[top],top--;
if(top>0)cir[++num]=st[top],top--;
}
}
if(top>0)top--;
b[x]=0;
}
long long dfs2(long long x,long long fa)
{
long long ans=0;
vis[x]=1;
for(int i=h[x];i;i=e[i].nxt)
if(i!=(fa^1)&&!ic[e[i].v])ans=max(ans,dfs2(e[i].v,i)+e[i].d);
return ans;
}
void dfs3(long long x,long long fa)
{
for(int i=h[x];i;i=e[i].nxt)
if(i!=(fa^1)&&((!ic[e[i].v])||(e[i].v==id)))
{
dis[e[i].v]=dis[x]+e[i].d;
dfs3(e[i].v,i);
}
}
void dfs4(long long x,long long fa)
{
if(dis[x]>now)now=dis[x],y=x;
for(long long i=h[x];i;i=e[i].nxt)
if(i!=(fa^1)&&((!ic[e[i].v])||(e[i].v==id)))dfs4(e[i].v,i);
dis[x]=0;
}
long long diameter(long long x)
{
dfs3(x,0);
now=0,y=0,dfs4(x,0);
dfs3(y,0);
now=0,dfs4(y,0);
return now;
}
long long getdis(long long x,long long y)
{
long long mx=0;
for(int i=h[x];i;i=e[i].nxt)
if(e[i].v==y)mx=max(mx,e[i].d);
return mx;
}
int main()
{
n=read();
for(int i=1;i<=n;i++)
{
v=read(),d=read();
add_edge(i,v,d),add_edge(v,i,d);
}
for(int i=1;i<=n;i++)
if(!vis[i])
{
flag=0,mx=0,top=0,num=0,q=1,t=0,dfs1(i,0);
for(int j=1;j<=num;j++)ic[cir[j]]=1,vis[cir[j]]=1;
for(int j=1;j<=num;j++)dep[j]=dep[j+num]=dfs2(cir[j],0),id=cir[j],mx=max(mx,diameter(cir[j]));
for(int j=2;j<=num;j++)s[j]=s[j-1]+getdis(cir[j-1],cir[j]);
s[num+1]=s[num]+getdis(cir[1],cir[num]);
for(int j=num+2;j<=num*2;j++)s[j]=s[j-1]+getdis(cir[j-num-1],cir[j-num]);
qv[++t]=dep[1]-s[1],qp[t]=1;
for(int j=2;j<=num*2;j++)
{
while(qp[q]<=j-num&&q<=t)q++;
mx=max(mx,dep[j]+s[j]+qv[q]);
while(qv[t]<=dep[j]-s[j]&&q<=t)t--;
qv[++t]=dep[j]-s[j],qp[t]=j;
}
ans+=mx;
}
printf("%lld\n",ans);
return 0;
}
例题 :
首先,有一个比较直接的转化。要使与最远的点之间的距离最短,显然就是在树上找到一条最长链,取其中点。如果在这个点的某一侧存在一条更长的链,那么这条链加上另一侧的链一定是更长的链,与最长链矛盾,得证。
接下来,就是要求树上的最长链。对于不过环的情况,与上一题同样,求树的直径。
对于过环的情况,我们需要枚举过环上的点。枚举起点和终点,需要 的复杂度,无法接受。需要注意的是,这里的两个点在环上距离不能超过环的总长的一半,否则可以取另外一条更短的路径,答案更优。
采用常规处理方式,我们枚举树上的断边。因为不可能走一整个环,所以一定有的边没有被走到,就是我们枚举的断边。为了便于计算,我们把整个环转化为一个序列。
假设目前枚举到了断点 后的边,环上点 不过环子树内最深节点深度为 ,边权前缀和 。我们进行分类讨论:(令起点为 ,终点为 )
:起点,终点均在 以及 之前,这种情况下答案的式子为 。我们发现对于每个 ,这种情况下的每个位置这个式子是不受影响的。因此,我们可以预处理每个前缀的这个式子最大值,记作 。这个最大值也比较好维护,记录 最大值,每次向后计算时利用 更新即可。
:起点,终点均在 以及 之后,这种情况与上一种情况同理,只是预处理顺序反过来了,记作 。
:起点在 以及 之前,终点在 以及 之后。这种情况下,答案的式子为 。我们考虑在 前维护 的最大值,记作 ; 后维护 的最大值,记作 ,两者都可以预处理。这种情况的答案为 。
最后,取三者之中的最大值,就是断这条边的过环最长链。由于两个点在环上距离不能超过环的总长的一半,所以对于每一次求出来的最长链需要取最小值。因为如果求出来的最长链不满足条件,会在另一次求解中求出其较小的情况,全部取最小值,最后一定满足条件。
代码实现时与讲解有一点出入,但大体一致,细节比讲解较多,建议采取讲解的写法。
#include <bits/stdc++.h>
using namespace std;
struct edge
{
long long v,nxt,d;
}e[300000];
long long n,u,v,d,l[300000],r[300000],a[300000],c[300000],h[300000],st[300000],cir[300000],dep[300000],s[300000],dis[3000000],cnt=1,top=0,num=0,mx=0,now=0,y=0,id=0,mi=1e16,z1=-1e16,z2=-1e16;
bool b[300000],ic[300000],flag=0;
inline long long read()
{
long long x=0,f=1;char ch=getchar();
while (ch<'0'||ch>'9'){if (ch=='-') f=-1;ch=getchar();}
while (ch>='0'&&ch<='9'){x=x*10+ch-48;ch=getchar();}
return x*f;
}
void add_edge(long long u,long long v,long long d)
{
e[++cnt].nxt=h[u];
e[cnt].v=v;
e[cnt].d=d;
h[u]=cnt;
}
void dfs1(long long x,long long fa)
{
if(flag)return;
st[++top]=x,b[x]=1;
for(int i=h[x];i;i=e[i].nxt)
if(i!=(fa^1))
{
if(!b[e[i].v])dfs1(e[i].v,i);
else
{
flag=1;
while(st[top]!=e[i].v&&top>0)cir[++num]=st[top],top--;
if(top>0)cir[++num]=st[top],top--;
}
}
if(top>0)top--;
b[x]=0;
}
long long dfs2(long long x,long long fa)
{
long long ans=0;
for(int i=h[x];i;i=e[i].nxt)
if(i!=(fa^1)&&!ic[e[i].v])ans=max(ans,dfs2(e[i].v,i)+e[i].d);
return ans;
}
void dfs3(long long x,long long fa)
{
for(int i=h[x];i;i=e[i].nxt)
if(i!=(fa^1)&&((!ic[e[i].v])||(e[i].v==id)))
{
dis[e[i].v]=dis[x]+e[i].d;
dfs3(e[i].v,i);
}
}
void dfs4(long long x,long long fa)
{
if(dis[x]>now)now=dis[x],y=x;
for(long long i=h[x];i;i=e[i].nxt)
if(i!=(fa^1)&&((!ic[e[i].v])||(e[i].v==id)))dfs4(e[i].v,i);
dis[x]=0;
}
long long diameter(long long x)
{
dfs3(x,0);
now=0,y=0,dfs4(x,0);
dfs3(y,0);
now=0,dfs4(y,0);
return now;
}
long long getdis(long long x,long long y)
{
long long mx=0;
for(int i=h[x];i;i=e[i].nxt)
if(e[i].v==y)mx=max(mx,e[i].d);
return mx;
}
int main()
{
n=read();
for(int i=1;i<=n;i++)
{
u=read(),v=read(),d=read();
add_edge(u,v,d),add_edge(v,u,d);
}
dfs1(1,0);
for(int j=1;j<=num;j++)ic[cir[j]]=1;
for(int j=1;j<=num;j++)dep[j]=dfs2(cir[j],0),id=cir[j],mx=max(mx,diameter(cir[j]));
for(int j=2;j<=num;j++)s[j]=s[j-1]+getdis(cir[j-1],cir[j]);
num++,dep[num]=dep[1],s[num]=s[num-1]+getdis(cir[1],cir[num-1]);
for(int i=0;i<=num+1;i++)l[i]=r[i]=a[i]=c[i]=-1e16;
for(int i=1;i<=num;i++)l[i]=max(l[i-1],dep[i]+s[i]+z1),a[i]=max(a[i-1],dep[i]+s[i]),z1=max(z1,dep[i]-s[i]);
for(int i=num;i>=1;i--)r[i]=max(r[i+1],dep[i]-s[i]+z2),c[i]=max(c[i+1],dep[i]-s[i]),z2=max(z2,dep[i]+s[i]);
for(int i=2;i<=num;i++)mi=min(mi,max(l[i-1],max(r[i],a[i-1]+s[num]+c[i])));
printf("%.1lf\n",(double)max(mx,mi)/2);
return 0;
}
后记
基环树真是玄学。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探