1. 什么是树形 DP?
树形 DP 是在树结构上进行的动态规划。由于树是无环连通图,具有天然的递归结构(子树),非常适合用递归 + 状态转移的方式来求解。
特点:
- 状态通常定义在节点上,如
dp[u]表示以节点u为根的子树的某种最优值。 - 转移过程通过遍历子树完成,即先处理所有子节点,再更新父节点。
- 常用 DFS(深度优先搜索)实现后序遍历。
2. 树的基本性质与遍历方式
树的性质
- 有
n个节点,n-1条边。 - 任意两点间有且仅有一条路径。
- 无环、连通。
遍历方式
- DFS(深度优先搜索):最常用,用于递归处理子树。
- BFS(广度优先搜索):可用于层次遍历,但 DP 中较少用。
在树形 DP 中,我们通常使用 后序遍历(Post-order DFS):先处理所有子节点,再处理当前节点。
3. 树形 DP 的基本思想
- 建树:使用邻接表存储树(注意无向图需避免回溯)。
- 选择根节点:通常任选一个节点(如 1 号节点)作为根,将无根树转为有根树。
- 状态定义:定义
dp[u][...]表示以u为根的子树在某种状态下的最优解。 - 状态转移:从子节点
v向父节点u转移。 - 边界条件:叶子节点通常有明确的初始值。
- 答案提取:根据问题,答案可能是
dp[root]或所有节点中的最大值等。
4. 经典例题 1:没有上司的舞会(最大独立集)
题目描述:公司有
n名员工,形成一棵树(上司-下属关系)。每个员工有一个快乐值。如果某个员工参加舞会,他的直接下属就不能参加。求最大快乐值总和。
本质:求树的最大独立集(即选点,任意两点不相邻)。
状态定义
设 dp[u][0] 表示 不选 节点 u 时,以 u 为根的子树的最大快乐值。
设 dp[u][1] 表示 选 节点 u 时,以 u 为根的子树的最大快乐值。
状态转移
dp[u][0] = Σ max(dp[v][0], dp[v][1]):u不选,则子节点v可选可不选。dp[u][1] = happy[u] + Σ dp[v][0]:u选,则所有子节点v都不能选。
边界条件
叶子节点:dp[leaf][0] = 0, dp[leaf][1] = happy[leaf]
C++ 实现
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int MAXN = 6005;
vector<int> tree[MAXN]; // 邻接表
int happy[MAXN]; // 快乐值
int dp[MAXN][2]; // dp[u][0/1]: 不选/选 u 的最大值
bool hasFather[MAXN]; // 标记是否有父节点(用于找根)
void dfs(int u) {
dp[u][1] = happy[u]; // 选 u
dp[u][0] = 0; // 不选 u
for (int v : tree[u]) {
dfs(v); // 先处理子节点
// 状态转移
dp[u][0] += max(dp[v][0], dp[v][1]);
dp[u][1] += dp[v][0];
}
}
int main() {
int n;
cin >> n;
// 输入快乐值
for (int i = 1; i <= n; i++) {
cin >> happy[i];
}
// 建树
for (int i = 1; i < n; i++) {
int a, b;
cin >> a >> b;
tree[b].push_back(a); // b 是 a 的上司
hasFather[a] = true;
}
// 找根节点(没有父节点的)
int root = 1;
while (hasFather[root]) root++;
// 从根开始 DFS
dfs(root);
// 答案是根节点选或不选的最大值
cout << max(dp[root][0], dp[root][1]) << endl;
return 0;
}
5. 经典例题 2:树的重心
题目描述:树的重心是指删除该节点后,使得剩下的连通块中最大子树的节点数最小的节点。
本质:最小化最大子树大小。
状态定义
设 sz[u] 表示以 u 为根的子树的节点总数(包括 u 自身)。
设 maxPart[u] 表示删除 u 后,最大连通块的大小。
状态转移
sz[u] = 1 + Σ sz[v](v是u的子节点)maxPart[u] = max( max(sz[v]), n - sz[u] )
(n - sz[u]是父方向的连通块)
C++ 实现
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int MAXN = 100005;
vector<int> tree[MAXN];
int sz[MAXN]; // 子树大小
int maxPart[MAXN]; // 删除 u 后最大连通块大小
int n, bestNode;
int minMaxPart = 1e9;
void dfs(int u, int parent) {
sz[u] = 1;
int maxSubtree = 0; // u 的最大子树大小
for (int v : tree[u]) {
if (v == parent) continue; // 避免回溯到父节点
dfs(v, u);
sz[u] += sz[v];
maxSubtree = max(maxSubtree, sz[v]);
}
// 父方向的连通块大小
int parentPart = n - sz[u];
maxPart[u] = max(maxSubtree, parentPart);
// 更新最优解
if (maxPart[u] < minMaxPart) {
minMaxPart = maxPart[u];
bestNode = u;
}
}
int main() {
cin >> n;
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
tree[u].push_back(v);
tree[v].push_back(u);
}
dfs(1, -1); // 从节点 1 开始,父节点为 -1
cout << "树的重心是: " << bestNode << endl;
cout << "最大连通块大小: " << minMaxPart << endl;
return 0;
}
6. 经典例题 3:树的直径(DP 解法)
题目描述:树的直径是树中最长路径的长度(边数或权值和)。
DP 方法:对每个节点,记录从该节点向下延伸的最长路径和次长路径。
状态定义
设 down1[u] 表示从 u 向下延伸的最长路径长度。
设 down2[u] 表示从 u 向下延伸的次长路径长度。
全局答案 ans = max(ans, down1[u] + down2[u])
C++ 实现(边权为 1)
#include <iostream>
#include <vector>
using namespace std;
const int MAXN = 100005;
vector<int> tree[MAXN];
int down1[MAXN], down2[MAXN]; // 最长和次长向下的路径
int ans = 0;
void dfs(int u, int parent) {
down1[u] = down2[u] = 0;
for (int v : tree[u]) {
if (v == parent) continue;
dfs(v, u);
int len = down1[v] + 1; // 边权为 1
if (len > down1[u]) {
down2[u] = down1[u];
down1[u] = len;
} else if (len > down2[u]) {
down2[u] = len;
}
}
ans = max(ans, down1[u] + down2[u]);
}
int main() {
int n;
cin >> n;
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
tree[u].push_back(v);
tree[v].push_back(u);
}
dfs(1, -1);
cout << "树的直径: " << ans << endl;
return 0;
}
7. 经典例题 4:树上背包问题
题目描述:有 $ n $ 门课,每门课有学分
credit[i],某些课有先修课(形成一棵树)。最多选 $ m $ 门课,求最大学分。
本质:树形依赖背包问题。
状态定义
dp[u][j]:在以 u 为根的子树中,选 j 门课的最大学分。
转移方式
对每个子节点 v,做一次分组背包:
for (int j = m; j >= 1; j--) // 倒序枚举容量
for (int k = 0; k < j; k++) // 在 v 子树中选 k 门
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k])
注意:u 本身也要选,所以至少要留 1 个容量给 u。
C++ 实现
#include <iostream>
#include <vector>
using namespace std;
const int MAXN = 305;
vector<int> tree[MAXN];
int credit[MAXN];
int dp[MAXN][MAXN]; // dp[u][j]: u 子树选 j 门课的最大收益
int sz[MAXN]; // 子树大小(优化用)
void dfs(int u) {
sz[u] = 1;
dp[u][0] = 0;
dp[u][1] = credit[u]; // 选自己
for (int v : tree[u]) {
dfs(v);
// 合并子树 v(分组背包)
for (int j = min(sz[u] + sz[v], 300); j >= 1; j--) {
for (int k = 0; k <= min(j - 1, sz[v]); k++) {
if (dp[u][j - k] != -1 && dp[v][k] != -1) {
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
}
}
}
sz[u] += sz[v];
}
}
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) {
int fa;
cin >> fa >> credit[i];
tree[fa].push_back(i);
}
// 初始化 dp 为 -1(不可达)
memset(dp, -1, sizeof(dp));
dfs(0); // 加入虚拟根节点 0
int ans = 0;
for (int j = 0; j <= m; j++) {
ans = max(ans, dp[0][j]);
}
cout << ans << endl;
return 0;
}
8. 树形 DP 的常见优化技巧
- 子树大小优化:在树上背包中,枚举容量时只需到
min(m, sz[u])。 - 长链剖分优化:对特定问题(如
k级祖先),可将复杂度从 $ O(n^2) $ 优化到 $ O(n) $。 - 换根 DP(二次扫描):先以某点为根做一次 DP,再通过 DFS 换根,计算每个点为根的答案。
- 记忆化搜索:避免重复计算。
- 使用 vector 动态分配:节省空间。
9. 总结与注意事项
树形 DP 的通用步骤:
- 建图(邻接表)
- 确定根节点
- 定义状态(
dp[u][...]) - 写 DFS 函数,后序遍历
- 处理子节点后更新父节点
- 避免回溯(传
parent参数) - 提取答案
常见错误:
- 忘记标记父节点导致无限递归。
- 状态定义不清晰。
- 转移方程写错(尤其是树上背包的倒序枚举)。
- 忽略边界条件(叶子节点)。
10. 完整 C++ 模板代码汇总
// 通用树形 DP 模板
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
const int MAXN = 100005;
vector<int> tree[MAXN];
int dp[MAXN][2]; // 根据题目调整
void dfs(int u, int parent) {
// 初始化当前节点
dp[u][0] = 0;
dp[u][1] = 1; // 举例
for (int v : tree[u]) {
if (v == parent) continue;
dfs(v, u);
// 状态转移(根据题目填写)
dp[u][0] += max(dp[v][0], dp[v][1]);
dp[u][1] += dp[v][0];
}
}
int main() {
int n;
cin >> n;
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
tree[u].push_back(v);
tree[v].push_back(u);
}
dfs(1, -1);
cout << max(dp[1][0], dp[1][1]) << endl;
return 0;
}
结语:树形 DP 是算法竞赛和面试中的重点内容。掌握其核心思想——递归处理子树 + 状态合并,并熟练运用 DFS 遍历,就能解决大部分问题。多做题、多总结,是掌握树形 DP 的关键。
4175

被折叠的 条评论
为什么被折叠?



