树形DP

模拟赛里树形DP吃大亏,开贴重学
树形DP:树上与图上的DP
特征:给定一棵N节点的树作为问题(有根无根均可,但通常无根)
解法:
1.阶段:
一般以节点由深到浅,子树从小到大的顺序作为DP的阶段
即状态表示中,第一维通常是节点的编号(代表以该节点为根的子树)
2.状态:
依题而定
3.决策:
同理,依题而定
状态和决策的灵活性,使得树规的状态设计多种多样,但是阶段和写法基本一致:
阶段:以子树为阶段
写法:记忆化搜索,在递归求解该树的问题时先解决子树问题,然后依据子树信息和状态转移方程完成求解该树问题
对于以边形式或子节点-父节点关系的描述,我们的手段通常以前向星为存边手段
然后贴个记搜

简单的记搜
int work(int x,int y){
	if(f[x][y]!=0){
		return f[x][y];
	}
	if(x==0||y==0){
		return f[x][y];
	}
	f[x][y]=work(t[x].r,y);
	for(int i=0;i<=y-1;i++){
		f[x][y]=max(f[x][y],work(t[x].l,i)+work(t[x].r,y-1-i)+t[i].exp);
	}
	return f[x][y];
}

贴个前向星
code

点击查看代码
struct pathsim{//简化前向星 
	int t;//目标点 
	int n;//下一条边 
}a[o];
void add(int s,int t){//简化前向星的加边,无需边权 
	len++;//加边存next(结构体n)
	a[len].t=t;//存终点 
	a[len].n=s;//存next 
	h[s]=len; //实现链式 
} 
for(int i=head[n];i;i=a[i].next){
	代码
}
再贴个拓扑排序写的
点击查看代码
void work(){
    for(int i=1;i<=n;i++){
        if(a[i].et==0){
            q.push(i);
        }
    }
    while(!q.empty()){
        int u=q.front ();
        q.pop();
        f[u][1]+=a[u].exp;
        ans=max(f[u][1],ans);
        ans=max(f[u][0],ans);
        a[a[u].p].et--;
        if(a[a[u].p].et==0){
            q.push(a[u].p);
        }
        f[a[u].p][0]=max(f[a[u].p][0]+f[u][0],f[a[u].p][0]+f[u][1]);
        f[a[u].p][1]+=max(f[u][0],0);
    }
}

下面例题:
选课
分析:依赖背包模型:
对于每个父节点来说:选择的值就是所有子树选n个的最大值
对于选多少来说,我们就选择以选择数量为状态进行DP
决策就是选多少个的问题
下面处理多叉树问题:
1.链式前向星
2.多叉树转二叉树
蓝书给的链式前向星,因此我打多叉转二叉
原因就是可以直接考虑整个子树
状态转移方程:f[i][j+k]=max(f[i][j]+f[v][k],f[i][j+k])
i-j是指在i的兄弟的子树里选j个,而v-j是指从i的儿子的子树里选k个
然后就是森林转树的问题,建立一个虚的根节点“0”即可
Code

选课
#include <bits/stdc++.h>//选课
using namespace std;//树形DP 记忆化搜索
const int o=2222;//数据规模
struct node{//建树存节点信息
	int l;//左子
	int r;//右子
}t[o];//树
int m,n,f[o][o],a[o],ans;//数据
void build(int x,int y){//建树
	if(!t[y].l){//左儿子
		t[y].l=x;//建立与左儿子的联系
	}
	else{//兄弟或右儿子
		int s=t[y].l;//准备存右儿子
		while(t[s].r){//有右儿子
			s=t[s].r;//向下找儿子的右儿子
		}
		t[s].r=x;//建立联系
	}
}
void in(){//输入
	scanf("%d%d",&m,&n);//m和n
	int f;//建树用保存父节点
	for(int i=1;i<=m;i++){//输入m对父子关系
		scanf("%d%d",&f,&a[i]);//输入
		build(i,f);//建边
	}
}
int work(int x,int y){//记忆化搜素,以x为根节点选y个子节点的情况
	if(f[x][y]>0){//有值
		return f[x][y];//直接返回供调用
	}
	if(x==0||y==0){//x=0:以虚根为根,设为0,y=0:不选显然为0
		f[x][y]=0;//那就直接赋好0值
		return f[x][y];//返回即可
	}
	f[x][y]=work(t[x].r,y);//全选兄弟
	for(int i=0;i<=y-1;i++){//枚举选儿子的个数,y-1是因为依赖要选父节点
		f[x][y]=max(f[x][y],work(t[x].l,i)+work(t[x].r,y-i-1)+a[x]);//状态转移方程:从选左子树和右子树中选最优
	}
	return f[x][y];//返回最优值
}
void out(){//输出函数
	cout<<ans;//输出解
}
int main(){//主函数
	in();//输入
	ans=work(t[0].l,n);//整个问题可以看做以虚根节点的左子节点为子树的一个子问题,那么ans就是这个子问题的最优解
	out();//输出
	return 0;//结束
}

小胖守皇宫
对于点权的处理:
还是熟悉的树形,当然还是熟悉的子树划分状态
好吧,对于所有的点来说,在树当中,所有与它直接相关的点有三种情况
显然的,这三种情况就是:父节点,自己与子节点
同时,依据题意,我们应当对一个阶段按照这三种情况进行处理:
(这里需要同时对本节点和因为递归照顾到的子节点进行同时分析,为方便表达
设当前节点为dq,子节点为dqs,当然dqs可以是一个“子节点的集合"概念,此时dqs1,dqs2表示若干子节点)
1.dq被父守:此时dq已经确认是一个非“自守”即dq不守的情况,因此对于dqs这个子节点来说,他们不能被dq守,即dqs仅有“自守”与“子守”两种状态
2.dq自守:此时dq自守,那么dqs默认处于一个父守的状态,但是此时不保证dqs父守情况确实是最小值,仍需对“父守”,“自守”和“子守”三个状态
3.dq被dqs守即子守(即dqs1,dqs2...dqsi等状态),对于dq来说,dq仍然非“自守”
因此dqs仍然是一个仅有“自守”与“子守”两种状态,但是,不排除所有dqs全部处于“子守”状态成为最优解的可能
对于3情况,我们必须让一个dqs守dq,即一个dqs从“子守”转成“自守”,对于这一部分,补齐差量即可
那么,如何判断是否需要补齐差量呢?
开一个整型变量专门记录差量即可,对于dqs“自守”和“子守”两种状态比较最优,只有两种情况:“自守”优于“子守”,和“子守”优于“自守”,对应到差量:只有差量大于0和不大于0,由于我们要枚举所有的dqs,又由于存在性问题,我们要记录的就是差量最小值,如果不大于0,就说明存在“自守”优于“子守”,这时不用补差量,否则就是不存在上述情况,补差量即可

Code

小胖守皇宫
#include <bits/stdc++.h>//大胖守皇宫
using namespace std;//树形DP
const int o=1688,INF=0x3f3f3f3f;//o:节点数,INF最大值向下取最小 
struct pathsim{//简化前向星 
	int t;//目标点 
	int n;//下一条边 
}a[o];
int h[o],f[o][3],b[o];//h前向星的辅助数组,f实现状态转移,b存储原始数据
int len,n,r,ans;//len前向星边数,r树根,ans输出答案,n依题意输入 
void add(int s,int t){//简化前向星的加边,无需边权 
	len++;//加边存next(结构体n)
	a[len].t=t;//存终点 
	a[len].n=s;//存next 
	h[s]=len; //实现链式 
} 
void work(int u){//由于树/图的递归性质,采用递归写法实现(即搜索),防止超时同时实现重复子问题,采用记忆化搜索 
	/*在这里,每个节点i会出现三类情况
	1.自己守卫自己,用f数组中的f[i][0]表示
	2.父节点守卫自己,用f数组中的f[i][1]表示
	3.子节点守卫自己,用数组中的f[i][2]表示*/
	f[u][0]=b[u];//自守的代价显然是守卫该节点的价值
	int c=INF;//min初值为无限大
	for(int i=h[u];i;i=a[i].n){//前向星递归 
		int v=a[i].t;//枚举每一个儿子 
		work(v);//对儿子进行记忆化搜索
		f[u][0]+=min(f[v][0],min(f[v][1],f[v][2]));//对于自守,每个儿子有三种状态:自守,父守和子守三种情况,找出最小值加入当前答案
		f[u][1]+=min(f[v][0],f[v][2]);//对于父守:每个儿子有两种状态(因为它们的父节点不是自守,所以它们不能自守):自守和子守,找最小值加入答案 
		f[u][2]+=min(f[v][0],f[v][2])//对于子守:每个儿子仍是上述两种状态 ,选最小值加入即可
		c=min(c,f[v][0]-f[v][2])//为保证子守情况下,至少有一个儿子并且保证是最小值,由于原最小方案已加入,所以要取子节点v的自守与子守的最小差值, 补足差量 
	} 
	if(c>0){//注意:如果c大于0,此时说明任何情况下u子节点v采用“子守”永远优于“自守”方案,即不满足“至少有一个儿子v守护父节点i”,因此补足差量实现至少一个儿子守护,但是如果是c不大于0,可以认为方案中有至少一个儿子自守的情况作为最优解加入了答案,这个儿子v就当然实现了u“子守”的情况,因此此时不用考虑补差量 
		f[u][2]+=c;//补齐差量 
	} 
} 
void in(){//输入函数 
	r=1; 
	scanf("%d",&n);//输入数据规模 
	int x,y,z,k;//定义输入变量 
	for(int i=1;i<=n;i++){//循环输入n次 
		scanf("%d%d%d",&x,&y,&z);//输入节点编号,权值,儿子数 
		b[x]=y;//存储权值 
		int k=0;//初始化 
		for(int i=1;i<=z;i++){//输入子节点编号 
			if(k==r){//快速找根:如果儿子被确定与当前根节点相同,那么新的根节点会是他的父节点 
				r=x;//修改根节点使之等于父节点 
			}
			scanf("%d",&k);//读入子节点编号 
			add(x,k);//加边 
		}
	}
} 
void out(){//输出函数 
	ans=min(f[r][0],f[r][2]);//r是整个问题的答案,在不同的方案中取最优值:由于根节点没有父亲只能自守或子守选最优方案当做答案 
	cout<<ans;//输出即可 
}
int main(){
//	freopen("fat.in","r",stdin);//文件输入 
//	freopen("fat.out","w",stdout);//文件输出 
	in();//输入 
	work(r);//求解 
	out();//输出 
	return 0;//结束 
}

可怜与超市
根据决策分为依赖和0/1
但是由于金钱数据规模过大跑不了背包
被迫换设计成较小的物品数
既然没有了价格限制,我们的决策自然变成了:
买j个,用劵或不用劵
而对于劵的依赖性:
由于父节点用了劵,子节点就可以选择用劵或不用劵,而如果父节点不用劵:那么子节点一定不能用劵
因此,我们要维护的有用劵和不用劵两个状态,这一维状态就是我们的决策
有了决策,就有了状态转移
而我们因为枚举不同的物品购买数,所以要借助一个辅助数组叫size;
size表示一个父节点当前所有可以购买的子节点数
接下来的任务就是遍历每一个子节点并访问子树
最终得到子问题的最优解
而由于0/1性质,我们需要倒序循环
对于每次决策:
决策维0:f[i][j+k][0]=min(f[i][j+k][0],f[i][j][0]+f[v][k][0])
秉承父不用劵子不用劵的原则还有尽可能省钱的原则,我们应当从以i为节点的子树中挑
j个,从v为节点的子树中挑k个最优答案
决策维1:f[i][j+k][0]=min(f[i][j+k][1],f[i][j][1]+min(f[y][k][0],f[y][k][1]))
i选了券决定f[i][j]带券,但是y就可带可不带选最小值了
懒得打字就把原来写过的题解搬上来吧
挂个题解链接
Code

点击查看代码
#include <bits/stdc++.h>//可怜与超市 
#define sbcrs sbwqz//树形DP(依赖背包类树形) 
using namespace std;//分类讨论 
#define ll long long //开long long 
const int o=5005;//数据规模 
class supermarket{//定义数据类 
	public://公开访问:在其他位置可以使用 
	ll n,b,ans,h[o],cnt;//定义数据:n种类,b钱数,ans答案,h前向星用,cnt记边数 
	int size[o],f[o][o][2];//size存子节点个数,f状态转移 
	struct node{//定义数据节点 
		ll c;//c原价 
		ll d;//d优惠价 
		ll x;//x父节点编号 
	}a[o];//a存储每一个数据节点 
	struct tree{//建树/建图 
		int t;//终点 
		int n;//下一条边 
	}p[o*2];//
}w;//存储数据的类封装 
namespace sbwqz{//定义函数封装 
	void add(ll s,ll t){//建边函数:s起点t终点 
		w.cnt++;//边数增加准备存边 
		w.p[w.cnt].t=t;//存终点 
		w.p[w.cnt].n=w.h[s];//存下一条边 
		w.h[s]=w.cnt;//保证链式 
	}
	void pre(){//预处理 
		memset(w.f,0x3f,sizeof(w.f));//求最小值,初始化为较大数 
	}
	void in(){//实现输入 
		scanf("%d%d",&w.n,&w.b);//输入n和b 
		scanf("%d%d",&w.a[1].c,&w.a[1].d);//对1号节点输入特殊处理 
		for(int i=2;i<=w.n;i++){//从2开始 进行正常输入 
			scanf("%d%d%d",&w.a[i].c,&w.a[i].d,&w.a[i].x);//分别输入c.d.x 
			add(w.a[i].x,i);//建边,a[i]是父节点,i子节点 
		}
	}
	void find(){//查找最优答案 
		for(int i=1;i<=w.n;i++){//从1到n遍历 
			if(w.f[1][i][1]<=w.b){//以1为根,对所有用劵的状态查找 
				w.ans=i;
			}
		}
	}
	void out(){//输出函数 
		cout<<w.ans;//输出答案 
	}
	void work(int x){//求解函数 x为根节点 
		w.size[x]=1;//初始化,可访问点初始为1 
		w.f[x][1][1]=w.a[x].c-w.a[x].d;//如果只买x用劵的话显然 
		w.f[x][1][0]=w.a[x].c;//同理,不用劵也是显然对 
		w.f[x][0][0]=0;//直接不买花销自然为0 
		for(int i=w.h[x];i;i=w.p[i].n){//遍历所有边 
			int y=w.p[i].t;//找到终点 (子节点) 
			work(y);//对子节点进行求解 
			for(int j=w.size[x];j>=0;j--){//0/1背包DP倒序循环 
				for(int k=w.size[y];k>=0;k--){//同理 
					w.f[x][j+k][1]=min(w.f[x][j+k][1],w.f[x][j][1]+min(w.f[y][k][0],w.f[y][k][1]));//用劵的最小值 
					w.f[x][j+k][0]=min(w.f[x][j+k][0],w.f[x][j][0]+w.f[y][k][0]);//不用劵的最小值 
				}
			}
			w.size[x]+=w.size[y];//增大x规模 
		}
	}
}
using namespace sbcrs;//调用封装好的函数准备实现功能 
int main(){
	freopen("supermarket.in","r",stdin);//文件输入 
	freopen("supermarket.out","w",stdout);//文件输出 
	pre();//预处理 
	in();//输入 
	work(1);//求解 
	find();//查找最优解 
	out();//输出 
	return 0;//结束 
}

偷天换日
树形DP套0/1背包
如果没跑到展厅就是个树形结构
此时毫无疑问就是以子树为子问题的问题
那么这时候就是树形
跑到了就0/1背包,但是注意一进一出,回到父节点的边权要乘2
状态转移方程:
树形:f[i][j]=max(f[2i][j2xzuo],f[2i+1][j2xyou])
i是当前节点,j时间,xzuo就是跑左子节点的边权,xyou就是跑右子节点的边权
背包:(要注意在代价的位置留出来进出的时间)
f[i][j]=max(f[i][j],f[i][jc[x]]+w[x])
c[x],w[x]就是画的价值和代价了
然后就是一些小点了
1.边权乘2然后就不用管了
2.输入问题由于DFS序,我们可以依靠记搜+循环解决
3.什么时候算跑路成功,答案是n-1才算,因为第n时刻警察就在博物馆门口撞上你了。。。会有一个非常尴尬的场面,已经想象出来了
结束
code

偷天换日
#include <bits/stdc++.h>
int n,m,a,b;
const int o=5700;
int v[o],f[o][o],c[o];
namespace mf{
	int read(){
		int x=1,y=0;
		char ch=getchar();
		while(!isdigit(ch)){
			if(ch=='-'){
				x=-1;
			}
			ch=getchar(); 
		}
		while(isdigit(ch)){
			y=(y<<1)+(y<<3)+(ch&15);
			ch=getchar();
		}
		return x*y;
	}
	void work(int u){
		int x,y;
		x=read();
		y=read();
		x<<=1;
		if(y){
			for(int i=1;i<=y;i++){
				v[i]=read();
				c[i]=read();
			}
			for(int i=1;i<=y;i++){
				for(int j=n;j>=c[i]+x;j--){
					f[u][j]=std::max(f[u][j],f[u][j-c[i]]+v[i]);
				}
			}
		}
		else{
			work(u<<1);
			work(u<<1|1);
			for(int i=x;i<=n;i++){
				for(int j=0;j<=i-x;j++){
					f[u][i]=std::max(f[u][i],f[u<<1][i-j-x]+f[u<<1|1][j]);
				}
			}
		}
	}
	void in(){
		n=read();
		n--;
	}
	void out(){
		printf("%d",f[1][n]);
	}
}
int main(){
	//freopen("steal.in","r",stdin);
	//freopen("steal.out","w",stdout);
	mf::in();
	mf::work(1);
	mf::out();
	return 0;
}

没有上司的舞会
维护两种状态即可,一个是直接上司(父节点)来(定义决策维为1)
一个是直接上司不来(定义决策维为0)
对于1情况,每个子节点必须不到,即选0
对于0情况,子节点可以在0/1中选就行了,比较最大值加入答案即可
f[a[u].p][0]+=max(f[u][0],f[u][1]);
f[a[u].p][1]+=max(f[u][0],0);
写法是拓扑排序
code

没有上司的舞会
#include <bits/stdc++.h>
using namespace std;
const int o=6666;
struct node{
	int exp;
	int et;
	int p;
}a[o];
int f[o][2],n,ans,s,x,y;
queue<int>q;
void in(){
	scanf("%d",&n);
	for(int i=1;i<=n;i++){
		scanf("%d",&a[i].exp);
	}
    while(scanf("%d%d",&x,&y)!=EOF){
        if(x==0&&y==0){
            break;
        }
        a[y].et++;
        a[x].p=y;
    }
}
void work(){
    for(int i=1;i<=n;i++){
        if(a[i].et==0){
            q.push(i);
        }
    }
    while(!q.empty()){
        int u=q.front ();
        q.pop();
        f[u][1]+=a[u].exp;
        ans=max(f[u][1],ans);
        ans=max(f[u][0],ans);
        a[a[u].p].et--;
        if(a[a[u].p].et==0){
            q.push(a[u].p);
        }
        f[a[u].p][0]=max(f[a[u].p][0]+f[u][0],f[a[u].p][0]+f[u][1]);
        f[a[u].p][1]+=max(f[u][0],0);
    }
}
void out(){
    cout<<ans;
}
int main(){
    in();
    work();
    out();
    return 0;
}

总结:最近模拟赛里树形DP吃大亏,我感觉是因为没有掌握树形DP的设计方式,以及认为自己不会写导致的,所以熟悉一下树形的阶段,状态和决策的枚举
其实拿到任何一个DP题,都需要考虑如何设计它的阶段,状态和决策,这里是体现DP的思考量的位置,并且这里能看出来选手的想象力,创造力和思维能力
而对于任何一种DP题,最重要的是状态转移方程,而状态转移方程从哪来?来自于阶段,状态和决策的划分
而三要素从哪里来?阶段由其本身决定,而后两者,其实就来自于题面
从题面中充分提取信息,再加上码力,树规也是一类很简单的DP

posted @   2K22  阅读(53)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
点击右上角即可分享
微信分享提示