基环树dp

基环树dp

顾名思义,基于基环树的dp

基环树

也叫做环套树,实际上就是在一棵树上加了一条边,使得这棵树中出现了一个环。所以对于基环树问题,我们需要考虑的就是如何处理这个环。

基环树的常见处理方式

暴力拆开

例题 P1453 城市环路
题目给出了一颗基环树,已知树上每个点的点权,要求得到一个点权和最大的点集的点权,使得这个集合中任意两个点不会被一条边相连。可以发现是最大独立集模型,经典dp套路可过,dp详细过程见代码注释。所以需要考虑的就是怎么处理这个环。首先需要找到这个环,跑一遍 \(dfs\) 并维护一个数组 \(vis\) 记录每个点是否出现。如果 \(dfs\) 过程中走到了一个已经出现过的点,则直接退出,并记录当前点和它的父亲作为我们断开环的两个断点,记为 \(p_{1}\)\(p_{2}\)。我们考虑把边 \(p_{1} \to p_{2}\) 断开使整个基环树变成一棵普通的树,那么需要考虑断开后会对整体贡献造成怎样的影响。我们知道,\(p_{1}\)\(p_{2}\) 不能同时被选中。我们钦定两者之一不选,那么另一个选不选都使合法的,没有影响。所以我们先以 \(p_{1}\) 为根跑一边dp,只取 \(p_{1}\) 不选的情况作为待定答案 \(ans_{1}\),这样dp时对 \(p_{2}\) 选或不选都没有要求了。同理我们以 \(p_{2}\) 为根跑一次dp得到 \(ans_{2}\),然后取 \(max(ans1,ans2)\) 就是答案。

代码:

点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 100010

/*思路:基环树dp板子之一。分析题意,题目要求我们求n个点中若干个点构成的集合的点
权和的最大值,要求同一条边上的两个点最多选择一个,可以发现是最大独立集模型。
设dp[i][0/1]表示以i为根的子树中,第i个点选不选得到的最大值。初始所有dp[i][1]设为
p[i]代表i自己的权值。转移时枚举i的儿子,如果选i,儿子只能不选,直接累加;如果
不选i,儿子可选可不选,取max后累加。方程比较简单,直接看代码即可。
重点是题目说给出了一颗基环树,也就是只有一个环的树,所以我们需要考虑这个环怎么
处理。我们想要一颗树,所以自然想到要把环断开。思考断开之后怎么处理。我们假设
环的两个断点分别为p1,p2,也就是我们删掉了p1->p2这条双向边。那么根据题目要求,
我们判定p1,p2只能有一个被选中。所以我们想到分别以p1,p2为根做两边dp,每次dp强制
令p1,p2不能被选中,这样另一个点是否被选中的情况都能覆盖到。最后对两次dp的结果
取较大值就行。注意dp时要进行判断,如果搜到的x,y分别对应p1,p2直接跳过。 
考虑如何找到这个环。我们用vis数组记录某个点是否被访问,然后对基环树跑一次dfs。
如果我们访问到一个已经访问的点,说明找到了一个环,则此时的x,y必定在环上,直接记录
*/

int n,u,v,ans;
double K;
int p[N];
struct edge{
	int to,nxt;
}e[2*N];
int head[N],tot;
int dp[N][2]; 
int p1,p2;
bool vis[N],flag=0;

void add(int u,int v){
	e[++tot].to=v;
	e[tot].nxt=head[u];
	head[u]=tot;
}

//dfs判环,找到环上的两个节点 
void get(int x,int fa){
	vis[x]=1;
	for(int i=head[x];i;i=e[i].nxt){
		int y=e[i].to;
		if(y==fa) continue;
		if(!vis[y]){
			get(y,x);
		}else if(vis[y]){
			p1=x,p2=y;
		}
	}
}

//最大独立集 
void dfs(int x,int fa){
	dp[x][0]=0;//自己不选,初始没有贡献
	dp[x][1]=p[x];//自己选上,初始贡献为自己的值
	for(int i=head[x];i;i=e[i].nxt){
		int y=e[i].to;
		if(y==fa) continue;
		if((x==p1&&y==p2)||(x==p2&&y==p1)) continue;
		dfs(y,x);
		dp[x][0]+=max(dp[y][0],dp[y][1]);//x不选,y选不选都行
		dp[x][1]+=dp[y][0];//x选上,y一定不选 
	} 
}

signed main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++){
		cin>>p[i]; 
	}
	for(int i=1;i<=n;i++){
		cin>>u>>v;
		u++;v++;
		add(u,v);
		add(v,u);
	}
	cin>>K;
	//找出一个环
	get(1,0); 
	//断开环,分别钦定两个断点不放跑两遍dfs
	dfs(p1,0);
	ans=max(ans,dp[p1][0]);
	dfs(p2,0);
	ans=max(ans,dp[p2][0]);
	K=ans*1.0*K;
	cout<<fixed<<setprecision(1)<<K;
	return 0;
}

例题 P2607 骑士
分析题意,题目给出了一个基环树森林,同样最大独立集模型,只不过变成了求森林中每棵树的最大独立集之和。考虑直接搜每一个连通块,dp统计答案然后求和。但这题有一个需要注意的点:不保证边不重复。我们考虑,如果出现重复边,树上的环就是一个二元环,只包含两个点。而这样的环如果按照上一题的方式断开,就会导致树不连通。所以首先要进行去重操作,使用 \(map\) 即可。然后我们考虑,由于重边的存在,一些基环树可能会退化为树。所以对于每个连通块需要进行判定,如果是树则只执行一个dp即可。

代码:

点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 1000010 

/*思路:要求互相为敌的骑士不能同时选中,则把互相为敌的骑士连边,构成了最大独立集
模型,直接套状态。由于本题没有保证不重,所以实际上给出的是一颗森林,并且需要进行
去重操作。用map做一个去重,然后扫每一个连通块。如果不是基环树,直接dp;如果是
基环树,按照经典套路做基环树dp即可。累加每一个连通块的答案得到最终答案。*/

int n,u,v,ans;
int p[N];
unordered_map<int,bool> mp;//防止重边 
struct edge{
	int to,nxt;
}e[2*N];
int head[N],tot;
int dp[N][2]; 
int p1,p2;
bool vis[N],flag=0;

void add(int u,int v){
	e[++tot].to=v;
	e[tot].nxt=head[u];
	head[u]=tot;
}

//dfs判环,找到环上的两个节点 
void get(int x,int fa){
	vis[x]=1;
	for(int i=head[x];i;i=e[i].nxt){
		int y=e[i].to;
		if(y==fa) continue;
		if(!vis[y]){
			get(y,x);
		}else if(vis[y]&&!flag){
			p1=x,p2=y;
			flag=1;
		}
	}
}

//最大独立集 
void dfs(int x,int fa){
	dp[x][0]=0;//自己不选,初始没有贡献
	dp[x][1]=p[x];//自己选上,初始贡献为自己的值
	for(int i=head[x];i;i=e[i].nxt){
		int y=e[i].to;
		if(y==fa) continue;
		if((x==p1&&y==p2)||(x==p2&&y==p1)) continue;
		dfs(y,x);
		dp[x][0]+=max(dp[y][0],dp[y][1]);//x不选,y选不选都行
		dp[x][1]+=dp[y][0];//x选上,y一定不选 
	} 
}

signed main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++){
		cin>>p[i]>>v;
		u=i;
		if(u>v) swap(u,v);
		int hash=u*N+v;
		if(mp[hash]==1) continue;
		add(u,v);
		add(v,u);
		mp[hash]=1;
	}
	for(int i=1;i<=n;i++){
		flag=0;//清空变量
		int res=-1;//临时记录 
		if(vis[i]) continue;//如果已经搜过就跳过 
		//找出一个环
		get(i,0); 
		if(!flag){//这个连通块不是基环树
			//直接dp一遍求答案 
			dfs(i,0);
			res=max(dp[i][0],dp[i][1]); 
		} else{//当前连通块时基环树 
			//断开环,分别钦定两个断点不放跑两遍dfs
			dfs(p1,0);
			res=max(res,dp[p1][0]);
			dfs(p2,0);
			res=max(res,dp[p2][0]);
		}
		ans+=res; 
	}
	cout<<ans;
	return 0;
}
分类讨论

例题 P4381 Island
分析题意,题目给出了一个基环树森林,要求得到每一棵树的直径长度之和。也就是说这是一个基环树求直径的题。我们考虑,对于一颗基环树,直径可能有以下两种情况:其一,直径在基环树的环上某个点的子树内部,与环上其他点没有关联。其二,直径从环上某个点的子树内部开始,一直到环上另一个点的子树内部,走过环的一段。对于两种情况我们分开考虑。对于第一种情况,直接进行树形dp求解子树答案。对于第二种情况,则需要对环再进行一次dp,然后取两种情况得到的答案的较大值。具体地,我们有以下流程:

  1. 利用 \(dfs\) 搜索到树上的环,记录断点,并继续搜索并记录整个环的每个点,同时维护环上点之间距离的前缀和,方便后期转移。
  2. 对于环上每个点的子树跑一边树形dp,记录这些子树的直径长度,取其最大值作为第一种情况的答案 \(ans_{1}\),同时把这些树的直径作为初始值赋值给环的dp数组。
  3. 断环为链,同时把链复制一遍,保证dp时能够从两侧(经过断点的一侧和不经过断点的一侧)进行转移,然后对链进行一次dp,选定一组起点和终点并得到跨越链上若干个点的最大直径长度,记为 \(ans_{2}\)。注意由于断环为链之后复制了一遍,所以为了保证时间复杂度使用单调队列优化。
  4. \(max(ans_{1},ans_{2})\) 就是最终答案。
    每一步的详细实现过程不再赘述,参考这里的第一篇题解,说的很详细。

代码

点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 2000100

int n,u,w;
struct edge{
	int to,nxt,w;
}e[N];
int head[N],tot;
//记录属于某个环的点的编号以及环上点之间的距离(从衔接点开始记录前缀和) 
int r[N],sum[N],st,cnt;
/*为了节省重置数组的时间,这里不重置cnt,用st记录环的起始点*/ 
//v用来标记找环时dfs走过的点,vis用来标记走完的基环树上的点,v中记为2的是衔接点 
int vis[N],v[N]; 
int f[N],ans1,ans2;//ans存储当前子树的最长链,ans2存储子树dp的答案
int dp[N],ans3;//第二种情况的dp数组,在求出环上每个子树的值后考虑环上的路径选取 
int res;//总答案 

void add(int u,int v,int w){
	e[++tot].to=v;
	e[tot].w=w;
	e[tot].nxt=head[u];
	head[u]=tot;
}

//第一遍dfs,找到基环树中的环,并把这个环剔除出来。 
bool dfs(int x,int fa){
	//判断第一次走到重复的点,则记录为衔接点 
	if(v[x]==1){
		v[x]=2;//记录为衔接点
		r[++cnt]=x;
		vis[x]=1;
		return 1;//改变返回值为1,代表开始遍历环 
	}
	v[x]=1;
	for(int i=head[x];i;i=e[i].nxt){
		/*如果当前边不是上一条边并且已经开始遍历环,则找到了环上某个未经记录的节
		点,记录*/
		if(i==((fa-1)^1)+1) continue;
		if(dfs(e[i].to,i)==0) continue;
		if(v[x]!=2){//当前节点不是衔接点 
			r[++cnt]=x;
			vis[x]=1;
			sum[cnt]=sum[cnt-1]+e[i].w; 
		}else{//是衔接点,只记录边权 
		//此时给sum[0]赋一个值,保证断环为链后复制sum时的正确性 
			sum[st-1]=sum[st]-e[i].w;
			return 0;//回溯到衔接点被走到之前,进入二阶段开始跑环 
		}
		return 1;//二阶段跑环期间都返回1 
	}
	return 0;
}

//分类讨论。第一种情况:直径在环之外的某个子树内,对每个子树跑一个dp求答案 
void solve1(int x){
	vis[x]=1;
	for(int i=head[x];i;i=e[i].nxt){
		int y=e[i].to;
		if(vis[y]) continue;
		solve1(y);
		//尝试更新当前子树的直径
		ans1=max(ans1,f[x]+f[y]+e[i].w);
		//尝试更新父亲的dp值
		f[x]=max(f[x],f[y]+e[i].w); 
	}
	return;
}

//分类讨论。第二种情况:直径是环上某个点的子树到环上另一个点的子树的链
int solve2(int rt){
	st=cnt+1;//开启一段新的区间,进行本次dp
	ans2=0,ans3=0;
	//找到环,开始操作 
	dfs(rt,0);
	/*先跑出第一种情况的值,并为环上的数组赋初值,同时断环为链,
	把环复制一遍,并叠加到一起 */
	for(int i=st;i<=cnt;i++){
		ans1=0;//清零 
		solve1(r[i]);//对环上的节点的子树跑一边dp
		ans2=max(ans2,ans1);//尝试更新第一种情况的答案
		//断环为链,并复制一遍 
		dp[i+cnt-st+1]=dp[i]=f[r[i]];//给环上的dp数组赋初值
		sum[i+cnt-st+1]=sum[i+cnt-st]+sum[i]-sum[i-1];//统计前缀和数组 
	}
	//单调队列优化链(原来的环)上的dp
	deque<int> q;
	for(int i=st;i<=2*cnt-st+1;i++){
		while(q.size()&&q.front()<=i-cnt+st-1) q.pop_front();
		if(!q.empty()) //更新环上的答案 
			ans3=max(ans3,dp[i]+dp[q.front()]+sum[i]-sum[q.front()]) ;
		//维护单调
		while(q.size()&&dp[q.back()]-sum[q.back()]<=dp[i]-sum[i])
			q.pop_back(); 
		q.push_back(i);
	} 	
	//返回两种情况的较大值
	return max(ans3,ans2); 
}

signed main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++){
		cin>>u>>w;
		add(i,u,w);
		add(u,i,w);
	}
	//筛连通块,分别进行dp 
	for(int i=1;i<=n;i++){
		if(!vis[i]) res+=solve2(i); 
	}
	cout<<res;
	return 0;
}

总结

总的来说,基环树的一般处理方法分为断环分类讨论两种。

posted @ 2025-05-30 19:49  Yun_Mo_s5_013  阅读(44)  评论(0)    收藏  举报