算法知识图谱总览 / 动态规划基础族 / 树形DP

树形DP

P0intermediatedeepG-A-11

What · 是什么

树上递归DP,DFS后序传递子树最优解到父节点

树形DP是在树结构上进行的动态规划。利用树固有的递归性质,通常通过DFS后序遍历:先递归处理所有子树得到子问题的最优解,再将子树结果合并(向上传递)给父节点。典型问题包括没有上司的舞会(最大独立集)、树的直径、换根DP等。

Why · 为什么

直觉 · Intuition

树上的每个节点都可以看作子树的根。要解决以 u 为根的问题,只需先解决 u 的所有子节点 v 的子问题,再把各子问题的解按某种规则合并到 u。这天然符合DFS后序遍历的顺序——先深入到叶节点(边界),再回溯时合并。

原理 · Principle

基本范式:f(u, state) 表示以 u 为根的子树在某个状态下的最优解。DFS(u) 遍历 u 的所有子节点 v,递归调用 DFS(v) 后,将 f(v, *) 合并到 f(u, *)。转移方程随问题而定:如最大独立集 f(u,0)=sum max(f(v,0),f(v,1)), f(u,1)=sum f(v,0)+val[u]。换根DP(二次扫描):第一次DFS预处理子树信息,第二次DFS用父节点信息推导换根后的值。

历史 · History

树形DP是竞赛中的核心DP类型之一。其基本思想源于树的后序遍历——先处理子树再合并到根。换根DP(二次扫描/重新根DP)是树形DP的重要扩展,用于解决「不指定根、根的选择影响答案」的问题。OI Wiki以P1352(没有上司的舞会)为经典教学题。

How · 实现

伪代码

dfs(u, parent):
  f[u][0] = 0; f[u][1] = val[u]
  for v in children(u):
    if v == parent: continue
    dfs(v, u)
    f[u][0] += max(f[v][0], f[v][1])
    f[u][1] += f[v][0]
  # f[u][0] = u不选, f[u][1] = u选
  answer = max(f[root][0], f[root][1])

Python 实现

import sys
from collections import defaultdict


def tree_independent_set(n, edges, val):
    """树上最大权独立集 (没有上司的舞会)
    n: 节点数 (1-indexed)
    edges: [(u, v), ...] 无向边
    val: [val_1, val_2, ..., val_n] 各节点权值
    返回: 最大权独立集的权值和
    """
    adj = defaultdict(list)
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)

    f = [[0, 0] for _ in range(n + 1)]
    visited = [False] * (n + 1)

    def dfs(u):
        visited[u] = True
        f[u][0] = 0       # u 不选
        f[u][1] = val[u]  # u 选
        for v in adj[u]:
            if visited[v]:
                continue
            dfs(v)
            f[u][0] += max(f[v][0], f[v][1])
            f[u][1] += f[v][0]

    dfs(1)  # 以节点 1 为根
    return max(f[1][0], f[1][1])


def reroot_dp(n, edges):
    """换根DP: 求使得所有节点深度和最大的根节点 (POI2008 STA)
    返回: (最佳根节点, 最大深度和)
    """
    adj = defaultdict(list)
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)

    sz = [0] * (n + 1)  # 子树大小
    f = [0] * (n + 1)   # 以 i 为根时的深度和

    def dfs1(u, fa):
        sz[u] = 1
        f[u] = 0
        for v in adj[u]:
            if v == fa:
                continue
            dfs1(v, u)
            sz[u] += sz[v]
            f[u] += f[v] + sz[v]  # v 子树所有点深度+1

    dfs1(1, 0)

    best_node, best_val = 1, f[1]

    def dfs2(u, fa):
        nonlocal best_node, best_val
        for v in adj[u]:
            if v == fa:
                continue
            # 换根: u -> v
            f[v] = f[u] - sz[v] + (n - sz[v])
            if f[v] > best_val:
                best_val = f[v]
                best_node = v
            dfs2(v, u)

    dfs2(1, 0)
    return best_node, best_val


if __name__ == "__main__":
    sys.setrecursionlimit(10000)

    # P1352 没有上司的舞会 示例
    # 节点1权值1, 节点2权值1, 节点3权值1
    # 边: 1-2, 2-3 (链)
    # 选1和3 -> 权值2
    n = 3
    edges = [(1, 2), (2, 3)]
    val = [0, 1, 1, 1]  # 0号不用, 1-indexed
    result = tree_independent_set(n, edges, val)
    assert result == 2, f"Expected 2, got {result}"

    # 换根DP测试: 3节点链 1-2-3, 求深度和最大的根
    # root=1: depths 0,1,2 -> sum=3; root=2: 1,0,1 -> sum=2; root=3: 2,1,0 -> sum=3
    n2 = 3
    edges2 = [(1, 2), (2, 3)]
    root, depth_sum = reroot_dp(n2, edges2)
    assert depth_sum == 3, f"Expected depth_sum=3, got {depth_sum}"

    print("All tests passed.")
PyPy 提示PyPy递归性能优于CPython但深递归仍需sys.setrecursionlimit。树形DP的Python递归在n>1e5时可能栈溢出,可改用sys.setrecursionlimit(1<<20)或手写栈迭代。PyPy对defaultdict(list)建图效率好。
时间复杂度O(n) — 每个节点访问一次,转移按度数均摊
空间复杂度O(n) — 邻接表+DP数组+递归栈
注意 · 局限Python递归深度默认1000,n>1000必须setrecursionlimit。n>1e5的树可能栈溢出。换根DP的第二次DFS需正确传递父节点信息。

易错点

例题

OJ题号难度考点链接
LuoguP13522没有上司的舞会:树上最大权独立集,树形DP入门模板https://www.luogu.com.cn/problem/P1352
LuoguP34782POI2008 STA-Station:换根DP模板,求深度和最大的根https://www.luogu.com.cn/problem/P3478
HDU21963Computer:树形DP求每个节点最远距离,需换根+次远维护https://acm.hdu.edu.cn/showproblem.php?pid=2196
步骤迷你图(节点 = 算法步骤 · 边 = 流程 · 绿色 = 起始步骤)
上游 · 本知识点依赖(前置/变体/组合/共现)
下游 · 依赖本知识点

信源

✓ = 已核验 · △ = 未核验