树形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.多叉树转二叉树
蓝书给的链式前向星,因此我打多叉转二叉
原因就是可以直接考虑整个子树
状态转移方程:
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:
秉承父不用劵子不用劵的原则还有尽可能省钱的原则,我们应当从以i为节点的子树中挑
j个,从v为节点的子树中挑k个最优答案
决策维1:
i选了券决定
懒得打字就把原来写过的题解搬上来吧
挂个题解链接
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
状态转移方程:
树形:
i是当前节点,j时间,xzuo就是跑左子节点的边权,xyou就是跑右子节点的边权
背包:(要注意在代价的位置留出来进出的时间)
然后就是一些小点了
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中选就行了,比较最大值加入答案即可
写法是拓扑排序
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
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具