树形DP
给出一棵树,要求以最少代价(或最大收益)完成给定的操作。
基本操作
- 树的遍历,用DFS从根节点开始进行记忆化搜索
- 从树最深处开始往回进行DP,用子节点dp值来更新父节点dp值
复杂度分析:遍历每个节点,总复杂度为
例题
某大学有
他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。
现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数
所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。
题目分析
根据dp[i][0]
表示不选择当前节点的最优解,dp[i][1]
表示选择当前节点的最优解。
状态转移方程有两种情况(设
1.不选择当前节点,那么它的子节点可选可不选,取其中的最大值,即
dp[u][0] += max(dp[v][0], dp[v][1])
2.选择当前节点,那么它的子节点不可选,即
dp[u][1] += dp[v][0]
实现代码
#include <bits/stdc++.h>
using namespace std;
const int N = 6005;
int val[N], dp[N][2], father[N];
vector<int> G[N];
void addedge(int from, int to)
{
G[from].push_back(to); // 用邻接表建树
father[to] = from; // 父子关系
}
void dfs(int u)
{
dp[u][0] = 0; // 赋初值:不参加宴会
dp[u][1] = val[u]; // 赋初值:参加宴会
for (int i = 0; i < G[u].size(); i++)
{ // 遍历u的邻居v。逐一处理这个父结点的每个子结点
int v = G[u][i];
dfs(v); // 深搜子结点
dp[u][1] += dp[v][0]; // 父结点选择,子结点不选
dp[u][0] += max(dp[v][0], dp[v][1]); // 父结点不选,子结点可选可不选
}
}
int main()
{
int n;
scanf("%d", &n);
for (int i = 1; i <= n; i++)
scanf("%d", &val[i]); // 输入快乐指数
for (int i = 1; i < n; i++)
{
int u, v;
scanf("%d%d", &u, &v);
addedge(v, u);
}
int t = 1;
while (father[t])
t = father[t]; // 查找树的根结点
dfs(t); // 从根结点开始,用dfs遍历整棵树
printf("%d\n", max(dp[t][0], dp[t][1]));
return 0;
}
模板
由例题,可以总结出来树形dp核心部分的模板:
void dfs(int u)
{
dp[u][...] = ...; //初始化
for(int i=0; i < edge[u].size(); i++) //遍历处理u的子节点v
{
int v = edge[u][i];
dfs(v); //深搜子结点
dp[u][...] = ...; //状态转移方程
}
}
最大独立集
在树中选出尽量多的节点,使得任何两个节点均不相邻。
其实上面的第一道例题就是典型的求最大独立集题型,在这里就不加以赘述了。
最小点覆盖
在树中选出尽量少的节点,使得树上每一条边都至少有一端的节点被选中。
定义状态:dp[i][0]
表示不选择当前节点的最优解,dp[i][1]
表示选择当前节点的最优解。
状态转移方程有两种情况(设
1.不选择当前节点,那么它的子节点必须选,即
dp[u][0] += dp[v][1]
2.选择当前节点,那么它的子节点可选可不选,即
dp[u][1] += min(dp[v][0], dp[v][1])
代码实现
与前者类似
最小支配集
在树中选出尽量少的节点,使得树上每个点要么被选、要么被它的相邻点支配(即该点有相邻点被选)
- 定义状态:
- 状态1:
dp[i][0]
表示选择当前节点 - 状态2:
dp[i][1]
表示不选择当前节点,且无子节点被选(即父节点被选) - 状态3:
dp[i][2]
表示不选择当前节点,且其至少有一个子节点被选.
- 状态1:
状态转移方程有两种情况(设
-
选择当前节点,那么它的子节点可选可不选,取其中的最小值,即
dp[u][0] += min(dp[v][0], dp[v][1], dp[v][2])
-
不选当前节点,且父节点被选,则当前节点的子节点属于状态3,故
dp[u][1] += dp[v][2]
-
不选当前节点,且其至少有一个子节点被选
若点
没有子节点,dp[u][2]
应被初始化为INF
若有,则其子节点可能是状态1或状态3。
而点 至少有一个子节点被选中,所以至少有一个节点是状态1;
则转移方程:if(u 没有子节点) dp[u][2] = INF; else dp[v][2] += min(dp[v][0], dp[v][2]); 遍历完后:dp[v][2] += inc; 其中: if(dp[v][0] <= dp[v][2]) inc = 0; //如果有至少一个子节点被选择,则不用补差值 else inc = min(inc, dp[v][0] - dp[v][2]); //如果没有,则选择两个状态下差值最小的子节点
代码实现
void dfs(int u)
{
dp[u][0] = 1;
dp[u][1] = dp[u][2] = 0;
bool s = false; //转移状态2时记录是否有子节点被选择
int sum = 0, inc = INF;
for (int i = 0; i < edge[u].size(); i ++)
{
int v = edge[u][i];
dfs(v);
dp[u][0] += min({dp[v][0], dp[v][1], dp[v][2]}); //转移状态1
if (dp[v][0] <= dp[v][2]) //转移状态2时,判断子节点
{
sum += dp[v][0];
s = true;
}
else
{
sum += dp[v][2];
inc = min(inc, dp[v][0] - dp[v][2]);
}
if (dp[v][2] != INF && dp[u][1] != INF) //转移状态3
dp[u][1] += dp[v][2];
else dp[u][1] = INF;
}
if (inc == INF && !s) //转移状态2
dp[u][1] = INF; //没有子节点时初始化为INF
else
{
dp[u][1] = sum;
if (!s) dp[u][1] += inc;
}
}
树上背包
树上的背包问题,简单来说就是背包问题与树形 DP 的结合。
例题
某收费有线电视网计划转播一场重要的足球比赛。他们的转播网和用户终端构成一棵树状结构,这棵树的根结点位于足球比赛的现场,树叶为各个用户终端,其他中转站为该树的内部节点。
从转播站到转播站以及从转播站到所有用户终端的信号传输费用都是已知的,一场转播的总费用等于传输信号的费用总和。
现在每个用户都准备了一笔费用想观看这场精彩的足球比赛,有线电视网有权决定给哪些用户提供信号而不给哪些用户提供信号。
写一个程序找出一个方案使得有线电视网在不亏本的情况下使观看转播的用户尽可能多。
题目分析
我们设
转移的过程结合了树形 DP 和 背包 DP 的特点,我们枚举
记点
注意上面状态转移方程中的几个限制条件,这些限制条件确保了一些无意义的状态不会被访问到。
则
for (int i = 0; i < edge[u].size(); i++) // 把u的每个子树看作一个组
{
...
for (int j = sum[u]; j >= 0; j--) // 把u下的用户总数总数看成背包容量
for (int k = 1; k <= sum[v]; k++) // 用k遍历每个子树的每个用户
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k] - w);
}
实现代码
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <vector>
#include <cstring>
using namespace std;
const int N = 3e3 + 5;
struct node
{
int v, w;
node(int x = 0, int y = 0) : v(x), w(y) {}
};
vector<node> edge[N];
int n, m, val[N];
int sum[N]; //节点下的用户数量
int dp[N][N];
void dfs(int u)
{
if (val[u]) //用户节点初始化
{
dp[u][1] = val[u];
sum[u] = 1;
return;
}
for (int i = 0; i < edge[u].size(); i++)
{
int v = edge[u][i].v, w = edge[u][i].w;
dfs(v);
sum[u] += sum[v];
for (int j = sum[u]; j > 0; j--)
for (int k = 1; k <= sum[v]; k++)
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k] - w);
}
return;
}
int main()
{
memset(dp, -0x3f3f3f3f, sizeof dp); //初始化一个极大负值,因为dp可能为负
scanf("%d %d", &n, &m);
for (int i = 1; i <= n - m; i++)
{
int k;
scanf("%d", &k);
while (k--)
{
int a, c;
scanf("%d %d", &a, &c);
edge[i].push_back(node(a, c));
}
}
for (int i = 1; i <= m; i++)
scanf("%d", &val[n - m + i]);
for (int i = 1; i <= n; i++)
dp[i][0] = 0; //选0个用户的花费是0
dfs(1);
for (int i = m; i >= 1; i--)
if (dp[1][i] >= 0)
{
printf("%d", i);
break;
}
return 0;
}
换根 DP
树形 DP 中的换根 DP 问题又被称为二次扫描,通常不会指定根结点,并且根结点的变化会对一些值,例如子结点深度和、点权和等产生影响。
通常需要两次 DFS,第一次 DFS 预处理诸如深度,点权和之类的信息,在第二次 DFS 开始运行换根动态规划。
例题
给定一个
题目分析
不妨令
取与
所有在
所有不在
设状态
于是在第二次 DFS 遍历整棵树并状态转移
题单
序号 | 题号 | 标题 | 题型 | 难度评级 |
---|---|---|---|---|
1 | P1352 | 没有上司 的舞会 | 树形DP/最大独立集 | ⭐ |
2 | hdu 2196 | Computer | 树形DP | ⭐⭐ |
3 | P2015 | 二叉苹果树 | 树上背包 | ⭐⭐ |
4 | P1273 | 有线电视网 | 树上背包 | ⭐⭐ |
5 | P2014 | 选课 | 树上背包 | ⭐⭐ |
6 | P3047 | Nearby Cows G | 树形DP | ⭐⭐⭐ |
7 | P3698 | 小Q的棋盘 | 树形DP | ⭐⭐ |
8 | P2607 | 骑士 | 树形DP | ⭐⭐⭐ |
9 | P2016 | 战略游戏 | 最小点覆盖 | ⭐ |
10 | P2899 | Phone Network G | 最小支配集 | ⭐⭐ |
11 | P2986 | Great Cow Gathering G | 换根dp | ⭐⭐ |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)