【动态规划】树形DP入门

· · 算法·理论

前言

作者太弱了,如果发现文章问题欢迎在评论区评论或私信我。
本文部分内容转自 oiwiki。

正文

树形 DP,即在树上进行的 DP。由于树固有的递归性质,树形 DP 一般都是递归进行的。 -oiwiki

树形 dp 听上去很难,其实和线性 dp 差不了多少。

I.普通树形 dp

这类树形 dp 比较简单,一般只需要进行一次 dp。
嘴上说不清楚,还是看看例题吧:

例题1:P1122 最大子树和

题意:给定一棵树和每个点的点权,求其最大子树和。

这像一个在树上的最大子段和。由于题上没给根,所以我们以 1 节点为根,很明显这不影响答案。设 dp_uu 的子树在选了 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 主要还是考思维,难度嘛......板子多,难题也很多。 相信没有多少人会看到这里(我还是太蒻了),感谢你的支持。 有疑问欢迎提出。