树形 DP 学习笔记
树形 DP-1
本文讲解如何用树形 DP 解决兄弟节点间没有数量上的约束关系的树上问题。状态通常是 \(dp_i\) 表示以 \(i\) 为根节点的子树的信息。
一般是将各个子树的信息合并至根节点的信息,故一般先递归再转移。
这种树形 DP 较为简单,写几道例题看一看。
P1122 最大子树和
设 \(dp_x\) 表示以 \(x\) 为根的子树的最大子树和。对于边 \(x\rightarrow to\),其决策是以 \(x\) 为根的子树选不选以 \(to\) 为根的子树中的最大子树和。故有转移 dp[x]=max(dp[x],dp[x]+dp[to]);。
P1352 没有上司的舞会
经典树形 DP 入门题。
发现每个点选或不选会影响其子孙节点的决策,故考虑升维。
\(dp_{x,0/1}\) 表示以 \(x\) 为根的子树中,\(x\) 选或不选,最大快乐指数。则对于边 \(x\rightarrow to\),有转移:
dp[x][0]+=max(dp[to][0],dp[to][1]);
dp[x][1]+=dp[to][0];
P2585 [ZJOI2006] 三色二叉树
状态转移很套路,设二维就可以了,一维是根节点,二维是染的色,可以将颜色绿、红、蓝分别设为数字 \(\texttt{0/1/2}\) 进行转移。
主要是谈谈怎么建树。可以直接递归建树,因为题目给出的输入顺序是按照 DFS 序给出的,所以我们可以顺其自然,一边递归一边建树。如下:
int build(){
int cur=++tot;
if(s[cur]=='2'){
ls[cur]=build();
rs[cur]=build();
}
else if(s[cur]=='1')
ls[cur]=build();
return cur;
}
P1131 [ZJOI2007] 时态同步
\(dp_x\) 表示以 \(x\) 为根的子树时态同步所需要的代价。我们需要先找到这棵子树中最长的距离,然后再调整其他的距离。但最长的距离也需要维护,故进行 \(2\) 遍 DFS。一遍求最长距离,一遍转移。
部分代码:
dis[x]=max(dis[x],dis[to]+w);//以x为根节点的子树中的最长链
dp[x]+=dis[x]-dis[to]-w;//将其它链调整至最长链的长度
树形 DP-2
本文讲解如何用树形 DP 解决兄弟节点间有数量上的约束关系的问题。解决方法大致是转化为多重背包问题,把一个节点的子节点视为物品,讲题目给定的数量约束视为背包容量。
另放一些思维/代码很难的树形 DP。
写一道题目了解一下大致框架。
P2015 二叉苹果树
设 \(dp_{i,j}\) 表示在以 \(i\) 为根的子树上保留 \(j\) 条边,至多保留的苹果数目。
对于根节点 \(cur\),一棵一棵子树进行遍历,若遍历到了以 \(nxt\) 为根节点的子树,那么可以有如下转移方程:dp[cur][j]=max(dp[cur][j],dp[cur][j-k-1]+dp[nxt][k]+w);。需要注意的是,方程中的 dp[cur][j-k-1] 并不是在 \(cur\) 的所有子树中选取 \(j-k-1\) 条边,而是在 \(nxt\) 之前的子树中选取。所以,我们可以理解转移方程为:在当前子树中选取 \(k\) 条边,在前面的子树中选取 \(j-k-1\) 条边的最大贡献。为什么少了一条呢,因为还有 \(cur\rightarrow nxt\) 的那一条边啊。
至此,这道题已经有了正解,但是我们还可以进一步优化。
我们 \(j\) 的枚举上限可以缩小为 \(\min(Q,siz_{cur})\),其中 \(siz_{cur}\) 表示 \(cur\) 当前遍历过的子树中节点数量。同理,\(k\) 的枚举上限可以缩小为 \(\min(Q,siz_{nxt})\)。这两个优化看上去微不足道,却可以把时间复杂度从立方级别优化为平方级别。
因为作者个人能力有限,请读者移步别处查看此结论的证明。
贴个代码罢。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=105;
int n,q;
int siz[N];
int dp[N][N];
struct node{
int id,val;
};
vector<node>v[N];
void DFS(int x,int fa){
siz[x]=0;
for(int i=0;i<v[x].size();i++){
int to=v[x][i].id,w=v[x][i].val;
if(to==fa)
continue;
DFS(to,x);
siz[x]+=siz[to]+1;
for(int j=min(siz[x],q);j>=0;j--)
for(int k=0;k<=min(siz[to],j-1);k++)
dp[x][j]=max(dp[x][j],dp[x][j-k-1]+dp[to][k]+w);
}
return;
}
signed main(){
cin>>n>>q;
for(int i=1;i<n;i++){
int x,y,z;
cin>>x>>y>>z;
v[x].push_back({y,z});
v[y].push_back({x,z});
}
DFS(1,0);
cout<<dp[1][q];
return 0;
}
P2014 [CTSC1997] 选课
和前一道例题大同小异。
根据选课的约束关系建树。然后设 \(dp_{i,j}\) 表示以 \(i\) 为根的子树中选 \(j\) 门课获得的最大学分,转移差不多,就不说了。同时,因为可能有多棵树,不好维护,考虑给所有独立的树建立一个必须选的虚根 \(0\),成为一棵大的树,这样方便处理一些。
故最后答案为 \(dp_{0,M+1}\)。
P1272 重建道路
上点难度。
状态比较好设:\(dp_{i,j}\) 表示以 \(i\) 为根节点的子树保留恰好 \(j\) 个点时最少的删边数。初始状态则有 \(dp_{i,1}\) 等于点 \(i\) 连出去的边的个数,即 v[i].size()。
转移呢?先放个代码:dp[cur][j]=min(dp[cur][j],dp[nxt][k]+dp[cur][j-k]-1);
这个转移唯一难以理解的是,这个 -1 是怎么回事?其实很简单,别忘了,dp[cur][j-k] 统计的是 \(nxt\) 前面所有子树的答案,统计他们时,是删掉了 \(cur\rightarrow nxt\) 这一条边的,但这条边现在是不能删的!所以我们多删了一条边,故减回去。
这题的答案也有点小坑。应该这么统计:
int ans=dp[1][p];
for(int i=2;i<=n;i++)
ans=min(ans,dp[i][p]+1);
为什么呢?当我们选点 \(2\) 到 \(n\) 时,dp[i][p] 只是求取的将其子树中某些边删去的答案,但它到其父亲的边也要删去,才能将他这棵子树“剔除”在外。故删边数加 \(1\)。(点 \(1\) 不用加 \(1\),因为 \(1\) 是根节点啊。)
POJ1848 Tree
前言:恶心题目,甚至没提到有多测,而且 POJ 不支持看数据点,导致我现在没有条出来。不过 no problem。同时其实应该放到学习笔记(一)中的因为不是树上背包。不过思维难度较大,就放(二)里了。
题意:给定一棵树,求最少要加多少条边使得树上每个节点都仅在一个简单环里。
设状态 \(dp_{i,0/1/2}\),分别表示:
- \(dp_{i,0}\) 表示 \(i\) 子树内的全部节点均仅包含在一个简单环中的最小加边数。
- \(dp_{i,1}\) 表示除 \(i\) 之外,\(i\) 子树内的全部节点均仅包含在一个简单环中的最小加边数。
- \(dp_{i,2}\) 表示除 \(i\) 出发的一条长度大于 \(1\) 的链以外,\(i\) 子树内的全部节点均仅包含在一个简单环中的最小加边数。
然后对于边 \((u,v)\) 设 \(sum=\sum dp_{v,0}\),则有转移:
- \(dp_{u,1}=sum\)。
- \(dp_{u,0}=sum-dp_{v,0}+dp_{v,2}+1\)。这里用一下网上资料的图片:

表示 \(u\) 从 \(v\) 伸出去的链上接回来,加了一条边。 - \(dp_{u,0}=sum-dp_{v1,0}-dp_{v2,0}+\min(dp_{v1,1},dp_{v1,2})+\min(dp_{v2,1},dp_{v2,2})+1\)。同样用网上资料的一张图:

表示 \(R\)、\(B\) 延伸部分、\(C\) 延伸部分组成环。注意此时 \(B,C\) 不能已全部成环,这样无法连接,所以不将 \(F[B][0],F[C][0]\) 计入贡献。 - \(dp_{u,2}=sum-dp_{v,0}+\min(dp_{v,1},dp_{v,2})\) 表示 \(u\) 接上 \(v\) 的链。
什么?代码?没有。
P8867 [NOIP2022] 建造军营
同样不是树形背包但是难度比较大,故放至(二)。本文同步载于我的另一篇文章,部分参考本题题解区第一篇题解。
首先缩点,将整张图转化为一棵树。那么树上一个节点代表一个边双,每一个边双里的点和边都可以自由分配。(敌人无论如何都无法炸一条边使得这个边双不连通。)
转化为树之后,问题成为一个显然的树形 DP。设计状态为 \(dp_{x,0/1}\) 表示以 \(x\) 为根的子树内建/不建军营的方案数。发现这样不好转移,考虑增加限制。我们强制 \(dp_{x,0/1}\) 表示以 \(x\) 为根的子树内建/不建军营的方案数,若建军营,则强制要求所有军营必须通过已派兵看守的边与 \(x\) 连通。
这个限制第一篇题解中没有做详细解释,我自己的理解是:这样做可以不重不漏且方便统计。需要注意的一点是这样做不会漏掉某子树内的军营自成一体,不通过已派兵看守的边与 \(x\) 连通的情况,因为这种情况早在对该点进行 DP 的时候就考虑过了。
同时还要有一个限制:强制限制不选 \(x\to fa_x\) 的边。这个的原因是由上一个限制而来,因为后面对于 \(fa_x\) 进行 DP 的时候,若 \(x\) 内部有军营,则会强制限制选 \(fa_x\to x\) 的边,这时若在对于 \(x\) 进行 DP 时也选了这条边则会导致重复计算。借用一下第一篇题解的图:

那么有了这两个限制,转移就变得简单而不重不漏了。首先赋初值:
- \(f_{x,0}=2^{E_x}\),表示 \(x\) 边双内部。不选点,边随便选,方案数即为 \(2_{E_x}\),其中 \(E_x\) 表示该边双里的边数。
- \(f_{x,1}=2^{E_x+V_x}-2^{E_x}\),表示边、点都任意选,但是要减去 \(f_{x,0}\) 的方案数,其中 \(V_x\) 表示该边双里的点数。
然后进行转移:
-
计算 \(dp_{x,0}\):比较简单,\(dp_{x,0}\leftarrow dp_{x,0}\times \prod_{to\in son(x)}(2\times dp_{to,0})\),表示乘上各个儿子子树方案数的乘积。单棵子树的方案数为 \(dp_{to,0}\times 2\),其中 \(2\) 表示边 \(to\to x\) 选或不选都可以。
-
计算 \(dp_{x,1}\):考虑一棵一棵添加子树。
- 若到新增前当前子树 \(x\) 内未建造过军营,那么当前儿子 \(to\) 子树内必须有军营,即 \(dp_{x,1}\leftarrow dp_{x,1}+dp_{x,0}\times dp_{to,1}\)。
- 若到新增前当前子树 \(x\) 内已有军营,那么当前儿子子树内可以有军营也可以没有,即 \(dp_{x,1}\leftarrow dp_{x,1}+dp_{x,1}\times(dp_{v,1}+2\times dp_{v,0})\),\(2\) 的含义同上文。
综上,即 \(dp_{x,1}\leftarrow dp_{x,1}+dp_{x,0}\times dp_{to,1}+dp_{x,1}\times(dp_{v,1}+2\times dp_{v,0})\)。
最后是统计答案。令 \(s(x)\) 表示以 \(x\) 为根节点的子树内的边数,则有 \(s(x)=E_x+\sum_{to\in son(x)}[s(x)+1]\),统计答案分两种情况。
- \(ans\leftarrow ans+dp_{x,1}\times 2^{s(1)-s(x)-1}\),表示只有 \(x\) 子树内有军营,子树外除了 \(x\to fa\) 的边以外的边任意选。
- \(ans\leftarrow ans+dp_{1,1}\),即以 \(1\) 为根的子树的情况单独考虑。
那么题目就做完了,笔者有一个小错误一直卡在 15pts,直到 2025.5.3 终于调出来了。再次纪念并放上代码。代码不难但细节很多,码量较大:
#include<bits/stdc++.h>
#define int long long
#define ID(i) i*2-2
using namespace std;
const int N=5e5+5,M=1e6+5,mod=1e9+7;
int n,m,tot,col,ans,dfn[N],low[N],is[M<<1],vis[N],dccV[N],dccE[N],in[N],siz[N],dp[N][2];
struct node{int to,id;};
struct edge{int x,y;}e[M];
vector<node>v[N];
vector<int>g[N];
int fpow(int a,int b,int p){int ans=1;while(b){if(b&1)ans=ans*a%p;a=a*a%p,b/=2;}return ans;}
void Tarjan(int x,int preid){
dfn[x]=low[x]=++tot;
for(int i=0;i<v[x].size();i++){
int to=v[x][i].to,id=v[x][i].id;
if((id^1)==preid)continue;
if(!dfn[to]){
Tarjan(to,id);
if(dfn[x]<low[to])is[id]=is[id^1]=1;
low[x]=min(low[x],low[to]);
}
else low[x]=min(low[x],dfn[to]);
}
return;
}
void DFS(int x,int now){
if(vis[x])return;
vis[x]=1,dccV[now]++,in[x]=now;
for(int i=0;i<v[x].size();i++){
int to=v[x][i].to,id=v[x][i].id;
if(is[id])continue;
DFS(to,now);
}
return;
}
void dfs(int x,int fa){
siz[x]=dccE[x];
for(int i=0;i<g[x].size();i++){
int to=g[x][i];
if(to==fa)continue;
dfs(to,x);
siz[x]+=siz[to]+1;
}
return;
}
void DP(int x,int fa){
for(int i=0;i<g[x].size();i++){
int to=g[x][i];
if(to==fa)continue;
DP(to,x);
dp[x][1]=(dp[to][1]*dp[x][0]%mod+(dp[to][0]*2%mod+dp[to][1])%mod*dp[x][1]%mod)%mod,dp[x][1]%=mod;
dp[x][0]=dp[x][0]*dp[to][0]*2%mod,dp[x][0]%=mod;
}
if(x==1)ans+=dp[x][1],ans%=mod;
else ans+=dp[x][1]*fpow(2,siz[1]-siz[x]-1,mod)%mod,ans%=mod;
return;
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
for(int i=1;i<=m;i++){
int x,y;
cin>>x>>y;
if(x==y)continue;
e[i]={x,y};
v[x].push_back({y,ID(i)});
v[y].push_back({x,ID(i)+1});
}
for(int i=1;i<=n;i++)if(!dfn[i])Tarjan(i,-1);
for(int i=1;i<=n;i++)if(!vis[i])DFS(i,++col);
for(int i=1;i<=m;i++){
if(e[i].x==e[i].y)continue;
if(in[e[i].x]!=in[e[i].y])
g[in[e[i].x]].push_back(in[e[i].y]),g[in[e[i].y]].push_back(in[e[i].x]);
else dccE[in[e[i].x]]++;
}
for(int i=1;i<=col;i++)
dp[i][0]=fpow(2,dccE[i],mod),dp[i][1]=(fpow(2,dccE[i]+dccV[i],mod)-dp[i][0]+mod)%mod;
dfs(1,0),DP(1,0);
cout<<ans;
return 0;
}
后记
再放几道题吧,但是不想写解析了。

浙公网安备 33010602011771号