本讲定位:前四讲的 DP 都发生在序列上。本讲把舞台换成树——你会发现树反而是最适合 DP 的结构:每棵子树都是一个天然的子问题,DFS 的回溯顺序就是天然的填表顺序。学完本讲,“在树上选点/选边/分配资源”这一大类问题都有了统一的解法框架。
前置知识:专题一(依赖决定顺序)、专题三(背包,第 5 节用到);树的存储(邻接表)与 DFS。
标注 ⭐ 的小节为进阶内容。
回忆专题一的核心原则:转移用到谁,谁就必须先算完。序列上我们靠”下标从小到大”保证这一点;树上呢?
任选一个点作根,每个结点 $u$ 的子树就是一个独立的小世界。如果状态定义成”$u$ 的子树内部的答案”,那么 $f[u]$ 只依赖它的孩子 $f[v_1], f[v_2], \ldots$——而 DFS 恰好在回溯到 $u$ 之前,已经把所有孩子递归算完了。也就是说:
树形 DP = 状态挂在”子树”上 + 按 DFS 后序(先孩子后父亲)转移。
它本质上就是专题一的记忆化搜索,而且树上无环、每个点只会被访问一次,连 vis 数组都不需要。
代码骨架(本讲所有题共用):
void dfs(int u, int fa) {
初始化 f[u](只考虑 u 自己)
for (u 的每个邻居 v) {
if (v == fa) continue; // 无向存树,防止走回头路
dfs(v, u); // 先把子树 v 算完
用 f[v] 更新 f[u]; // 合并孩子的答案
}
}
树用无向图方式存(每条边存两个方向),DFS 时带上父结点参数防回头——这比”只存父到子的有向边”更通用,因为很多题不告诉你谁是根。
题意:$n \le 1.6 \times 10^4$ 个结点的树,点有权值(可负)。选出一个非空连通块,使权值和最大。
样例:权值
-1 -1 -1 1 1 1 0,边1-4, 2-5, 3-6, 4-7, 5-7, 6-7→ 答案3(选 $\{4,5,6,7\}$)。
这是专题二”最大子段和”的树上版。序列上我们用”以 $i$ 结尾”固定接口;树上,连通块的接口是什么?——它总有一个最高点(离根最近的点)。以最高点来分类:
$$ f[u] = \text{在 } u \text{ 的子树内、包含 } u \text{ 的连通块的最大权值和} $$
包含 $u$ 的连通块 = $u$ 自己,加上若干个孩子方向的”包含 $v$ 的连通块”(要连通,必须经过 $v$ 接入)。每个孩子的贡献 $f[v]$ 是正的才要,是负的就整块不选:
$$ f[u] = w_u + \sum_{v \in \text{son}(u)} \max\big(f[v],\ 0\big) $$
答案是 $\max_u f[u]$(最优连通块的最高点可能是任何结点)。
#include<bits/stdc++.h>
using namespace std;
int n, w[16005], f[16005]; // f[u]:包含 u 的、完全落在 u 子树内的连通块的最大权值和
vector<int> g[16005];
int ans = INT_MIN;
void dfs(int u, int fa) {
f[u] = w[u];
for (int v : g[u]) {
if (v == fa) continue;
dfs(v, u); // 先算完子树(后序)
f[u] += max(f[v], 0); // 子树的连通块是正贡献才接上
}
ans = max(ans, f[u]);
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &w[i]);
for (int i = 1; i < n; i++) {
int u, v;
scanf("%d%d", &u, &v);
g[u].push_back(v);
g[v].push_back(u);
}
dfs(1, 0);
printf("%d\n", ans);
return 0;
}