ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

换根DP:树形动态规划的高效优化技巧

换根DP:树形动态规划的高效优化技巧

1. 换根DP的核心思想与应用场景

换根DP(也称为二次扫描法)是树形动态规划中的一种特殊技巧,主要用于解决需要以不同节点为根进行多次计算的问题。这类问题的典型特征是:我们需要对树中的每个节点作为根的情况进行计算,但直接暴力枚举每个节点作为根会导致O(n²)的时间复杂度,这在处理大规模树结构时(如n=1e6)显然不可行。

换根DP的精妙之处在于,它通过两次深度优先搜索(DFS)将时间复杂度优化到O(n):

  • 第一次DFS:以任意节点(通常选择1号节点)为根,计算子树相关信息
  • 第二次DFS:利用父节点的信息推导子节点的信息,实现"换根"操作

这种技巧在以下场景特别有用:

  1. 需要找到使某种树指标最优的根节点(如最大深度和、最小代价等)
  2. 需要对每个节点作为根时计算全局性质
  3. 树结构动态变化时需要快速重新计算全局信息

提示:换根DP不是一种独立的算法,而是对常规树形DP的优化技巧。掌握它需要先熟悉基础的树形DP和DFS遍历。

2. 洛谷P10962问题解析

2.1 题目重述与理解

原题描述为:给定一棵N个节点的树,找出一个节点作为根,使得所有节点的深度之和最大。其中:

  • 节点深度定义为到根节点的距离(边数)
  • 树是无向无环连通图
  • 典型数据范围:N ≤ 1e6

这个问题如果采用暴力解法,对每个节点作为根进行一次DFS计算深度和,时间复杂度为O(n²),对于n=1e6显然会超时(1e12次操作)。这正是换根DP大显身手的场景。

2.2 暴力解法的局限性

让我们先看看暴力解法的伪代码:

max_sum = -inf best_root = None for each node u in tree: total = 0 perform DFS/BFS with u as root: for each node v: total += depth[v] if total > max_sum: max_sum = total best_root = u

这种解法的问题在于:

  1. 每次换根都重新计算所有节点的深度,没有利用之前计算的信息
  2. 对于每个节点都进行O(n)的遍历,整体O(n²)复杂度
  3. 在树结构不变的情况下,存在大量重复计算

3. 换根DP的数学推导

3.1 定义关键变量

我们需要定义几个关键变量来帮助推导:

  • sz[u]:以u为根的子树大小(节点数)
  • dep[u]:在初始根(通常为节点1)下的深度
  • f[u]:以u为根时的深度和(即我们要求的量)

3.2 第一次DFS:预处理

第一次DFS以节点1为根,计算两个基本信息:

  1. 每个节点的子树大小sz[u]
  2. 每个节点到根节点的深度dep[u]

同时,我们可以计算出f[1](以1为根时的深度和):

def dfs1(u, parent): sz[u] = 1 for v in children of u: if v == parent: continue dep[v] = dep[u] + 1 dfs1(v, u) sz[u] += sz[v] f[1] += dep[u] # 累加所有节点的深度

3.3 第二次DFS:换根转移

这是换根DP的核心部分。当我们从父节点u转移到子节点v时,如何快速计算f[v]?

关键观察:

  1. 当根从u变为v时,v的子树中所有节点的深度都减1(因为它们离根更近了)
  2. 不在v子树中的节点深度都加1(因为它们离根更远了)

因此,转移方程为:f[v] = f[u] - sz[v] + (n - sz[v])简化为:f[v] = f[u] + n - 2 * sz[v]

实现代码:

def dfs2(u, parent): for v in children of u: if v == parent: continue f[v] = f[u] + n - 2 * sz[v] dfs2(v, u)

4. 完整算法实现与优化

4.1 C++完整代码示例

#include <iostream> #include <vector> using namespace std; const int MAXN = 1e6 + 5; vector<int> tree[MAXN]; int sz[MAXN], dep[MAXN]; long long f[MAXN]; int n; void dfs1(int u, int parent) { sz[u] = 1; for (int v : tree[u]) { if (v == parent) continue; dep[v] = dep[u] + 1; dfs1(v, u); sz[u] += sz[v]; } } void dfs2(int u, int parent) { for (int v : tree[u]) { if (v == parent) continue; f[v] = f[u] + n - 2 * sz[v]; dfs2(v, 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); } dfs1(1, 0); for (int i = 1; i <= n; ++i) f[1] += dep[i]; dfs2(1, 0); long long max_sum = -1; int best_root = 0; for (int i = 1; i <= n; ++i) { if (f[i] > max_sum) { max_sum = f[i]; best_root = i; } } cout << best_root << endl; return 0; }

4.2 算法复杂度分析

  1. 时间复杂度:O(n)
    • 两次DFS都是O(n)的遍历
    • 最后的查找最大值也是O(n)
  2. 空间复杂度:O(n)
    • 存储树结构:O(n)
    • 各种数组:O(n)

4.3 边界条件与注意事项

  1. 树的存储:对于大规模数据(n=1e6),务必使用邻接表而非邻接矩阵
  2. 数据类型:深度和可能很大(n²级别),使用long long而非int
  3. 初始根选择:理论上可以选择任意节点作为初始根,但通常选择1号节点最方便
  4. 递归深度:对于极端链状树,递归DFS可能导致栈溢出,可以改用BFS或显式栈实现

5. 相关例题与变式

5.1 洛谷P2986 [USACO10MAR] Great Cow Gathering

题目变式:每个节点有牛的数量c[i],边有长度w,求使所有牛到根的总距离最小的根节点。

解法调整:

  1. sz[u]改为子树中牛的总数
  2. 转移方程变为:f[v] = f[u] + (total_cows - 2 * sz[v]) * w(u,v)

5.2 洛谷P3047 [USACO12FEB] Nearby Cows

题目特点:对每个节点求距离不超过k的节点权值和。

解法思路:

  1. 定义f[u][k]为以u为根,距离不超过k的和
  2. 第一次DFS计算子树贡献
  3. 第二次DFS合并父亲节点的贡献
  4. 注意去重处理

5.3 实战技巧总结

  1. 换根DP的通用思考步骤:

    • 确定要计算的信息
    • 设计第一次DFS计算的子树信息
    • 推导换根时的转移方程
    • 实现第二次DFS完成信息传递
  2. 常见错误:

    • 忘记处理父节点与子节点的关系导致循环调用
    • 转移方程推导错误
    • 数据类型范围不足
  3. 调试技巧:

    • 先用小样例验证
    • 打印中间结果检查
    • 对比暴力解法的结果

在实际编程竞赛中,换根DP是一个强大的工具,掌握它可以在处理树形问题时游刃有余。建议通过大量练习来熟悉各种变式和技巧。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进