基环树及其常见问题

基环树

众所周知,一颗树是由\(N\)个点\(N-1\)条边组成的连通图,我们在树上任意加上一条边,树上就会产生一个环,这样\(N\)个点\(N\)条边组成的连通无向图就是基环树,当然,若不一定连通,这也可能是一个由基环树构成的森林,简称基环树森林
在有向图中,也有类似的概念,\(N\)个点\(N\)条边,每个点有且仅有一条出边的连通有向图内部有一个环,而其他边就好像向内收缩的样子,我们称这种有向图为内向基环树
类似的,每个点有且仅有一条入边的有连通向图好像对向外扩展,这种有向图被称为外向基环树
若不保证连通,也有可能是内/外向树森林
)
对于基环树的结构虽然简单,但比一般的树要复杂一些,因此常常成为一些经典模型的扩展,如基环树直径,基环树上两点路径,基环树上动态规划……
对于解决基环树问题,我们一般是先找到树上的环,并且以环作为基环树的“广义根节点”,把除了环以外的节点作为若干颗子树进行处理,然后考虑和环有一起计算
无论如何,基环树找环基本是必备操作,但我们上面提到的三种基环树,找环的方法也不尽相同
先看第一个普通基环树找环的过程:

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;

下面我们讨论几个经典模型在基环树上的扩展

基环树的直径

同样的,基环树的直径是基环树上最长的链,下面我们讨论它的求法
很明显,基环树的直径有两种可能,一是在环上某个节点的子树上,一是跨环的直径
对于第一种情况,我们只需要找出环,然后将环上节点标记,对每一个环上的节点的子树上进行一般的求直径的方法更新答案即可,我们设环上节点为\(s[i]\),一共有\(cnt\)个节点
对于第二种情况,我们需要先\(dfs\)找出环上每个节点作为一端的在自己子树上的最长链,这个通过\(dfs/bfs\)可以\(O(n)\)求出,记作\(d[s[i]]\)
然后第二步,我们对环上的节点做前缀和,记作\(sum[i]\),也即\(sum[i]=cost[s[1],s[cnt]]+\sum_{j=2}^{i}cost[s[j-1],s[j]]\),,于是我们需要解决的问题就变成了:找到一对\(i,j\),使得\(d[s[i]]+d[s[j]]+max(sum[cnt]-abs(sum[s[i]]-sum[s[j]]),abs(sum[s[i]]-sum[s[j]]))\),我们之所以在处理前缀和的时候直接加上了\(1-cnt\)的边的边权,是因为我们需要做前缀和,这个并不影响答案并且更好处理,具体原因看代码实现并自己手玩一下就可以理解了
至于这个找\(i,j\)的过程,我们可以将环复制一倍变成链来处理,直接进行DP,因为我们将环变成了两倍,于是我们就不必考虑\(\max\)函数,这是因为在最优化DP中,我们只需要考虑最优解是否在决策集合内,即\(i,j\),在环中会出现为\(i+cnt,j+cnt,i,j\)四个,若我们设定\(i<j\)的话,我们只会考虑两个决策,即\(i,j\)\(j,i+cnt\),反之同理,这样的两个决策就对应了\(\max\)函数里的两个参数,于是我们的DP式就变成了:

\[\max_{j<i,i-j<cnt}\lbrace sum[s[i]]-sum[s[j]] +d[s[i]]+d[s[j]]\rbrace \]

对于这个式子,我们可以采用单调队列优化,将\(sum[s[i]]+d[s[i]]\)提出来,使用单调队列维护单调递增的\(d[s[j]]-sum[s[j]]\),直接\(O(cnt)\)DP更新答案即可

我们梳理一下这个过程

  1. 找到图中的环,并对其做前缀和
  2. 对环上每一个节点的子树找到直径更新答案
  3. 对环上每一个节点进行dfs/bfs/dp找到子树内以自己为一端的最长链
  4. dp求出跨环的答案并更新
    其中过程\(2,3\)是可以合为一步的,还记得\(dp\)求直径吗,那个\(d\)数组就是我们需要的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的传呼机

为了随时与 \(rainbow\) 快速交流,\(Freda\) 制造了两部传呼机。
\(Freda\)\(rainbow\) 所在的地方有 \(N\) 座房屋、\(M\) 条双向光缆。
每条光缆连接两座房屋,传呼机发出的信号只能沿着光缆传递,并且传呼机的信号从光缆的其中一端传递到另一端需要花费 \(t\) 单位时间。
现在 \(Freda\) 要进行 \(Q\) 次试验,每次选取两座房屋,并想知道传呼机的信号在这两座房屋之间传递至少需要多长时间。
\(N\) 座房屋通过光缆一定是连通的,并且这 M 条光缆有以下三类连接情况:
\(A\):光缆不形成环,也就是光缆仅有\(N−1\)条。
\(B\):光缆只形成一个环,也就是光缆仅有 \(N\) 条。
\(C\):每条光缆仅在一个环中。
请你帮帮他们。
输入格式
第一行包含三个用空格隔开的整数,\(N、M\)\(Q\)

接下来 \(M\) 行每行三个整数 \(x、y、t\),表示房屋 \(x\)\(y\) 之间有一条传递时间为 \(t\) 的光缆。

最后 \(Q\) 行每行两个整数 \(x、y\),表示 \(Freda\) 想知道在 \(x\)\(y\)之间传呼最少需要多长时间。

输出格式
输出 \(Q\) 行,每行一个整数,表示 \(Freda\) 每次试验的结果。

数据范围
\(2≤N≤10000,\)
\(N−1≤M≤12000,\)
\(Q=10000,\)
\(1≤x,y≤N,\)
\(1≤t<32768\)
\(30%\)的数据,\(M=N-1\)
\(50%\)的数据,\(M=N\)
\(20%\)的数据,\(M>N\)

分析

看到本题会发现这是一个全源最短路径问题,嗯此时你想到了\(Floyd\),对不起,你只有\(5pts\)
我们先来考虑\(M=N-1\)的数据,很明显\(N\)个村庄构成一颗无根树,我们任选一个根节点进行\(dfs\),求出\(d\)数组,其中\(d[i]\)表示节点\(i\)与根节点的距离,我们再倍增/树剖处理出\(LCA\),对于一个询问\(u,v\),答案就是\(d[u]+d[v]-2\times d[lca(u,v)]\)

再来考虑\(N=M\)的数据,此时的询问有两种可能性

  1. \(u,v\)都在环上某个节点的子树上,此时我们按照一棵树处理即可
  2. \(u,v\)分别在环上某个节点的子树上
    对于第二种情况,我们设\(s[1]\sim s[cnt]\)为环上的节点,\(sum[i]=cost[s[1],s[cnt]]+\sum_{j=2}^icost[s[j],s[j-1]]\),\(b[i]\)表示节点\(i\)属于环上哪一个节点的子树,这里需要注意的是,我们的\(sum\)使用的是\(i\)来做下标,那么我们还需要一个\(ran\)数组与\(s\)建立映射才能正确通过节点查找前缀和,那么答案就是

\[d[u]+d[v]+min(sum[cnt]-|sum[ran[b[u]]]-sum[ran[b[v]]]|,|sum[ran[b[u]]]-sum[ran[b[v]]]|) \]

第三种情况超出了讨论范围,涉及到数据结构仙人掌树,也即圆方树

#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\) 个顶点的有向图,每个顶点有且仅有一条出边。

对于顶点 \(i\),记它的出边为 \((i,a[i])\)

再给出 \(q\) 组询问,每组询问由两个顶点 \(a、b\)组成,要求输出满足下面条件的 \(x、y\)

从顶点 \(a\) 沿着出边走 \(x\) 步和从顶点 \(b\) 沿着出边走 \(y\) 步后到达的顶点相同。
在满足条件 \(1\) 的情况下,如果解不唯一,则还需要令 \(\max(x,y)\) 最小。
在满足条件 \(1\)\(2\) 的情况下,如果解不唯一,则还需要令 \(\min(x,y)\) 最小。
在满足条件 \(1、2\)\(3\) 的情况下,如果解不唯一,则还需要令 \(x≥y\)
如果不存在满足条件 \(1\)\(x、y\),输出 \(-1 -1\)

输入格式
第一行两个正整数 \(n\)\(q\)

第二行 n 个正整数\(a[1],a[2],…,a[n]\)

下面 q 行,每行两个正整数 a,b,表示一组询问。

输出格式
输出 q 行,每行两个整数。

数据范围
\(n,q≤500000,\)
\(a[i]≤n,\)
\(a,b\le n\)

分析

本题的图是一个外向树森林,于是我们可以使用拓扑排序找环,然后我们分开讨论几种情况下的答案

  1. 无解的情况。很明显就是两点不在一棵基环树上,这个可以使用并查集或者标记数组即可实现
  2. 对于两个节点都在同一个基环树环上节点的子树内,此时满足“从顶点 \(a\) 沿着出边走 \(x\) 步和从顶点 \(b\) 沿着出边走 \(y\) 步后到达的顶点相同。”的所走到的节点无疑就是\(a,b\)的公共祖先,而因限制条件的存在,合法的答案就是走到两个节点的\(LCA\)这个处理一下也很简单
  3. 两个节点在基环树环上不同节点的子树内,此时肯定是需要两个节点先跳至环上,设跳到了\(u,v\),此时我们就需要考虑是\(u->v\)走还是\(v->u\)走,由给定的三个限制条件可以很轻松得到
#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);

于是我们很容易的就可以完成这道题
流程如下:

  1. 建图
  2. 拓扑排序找环,统计环上信息
  3. 倍增准备LCA,顺带处理dep和连通块
  4. 接受提问和答案
#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]。
现在,上帝要把它们中的一部分投放到一个新的空间中去建造世界。
为了世界的和平与安宁,上帝希望所有被投放的世界元素都有至少一个没有被投放的世界元素限制它。
上帝希望知道,在此前提下,他最多可以投放多少种世界元素?

分析

因为每一种元素可以限制另外一种元素,于是如果我们将\(i,a[i]\)进行连边的话,就会形成一个基环树森林,但是我们仍然需要注意的是,我们该建立内向基环树森林还是外向基环树森林
于是我们来思考哪一种方式更加容易实现代码
这道题很明显是一道DP题,如果我们设\(f[u,0/1]\)表示(不)放节点u的时候,最多可以投放多少元素,容易写出状态转移方程

\[f[u,0]=\sum_{a[v]=u}\max(f[v,0],f[v,1]) \]

上式的含义是:当不放置节点\(u\)的时候,所有限制它的元素都可以放置,类似的,若放置\(u\),则方程为

\[f[u,1]=1+\max_{a[x]=u}\lbrace f[x,0]+\sum_{a[v]=u,v\ne x}\max(f[v,0],f[v,1])\rbrace \]

上式的含义是,如果要投放元素\(u\),则至少有一个限制它的元素不投放,剩余的随意
对于这个状态转移方程式,它告诉我们,我们需要快速使用\(x\)查找到所有\(a[v]=x\)\(v\),于是我们不妨把\(x\)的子节点都设为\(a[v]=x\)的节点\(v\),具体的,我们把每个\(a[i]\)\(i\)连有向边,\(a[i]\)为父节点
于是乎,我们就建立了一个每个节点有且仅有一条入边的有向图,即外向基环树森林
所以我们可以使用不断跳父亲节点的方式找到环,跳到最后的两个节点\(p,a[p]\),我们断开它们的连接,在以p为根节点的树上进行一次DP,这一次DP就相当于没有用上\(p\)可以限制\(a[p]\)的条件,两个答案都可以更新
第一次DP只有一种情况没有考虑到,即\(a[p]\)\(p\)所限制,对于这个的解决办法就是,我们进行第二次DP,通过特判使得其强制性的被\(p\)所限制,即dp到节点\(a[p]\)的时候,强制性的令\(f[a[p],1]=f[a[p],0]+1\),也即不需要任何一个子节点来限制它(已被p所限制),最后的答案我们就只取\(f[p,0]\)更新答案即可

#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;
}

本节知识点及需要背的板子:

  1. 三种基环树(森林)的定义与性质
  2. 三种基环树不同的找环方法
  3. 基环树经典模型扩展:路径问题,直径,动态规划
  4. 基环树的环上统计信息时的细节注意,sum数组的细节
  5. 使用回溯找到环
  6. 示例代码中的代码技巧
    本节重要思想
  7. 基环树问题的通用思路,将环视作广义根节点
  8. 环上节点的子树分别计算,最后合并的分治思想
  9. 基环树上动态规划的环形解决
posted @ 2022-11-30 22:39  spdarkle  阅读(260)  评论(0编辑  收藏  举报