树链剖分:将树形问题转化为区间操作的高效算法

📅 2026/7/30 2:11:31
树链剖分:将树形问题转化为区间操作的高效算法
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中用vectorint 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];同时我们仍然需要邻接表vectorint 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*21] (sum[p*21] lazy[p] * (pr - mid)) % MOD; lazy[p*21] (lazy[p*21] lazy[p]) % MOD; // 清空当前节点懒标记 lazy[p] 0; } }注意计算区间和时sum[p] (sum[p*2] sum[p*21]) % 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。调试技巧小数据画图用n5左右的小树手工模拟两次DFS在纸上画出树形标出每个节点的fa,dep,sz,son,id,top。然后模拟一次路径操作看跳链过程和区间计算是否正确。这是理解算法最有效的方式。打印中间变量在DFS和跳链函数中打印关键变量如u,v,top,id与你的手工模拟结果对比。检查取模最容易出错的地方。确保所有加法、乘法后都紧跟取模操作包括懒标记下传时的乘法(mid - pl 1)。边界条件根节点的父节点设为0。在跳链循环中当u和v跳到同一条链后处理区间时l和r的大小要判断清楚用dep判断谁左谁右。树链剖分是一个“前期投入大后期收益高”的算法。一旦你理解了两次DFS如何构建映射以及跳链算法如何利用这个映射它就会成为一个非常稳定和强大的工具。它解决的远不止P3384这类模板题更是许多复杂树上问题如结合线段树维护复杂信息的基石。多写几遍多调试几次当你能独立、流畅地敲出这近百行代码时你对树形数据结构的理解会上一个大台阶。