文章目录

树形 DP

树形 DP

一、树形 DP 概述

1.1 什么是树形 DP

树形 DP(Tree DP),即树状动态规划,是在树结构上进行的动态规划。它利用树的递归性质将大问题分解为子树的子问题,通过自底向上(或自顶向下)的方式合并子问题的解,最终得到原问题的解。

树是一种特殊的图——无环连通图,具有天然的递归结构:以任意节点为根,每个节点的子树都是一棵独立的树。这种递归结构与 DP 的"最优子结构"性质高度契合,因此树上的很多最优化、计数问题都可以用 DP 高效解决。

1.2 树形 DP 的基本特征

  • 状态定义:通常以"以 u 为根的子树"为状态的第一维,即 dp[u] 表示以 u 为根的子树中的最优解/方案数。
  • 转移方式:父节点的答案由其所有子节点的答案合并而来。
  • 遍历顺序:绝大多数情况下使用后序遍历(先处理所有子节点,再处理父节点)。
  • 时间复杂度:因问题而异,从 \(O(n)\)\(O(nk)\)\(O(n^2)\) 不等,但通常是线性或近似线性的。

1.3 为什么树适合 DP

树的性质 对应 DP 的性质
无环 不会重复计算,无后效
递归子结构 最优子结构
父子关系明确 转移方向清晰
连通性 可以通过 DFS 完整遍历

注:树形 DP 的本质是在树的 DFS 序上做 DP。因为树的 DFS 遍历天然保证了"处理完子树再处理父亲"的顺序,这恰好满足了 DP 对计算顺序的要求。


二、基础概念与实现方式

2.1 树的存储

树形 DP 中最常用的存图方式是邻接表

vector<int> g[MAXN];  // 无向树,每条边存两次

void add_edge(int u, int v) {
    g[u].push_back(v);
    g[v].push_back(u);
}

有根树 vs 无根树

  • 很多题目给出的是无根树(没有指定根节点),我们需要任选一个根(通常选 1 号节点)将其转化为有根树来处理。
  • 根的选择不影响最终答案,但会影响 DP 的实现方式。

2.2 DFS 框架

树形 DP 几乎都基于**深度优先搜索(DFS)**实现。核心框架如下:

void dfs(int u, int fa) {  // u 是当前节点,fa 是父节点(防止往回走)
    // 1. 初始化 dp[u]
    dp[u] = ...;
    
    for (int v : g[u]) {
        if (v == fa) continue;  // 跳过父节点
        dfs(v, u);              // 递归处理子树
        
        // 2. 用子节点 v 的 dp 值更新父节点 u 的 dp 值
        dp[u] = merge(dp[u], dp[v]);
    }
    
    // 3. 后序处理(可选)
    // ...
}

关键要点

  1. 父节点参数 fa:因为是无向图存边,需要用 fa 记录父节点,防止 DFS 往回走导致死循环。
  2. 后序顺序:先递归所有子节点,再处理当前节点。这保证了计算 dp[u] 时,所有 dp[v](v 是 u 的子节点)都已经算好了。
  3. 合并操作 merge:这是不同题型的核心差异——如何把子节点的答案合并到父节点上。

2.3 状态设计的一般原则

树形 DP 的状态通常包含以下维度:

维度 含义 示例
第一维 节点编号(以 u 为根的子树) dp[u]
第二维 u 的状态(选/不选、颜色、度数等) dp[u][0/1]
第三维 子树中选了多少个/体积限制等 dp[u][k]

状态设计的核心原则:状态必须包含足够的信息,使得父节点在合并时不需要知道子树内部的具体细节,只需要知道子树根节点的"状态"即可。


三、最大独立集 / 最小点覆盖 / 最小支配集

这三个是树形 DP 中最经典的入门模型,务必熟练掌握。

3.1 最大独立集

问题定义

给定一棵 n 个节点的树,从中选出尽可能多的节点,使得任意两个被选中的节点都不相邻(没有边相连)。求最多选多少个节点。

状态定义

  • dp[u][0]:在以 u 为根的子树中,不选 u 时的最大独立集大小
  • dp[u][1]:在以 u 为根的子树中,选 u 时的最大独立集大小

转移方程

选 u 的情况(dp[u][1])
既然选了 u,那么 u 的所有子节点都不能选(因为相邻)。所以:

\[dp[u][1] = 1 + \sum_{v \in sons(u)} dp[v][0] \]

其中 +1 是选 u 自己。

不选 u 的情况(dp[u][0])
既然不选 u,那么每个子节点 v 可以选也可以不选,取较大值:

\[dp[u][0] = \sum_{v \in sons(u)} \max(dp[v][0], dp[v][1]) \]

初始化

  • 叶子节点:dp[leaf][0] = 0dp[leaf][1] = 1
  • (其实不需要专门初始化,DFS 后序遍历自然会处理好)

最终答案

\[\max(dp[root][0], dp[root][1]) \]

code

int dp[MAXN][2];

void dfs(int u, int fa) {
    dp[u][1] = 1;   // 选 u,先加 1
    dp[u][0] = 0;   // 不选 u
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        dp[u][1] += dp[v][0];          // 选 u,则子节点必不选
        dp[u][0] += max(dp[v][0], dp[v][1]);  // 不选 u,子节点可选可不选
    }
}

时间复杂度\(O(n)\)。每个节点恰好被处理一次。


3.2 最小点覆盖

问题定义

给定一棵 n 个节点的树,从中选出尽可能少的节点,使得树中每条边至少有一个端点被选中。求最少选多少个节点。

状态定义

  • dp[u][0]:在以 u 为根的子树中,不选 u 时的最小点覆盖大小
  • dp[u][1]:在以 u 为根的子树中,选 u 时的最小点覆盖大小

转移方程

选 u 的情况(dp[u][1])
选了 u,则 u 与子节点的所有边都被覆盖了。子节点 v 可选可不选,取较小值:

\[dp[u][1] = 1 + \sum_{v \in sons(u)} \min(dp[v][0], dp[v][1]) \]

不选 u 的情况(dp[u][0])
不选 u,则为了覆盖 u 与子节点 v 之间的边,每个子节点 v 必须选

\[dp[u][0] = \sum_{v \in sons(u)} dp[v][1] \]

最终答案

\[\min(dp[root][0], dp[root][1]) \]

对比最大独立集

问题 选 u 时子节点 不选 u 时子节点 优化目标
最大独立集 必须不选 可选可不选(取 max) 最大化
最小点覆盖 可选可不选(取 min) 必须选 最小化

注:两者的转移不是简单的"取反"关系,不要混淆。


3.3 最小支配集

问题定义

给定一棵 n 个节点的树,从中选出尽可能少的节点,使得树中每个节点要么被选中,要么与至少一个被选中的节点相邻。求最少选多少个节点。

与点覆盖的区别:点覆盖要求"每条边被覆盖",支配集要求"每个点被覆盖"。支配集更难。

状态定义

需要 3 种状态(比前两个问题多一种):

  • dp[u][0]:u 被选中(u 支配自己)
  • dp[u][1]:u 没被选中,但被至少一个子节点支配
  • dp[u][2]:u 没被选中,且没被子节点支配(因此必须被父节点支配)

转移方程

状态 0:选 u(dp[u][0])
选了 u,则 u 可以支配所有子节点。子节点 v 可以是任意状态,取最小:

\[dp[u][0] = 1 + \sum_{v \in sons(u)} \min(dp[v][0], dp[v][1], dp[v][2]) \]

状态 2:u 不选,且不被子节点支配(dp[u][2])
u 不选,也不被子节点支配 → 每个子节点 v 都不能选(否则 v 会支配 u),且 v 必须被它们自己的子节点支配:

\[dp[u][2] = \sum_{v \in sons(u)} dp[v][1] \]

状态 1:u 不选,但被子节点支配(dp[u][1])
这是最复杂的转移。u 不选,但至少有一个子节点 v 选了(v 支配 u),其余子节点可以是状态 0 或 1。

设子节点集合为 \(V\),则:

\[dp[u][1] = \min_{v \in V} \left( dp[v][0] + \sum_{w \in V, w \neq v} \min(dp[w][0], dp[w][1]) \right) \]

但这样直接枚举是 \(O(n^2)\) 的。可以优化为:

\[dp[u][1] = \sum_{v} \min(dp[v][0], dp[v][1]) + \Delta \]

其中 \(\Delta\) 是"修正项"——如果所有子节点都选了 \(\min\)\(dp[v][1]\)(即没有子节点选了),那么需要选一个代价最小的子节点改为选它(状态 0),增量为 \(dp[v][0] - dp[v][1]\)

\[\Delta = \max\left(0, \min_{v \in V}(dp[v][0] - \min(dp[v][0], dp[v][1]))\right) \]

即:

  • 如果存在至少一个 v 使得 \(dp[v][0] \le dp[v][1]\),则 \(\Delta = 0\)(已经有子节点选了)
  • 否则,需要选一个代价增量最小的子节点改为选它

最终答案

\[\min(dp[root][0], dp[root][1]) \]

(根节点没有父节点,所以不能是状态 2)

三种经典模型对比

模型 状态数 转移复杂度 核心思想
最大独立集 2 O(n) 选了父就不能选子
最小点覆盖 2 O(n) 不选父就必须选子
最小支配集 3 O(n) 需考虑被谁支配

四、树的直径

详见:

图论:LCA、树的直径、树的重心、二分图与 Tarjan 缩点

4.1 问题定义

树的直径(Diameter)是树中最长的简单路径的长度(路径上边数或点数,视题目而定)。

4.2 性质

  1. 树的直径可能有多条,但它们的中点(或中间边)相同。
  2. 从任意节点出发,DFS/BFS 找到最远点 u,再从 u 出发 DFS/BFS 找到最远点 v,则 u-v 就是一条直径。(这是两遍 DFS 求直径的方法)
  3. 树上任意一点的最远点一定是直径的某个端点。

4.3 DP 求直径

状态定义

  • dp[u]:以 u 为根的子树中,从 u 出发向下走的最长路径长度

转移

对于 u 的每个子节点 v:

\[dp[u] = \max_{v \in sons(u)} (dp[v] + w(u, v)) \]

其中 \(w(u, v)\) 是边 u-v 的长度(如果是无权树,w = 1)。

如何求直径

在计算 dp[u] 的过程中,维护 u 的前两大子树路径长度,它们的和就是经过 u 的最长路径(即 u 的"向下最长" + "向下次长")。对所有节点取最大值,就是树的直径。

int dp[MAXN];  // 从 u 向下的最长路径
int diameter;  // 直径

void dfs(int u, int fa) {
    dp[u] = 0;
    int max1 = 0, max2 = 0;  // 最长和次长
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        int val = dp[v] + 1;  // +1 是边权(无权树)
        if (val > max1) {
            max2 = max1;
            max1 = val;
        } else if (val > max2) {
            max2 = val;
        }
        dp[u] = max(dp[u], val);
    }
    diameter = max(diameter, max1 + max2);  // 经过 u 的最长路径
}

时间复杂度

\(O(n)\),每个节点恰好处理一次。

4.4 两遍 DFS 法(补充)

虽然这不是 DP,但也是求树直径的经典方法,一并介绍:

  1. 从任意节点出发,DFS 找到最远点 u
  2. 从 u 出发,DFS 找到最远点 v
  3. u 到 v 的距离就是直径

证明思路:利用直径的性质——任意点的最远点是直径端点。

这种方法只能求无权树的直径(或边权非负的树),而 DP 法更通用。


五、树的重心

5.1 问题定义

树的重心(Centroid)是一个节点,将其删除后,剩余的各连通块中,最大的那个连通块的大小最小

换句话说,重心是树的"最中心"的节点,删掉它后树被拆得最均匀。

5.2 性质

  1. 一棵树最多有两个重心,且它们相邻。
  2. 以重心为根,每个子树的大小都不超过总节点数的一半。
  3. 树中所有点到某个点的距离和中,到重心的距离和最小。
  4. 把两棵树通过一条边相连,新树的重心在原来两棵树重心的连线上。

5.3 DP 求重心

状态定义

  • sz[u]:以 u 为根的子树的大小(节点数)

转移

\[sz[u] = 1 + \sum_{v \in sons(u)} sz[v] \]

求重心

对于每个节点 u,删除 u 后,各连通块的大小为:

  • 每个子树 v 的大小:sz[v]
  • 父节点那一侧的大小:n - sz[u]

取最大值,记为 max_part[u]。使 max_part[u] 最小的 u 就是重心。

int sz[MAXN];
int max_part[MAXN];
int centroid;  // 重心
int n;

void dfs(int u, int fa) {
    sz[u] = 1;
    max_part[u] = 0;
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        sz[u] += sz[v];
        max_part[u] = max(max_part[u], sz[v]);
    }
    max_part[u] = max(max_part[u], n - sz[u]);  // 父节点那一侧
    if (max_part[u] < max_part[centroid]) {
        centroid = u;
    }
}

时间复杂度

\(O(n)\)

5.4 重心的应用

  • 点分治(Centroid Decomposition):这是重心最重要的应用。通过不断找重心、删重心、分治处理各子树,可以在 \(O(n \log n)\) 时间内解决很多树上路径问题。
  • 求树的最小距离和
  • 平衡树结构的保证

六、树上背包(有依赖的背包)

6.1 问题模型

树上的每个节点代表一个物品,具有体积 \(w_u\)价值 \(v_u\)。选一个节点就必须选它的父节点(有依赖关系)。背包容量为 m,求最大价值。

这就是有依赖的背包问题,在树上就是树形背包

6.2 状态定义

  • dp[u][k]:在以 u 为根的子树中选若干节点(必须包含 u),总体积恰好为 k 时的最大价值

注意"必须包含 u",这是有依赖背包的关键——选子树内的东西就必须选根。

6.3 朴素转移(分组背包思想)

初始时,dp[u][w_u] = v_u(只选 u 自己)。

然后依次考虑每个子节点 v。对于每个子节点,相当于一个物品组:可以选 v 的子树中的某些节点(但必须包含 v)。这就是一个分组背包问题。

转移方程:

\[dp[u][i] = \max_{0 \le j \le i - w_u} \left( dp[u][i - j] + \max_{0 \le k \le j} dp[v][k] \right) \]

更简洁的写法(标准分组背包):

for 每个子节点 v:
    for i from m down to w_u:
        for j from 0 to i - w_u:
            dp[u][i] = max(dp[u][i], dp[u][i - j] + dp[v][j])

时间复杂度

  • 朴素实现:\(O(n \cdot m^2)\),对每个节点做 m² 的分组背包
  • 这通常过不了较大的数据

6.4 优化:DFS 序上做背包

核心思想

利用树的 DFS 序,将树形背包转化为线性 DP。

对树进行 DFS,记录每个节点的进入时间 in[u] 和子树大小 sz[u]。在 DFS 序数组上做 DP:

  • dp[i][k]:考虑前 i 个节点(DFS 序中的前 i 个),体积为 k 时的最大价值

转移:

  • 选第 i 个节点(设为 u):则必须选它,且它的子树都在 i+1 到 i+sz[u]-1 范围内。转移到 dp[i+1][k-w_u] + v_u
  • 不选第 i 个节点:则它的整个子树都不能选(因为有依赖)。直接跳到 i + sz[u],转移到 dp[i + sz[u]][k]

时间复杂度

\(O(n \cdot m)\),比朴素的 \(O(nm^2)\) 快很多。

:这种优化适用于"选父必须选子"的依赖关系。但实际中,依赖关系是"选子必须选父",DFS 序法需要仔细处理顺序。

6.5 另一种常用写法:子树合并

void dfs(int u, int fa) {
    dp[u][w[u]] = v[u];  // 初始:只选 u
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        // 类似 01 背包,倒序枚举
        for (int j = m; j >= w[u]; j--) {
            for (int k = 0; k <= j - w[u]; k++) {
                dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
            }
        }
    }
}

这个写法直观但复杂度是 \(O(n \cdot m^2)\),需要结合优化才能过大数据。

6.6 进一步优化的条件

如果题目中的体积都是 1(即每个物品体积为 1,求选 k 个物品的最大价值),则有更优的 \(O(nk)\) 做法,因为子树大小限制了枚举范围,实际效率很高。


七、换根 DP(二次扫描与换根法)

7.1 适用场景

有一类树形 DP 问题,需要求出以每个节点为根时的答案。如果对每个节点都跑一遍 DP,复杂度是 \(O(n^2)\),可能超时。

换根 DP(也叫二次扫描与换根法rerooting technique)通过两次 DFS,将复杂度降为 \(O(n)\)

典型问题:求树上每个节点到其他所有节点的距离之和(树的距离和问题)。

7.2 基本思想

  1. 第一次 DFS(后序):任选一个根(比如 1 号节点),计算以该根为基准的子树 DP 值。
  2. 第二次 DFS(前序):从根出发,利用父节点的答案推导子节点的答案(即"换根"操作)。

核心是找到一个换根公式:已知以 u 为根时的答案 ans[u],如何快速求出以 u 的子节点 v 为根时的答案 ans[v]

7.3 经典例题:树的距离和

问题

求树上每个节点 u,到其他所有节点的距离之和 ans[u]

第一次 DFS(后序,以 1 为根)

  • sz[u]:以 u 为根的子树大小
  • dp[u]:u 到其子树内所有节点的距离之和

转移:

\[sz[u] = 1 + \sum_{v} sz[v] \]

\[dp[u] = \sum_{v} (dp[v] + sz[v]) \]

(每个子节点 v 的子树内的点到 u 的距离 = 到 v 的距离 + 1,共 sz[v] 个点,所以加 sz[v])

第二次 DFS(前序,换根)

已知 ans[u],求 ans[v](v 是 u 的子节点)。

当根从 u 换到 v 时:

  • v 的子树内的所有节点:距离都减少 1(共 sz[v] 个,减少 sz[v])
  • 其余节点(不在 v 的子树内):距离都增加 1(共 n - sz[v] 个,增加 n - sz[v])

因此:

\[ans[v] = ans[u] - sz[v] + (n - sz[v]) = ans[u] + n - 2 \cdot sz[v] \]

这就是换根公式

代码框架

int sz[MAXN];
long long dp[MAXN];  // 子树内距离和
long long ans[MAXN]; // 全局距离和
int n;

void dfs1(int u, int fa) {  // 后序:算 sz 和 dp
    sz[u] = 1;
    dp[u] = 0;
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs1(v, u);
        sz[u] += sz[v];
        dp[u] += dp[v] + sz[v];
    }
}

void dfs2(int u, int fa) {  // 前序:换根算 ans
    for (int v : g[u]) {
        if (v == fa) continue;
        ans[v] = ans[u] + n - 2 * sz[v];  // 换根公式
        dfs2(v, u);
    }
}

// 调用:
dfs1(1, 0);
ans[1] = dp[1];
dfs2(1, 0);

时间复杂度

\(O(n)\),两次 DFS。

7.4 换根 DP 的一般步骤

  1. 选定初始根:通常选 1 号节点
  2. 后序 DFS:计算子树 DP(以当前节点为根的子树内部的答案)
  3. 推导换根公式:思考根从父换到子,答案如何变化
  4. 前序 DFS:利用父节点的答案计算子节点的答案

7.5 常见换根 DP 题型

问题 状态设计
每个节点的距离和 sz[u] + dp[u](子树内距离和)
每个节点的最大距离(树的半径) 维护子树最长/次长路径 + 父节点方向的最长路径
树上最长路径(直径)经过的节点数 记录直径端点 + 前缀后缀最大值
每个节点为根时的最大独立集 维护子树 dp + 向上的贡献

:换根 DP 的关键在于"信息的可维护性"——需要保证父节点的信息足以推导出子节点的信息。如果状态过于复杂,换根公式可能非常难写。


八、基环树 DP

8.1 什么是基环树

基环树(Pseudotree / Unicyclic Graph):恰好含有一个环的连通图。也叫单环图

基环树可以看作"一棵树 + 一条额外的边",也可以看作"一个环,环上每个节点挂着一棵树"(外向树)。

      树     树
       \   /
     ---环---
       /   \
      树     树

n 个节点 n 条边的连通图就是基环树。

8.2 基环树 DP 的基本思想

基环树比普通树多了一个环,因此不满足树的无环性质,不能直接树形 DP。

核心思路

  1. 找到环
  2. 破环为链(或"断边"):枚举环上的约束条件,把环断开,转化为普通的树/链做 DP
  3. 合并结果

8.3 找环

找基环树的环有多种方法:

方法一:拓扑排序(剥洋葱)

不断删除度数为 1 的节点(叶子),最后剩下的度数 ≥ 2 的节点就是环上的节点。

int deg[MAXN];
bool on_cycle[MAXN];

void find_cycle() {
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        if (deg[i] == 1) q.push(i);
    }
    while (!q.empty()) {
        int u = q.front(); q.pop();
        on_cycle[u] = false;
        for (int v : g[u]) {
            if (on_cycle[v]) {  // 还在环上
                deg[v]--;
                if (deg[v] == 1) q.push(v);
            }
        }
    }
}

方法二:DFS 栈

用 DFS 遍历,记录访问栈,遇到回边时回溯得到环。

拓扑排序法最简单,推荐优先使用。

8.4 基环树最大独立集

这是基环树 DP 的经典入门题。

思路

找到环,任选环上一条边 (u, v) 断开:

  • 情况 1:不选 u,则 v 可选可不选 → 以 v 为根做树的最大独立集
  • 情况 2:不选 v,则 u 可选可不选 → 以 u 为根做树的最大独立集

两种情况取最大值。

为什么这样做?因为环上只有一条额外的边造成了约束,枚举这条边两端的选择情况(不能同时选),就把环的约束消除了。

一般化方法

对于基环树 DP,通用做法是:

  1. 找到环,设环上节点依次为 \(c_1, c_2, \dots, c_k\)
  2. 对每个环上节点 \(c_i\),先对它挂的外向树做一次树形 DP,得到 \(c_i\) 的各种状态值
  3. 在环上做环形 DP(破环为链,枚举起点状态):
    • 枚举 \(c_1\) 的状态,然后按链 DP
    • 保证 \(c_k\) 的状态与 \(c_1\) 不冲突
  4. 对所有合法情况取最优

8.5 基环树直径

基环树的直径有两种可能:

  1. 完全在某棵外向树中(不经过环)
  2. 经过环(从一棵外向树进环,走一段环,再进另一棵外向树)

对于情况 2,需要在环上用单调队列前缀和优化来求最大值。


九、树形 DP 的优化

9.1 长链剖分(长链剖分优化 DP)

适用场景

状态中带有"深度"维度的树形 DP,例如:

  • dp[u][k]:u 的子树中,距离 u 恰好为 k 的节点数/最大权值
  • 与深度有关的计数/最优化问题

核心思想

  • 长链:从节点 u 到其最深叶子的路径
  • 每个点属于恰好一条长链
  • 合并子节点时,重儿子(长链的延续)的信息直接继承(指针移动,O(1)),轻儿子的信息暴力合并
  • 每个点作为轻儿子被合并一次,总复杂度 \(O(n)\)

实现方式

通常用指针数组动态分配内存来实现"重儿子直接继承"的效果。

9.2 单调队列优化

适用场景

树上背包中,如果转移是"滑动窗口取 max"的形式(比如有距离限制的背包),可以用单调队列将一维复杂度从 m 降到 1。

9.3 点分治 + DP

对于"统计树上满足条件的路径数目/最大权值"类问题,如果直接树形 DP 难以处理,可以用点分治

  1. 找重心
  2. 统计经过重心的路径的答案
  3. 删掉重心,递归处理各子树

时间复杂度通常为 \(O(n \log n)\)\(O(n \log^2 n)\)

9.4 状态压缩优化

某些问题中子节点的状态可以用二进制位表示(如"颜色不超过 10 种"),可以用状态压缩将多个状态合并为一个整数,加速转移。


附录:常见误区与注意事项

1. 根节点的选择

  • 无根树问题通常选 1 号节点为根
  • 注意根节点是否有特殊约束(如支配集问题中根不能是状态 2)
  • 换根 DP 中第一次 DFS 的根选哪个不影响正确性,但选 1 最方便

2. DFS 栈溢出

  • n 较大(如 1e5)时,递归 DFS 可能栈溢出
  • 解决方案:用 BFS + 后序遍历、手写栈(非递归 DFS)、或在 C++ 中扩栈
  • 实际竞赛中,n ≤ 1e5 时递归通常没问题(深度不会达到 1e5),但链状树要小心

3. 状态定义不清

  • 做树形 DP 前一定要明确状态的含义
  • 特别是"是否包含 u"、"u 的状态"等关键信息必须在状态定义中说清楚
  • 建议先在纸上写出状态的文字定义,再写转移方程

4. 转移顺序错误

  • 后序遍历:先子后父(绝大多数情况)
  • 前序遍历:先父后子(换根 DP 的第二次 DFS)
  • 不要搞反遍历顺序

5. 初始值错误

  • 求最大值:初始化为 -INF(注意边界状态要初始化为 0 或正确值)
  • 求最小值:初始化为 INF
  • 计数问题:初始化为 0,基准情况为 1
  • 不注意初始化会导致 WA 或溢出

6. 树上背包的合并顺序

  • 做树上背包时,一定要用类似 01 背包的倒序枚举
  • 初始时 dp[u] 只包含 u 自己,然后逐个合并子树
  • 如果正序枚举会导致重复选(完全背包效果)

7. 基环树漏情况

  • 基环树问题一定要考虑"完全在子树内"和"经过环"两种情况
  • 破环为链时,枚举的情况要覆盖所有合法情形,且不重复不遗漏

8. 数据范围

  • 树形 DP 的答案可能很大(如计数问题),注意开 long long
  • 取模问题注意模运算的正确性
  • 多组数据注意清空数组

总结:树形 DP 的核心在于状态设计转移方程。做题时,先分析问题在树上的递归结构,设计出以"子树"为核心的状态,再考虑如何从子节点合并到父节点。入门从最大独立集、树直径、树重心三个基础模型开始,进阶掌握树上背包、换根 DP、基环树 DP,就能覆盖绝大多数题型。

参考资料:李煜东《算法竞赛进阶指南》、OI-Wiki
部分内容由 AI 润色

posted @ 2026-09-03 14:19  lvwangshu  阅读(14)  评论(0)    收藏  举报