【动态规划】树形DP入门
Little_Cake_qwq
·
·
算法·理论
前言
作者太弱了,如果发现文章问题欢迎在评论区评论或私信我。
本文部分内容转自 oiwiki。
正文
树形 DP,即在树上进行的 DP。由于树固有的递归性质,树形 DP 一般都是递归进行的。 -oiwiki
树形 dp 听上去很难,其实和线性 dp 差不了多少。
I.普通树形 dp
这类树形 dp 比较简单,一般只需要进行一次 dp。
嘴上说不清楚,还是看看例题吧:
例题1:P1122 最大子树和
题意:给定一棵树和每个点的点权,求其最大子树和。
这像一个在树上的最大子段和。由于题上没给根,所以我们以 1 节点为根,很明显这不影响答案。设 dp_u 为 u 的子树在选了 u 的情况下的最大子树和。
贪心考虑对于 u 的每一个子节点 v 选或不选 v 的最优大树和:显然,当 dp_v 大于 0 时,我们选它一定会加大和,所以是优的;而 dp_v 小于 0 时,明显选了会拉低和,所以是不优的。
因此,转移方程为:
dp_u=\sum\max(dp_v,0)+a_u
代码如下:
const int N=2e5+8;
int n,ans=-INF;
int a[N],dp[N];
vector<int> edge[N];
void dfs(int u,int fa)//记录父亲防止重复递归
{
dp[u]=a[u];
for(auto v:edge[u])
{
if(v==fa) continue;
dfs(v,u);
dp[u]+=max(dp[v],0ll);
}
ans=max(ans,dp[u]);
}
signed main()
{
n=read();
for(int i=1;i<=n;i++) a[i]=read();
for(int i=1;i<n;i++)
{
int u,v;u=read(),v=read();
edge[u].pb(v);
edge[v].pb(u);
}
dfs(1,0);
cout<<ans;
return 0;
}
总结:这类树形 dp 比较好想和好写,适合入门练习。
II.树上背包
既然可以在树上搞最大子树和,那是不是也可以在树上搞背包呢?
例题:P2014 [CTSC1997] 选课
题意:有 n 个课程,每个课程可能会有“先修课”,给定每个课程的学分和最大听课数量,求最大学分。
考虑将“先修课”变成父亲,0 看作是一门所有没有“先修课”的课程的先修课,然后以 0 为根开始 dp。
设 dp_{u,j} 表示 u 节点的子树中选 j 个节点的最大学分。对于每一个儿子 v,考虑枚举在 v 中选的节点数量。设这个量为 k,则其会在其他子树中选 j-k 个节点,转移方程为:
dp_{u,j}=\max(dp_{u,j},dp_{v,k}+dp_{u,j-k})
注意转移时的 j 需要倒着枚举。代码如下:
const int N=300+8;
int n,m;
int a[N],dp[N][N];
vector<int> edge[N];
void dfs(int u)
{
dp[u][1]=a[u];
for(auto v:edge[u])
{
dfs(v);
for(int i=m+1;i>=1;i--)//倒着枚举!!!
for(int j=0;j<i;j++)
dp[u][i]=max(dp[u][i],dp[v][j]+dp[u][i-j]);
}
}
signed main()
{
ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
n=read();m=read();
for(int i=1;i<=n;i++)
{
int u,v;u=read(),v=read();
edge[u].pb(v);
}
dfs(0);
cout<<dp[0][m+1];
return 0;
}
总结:树上背包还是挺水的,理解了就可以了。
III.换根dp
树形 DP 中的换根 DP 问题又被称为二次扫描,通常不会指定根结点,并且根结点的变化会对一些值,例如子结点深度和、点权和等产生影响。 -oiwiki
换根 dp 一般都会有两次 dfs,第一次一般为以 1 为根的 dp,第二次为换根 dp。
例题:P2986 [USACO10MAR] Great Cow Gathering G
题意:给定一棵树,每个点有点权。请你确定一个点 u,使得以 u 为根时每个点的深度乘这个点的权值之和(以下简称为 u 的值)最小,请你求出这个最小值。
首先,假设 1 为根,求出它的值。用 dp 转移维护此时每个点 u 的子树权值和 sz_u 与此时 u 子树上的节点到 u 节点的深度和 dp_i。
$$sz_u=\sum sz_v+c_u$$
接着考虑 $dp_u$ 如何转移。由于对于每一个 $sz_v$,它从 $v$ 经过一条边到 $u$ 都会有 $sz_v$ 的贡献(可以仔细想想这里),所以转移为:
$$dp_u=\sum(dp_v+sz_v\times w)$$
然后考虑 $O(n)$ 求出每一个 $u$ 作为根的值。设 $g_u$ 为 $u$ 的值,则从其换根到儿子 $v$ 时,会少了 $sz_v$ 跨过这条边,但会多出 $\sum c_i-sz_v$ 跨过这条边,所以有:
$$g_v=g_u-w\times (sz_v+\sum c_i-sz_v)$$
答案即为 $\min_{u=1} ^ng_u$。代码如下:
```cpp
int n,s,ans=INF;
int sz[N],dp[N],g[N];
vector<PII> edge[N];
void dfs(int u,int fa)
{
for(auto e:edge[u])
{
int v=e.fir,w=e.sec;
if(fa==v) continue;
dfs(v,u);
sz[u]+=sz[v];
dp[u]+=dp[v]+sz[v]*w;
}
}
void DFS(int u,int fa)
{
for(auto e:edge[u])
{
int v=e.fir,w=e.sec;
if(fa==v) continue;
g[v]=g[u]-sz[v]*w+(s-sz[v])*w;
DFS(v,u);
}
ans=min(ans,g[u]);
}
signed main()
{
n=read();
for(int i=1;i<=n;i++) sz[i]=read(),s+=sz[i];
for(int i=1;i<=n-1;i++)
{
int u,v,w;
u=read().v=read(),w=read();
edge[u].pb(mkp(v,w));
edge[v].pb(mkp(u,w));
}
dfs(1,0);g[1]=dp[1];DFS(1,0);
cout<<ans;
return 0;
}
```
练习题目:
- [P2016 [SEERC 2000] 战略游戏](https://www.luogu.com.cn/problem/P2016)
- [P2015 二叉苹果树](https://www.luogu.com.cn/problem/P2015)
- [P3478 [POI 2008] STA-Station](https://www.luogu.com.cn/problem/P3478)
---
(你不会以为没了吧)
接下来是一些进阶讲解:
### I.贪心+树形dp
#### 例题:[P2458 [SDOI2006] 保安站岗](https://www.luogu.com.cn/problem/P2458)
题意:给定一棵树,你需要对一些节点进行染色,每个染色节点可以将距离不大于 $1$ 的节点覆盖,求使树上所有节点被覆盖的最小花费。
还是以 $1$ 节点为根,设 $dp_{u,0/1/2}$ 表示节点 $u$ 被自己/儿子/父亲覆盖的最小花费。$dp_{u,0}$ 和 $dp_{u,2}$ 的转移比较简单:
$$dp_{u,0}=\sum \min(dp_{v,0},dp_{v,1},dp_{v,2})+w_u$$
$$dp_{u,2}=\sum \min(dp_{v,0},dp_{v,1})$$
接下来考虑 $dp_{u,1}$ 的转移。不难发现 $dp_{u,1}=\sum dp_{v,0/1}$ ,且必须加上一次 $dp_{v,0}$,所以考虑贪心。先把每一个 $v$ 都加上 $dp_{v,0}$,然后对 $dp_{v,1}-dp_{v,0}$ 排序,把除最后一项以外的的小于 $0$ 的 $dp_{v,1}-dp_{v,0}$ 加上便是答案。这样便保证了答案的最优和正确性。注意如果没有儿子,$dp_{u,1}=\infty$。
答案就为 $\min(dp_{1,0},dp_{1,1})$。代码如下:
```cpp
const int N=1508;
int n;
int w[N];
vector<int> edge[N];
int dp[N][3];
void dfs(int u,int fa)
{
dp[u][0]=w[u];
dp[u][1]=0;
dp[u][2]=0;
vector<int> g;
for(auto v:edge[u])
{
if(v==fa) continue;
dfs(v,u);
dp[u][0]+=min(dp[v][0],min(dp[v][1],dp[v][2]));
dp[u][2]+=min(dp[v][0],dp[v][1]);
dp[u][1]+=dp[v][0];
g.pb(dp[v][1]-dp[v][0]);
}
if(!g.size())
{
dp[u][1]=1e18;
return ;
}
sort(g.begin(),g.end());
for(int i=0;i<g.size()-1;i++)
{
int diff=g[i];
if(diff<0) dp[u][1]+=diff;
else break;
}
}
signed main()
{
n=read();
for(int i=1,t,u,v;i<=n;i++)
{
u=read();
w[u]=read(),t=read();
while(t--) v=read(),edge[u].pb(v),edge[v].pb(u);
}
dfs(1,0);
cout<<min(dp[1][0],dp[1][1]);
return 0;
}
```
### II.二分+树上博弈+树形dp
~~似乎啥都可以博弈和二分。~~
#### 例题:[P3554 [POI 2013] LUK-Triumphal arch](https://www.luogu.com.cn/problem/P3554)
题意:给一颗 $n$ 个节点的树,初始时 $1$ 号节点被染黑,其余是白的。两个人轮流操作,一开始 B 在 $1$ 号节点。每一轮,A 选择 $k$ 个点染黑,然后 B 走到一个相邻节点,如果 B 当前处于白点则 B 胜,否则当 A 将所有点染为黑点时 A 胜。求能让 A 获胜的最小的 $k$。
不难发现答案具有单调性,所以考虑二分答案,将题目转化为判断性问题。
通过贪心不难发现,B 不会走回头路,只会一直走到叶子节点。**考虑 B 走到 $u$ 节点:当儿子结点个数小于等于 $k$ 时,B 只能走下去;当儿子节点个数大于 $k$ 时,染不完的儿子可以通过前面剩余的染色次数染完**。所以设$dp_u$ 为 $u$ 的子树需要的更多染色次数,则有转移:
$$dp_u=\max(\sum(dp_v+1)-k,0)$$
方程解释:自己的 $k$ 次机会先把儿子染完,多余的把儿子节点需要的染完。
最后判断 $dp_1$ 是否大于 $0$,即根节点是否需要多余的染色即可。
代码如下:
```cpp
const int N=5e5+8;
int n;
vector<int> edge[N];
int dp[N];
void dfs(int u,int fa,int x)
{
int sum=0;
for(auto v:edge[u])
{
if(v==fa) continue;
dfs(v,u,x);
sum+=dp[v]+1;
}
dp[u]=max(0,sum-x);
}
bool check(int x) { dfs(1,0,x);return dp[1]==0; }
int main()
{
n=read();
for(int i=1;i<n;i++)
{
int u,v;u=read(),v=read();
edge[u].pb(v);
edge[v].pb(u);
}
int l=0,r=n,ans=n;
while(l<=r)
{
int mid=(l+r)>>1;
if(check(mid)) r=mid-1,ans=mid;
else l=mid+1;
}
cout<<ans;
return 0;
}
```
### III.强连通分量+树形dp
~~树也算个图吧,那配上强连通分量呢。~~
#### 例题:[P2515 [HAOI2010] 软件安装](https://www.luogu.com.cn/problem/P3554)
题意:有 $n$ 个点,每个点有一个出度,体积和价值。你需要选择一些点,体积和不超过 $m$。一个点的出度被选,这个点才能被选,求出被选择的点的最大价值。不保证无环。
【前置芝士】:[缩点](https://www.luogu.com.cn/problem/P3387)
考虑对这个图用 tarjan 缩点处理,使得这个图变成一颗以 $0$ 为节点的树,接着这道题就变成了[P2014 [CTSC1997] 选课](https://www.luogu.com.cn/problem/P2014)。
代码就放 tarjan 处理部分就可以了吧。
```cpp
int n,m;
int v[N],w[N],d[N];
vector<int> G[N];
int dfn[N],low[N],cnt;
int in[N],bl[N];
int st[N],tp;
vector<int> edge[N];
void tarjan(int u)
{
dfn[u]=low[u]=++cnt;
st[++tp]=u,in[u]=1;
for(auto v:G[u])
{
if(!dfn[v])
{
tarjan(v);
low[u]=min(low[u],low[v]);
}
else if(in[v]) low[u]=min(low[u],dfn[v]);
}
if(low[u]==dfn[u])
{
int y=-1;
do
{
y=st[tp];
bl[y]=u;in[y]=0;
}while(st[tp--]!=u);
}
}//tarjan模板
int W[N],V[N];
signed main()
{
n=read(),m=read();
for(int i=1;i<=n;i++) w[i]=read();
for(int i=1;i<=n;i++) v[i]=read();
for(int i=1;i<=n;i++)
{
d[i]=read();
if(d[i]) G[d[i]].pb(i);
}
for(int i=1;i<=n;i++)
if(!dfn[i]) tarjan(i);
for(int i=1;i<=n;i++)
{
if(bl[i]!=bl[d[i]])//如果自己和父亲不在同一环,则建边
edge[bl[d[i]]].pb(bl[i]);
else if(bl[i]==i) edge[0].pb(i);//如果自己在环中且记录的是自己,则向0建边
W[bl[i]]+=w[i],V[bl[i]]+=v[i];
}
```
### IV.树的直径
是的,除了两边 dfs,树形dp也可以求树的直径,且其可以处理负边权的情况。
#### 例题:[B4016 树的直径](https://www.luogu.com.cn/problem/B4016)(是的就是模板)
设 $dp_u$ 表示 $u$ 的子树中的节点到 $u$ 的最大距离。明显的有转移:
$$dp_u=\max(dp_v+w)$$
由于对于每一次 $dp_u$ 取最大值前,$dp_u$ 一定是当前最优解。所以在每一次取最大值前,将 $D$ 对直径 $dp_u+dp_v+w$ 取最大值,最后的 $D$ 一定是最大的。
代码如下:
```cpp
const int N=1e5+8;
int n,D=-INF;
int dp[N];
vector<PII> edge[N];
void dfs(int u,int v)
{
for(auto v:edge[u])
{
if(v==fa) continue;
dfs(v,u);
D=max(D,dp[u]+dp[v]+w);
dp[u]=max(dp[u],dp[v]+w);
}
}
```
#### 加餐:[P3629 [APIO2010] 巡逻](https://www.luogu.com.cn/problem/P3629)
题意:给定一棵树,你需要经过每条边再回到原点,但你可以加上 $k(1\le k\le 2)$ 条边,使你最终的路径长度最小。
不难发现在加边前必须经过每条边两次,即加边前的路径长度一定为 $2\times(n-1)$。
考虑 $k=1$ 的情况。我们根据贪心,发现**加边要使省下的路径长度最大,而这个路径长度就为树的直径**!所以考虑 $2$ 次 dfs 求出开始时的最大直径(为啥是两次 dfs 我们后面再说),答案即为 $2\times (n-1)-D+1$。
接着考虑 $k=2$ 的情况。第一条边我们按照 $k=1$ 的情况加边,第二条边我们按照一样的思路找直径。但是,我们第一次省下来的边就可能要被再走一次,很明显这不优。所以**第二次我们把刚刚直径上的边的边权取反,这样再做树形 dp 求树的直径变能求出第二次的直径 $D'$**。
但如何记录刚刚直径上的边呢?我们 $2$ 次 dfs 的优势就在这里:我们可以记录直径的两个端点,两个端点间的边(也就是直径)就可以标记取反了。
答案就为 $2\times(n-1)-D-D'+2$。代码如下:
```cpp
const int N=1e5+8;
int n,k,root,leaf,D1,D2;
int dep[N],f[N],vis[N],dis[N];
vector<int> edge[N];
void dfs1(int u,int fa)
{
dep[u]=dep[fa]+1;
for(auto v:edge[u])
{
if(v==fa) continue;
dfs1(v,u);
}
if(dep[u]>dep[root]) root=u;
}
void dfs2(int u,int fa)
{
dep[u]=dep[fa]+1;
f[u]=fa;
for(auto v:edge[u])
{
if(fa==v) continue;
dfs2(v,u);
}
if(dep[u]>dep[leaf]) leaf=u;
}
void dp(int u,int fa)
{
for(auto v:edge[u])
{
if(v==fa) continue;
dp(v,u);
int w=1;
if(vis[u]&&vis[v]) w=-1;
D2=max(D2,dis[v]+dis[u]+w);
dis[u]=max(dis[u],dis[v]+w);
}
}
int main()
{
cin>>n>>k;
for(int i=1;i<n;i++)
{
int u,v;cin>>u>>v;
edge[u].pb(v);
edge[v].pb(u);
}
dep[0]=-1;
dfs1(1,0);
dfs2(root,0);
D1=dep[leaf];
if(k==1) cout<<2*(n-1)-D1+1;
else
{
for(int i=leaf;i;i=f[i]) vis[i]=1;
dp(1,0);
cout<<2*(n-1)-D1-LD+2;
}
return 0;
}
```
## 最后
树形 dp 主要还是考思维,难度嘛......板子多,难题也很多。
相信没有多少人会看到这里(我还是太蒻了),感谢你的支持。
有疑问欢迎提出。