基环树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,然后取两种情况得到的答案的较大值。具体地,我们有以下流程:
- 利用 \(dfs\) 搜索到树上的环,记录断点,并继续搜索并记录整个环的每个点,同时维护环上点之间距离的前缀和,方便后期转移。
- 对于环上每个点的子树跑一边树形dp,记录这些子树的直径长度,取其最大值作为第一种情况的答案 \(ans_{1}\),同时把这些树的直径作为初始值赋值给环的dp数组。
- 断环为链,同时把链复制一遍,保证dp时能够从两侧(经过断点的一侧和不经过断点的一侧)进行转移,然后对链进行一次dp,选定一组起点和终点并得到跨越链上若干个点的最大直径长度,记为 \(ans_{2}\)。注意由于断环为链之后复制了一遍,所以为了保证时间复杂度使用单调队列优化。
- \(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;
}
总结
总的来说,基环树的一般处理方法分为断环和分类讨论两种。

浙公网安备 33010602011771号