基环树及其常见问题
基环树
众所周知,一颗树是由
在有向图中,也有类似的概念,
类似的,每个点有且仅有一条入边的有连通向图好像对向外扩展,这种有向图被称为外向基环树
若不保证连通,也有可能是内/外向树森林
)
对于基环树的结构虽然简单,但比一般的树要复杂一些,因此常常成为一些经典模型的扩展,如基环树直径,基环树上两点路径,基环树上动态规划……
对于解决基环树问题,我们一般是先找到树上的环,并且以环作为基环树的“广义根节点”,把除了环以外的节点作为若干颗子树进行处理,然后考虑和环有一起计算
无论如何,基环树找环基本是必备操作,但我们上面提到的三种基环树,找环的方法也不尽相同
先看第一个普通基环树找环的过程:
void get(int u,int v,int z){
sum[1]=z;//像下文说的求sum数组那样,先把断开的这条边的权值加上
while(v!=u){
h[++cnt]=v;
sum[cnt+1]=cost[lst[v]];
v=ver[lst[v]^1];
}//凭借标记不断回跳
h[++cnt]=u;
for(int i=1; i <= cnt; i++){
vis[h[i]]=true;
sum[i]+=sum[i-1];
}
}
void dfs(int u){
dfn[u]=++num;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(!dfn[v]){
lst[v]=i;
dfs(v);
}
else if(i^1!=lst[u]&&dfn[v]>dfn[u])get(u,v,cost[i]);
/*对环判定的解释:dfn是dfs序,之所以以i^1!=lst[u]&&dfn[v]>dfn[u]作为环的判断条件,是因为
1.i^1!=lst[u]是在特判父亲节点,因为无向图的成对存储
2.需要dfn[v]>dfn[u]是强制要求环在u的子树内出现,如此便不会重复统计
*/
}
}
对于内向基环树的环可以使用拓扑排序求得,当然拓扑排序本身就是有向图判环的绝招
scanf("%d%d",&n,&m);
for(int i=1;i <= n;i++){
scanf("%d",&f[i]);
in[f[i]]++;//i->f[i]
}
for(int i=1;i<=n;i++)
if(!in[i])q.push(i);
while(q.size()){
int x=q.front();
q.pop();
if(!--in[f[x]])q.push(f[x]);
}
对于外向基环树,我们发现,拓扑排序不行,因为每一个点都有且仅有1的入度,于是我们使用跳父亲的方式,即不断往上跳,因为每一个节点都有父亲,于是我们肯定可以跳到环上,然后就得到了环
while(!vis[fa[rt]])vis[fa[rt]]=1,rt=fa[rt];
u=rt,v=fa[rt];
while(u!=v){
s[++num]=v;
v=fa[v];
}
s[++num]=u;
下面我们讨论几个经典模型在基环树上的扩展
基环树的直径
同样的,基环树的直径是基环树上最长的链,下面我们讨论它的求法
很明显,基环树的直径有两种可能,一是在环上某个节点的子树上,一是跨环的直径
对于第一种情况,我们只需要找出环,然后将环上节点标记,对每一个环上的节点的子树上进行一般的求直径的方法更新答案即可,我们设环上节点为
对于第二种情况,我们需要先
然后第二步,我们对环上的节点做前缀和,记作
至于这个找
对于这个式子,我们可以采用单调队列优化,将
我们梳理一下这个过程
- 找到图中的环,并对其做前缀和
- 对环上每一个节点的子树找到直径更新答案
- 对环上每一个节点进行dfs/bfs/dp找到子树内以自己为一端的最长链
- dp求出跨环的答案并更新
其中过程 是可以合为一步的,还记得 求直径吗,那个 数组就是我们需要的d数组
给一道模板题
岛屿
题目就是给定基环树森林求每一颗基环树的直径
#include<bits/stdc++.h>
using namespace std;
const int N=1000005;
int ver[N<<1],cost[N<<1],nxt[N<<1],head[N],tot=1,n,cnt,num,dfn[N],lst[N],h[N<<1],q[N<<1],vis[N];
long long d[N],sum[N<<1],ans,Ans;
void add(int u,int v,int w){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
void get(int u,int v,int z){
sum[1]=z;
while(v!=u){
h[++cnt]=v;
sum[cnt+1]=cost[lst[v]];
v=ver[lst[v]^1];
}
h[++cnt]=u;
for(int i=1; i <= cnt; i++){
vis[h[i]]=true;
h[cnt+i]=h[i];
sum[cnt+i]=sum[i];
}
for(int i=1;i<=cnt+cnt;i++)sum[i]+=sum[i-1];
}//得到环之后的预处理
void dfs(int u){
dfn[u]=++num;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(!dfn[v]){
lst[v]=i;
dfs(v);
}
else if(i^1!=lst[u]&&dfn[v]>dfn[u])get(u,v,cost[i]);
}
}//处理找环
void dp(int u){
vis[u]=true;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(!vis[v]){
dp(v);
ans=max(ans,d[u]+d[v]+cost[i]);
d[u]=max(d[u],d[v]+cost[i]);
}
}
}//d数组就是需要的最长链
int main(){
scanf("%d",&n);
for(int u=1;u<=n;u++){
int v,w;
scanf("%d%d",&v,&w);
add(u,v,w);add(v,u,w);
}
for(int u=1;u<=n;u++)
if(!dfn[u]){
cnt=0;ans=0;
dfs(u);
for(int i=1;i<=cnt;i++)dp(h[i]);
int l=1,r=0;
for(int i=1;i<=cnt<<1;i++){
while(l<=r&&q[l]<=i-cnt)l++;
if(l<=r)ans=max(ans,d[h[i]]+d[h[q[l]]]+sum[i]-sum[q[l]]);
while(l<=r&&d[h[q[r]]]-sum[q[r]]<=d[h[i]]-sum[i])r--;
q[++r]=i;
}//单调队列统计答案
Ans+=ans;
}
printf("%lld",Ans);
}
基环树上路径
例题:Freda的传呼机
为了随时与
每条光缆连接两座房屋,传呼机发出的信号只能沿着光缆传递,并且传呼机的信号从光缆的其中一端传递到另一端需要花费
现在
请你帮帮他们。
输入格式
第一行包含三个用空格隔开的整数,
接下来
最后
输出格式
输出
数据范围
分析
看到本题会发现这是一个全源最短路径问题,嗯此时你想到了
我们先来考虑
再来考虑
都在环上某个节点的子树上,此时我们按照一棵树处理即可 分别在环上某个节点的子树上
对于第二种情况,我们设 为环上的节点, , 表示节点 属于环上哪一个节点的子树,这里需要注意的是,我们的 使用的是 来做下标,那么我们还需要一个 数组与 建立映射才能正确通过节点查找前缀和,那么答案就是
第三种情况超出了讨论范围,涉及到数据结构仙人掌树,也即圆方树
#define int long long
int s[1000005],num,head[1000005],ver[1000005],nxt[2000005],cost[2000005],tot=1,cnt,f[1000005][25];
int dep[1000005],b[1000005],d[1000005],sum[1000005],t,vis[1000005],dfn[1000006],lst[1000006],n,m,ran[1000005];
void add(int u,int v,int w){
nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
void bfs(int s){
queue<int>q;
q.push(s);
b[s]=s;
d[s]=0,dep[s]=1;
while(!q.empty()){
int u=q.front();q.pop();
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(dep[v]||vis[v])continue;
dep[v]=dep[u]+1;
b[v]=s;
d[v]=d[u]+cost[i];
f[v][0]=u;
for(int i=1;i<=t;i++)f[v][i]=f[f[v][i-1]][i-1];
q.push(v);
}
}
}//处理倍增LCA,d,b,dep
int lca(int x,int y){
if(dep[x]>dep[y])swap(x,y);
for(int i=t;i>=0;--i)if(dep[f[y][i]]>=dep[x])y=f[y][i];
if(x==y)return x;
for(int i=t;i>=0;--i)if(f[y][i]!=f[x][i])x=f[x][i],y=f[y][i];
return f[x][0];
}
void get(int u,int v,int z){
sum[1]=z;
while(u!=v){
s[++cnt]=v;
ran[v]=cnt;
sum[cnt+1]=cost[lst[v]];
v=ver[lst[v]^1];
}
s[++cnt]=u;
ran[u]=cnt;
for(int i=1;i<=cnt;i++){
sum[i]+=sum[i-1];
vis[s[i]]=1;
}
}//找环,注意ran数组建立映射
void dfs(int u){
dfn[u]=++num;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(!dfn[v]){
lst[v]=i;
dfs(v);
}
else if(i^1!=lst[u]&&dfn[v]>dfn[u])get(u,v,cost[i]);
}
}
void init(){
if(n-1==m){
bfs(1);
return ;
}
dfs(1);
for(int i=1;i<=cnt;i++)bfs(s[i]);
}
int solve(int u,int v){
if(n-1==m||b[u]==b[v]){
return d[u]+d[v]-2*d[lca(u,v)];
}
else {
int ans=d[u]+d[v];
return ans+min(sum[cnt]-abs(sum[ran[b[u]]]-sum[ran[b[v]]]),abs(sum[ran[b[u]]]-sum[ran[b[v]]]));//注意这里的计算唉
}
}
signed main(){
// freopen("communicate9.in","r",stdin);
int q;
scanf("%lld%lld%lld",&n,&m,&q);
t=log(n)/log(2)+1;
for(int i=1;i<=m;i++){
int u,v,w;
scanf("%lld%lld%lld",&u,&v,&w);
add(u,v,w);
add(v,u,w);
}
init();
while(q--){
int u,v;
scanf("%lld%lld",&u,&v);
printf("%lld\n",solve(u,v));
}
}
//这份代码已经可以获得80%的分数了
例题2会合
给定一个
对于顶点
再给出
从顶点
在满足条件
在满足条件
在满足条件
如果不存在满足条件
输入格式
第一行两个正整数
第二行 n 个正整数
下面 q 行,每行两个正整数 a,b,表示一组询问。
输出格式
输出 q 行,每行两个整数。
数据范围
分析
本题的图是一个外向树森林,于是我们可以使用拓扑排序找环,然后我们分开讨论几种情况下的答案
- 无解的情况。很明显就是两点不在一棵基环树上,这个可以使用并查集或者标记数组即可实现
- 对于两个节点都在同一个基环树环上节点的子树内,此时满足“从顶点
沿着出边走 步和从顶点 沿着出边走 步后到达的顶点相同。”的所走到的节点无疑就是 的公共祖先,而因限制条件的存在,合法的答案就是走到两个节点的 这个处理一下也很简单 - 两个节点在基环树环上不同节点的子树内,此时肯定是需要两个节点先跳至环上,设跳到了
,此时我们就需要考虑是 走还是 走,由给定的三个限制条件可以很轻松得到
#define pe pair<int,int>
#define x first
#define y second
pe cmp(pe a,pe b){
if(max(a.x,a.y)<max(b.x,b.y))return a;
if(max(a.x,a.y)>max(b.x,b.y))return b;
if(min(a.x,a.y)<min(b.x,b.y))return a;
if(min(a.x,a.y)>min(b.x,b.y))return b;
return a.x >= a.y ? a : b;
}
//主函数中的调用:
pe a,b;
int sx=s[id[x]],sy=s[id[y]],now=num[pos[id[x]]];//跳至环上节点sx,sy并得到这个环的大小now,我们直接将两个决策存为a,b再比较谁优秀
a.x=dep[x]+(sy-sx+now)%now;//代码小技巧:abs(sy-sx)%now=(sy-sx+now)%now,前提:sy-sx>=-now
a.y=dep[y];
b.x=dep[x];
b.y=dep[y]+(sx-sy+now)%now;
pe ans=cmp(a,b);
于是我们很容易的就可以完成这道题
流程如下:
- 建图
- 拓扑排序找环,统计环上信息
- 倍增准备LCA,顺带处理dep和连通块
- 接受提问和答案
#include<bits/stdc++.h>
#define pe pair<int,int>
#define x first
#define y second
using namespace std;
const int N=500006;
int n,m,t,f[N][20],in[N],pos[N],cnt,num[N],s[N],dep[N],id[N];
vector<int>e[N];
queue<int>q;
void bfs(){//处理各个环上节点的子树信息,如归属id,dep等
for(int i=1;i<=n;i++)
if(pos[i]){
id[i]=i;
q.push(i);
}
else e[f[i][0]].push_back(i);
while(q.size()){
int x=q.front();
q.pop();
for(int i=0;i<e[x].size();i++){
int y=e[x][i];
dep[y]=dep[x]+1;
id[y]=id[x];
q.push(y);
}
}
}
int lca(int x,int y){
if(dep[x]>dep[y])swap(x,y);
for(int i=t;i>=0;i--)if(dep[f[y][i]]>=dep[x])y=f[y][i];
if(x==y)return x;
for(int i=t;i>=0;i--)if(f[x][i]!=f[y][i])x=f[x][i],y=f[y][i];
return f[x][0];
}
pe cmp(pe a,pe b){
if(max(a.x,a.y)<max(b.x,b.y))return a;
if(max(a.x,a.y)>max(b.x,b.y))return b;
if(min(a.x,a.y)<min(b.x,b.y))return a;
if(min(a.x,a.y)>min(b.x,b.y))return b;
return a.x >= a.y ? a : b;
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i <= n;i++){
scanf("%d",&f[i][0]);
in[f[i][0]]++;
}
t=log(n)/log(2);
for(int i=1;i<=t;i++)
for(int x=1;x<=n;x++)
f[x][i]=f[f[x][i-1]][i-1];//直接处理倍增数组
for(int i=1;i<=n;i++)
if(!in[i])q.push(i);
while(q.size()){
int x=q.front();
q.pop();
if(!--in[f[x][0]])q.push(f[x][0]);
}//拓扑排序
for(int i=1;i<=n;i++)
if(in[i]&&!pos[i]){//拓扑排序之后还有入度的节点就是环上的节点,此时处理连通块
++cnt;
for(int j=i;!pos[j];j=f[j][0]){//标记整个环
pos[j]=cnt;
s[j]=++num[cnt];
}
}
//处理连通块
bfs();
while(m--){
int x,y;
scanf("%d %d",&x,&y);
if(pos[id[x]] != pos[id[y]])puts("-1 -1");
else if(id[x]==id[y]){
int p=lca(x,y);
printf("%d %d\n",dep[x]-dep[p],dep[y]-dep[p]);
}
else{
pe a,b;
int sx=s[id[x]],sy=s[id[y]],now=num[pos[id[x]]];
a.x=dep[x]+(sy-sx+now)%now;
a.y=dep[y];
b.x=dep[x];
b.y=dep[y]+(sx-sy+now)%now;
pe ans=cmp(a,b);
printf("%d %d\n",ans.x,ans.y);
}
}
return 0;
}
套路总结
遇题先分析哪种基环树(森林),第二步找环,第三步处理需要的信息,第四步就是按照题目要求统计答案,注意我们基环树对sum的定义方式
基环树上DP
对于基环树上的DP,无非就是树形DP还要处理环的叠加,众所周知,我们对于带环的DP处理方式有两种,一是进行两次DP,一次断开,一次强制连接(通过赋值特判等实现),另一个是将环复制一倍进行DP
对于基环树上的DP,这两种做法也有着不同的实现方式
第一种做法与树形DP相似,只是加入了特判
第二种做法需要我们将子树信息统计完整之后直接转为序列DP
当然应用得更多的还是第一种
下面以一道例题来说明这种情况
创世纪
上帝手中有 N 种世界元素,每种元素可以限制另外 1 种元素,把第 i 种世界元素能够限制的那种世界元素记为 a[i]。
现在,上帝要把它们中的一部分投放到一个新的空间中去建造世界。
为了世界的和平与安宁,上帝希望所有被投放的世界元素都有至少一个没有被投放的世界元素限制它。
上帝希望知道,在此前提下,他最多可以投放多少种世界元素?
分析
因为每一种元素可以限制另外一种元素,于是如果我们将
于是我们来思考哪一种方式更加容易实现代码
这道题很明显是一道DP题,如果我们设
上式的含义是:当不放置节点
上式的含义是,如果要投放元素
对于这个状态转移方程式,它告诉我们,我们需要快速使用
于是乎,我们就建立了一个每个节点有且仅有一条入边的有向图,即外向基环树森林
所以我们可以使用不断跳父亲节点的方式找到环,跳到最后的两个节点
第一次DP只有一种情况没有考虑到,即
#define N 1000005
int n,a[N],root,f[N][2],ans,head[N],ver[N],nxt[N],tot,vis[N];
void add(int u,int v){
nxt[++tot]=head[u],ver[tot]=v,head[u]=tot;
}
int dp(int u,int t){
f[u][0]=f[u][1]=0;
vis[u]=1;
int mn=0x3f3f3f3f;
for(int i=head[u];i;i=nxt[i]){
int v=ver[i];
if(v!=root){
int x=dp(v,t);
mn=min(mn,x-f[v][0]),f[u][0]+=x;
}
}
f[u][1]=f[u][0]-mn+1;
if(t&&u==a[root])f[u][1]+=mn;
return max(f[u][0],f[u][1]);
}
int solve(int u){
root=u;
while(!vis[a[root]])vis[root]=1,root=a[root];
int ans=dp(root,0);
dp(root,1);
return max(ans,f[root][0]);
}
int main() {
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
add(a[i],i);
}
for(int i=1;i<=n;i++)if(!vis[i])ans+=solve(i);
printf("%d\n",ans);
return 0;
}
本节知识点及需要背的板子:
- 三种基环树(森林)的定义与性质
- 三种基环树不同的找环方法
- 基环树经典模型扩展:路径问题,直径,动态规划
- 基环树的环上统计信息时的细节注意,sum数组的细节
- 使用回溯找到环
- 示例代码中的代码技巧
本节重要思想 - 基环树问题的通用思路,将环视作广义根节点
- 环上节点的子树分别计算,最后合并的分治思想
- 基环树上动态规划的环形解决
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战