树链剖分:将树形问题转化为区间操作的高效算法
1. 从“暴力遍历”到“优雅剖分”:为什么我们需要树链剖分?
如果你写过一些树上的算法题,比如求树上两点路径上的节点权值和,或者给某个子树的所有节点统一加上一个值,你的第一反应可能是深度优先搜索(DFS)。这很自然,树的结构天生适合递归。但当题目数据范围上升到十万甚至百万级别,并且伴随着大量的修改和查询操作时,暴力DFS的O(N)时间复杂度会让你立刻超时。这时,你就需要一个能将树“拍平”,并利用高效数据结构(如线段树)进行区间操作的强力工具——树链剖分。
树链剖分,尤其是其中的重链剖分,其核心思想非常巧妙:它通过一次DFS,将一棵树“解剖”成若干条线性链,并给每个节点重新编号。这个新编号的神奇之处在于,树上任意一条路径,都可以被拆分成O(log N)段连续的编号区间;任意一棵子树,其所有节点的新编号也必然是一个连续的区间。这样一来,我们就把树上复杂的路径和子树问题,转化为了序列上经典的区间问题,从而可以祭出线段树、树状数组等“大杀器”,将单次操作的时间复杂度从O(N)优化到O(log² N)甚至O(log N)。
P3384这道题被冠以“模板”之名,是因为它几乎涵盖了重链剖分最经典、最全面的应用场景:路径修改/查询和子树修改/查询。弄懂这道题,你就掌握了树链剖分80%的实战技能。接下来,我将以一个过来人的视角,带你从零开始,彻底吃透这个“模板”,不仅告诉你每一步怎么写,更会解释清楚每一步为什么要这么做,以及我在实战中踩过的那些坑。
2. 解剖前的准备工作:理解核心概念与存储结构
在动代码之前,我们必须先建立清晰的脑内模型。重链剖分有几个关键概念,它们共同构成了算法的骨架。
2.1 必须搞懂的四个核心概念
- 重儿子:对于一个节点
u,它的所有儿子节点中,子树大小最大的那个儿子,就是u的重儿子。如果有多个儿子子树大小相同,可以任意指定一个。重儿子是连接“重链”的关键。 - 轻儿子:节点
u除重儿子以外的其他儿子。 - 重链:由一系列重儿子连接起来的路径。从某个轻儿子开始,不断走向其重儿子,直到叶子节点,形成的一条链就是重链。整棵树会被分解成若干条互不相交的重链。
- 轻边:连接一个节点与其轻儿子的边。
理解这些概念的最好方式是看图。想象一棵树,我们标记出每个节点的重儿子,然后把这些重儿子连起来,你会发现树被分割成了几条“主干道”(重链)和连接这些主干道的“支路”(轻边)。我们的目标,就是把“主干道”上的节点,在序列(线段树)中安排到连续的位置。
2.2 数据结构设计:用什么来承载这棵树?
对于树结构,我们通常使用邻接表来存储。在C++中,用vector<int> g[N]是最常见的选择,简单高效。但在这道题里,我们还需要存储和每个节点相关的许多信息,为了方便管理和传递,我强烈建议使用结构体数组来封装节点信息。
struct Node { int fa; // 父节点 int dep; // 深度 int sz; // 子树大小 int son; // 重儿子,初始为-1或0表示无 int top; // 所在重链的顶端节点 int id; // 节点的新编号(DFS序) int val; // 节点的原始权值 int rval; // 节点在新编号序列(线段树)中对应的值 } node[N];同时,我们仍然需要邻接表vector<int> g[N]来存储树的边关系。node数组和g邻接表共同完整地描述了这棵树。node[u]存储节点u的剖分信息,g[u]存储节点u的所有邻居(子节点和父节点,取决于建图方式)。
注意:很多初学者会混淆
id和rval。id是位置,它决定了这个节点在线段树数组中的下标。rval是值,它是节点原始权值val按照id顺序排列后,在线段树中对应位置的值。在第一次DFS后,我们得到了id;在第二次DFS前,我们需要根据id将val赋值到rval数组,然后用rval数组去初始化线段树。
3. 两次DFS:完成树的“手术式”解剖
这是整个算法的核心预处理步骤,所有神奇的性质都在这两次遍历中产生。
3.1 第一次DFS:摸清家族底细
这次DFS的目标是求出每个节点的父节点fa、深度dep、子树大小sz,并初步找出重儿子son。这是一个标准的后序遍历过程。
void dfs1(int u, int father) { node[u].fa = father; node[u].dep = node[father].dep + 1; node[u].sz = 1; // 至少包含自己 node[u].son = -1; // 初始化为无重儿子 int max_sz = 0; for (int v : g[u]) { if (v == father) continue; dfs1(v, u); node[u].sz += node[v].sz; // 回溯时累加子树大小 // 寻找子树最大的儿子,即重儿子 if (node[v].sz > max_sz) { max_sz = node[v].sz; node[u].son = v; } } }为什么需要这些信息?
fa和dep:在后续查询两点路径时,我们需要让深度大的节点向上跳,直到两点位于同一条重链。这需要知道父节点和深度差。sz:定义重儿子的依据。同时,子树大小也用于第二次DFS中给子树节点分配连续的id。son:构建重链的“指南针”,告诉我们下一次DFS应该优先走哪条路。
3.2 第二次DFS:分配“身份证”并拉起重链
这次DFS的目标是给每个节点分配一个唯一的、具有良好性质的新编号id,并确定每个节点所在重链的顶端top。这次遍历需要优先走重儿子。
int cnt = 0; // 全局计时器,用于分配id int rval[N]; // 按id顺序存放的权值数组 void dfs2(int u, int topf) { // topf是当前重链的顶端 node[u].id = ++cnt; // 分配新id node[u].top = topf; rval[cnt] = node[u].val; // 将原权值按新id顺序存储 // 1. 必须先处理重儿子!保证重链上的id连续。 if (node[u].son != -1) { dfs2(node[u].son, topf); // 重儿子继承当前链的顶端 } // 2. 再处理轻儿子 for (int v : g[u]) { if (v == node[u].fa || v == node[u].son) continue; dfs2(v, v); // 轻儿子自己作为一条新重链的顶端 } }这是整个算法最精妙的部分,务必理解:
- 优先处理重儿子:这保证了同一条重链上的所有节点,它们的
id是连续的。这是实现路径拆分成连续区间的关键。 - 轻儿子开启新链:每个轻儿子都会成为一条新重链的起点(顶端)。
rval数组:我们最终要用线段树维护的序列,就是rval[1..n]。它的下标是id,值是节点权值。
踩坑实录:这里最容易出错的就是
dfs2的调用顺序和参数。一定要先递归重儿子,再递归轻儿子。并且,重儿子递归时传入的topf参数是当前链顶(继承),而轻儿子递归时传入的是它自己(新开链)。我曾经因为把顺序写反,导致重链节点id不连续,路径查询完全错误,调试了整整一个下午。
4. 核心操作实现:路径与子树的区间化
预处理完成后,我们手中就有了一张“地图”:任何节点,我们知道它的id(在线段树中的位置)和top(它属于哪条主干道)。现在来看如何利用这张地图解决问题。
4.1 子树修改/查询:最简单的部分
由于第二次DFS是DFS序,它有一个绝佳的性质:任何一棵子树,其所有节点的id构成一个连续的区间。设子树根节点为u,其id为node[u].id,子树大小为node[u].sz,那么这个区间就是:[node[u].id, node[u].id + node[u].sz - 1]
因此,子树操作就退化为了线段树的区间操作:
// 将以u为根的子树内所有节点值加k void update_subtree(int u, int k) { int l = node[u].id; int r = node[u].id + node[u].sz - 1; segtree.update(1, 1, n, l, r, k); // 调用线段树的区间更新函数 } // 查询以u为根的子树内所有节点值之和 int query_subtree(int u) { int l = node[u].id; int r = node[u].id + node[u].sz - 1; return segtree.query(1, 1, n, l, r); // 调用线段树的区间查询函数 }4.2 路径修改/查询:跳链算法的艺术
这是树剖的精华。对于两个节点u和v,我们通过不断地将深度较大的节点向上“跳”到其所在重链顶端的父节点,同时处理经过的链,直到它们位于同一条重链上。
// 将树上u-v路径上的所有节点值加k void update_path(int u, int v, int k) { while (node[u].top != node[v].top) { // 当u和v不在同一条重链上 // 选择所在链顶深度更大的节点向上跳 if (node[node[u].top].dep < node[node[v].top].dep) swap(u, v); // 此时u的链顶深度更深,处理u到其链顶的这段区间 int l = node[node[u].top].id; // 链顶的id int r = node[u].id; // u的id segtree.update(1, 1, n, l, r, k); // 更新这段连续区间 u = node[node[u].top].fa; // u跳到链顶的父节点 } // 循环结束后,u和v在同一条重链上 // 处理它们之间的最后一段区间 if (node[u].dep > node[v].dep) swap(u, v); int l = node[u].id; int r = node[v].id; segtree.update(1, 1, n, l, r, k); }路径查询query_path的逻辑与修改完全一致,只是将线段树的update调用换成query调用。
理解“跳链”:while循环每次处理一段重链。因为重链上id连续,所以从节点u到其链顶top的路径,对应序列区间[id[top], id[u]]。我们更新这个区间,然后把u设为top的父节点,相当于从一条链的尽头跳到了另一条链的开始。每次跳跃,都至少跨过一条轻边。由于从任何节点到根节点,最多经过O(log N)条轻边(这是一个关键性质,可以证明),所以整个路径操作的时间复杂度是O(log² N)(每次跳跃有一次O(log N)的线段树操作)。
实操心得:在
while循环里,swap(u, v)的判断条件是基于top的深度,而不是u和v本身的深度。这是因为我们要保证让“链顶更深”的节点向上跳,这样才能确保我们处理的区间是从一个节点到其链顶,这个区间是连续的。如果跳反了,区间就不连续了。这是我初期常犯的逻辑错误。
5. 线段树部分:沉默的基石
树链剖分之所以强大,是因为它将问题转化后,交给了线段树这种O(log N)的区间数据结构。这里的线段树就是最标准的支持区间加、区间求和的线段树,没有变化。但有几个细节需要注意:
- 建树:用第二次DFS得到的
rval[1..n]数组来初始化线段树。 - 数据范围与取模:P3384要求对结果取模。这意味着在线段树的每一个加法、乘法操作,以及
push_up、push_down、query的求和过程中,每做一次运算都要立即取模,防止溢出。 - 懒标记:必须使用懒标记来实现区间加的O(log N)复杂度,否则会退化为O(N)。
void push_down(int p, int pl, int pr) { if (lazy[p]) { int mid = (pl + pr) / 2; // 更新左儿子值和懒标记 sum[p*2] = (sum[p*2] + lazy[p] * (mid - pl + 1)) % MOD; lazy[p*2] = (lazy[p*2] + lazy[p]) % MOD; // 更新右儿子值和懒标记 sum[p*2+1] = (sum[p*2+1] + lazy[p] * (pr - mid)) % MOD; lazy[p*2+1] = (lazy[p*2+1] + lazy[p]) % MOD; // 清空当前节点懒标记 lazy[p] = 0; } }注意:计算区间和时,
sum[p] = (sum[p*2] + sum[p*2+1]) % MOD;这个push_up操作也别忘了取模。
6. 完整代码框架与调试技巧
将以上所有部分组合起来,并处理好输入输出,就得到了P3384的完整解法。主函数的逻辑通常是:
- 读入
n(节点数)、m(操作数)、root(根)、MOD。 - 读入每个节点的初始权值,存入
node[i].val。 - 读入
n-1条边,建立无向图g。 - 执行
dfs1(root, 0)和dfs2(root, root)。 - 用
rval数组初始化线段树。 - 循环处理
m个操作,根据操作类型调用update_path、query_path、update_subtree、query_subtree。
调试技巧:
- 小数据画图:用
n=5左右的小树,手工模拟两次DFS,在纸上画出树形,标出每个节点的fa,dep,sz,son,id,top。然后模拟一次路径操作,看跳链过程和区间计算是否正确。这是理解算法最有效的方式。 - 打印中间变量:在DFS和跳链函数中,打印关键变量(如
u,v,top,id),与你的手工模拟结果对比。 - 检查取模:最容易出错的地方。确保所有加法、乘法后都紧跟取模操作,包括懒标记下传时的乘法
(mid - pl + 1)。 - 边界条件:根节点的父节点设为0。在跳链循环中,当
u和v跳到同一条链后,处理区间时l和r的大小要判断清楚(用dep判断谁左谁右)。
树链剖分是一个“前期投入大,后期收益高”的算法。一旦你理解了两次DFS如何构建映射,以及跳链算法如何利用这个映射,它就会成为一个非常稳定和强大的工具。它解决的远不止P3384这类模板题,更是许多复杂树上问题(如结合线段树维护复杂信息)的基石。多写几遍,多调试几次,当你能独立、流畅地敲出这近百行代码时,你对树形数据结构的理解会上一个大台阶。