20250927 - 树形dp 总结

比赛链接:https://vjudge.net/contest/751527。

题这么多,我这总结该写到什么时候啊。

A - 二叉树深度

根本算不上树形 DP,只是一个简单的 DFS 深搜遍历。

由于题目已经固定根节点编号为 \(1\),所以从 \(1\) 出发 DFS 即可。分别算出每个节点的 \(dep\),即深度。转移 \(dp_u = dp_{fa} + 1\)

甚至不需要开数组,因为只要求出最大即可。

void DFS(int u,int fa,int dep){
    Ans=max(Ans,dep);
    for(int v:g[u])DFS(v,u,dep+1);return;
}

B - 树上漫步

由于要求偶数步,不难发现总是同是停留在奇偶深度的点上。具体地,如果 \(dep_u\) 为奇数,那么所有从 \(u\) 出发走偶数步的节点的深度一定也是奇数;偶数也一样。

浅浅证明一下。因为你从一个点出发,走一条边,由于这是一棵树,所以只能往上或者往下走。这样说的话,如果你走了两步,要么往上走了两层,要么往下走了两层,要么层数和当前相同(先上去再下来,或者先下去又上来),层数的奇偶性没有变(因为要么是 \(+2\) 要么是 \(-2\) 要么不变)。那么走偶数步,奇偶性肯定也不会变了。

那么就很简单了,算出每个节点的 \(dep\),然后让 \(sz_{0/1}\) 分别存储 \(dep_u \bmod 2\) 的值为 \(0/1\) 的节点的个数即可。最后输出 \(sz_{dep_i \bmod 2}\) 就 OK 啦。

void DFS(int u,int fa){
    dep[u]=dep[fa]+1,sz[dep[u]%2]++;
    for(int v:g[u])if(v^fa)DFS(v,u);return;
}

for(int i=1;i<=n;i++)
    cout<<sz[dep[i]%2]<<" ";cout<<"\n";

C - 树的直径

求树的直径。可以用两次 DFS 搞定(常规做法),我就是这么写的。

但是还有一种树形 DP 的方案。由于要求直径,肯定是从一个叶子走到另一个叶子。只要从根到这两个叶子的路径互不重合就 OK 了。

那么考虑用 \(dp_{u,0/1}\) 维护以 \(u\) 为根节点的子树能够到的最大深度(存进 \(dp_{u,0}\))和次大深度(存进 \(dp_{u,1}\))。转移比较简单,枚举 \(u\) 的每个儿子 \(v\),处理完 \(v\) 自身之后用 \(dp_{v,0/1}\) 尝试更新 \(dp_{u,0/1}\) 即可。

最后的答案便是 \(dp_{Root,0} + dp_{Root,1}\),可以自定义 \(Root = 1\),即强制标定 \(1\) 为根。

代码不贴了。其实是因为没写。

D - 会议

本人用的是换根 DP 的做法。树的重心是什么?好吃吗?

首先一遍 DFS 求出每棵子树的 \(size\),存进 \(sz_u\)

void DFSsz(int u,int fa){
    sz[u]=1;for(int v:g[u])if(v^fa)
        DFSsz(v,u),sz[u]+=sz[v];return;
}

接下来是换根 DP 的初始化部分。你要换根,首先得有个最初的值来给你换,对吧,不然你就什么都干不了,因此先跑一通 BFS 求出当以 \(1\) 节点为会议地点时这个距离之和是多少。

void BFS(){
    memset(f,0x3f,sizeof(f));
    f[1]=0,q.push(1);
    while(!q.empty()){
        int u=q.front();q.pop();
        for(int v:g[u])if(f[v]>f[u]+1)
            f[v]=f[u]+1,q.push(v);
    }
    for(int i=1;i<=n;i++)
        dp[1]+=f[i];return;
}

然后就是换根 DP 的部分了。

考虑从父节点 \(u\) 转移到当前节点 \(v\),换根,该怎么换呢?首先要考虑那些不是 \(v\) 的后代的节点,容易发现这些节点到 \(v\) 的距离之和会比它们到 \(u\) 的距离之和多出 \(n - sz_x\)(因为多走了一步嘛);而对于那些是 \(v\) 的后代的节点(即,处于 \(v\) 的子树内的节点),那么这些节点到 \(v\) 的距离之和会比它们到 \(u\) 的距离之和要少 \(sz_v\)(因为不用爬那么上面了),所以转移方程显而易见 \(dp_v = dp_u + n - 2 \times sz_v\)。注意千万别去更新节点 \(1\) 的情况。

void DFSans(int u,int fa){
    if(u!=1)dp[u]=dp[fa]-sz[u]+(n-sz[u]);
    for(int v:g[u])if(v^fa)DFSans(v,u);
}

最后只需要找出 \(dp_u\) 最小的节点并输出相应情况就好啦。

ID=1;for(int i=2;i<=n;i++)
    if(dp[i]<dp[ID])ID=i;cout<<ID<<" "<<dp[ID]<<"\n";

E - DFS Order

这题唯一要考虑的就是哪样设计 \(dfn_u\) 会最小,以及哪样设计 \(dfn_u\) 会最大。废话。

首先考虑 \(dfn_u\) 最小的情况。不难发现如果想要 \(dfn_u\) 最小,只要每次遍历的时候一直顺着根节点到 \(u\) 的顺序下来就行了,那么答案就是 \(dep_u\),DFS 算一遍就成。

而最大的情况,就是最后才遍历到 \(u\)。由于只有 \(u\) 的子树部分肯定比 \(u\) 后面遍历,其他部分都可以在 \(u\) 前面遍历,那么求出 \(sz_u\) 就可以解决问题了,答案就是 \(n - sz_u + 1\)

上述两个东西可以放在一个 DFS 里面搞定。

void DFS(int u,int fa){
    dep[u]=dep[fa]+1,sz[u]=1,Fat[u]=fa;
    for(int v:g[u])if(v^fa)DFS(v,u),sz[u]+=sz[v];return;
}

for(int i=1;i<=n;i++)
    cout<<dep[i]<<" "<<n-sz[i]+1<<"\n";

F - 女仆咖啡厅桌游吧

容易发现只有叶子结点可以随意整,其他地方你基本上左右不了。

也就是说,如果你要在 \(u\) 位置放一个什么东西,那么必须有另外一个位置给你放相对应的东西,但是由于你无法破坏你子树的结构情况(已经维护好了相互个数一样,如果你再安排个东西进去,而不放另一类的话,这个子树就坏掉啦),而只有叶子结点是没有限制的,所以只能去搞叶子结点啦。

具体一点。遍历所有 \(u\) 的子节点 \(v\),首先可以让 \(dp_u = dp_u + dp_v\),毕竟可以加上子树的贡献嘛。然后统计一下 \(u\) 的儿子节点中一共有多少个叶子结点(可以根据边的个数判断一下),假设为 \(cnt\) 个,那么最后还有 \(dp_u = dp_u + \lfloor \frac{cnt+1}{2} \rfloor\)。因为这下子只有这 \(cnt\) 个叶子结点以及 \(u\) 本身可以放东西了,那么能放多少是多少吧。

最终答案不用说 \(dp_1\)。毕竟题目已经固定 \(1\) 为根节点啦。

void DFS(int u,int fa){
    dp[u]=0;int cnt=0;
    for(int v:g[u])if(v^fa)
        DFS(v,u),dp[u]+=dp[v],cnt+=(g[v].size()==1);
    dp[u]+=(cnt+1)/2;return;
}

G - 最大子树和

和最大子段和很像的,只是说这会儿是树上的了。

定义 \(dp_u\) 表示以 \(u\) 为根节点的子树的最大子树和是多少。并且特殊的,强制铁定必须要包含 \(u\) 这个节点。

然后每次遍历 \(u\) 的子节点 \(v\),尝试用 \(v\) 的值更新 \(u\) 就好了,\(dp_u = \max(dp_u , dp_u + dp_v)\)。为什么是尝试呢,因为可能有负数的情况呀。注意一开始要 \(dp_u = a_u\)

最后的答案不是简单的 \(dp_{Root}\),而是 \(\max_{i=1}^{n} dp_i\),因为题目没固定说必须选根节点或者什么的。题目甚至没有说根节点是多少,但是我们可以铁定 \(Root = 1\)

void DFS(int u,int fa){
    dp[u]=a[u];for(int v:g[u]){
        if(v==fa)continue;DFS(v,u);
        dp[u]=max(dp[u],dp[u]+dp[v]);
    }return;
}

Ans=-0x3f3f3f3f;
for(int i=1;i<=n;i++)Ans=max(Ans,dp[i]);

H - 没有上司的舞会

很典的题目。

定义 \(dp_{u,0/1}\) 表示当前考虑了 \(u\) 以及他所有的下属的去不去舞会的情况,并且 \(u\) 这个人去与不去(用第二维的 \(0/1\) 表示)的情况下,最大的快乐指数之和。

由于领导层级关系不得破坏,所以需要找出那个没有上级的人,他就是最大的领导,让他为 \(Root\)。后来发现也可以不管,因为只需要保证一条边中不会两人都去就行了,但是这样也不影响,对吧。

那么答案就是 \(\max(dp_{Root,0} , dp_{Root,1})\),因为并没要求这个最大的领导到底要不要去。

转移的时候,枚举 \(u\) 的子节点 \(v\),然后分别转移:\(dp_{u,1} = dp_{u,1} + dp_{v,0}\),因为如果 \(u\) 去的话那么 \(v\) 就绝对不能去了;\(dp_{u,0} = dp_{u,0} + \max(dp_{v,0} , dp_{v,1})\),如果 \(u\) 没去,那么可以自由选择 \(v\) 去与不去,显然考虑更优的方案,故取 \(\max\)

注意要初始化 \(dp_{i,1} = a_i\)

void DFS(int u){
    dp[u][1]=a[u];
    for(int v:g[u]){
        DFS(v);dp[u][1]+=dp[v][0];
        dp[u][0]+=max(dp[v][1],dp[v][0]);
    }return;
}

I - 战略游戏

和上一题简直一模一样。

定义 \(dp_{u,0/1}\) 表示以 \(u\) 为根节点的子树在保证被完全看得到,并且 \(u\) 这个位置是否安排了人(用第二维的 \(0/1\) 表示)的情况下最少需要安排多少个人。

初始定义 \(dp_{u,1} = 1\),答案即为 \(\max(dp_{Root,0} , dp_{Root,1})\),可以自定义 \(Root = 1\)

枚举 \(u\) 的子节点 \(v\),然后分别转移:\(dp_{u,0} = dp_{u,0} + dp_{v,1}\),因为如果 \(u\) 没安排人那么必须要在 \(v\) 安排人,不然就看不到 \(u\) 或者看不到 \(v\) 了;\(dp_{u,1} = dp_{u,1} + \max(dp_{v,0} , dp_{v,1})\),因为如果 \(u\) 安排了人那么 \(v\) 安不安排人都无所谓了。

注意读入的方式似乎有点不同寻常。

void DFS(int u,int fa){
    dp[u][1]=1;
    for(int v:g[u]){
        if(v==fa)continue;DFS(v,u);
        dp[u][1]+=min(dp[v][1],dp[v][0]);
        dp[u][0]+=dp[v][1];
    }return;
}

J - 二叉苹果树

背包类型的树形 DP 啦。终于。

由于这个树的形态有点奇怪所以把它倒过来处理。

定义 \(dp_{u,k}\) 表示以 \(u\) 为根节点的子树在保留 \(k\)节点的情况下最多能留住多少苹果。注意这里处理的是保留的节点个数,因此需要让要求保留的树枝个数 \(m = m+1\)

转移的时候,枚举 \(u\) 的子节点 \(v\),然后枚举 \(i\)\(j\) 分别表示 \(u\) 留下的节点个数以及 \(v\) 留下的节点个数。转移的时候直接 \(dp_{u,i} = \max(dp_{u,i} , dp_{v,j} + w + dp_{u,i-j})\) 即可。当然了,\(w\)\(u \to v\) 这条树枝上的苹果的个数。

最后的答案当然就是 \(dp_{1,m}\) 啦。

void DFS(int u,int fa){
    for(auto [v,w]:g[u])if(v^fa){
        DFS(v,u);
        for(int i=m;i>=2;i--)for(int j=1;j<i;j++)
            dp[u][i]=max(dp[u][i],dp[v][j]+w+dp[u][i-j]);
    }return;
}

K - 选课

零零散散很多棵树,不好处理,那么用一个 \(0\) 来表示最大的根节点,把它们全部合并成一棵树。也要 \(m = m+1\)

和上题一样,也是背包类型的树上 DP。

定义 \(dp_{u,k}\) 表示当前考虑了以 \(u\) 为根节点的子树并且在其中选择了 \(k\) 门课程的情况下能得到的最大学分是多少。显然是肯定选择了 \(u\) 的,不然其他所有课都选不了。

转移的时候,枚举 \(u\) 的子节点 \(v\),然后分别枚举 \(i\)\(j\) 表示\(u\) 以下的这些位置选了多少节课,以及 \(v\) 以下的这些位置选了多少节课。转移显然,\(dp_{u,i} = \max(dp_{u,i} , dp_{v,j} + dp_{u,i-j})\)。最开始要初始化 \(dp_{u,1} = a_u\)

答案即为 \(dp_{0,m}\)

void DFS(int u,int fa){
    for(int v:g[u]){
        DFS(v,u);
        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]);
    }return;
}

L - 软件安装

与上题类似,多了缩点。

因为这题可能有环,但是树形 DP 显然无法解决环,那么唯一的办法就是缩点咯!

我这里采取 Tarjan 来把所有强联通分量都缩成一个点。那么就是说,不仅缩了环,肯定还把点缩成了点……虽然很奇怪,但是无关紧要嘛。等同于就是说,给每个节点新分配了一个编号 \(bel_u\)

与 K 题的不同点在于,这里的 DP 的代价不是 \(1\) 了,而是 \(w_i\)。而价值,一样的都是 \(v_i\)

放代码片段不好一览啊,这题就放个完整代码吧。

#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define fr first
#define se second
#define pb push_back
using namespace std;
const int N = 505;
struct node{int w,v;}a[N];
int n,m,Fat[N],cnt,dfn[N],f[N];
int dp[N][N],ID,W[N],V[N],bel[N],in[N];
bool vis[N];vector<int> g[N];stack<int> st;
int read(){
    int su=0,pp=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
    while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
    return su*pp;
}
void Tarjan(int u){
    ++cnt,dfn[u]=cnt,f[u]=cnt;
    vis[u]=1,st.push(u);for(int v:g[u])
        if(!dfn[v])Tarjan(v),f[u]=min(f[u],f[v]);
        else if(vis[v])f[u]=min(f[u],dfn[v]);else;
    if(dfn[u]!=f[u])return;
    ID++;while(1){
        int v=st.top();st.pop();
        bel[v]=ID,W[ID]+=a[v].w,V[ID]+=a[v].v;
        vis[v]=0;if(v==u)break;
    }return;
}
void DFS(int u){
    for(int i=W[u];i<=m;i++)dp[u][i]=V[u];
    for(int v:g[u]){DFS(v);
        for(int i=m-W[u];i>=0;i--)for(int j=0;j<=i;j++)
            dp[u][i+W[u]]=max(dp[u][i+W[u]],dp[v][j]+dp[u][i+W[u]-j]);
    }return;
}
int main(){
    n=read(),m=read();
    for(int i=1;i<=n;i++)a[i].w=read();
    for(int i=1;i<=n;i++)a[i].v=read();
    for(int i=1;i<=n;i++){Fat[i]=read();if(Fat[i])g[Fat[i]].pb(i);}
    for(int i=1;i<=n;i++)if(!dfn[i])Tarjan(i);
    for(int i=1;i<=n;i++)g[i].clear();
    for(int i=1;i<=n;i++)if(bel[i]!=bel[Fat[i]])
        g[bel[Fat[i]]].pb(bel[i]),in[bel[i]]++;
    for(int i=1;i<=ID;i++)if(!in[i])g[0].pb(i);
    DFS(0);cout<<dp[0][m]<<"\n";
    return 0;
}

M - 树上染色

定义 \(dp_{u,k}\) 表示以 \(u\) 为根节点的子树中,染了 \(k\) 个黑点的情况下那个什么收益最大是多少。

考虑每条边的贡献。如果左边有 \(x\) 个同色节点,右边有 \(y\) 个同色节点,贡献就是 \(x \times y\) 对吧。

那么容易求出 \(u \to v\) 这条边经过了多少次了:\(j \times (Bl-j) + (sz_u - j) \times (n-Bl-sz_u + j)\)。其中 \(j\) 表示 \(v\) 的子树上选择了多少个黑点,而 \(Bl\) 则是题目要求的黑点数量。

那么 DP 转移方程就很显然了。记上面那个东西为 \(cnt\),转移方程就是 \(dp_{u,i} = \max(dp_{u,i} , dp_{u,i-j} + dp_{v,j} + cnt \times w)\),其中 \(w\)\(u \to v\) 这条边的边权。

但是发现这个代码是有问题的,如果 \(j\) 按照倒序枚举,这个代码就过不去,而改成正序好像就可以了。不明所以的我查看了第一篇题解的解释才明白:由于正序枚举 \(j\) 的时候是从 \(0\) 开始枚举的,但是这道题目必须先把 \(j = 0\) 的情况转移掉才行,所以显然也是可以倒序枚举的。想要倒序枚举,只需要一开始先处理一下 \(j = 0\) 的情况就行了。

最终答案不用说是 \(dp_{1,Bl}\);因为你铁定了 \(1\) 当根嘛。

void DFS(int u,int fa){
    sz[u]=1;dp[u][0]=0,dp[u][1]=0;
    for(auto [v,w]:g[u])if(v^fa){
        DFS(v,u);sz[u]+=sz[v];
        for(LL i=min(Bl,sz[u]);i>=0;i--){
            if(dp[u][i]!=-1)dp[u][i]+=dp[v][0]+sz[v]*(n-Bl-sz[v])*w;
            for(LL j=min(i,sz[v]);j>=1;j--){
                if(dp[u][i-j]==-1||dp[v][j]==-1)continue;
                LL cnt=j*(Bl-j)+(sz[v]-j)*(n-Bl-sz[v]+j);
                dp[u][i]=max(dp[u][i],dp[u][i-j]+dp[v][j]+cnt*w);
            }
        }
    }
}

总结与概括

树形 DP ,顾名思义就是在树上做 DP,常和 DFS、BFS 搭配使用。如果题目没有说明根是多少,你就得先自己铁定根(一般会让 \(1\) 为根,特殊情况特殊考虑),然后通常来说 DP 会存储一些方便求解答案的东西,比如说以某个节点为根的子树的某种收益。有的时候还会加上一些限制,比如说限制只能花费多少代价,或者这个节点选与不选之类的。至于转移,转通常要遍历子节点,结合子树的状态来疯狂更新当前节点状态。树形 DP,大概就是这么个东西。

Thanks reading.

posted @ 2025-09-28 17:32  嘎嘎喵  阅读(31)  评论(0)    收藏  举报