树形DP换根法:从O(N²)到O(N)的优化思想与实战解析
2026/8/13 8:12:10 网站建设 项目流程

1. 从一个树形问题说起:为什么需要“换根”?

在算法竞赛和面试中,树形动态规划(Tree DP)是绕不开的经典题型。我们通常能熟练地处理以某个固定节点为根的子树问题,比如计算子树大小、子树最大权值和等。这类问题有一个共同点:我们默认了一个“根”节点,所有状态转移都从这个根向下(从父节点到子节点)进行。

但现实(或者说题目)往往更复杂。考虑这样一个问题:给定一棵带权树,对于树中的每一个节点,计算以该节点为整棵树的根时,所有节点到它的距离之和。换句话说,我们需要求出每个节点的“距离和”。如果你尝试用一次普通的树形DP来解决,会发现非常棘手。因为一次DFS只能固定一个根,要计算所有节点,难道要对每个节点都做一次O(N)的DFS吗?那总复杂度就是O(N²),对于节点数上万的数据规模,这显然是不可接受的。

这就是“换根法”(或称换根DP)要解决的核心痛点:在树形结构上,当我们需要计算以每个节点为根时的某个全局属性,并且这些属性之间存在可推导的递推关系时,通过一次(或两次)DFS,高效地计算出所有答案。

它的思想非常巧妙:我们先任选一个节点(比如1号节点)作为初始根,进行一次DFS,计算出以该节点为根时的答案,以及一些必要的子树信息。然后,我们再进行一次DFS(“换根”的过程),利用已知的根节点答案和子树信息,推导出它的子节点作为新根时的答案。通过这种方式,我们就能从已知的一个答案,“扩散”出所有节点的答案。

理解换根法,关键在于抓住两个核心:一是状态的重新定义与转移,二是父子关系互换时的增量计算。接下来,我们从一个最经典的例题入手,拆解其完整思路。

2. 经典例题拆解:所有节点到根节点的距离和

我们以最经典的问题作为切入点:给定一棵N个节点的无根树,每条边有一个长度(权值)。对于每个节点u,计算dp[u],表示所有节点到u的距离之和。即dp[u] = sum(dist(v, u)),其中v取遍所有节点。

2.1 第一次DFS:预处理子树信息

我们首先任意选取一个节点作为根(这里选节点1),将其转化为有根树。 这次DFS的目标有两个:

  1. 计算以每个节点u为根的子树大小size[u](包括u自身)。
  2. 计算以节点1为根的答案dp[1],即所有节点到根节点1的距离和。

定义状态:

  • size[u]: 以u为根的子树中的节点总数。
  • dp[u]: 在当前根(节点1)的视角下u的子树中所有节点到u的距离之和。注意,这个dp[u]还不是我们最终要求的全局dp[u],它仅代表子树内部的距离和。

第一次DFS的转移方程(自底向上,后序遍历):

  1. 初始化size[u] = 1(自身)。
  2. 对于u的每个子节点v,边权为w
    • 递归处理子节点v,得到size[v]dp[v](子树v内部的距离和)。
    • size[u] += size[v]
    • dp[u] += dp[v] + size[v] * w解释:dp[v]是子树v中所有节点到v的距离和。现在这些节点要走到u,需要多走一条边u-v(权值为w)。子树v中共有size[v]个节点,所以总距离额外增加size[v] * w

当DFS返回到根节点1时,我们得到了dp[1]。此时的dp[1]恰好就是所有节点到节点1的距离和,因为节点1的“子树”就是整棵树。我们记这个值为ans[1],即ans[1] = dp[1]

同时,我们也得到了所有节点的size[u]size[u]是一个非常重要的信息,它将在换根时起到关键作用。

2.2 第二次DFS:换根推导与答案传递

现在,我们知道了以节点1为根的答案ans[1]。假设我们知道了节点u的答案ans[u],如何推导出其子节点v的答案ans[v]呢?

这是换根法最精妙的部分。我们把根从u“换”到v,树的形态发生了变化。

  • u为根时,树被分为两部分:以v为根的子树(称为“子树部分”),和剩下的其他部分(称为“上方部分”)。
  • 当根变成v后:
    1. 对于原“子树部分”(即v的子树):这部分节点原来需要走到u,现在根变成了v,它们需要走的路程变短了。具体来说,对于这部分中的任意节点,到新根v的距离比到旧根u的距离少了(u, v)的权值w。这部分节点共有size[v]个,所以总距离减少size[v] * w
    2. 对于原“上方部分”(除v子树外的所有节点,共N - size[v]个):这部分节点原来需要走到u,现在根变成了v,它们需要走的路程变长了。因为它们需要先走到u,再经过边(u, v)才能到v。所以,每个节点到新根的距离比到旧根的距离多了权值w。总距离增加(N - size[v]) * w

综合以上两点,我们可以得到换根转移方程:ans[v] = ans[u] - size[v] * w + (N - size[v]) * w化简后:ans[v] = ans[u] + (N - 2 * size[v]) * w

这个公式的物理意义非常清晰:u换根到v,总距离的变化量是(N - 2 * size[v]) * w

  • 如果size[v] > N/2,即v的子树节点数超过一半,那么(N - 2*size[v])为负,总距离减少,符合直觉(根更靠近节点密集区)。
  • 如果size[v] < N/2,总距离增加。
  • 如果size[v] == N/2,总距离不变。

第二次DFS(先序遍历)就从已知ans[1]的根节点1开始,对于每个子节点v,利用上述公式计算ans[v],然后递归地对v的子节点继续进行换根推导。

注意:在第二次DFS中,我们是在一棵以1为根的有根树上进行遍历,利用父子关系和预处理好的size数组进行转移。ans[u]代表的是以u整棵树的根时的答案。

2.3 代码实现与注释

#include <iostream> #include <vector> using namespace std; using ll = long long; const int MAXN = 1e5 + 5; struct Edge { int to, w; }; vector<Edge> graph[MAXN]; ll size[MAXN]; // 子树大小 ll dp[MAXN]; // 第一次DFS用的dp,表示子树内节点到当前根的距离和 ll ans[MAXN]; // 最终答案,表示以每个节点为整棵树根时的距离和 int N; // 第一次DFS:计算size和dp(以u为根的子树信息) void dfs1(int u, int fa) { size[u] = 1; dp[u] = 0; for (auto &[v, w] : graph[u]) { if (v == fa) continue; dfs1(v, u); size[u] += size[v]; dp[u] += dp[v] + size[v] * w; // 关键递推 } } // 第二次DFS:换根,计算ans void dfs2(int u, int fa) { for (auto &[v, w] : graph[u]) { if (v == fa) continue; // 核心换根公式 ans[v] = ans[u] + (N - 2 * size[v]) * w; dfs2(v, u); } } int main() { cin >> N; for (int i = 1; i < N; ++i) { int u, v, w; cin >> u >> v >> w; graph[u].push_back({v, w}); graph[v].push_back({u, w}); } // 任选根节点1 dfs1(1, 0); // 此时dp[1]就是以1为根的答案 ans[1] = dp[1]; dfs2(1, 0); for (int i = 1; i <= N; ++i) { cout << ans[i] << endl; } return 0; }

复杂度分析:两次DFS,每个节点和每条边都被访问常数次,时间复杂度为 O(N)。空间复杂度 O(N)。这相比暴力 O(N²) 是巨大的优化。

3. 换根法的通用框架与状态设计

通过上面的例子,我们可以抽象出换根法解决问题的通用步骤和状态设计思路。

3.1 标准解题流程

  1. 定根与第一次DFS(预处理)

    • 任选一个节点作为初始根(通常选1)。
    • 进行一次自底向上的DFS(后序)。
    • 目标:计算出以每个节点u为根的子树的相关信息。这些信息通常包括两部分:
      • 子树贡献 (down[u]):完全位于u的子树内的节点对u的答案贡献。例如上例中的dp[u]
      • 子树属性 (size[u],maxVal[u]等):描述子树本身状态的量,如节点数、最大值等。这些属性可能用于计算“上方部分”的贡献。
  2. 第二次DFS(换根推导)

    • 进行一次自顶向下的DFS(先序)。
    • 目标:已知父节点u作为整棵树根时的答案ans[u],推导出子节点v的答案ans[v]
    • 核心:分类讨论贡献来源。当根从u换到v,对于新根v的答案,其贡献来源可以重新划分为:
      • 来自v的子树的贡献:这部分在第一次DFS中已经以v为根计算过了(即down[v]),但需要注意,此时的down[v]是在以u为整棵树根的视角下计算的,它本身就是v子树内部到v的距离和,无需改变。
      • 来自“上方部分”(除v子树外)的贡献:这部分是换根计算的关键。我们需要利用已知的ans[u]v的子树信息,推导出“上方部分”节点到新根v的贡献。

3.2 状态定义与转移设计心法

设计状态是换根DP的核心难点。通常需要定义两类状态:

  • down[u]:表示在以u的父节点为整棵树根的视角下,u子树u的贡献。这是一个“局部”状态,在第一次DFS中容易计算。
  • up[u]:表示在以u的父节点为整棵树根的视角下,u的子树外的所有部分(即“上方部分”)对u的贡献。这个状态有时显式定义,有时隐含在换根公式中。
  • ans[u]:最终答案,ans[u] = combine(down[u], up[u])。在第二次DFS中,我们就是通过ans[fa]来推导up[u],进而得到ans[u]

通用转移思路:在第二次DFS中,对于边(u->v, w)

  • 已知ans[u](包含了全树对u的贡献)。
  • 要求ans[v]
  • 思考:ans[u]的贡献由v的子树部分 + 其他部分 构成。
  • 那么,“其他部分”(即u的答案中除去v子树贡献的部分)对v的贡献,就是up[v]需要计算的核心。通常,up[v] = combine(up[u], down[u] without subtree_v),再根据边权w进行调整。

实操心得:很多复杂的换根DP问题,难点就在于如何正确地组合up[u]down[u]中除去子节点v贡献的部分。一个常见的技巧是,在第一次DFS时,不仅计算down[u],还记录下最大值和次大值(或对应的子节点编号)。这样在第二次DFS计算up[v]时,如果vu取得down[u]最大值的子节点,那么up[v]就应该用u的次大值来参与计算,以确保信息来自“其他部分”。

4. 进阶应用与变形分析

掌握了基础的距离和问题,我们来看几个变种,理解如何灵活运用换根框架。

4.1 例题:树的直径(所有节点最远距离)

问题:对于一棵带权树,求出每个节点到其他所有节点的最远距离

分析:这是一个典型的树形DP问题,但需要换根。对于每个节点u,其最远距离可能来自两个方向:

  1. 向下走,进入u的某个子树(即down方向的最长路径)。
  2. 向上走,经过父节点,可能进入父节点的其他子树,或者继续向上(即up方向)。

状态设计:

  • down1[u],down2[u]:记录以u为根的子树中,从u出发向下的最长次长路径长度,并记录最长路径来自哪个子节点son[u]。这是第一次DFS可以求出的。
  • up[u]:记录从u出发,向父节点方向走,能获得的最长路径长度。这是第二次DFS(换根)要求解的。
  • 最终答案ans[u] = max(down1[u], up[u])

第一次DFS:down1,down2,son。 对于节点u,遍历子节点v,边权w。计算len = down1[v] + w

  • 如果len > down1[u],则down2[u] = down1[u],down1[u] = len,son[u] = v
  • 否则如果len > down2[u],则down2[u] = len

第二次DFS(换根):up。 对于节点u和其子节点v,边权w。我们要计算up[v]

  • 如果v就是u的最长子节点 (v == son[u]),那么从v向上走,经过u之后,最远的路径可能是up[u] + w(继续向上),或者是down2[u] + w(走到u后进入u的次长子树)。所以up[v] = max(up[u], down2[u]) + w
  • 如果v不是u的最长子节点,那么从v向上走,经过u之后,最远的路径可能是up[u] + w,或者是down1[u] + w(走到u后进入u的最长子树)。所以up[v] = max(up[u], down1[u]) + w

通过这种方式,我们利用down1,down2son确保了up[v]的计算不会错误地包含v自身的子树贡献。

4.2 例题:树的最大权值连通块(K步换根)

问题(简化描述):树上每个节点有正负权值。定义连通块的权值为块内节点权值和。对于每个节点u,求以u为根时,权值最大的连通块(连通块必须包含根u,且是连通的)。这本质是求每个节点的“子树”最大和,但这里的“子树”是以u为根时的整棵树,需要换根。

分析:这是一个带权值的换根问题。定义f[u]为在以u为根的子树中,选择包含u的连通块的最大权值和(类似最大子段和,但必须在树上且包含根)。 第一次DFS可以求出每个节点向下的f[u]f[u] = val[u] + sum(max(0, f[v])),其中vu的子节点。因为负数的子树我们不选。

第二次DFS换根。设ans[u]是以u为整棵树根时的答案。已知ans[u],如何求子节点vans[v]

  • ans[v]由两部分组成:v向下的部分(即f[v]),和v向上的部分。
  • v向上的部分,可以看作是:从u出发,不经过v子树,能获得的最大贡献。这其实就是ans[u]减去v子树对u的贡献(如果贡献为正)。即up_part = ans[u] - max(0, f[v])
  • 那么,ans[v] = f[v] + max(0, up_part)。注意up_part可能为负,为负则不选。

这个例子展示了如何将“最大连续和”的思想与树形结构、换根操作结合。关键在于理解,换根后,子节点v的“上方部分”贡献,可以通过父节点u的全局答案减去v子树的贡献来推导。

4.3 边界条件与初始化陷阱

换根法看似公式简洁,但边界条件和初始化极易出错。

  1. 根节点的初始化:第一次DFS后,根节点的ans[root]通常就等于down[root]。但up[root]需要谨慎初始化。对于距离问题,up[root]通常为0(因为根没有父节点)。但对于像“最大距离”问题,up[root]可能初始化为一个极小值(如 -INF),表示向上没有路径。

  2. 叶子节点的处理:在第二次DFS的转移公式中,要确保公式对叶子节点也有效。例如在距离和问题中,叶子节点的size[v]为1,公式ans[v] = ans[u] + (N - 2) * w仍然成立。

  3. 负权边与零权边:上述公式对边权w没有正负要求。但如果问题涉及最大值、最小值(如最大路径和),并且允许负权边,那么初始化down数组时就不能简单初始化为0,而可能初始化为负无穷,并且状态转移中的max操作要小心处理。

  4. 多子树信息维护:当转移需要用到“除某个子树外”的信息时(如求次大值),务必在第一次DFS中就维护好。这是避免O(N²)复杂度的关键。常见的维护方法是记录最大值、次大值以及最大值来自哪个子节点。

踩坑记录:我曾在一个比赛中遇到一道题,需要计算每个节点到所有关键点的最大距离。我使用了换根法,但在维护up值时,只考虑了父节点uup[u]down1[u],忘记了如果vu取得down1[u]的子节点,那么应该用down2[u]来更新up[v]。这个错误导致在链式数据下答案错误。调试了很久才发现是状态转移的分类讨论漏了一种情况。教训是:设计换根转移时,必须画图,清晰地区分当前子节点是否为父节点获取关键信息的来源节点。

5. 从换根DP到更一般的“二次扫描”思想

换根法本质上是一种“二次扫描”思想在树形结构上的应用。其核心是:

  1. 第一次扫描(自底向上):收集子树信息,得到以每个节点为根的局部视图。
  2. 第二次扫描(自顶向下):利用已知的全局信息和父子关系,将局部视图整合或转化为其他节点为根的全局视图。

这种思想可以推广到一些非标准的“换根”场景。例如:

  • 无根树定根后的属性计算:很多树形问题本身不需要换根,但第一次DFS自底向上计算后,可能还需要一次自顶向下的DFS来传递一些诸如“父节点对子节点的限制信息”等。
  • 删除一条边后的统计问题:考虑删除树中的一条边(u, v),树被分成两棵子树。需要快速知道两棵子树的大小、权值和等信息。这可以转化为:以u为根,v的子树信息就是size[v];而以v为根,u的子树信息就是N - size[v](如果最初以u为根进行计算)。这其实就是一次隐性的“换根”思考。

理解“二次扫描”的精髓,就能在遇到新的树形统计问题时,判断能否通过一次预处理+一次推导来高效求解,从而避免对每个节点进行独立计算的暴力做法。

6. 总结与实战建议

换根DP是一种非常有力的工具,它将O(N²)的问题优化到O(N)。要掌握它,建议遵循以下路径:

  1. 理解经典模型:彻底吃透“所有节点距离和”这个例题。理解size数组的作用,以及ans[v] = ans[u] + (N - 2*size[v]) * w这个公式的每一个变量的含义推导过程。这是所有换根问题的基础。
  2. 掌握状态设计范式:遇到新问题,先想清楚:
    • 最终答案ans[u]是什么?
    • 它能否分解为down[u](子树贡献)和up[u](上方贡献)?
    • down[u]如何通过一次DFS求出?
    • 已知ans[u](即down[u]up[u]的组合),如何推导出子节点vup[v]ans[v]画图分类讨论是必须的。
  3. 注意细节与边界:多考虑叶子节点、根节点、负权、零权等边界情况。对于需要维护最大值/次大值的问题,确保在第一次DFS时就正确维护。
  4. 从简单到复杂练习
    • 基础:距离和、距离最大值(树的直径)。
    • 进阶:带点权的最大连通块和、树上每个点的最长路径(需维护前三长的边)、特定节点(如所有关键点)的统计信息。
    • 挑战:结合其他算法,如换根DP优化树上背包问题、与数位DP结合等。

最后,再分享一个调试小技巧:写完换根DP代码后,可以用小数据(N<=10)进行暴力对拍。暴力算法就是对每个节点作为根进行一遍DFS计算答案。虽然慢,但能确保正确性。用随机生成的树结构和小权值进行大量测试,能快速发现状态转移公式中的错误。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询