资讯详情

资讯详情

树链剖分模板题全解:路径与子树操作实战

1. 树链剖分到底在解决什么问题第一次接触树链剖分的人大概率是被“轻重链”“dfs序”“线段树维护”这一串名词劝退的。但如果你把它拆开看它本质上就是一套把树上路径操作转化成区间操作的工具。树上两点之间的路径在朴素做法下你需要一步一步往上跳最坏情况一条链退化成一条线复杂度直接飙到O(n)。而树链剖分做的事情就是给这棵树重新编个号让任意一条路径都能被拆成不超过O(log n)段连续的区间每一段交给线段树去处理。这个思路其实和“把一本书的目录重新编排让相关章节挨在一起”是一个道理。树原本的结构是散乱的父子关系决定了你没法直接用数组下标去表示一条路径。剖分之后每条重链上的节点在dfs序里是连续的于是路径查询就变成了若干个区间查询的拼接。洛谷P3384这道模板题之所以经典是因为它一口气覆盖了树链剖分最核心的四个操作路径修改、路径查询、子树修改、子树查询。前两个靠重链剖分加线段树后两个靠dfs序的连续性。很多人学树链剖分只记住了路径操作忽略了子树操作其实才是dfs序带来的“附赠福利”而这道题把两者绑在一起逼着你把整套体系吃透。适合谁来啃这块板子如果你已经会线段树能写基本的树dfs那这道题就是你的下一站。如果你连线段树的区间修改和懒标记都还没写熟建议先回去补课否则树链剖分的代码量会让你怀疑人生。这道题的代码长度通常在150到200行之间是检验你代码组织能力的一道坎。2. 核心思路拆解与方案选型2.1 为什么是重链剖分而不是长链剖分树链剖分有好几种剖法最常见的是重链剖分和长链剖分。重链剖分选的是子树大小最大的儿子作为重儿子长链剖分选的是深度最大的儿子。这道模板题用重链剖分原因很直接重链剖分能保证任意节点到根的路径上轻边的数量不超过log n条。这个结论的推导不复杂。每次走一条轻边意味着你进入了一个子树而这个子树的节点数最多是父节点子树的一半。为什么因为如果这个儿子是轻儿子说明它上面还有一个重儿子重儿子的子树大小至少和它一样大两个加起来不超过父节点的子树所以轻儿子的子树大小最多是父节点的一半。每次砍一半最多砍log n次就到根了。长链剖分虽然在某些特定问题上比如优化dp有奇效但它的轻边数量没有这个log保证路径操作的最坏复杂度会退化。所以做路径修改和查询重链剖分是标准选择。2.2 dfs序为什么能同时搞定子树dfs序的核心性质是一棵子树的所有节点在dfs序里是连续的一段区间。这个性质不依赖于你怎么剖分只要你是深度优先遍历进入一个节点时记录时间戳离开时记录结束时间戳那么这棵子树就对应[in[u], out[u]]这个区间。树链剖分在第一次dfs时顺便把子树大小、重儿子、深度、父节点都算出来第二次dfs时优先走重儿子这样既保证了重链上的dfs序连续又保证了子树的dfs序连续。两个性质一次遍历同时拿到这就是为什么树链剖分的dfs序写法如此优雅。你可以这样理解第一次dfs是“摸底”搞清楚每个节点的家庭情况第二次dfs是“排座位”让重链上的节点坐在一起同时保证一家人子树也坐在一起。两次dfs各司其职缺一不可。2.3 线段树在这里扮演什么角色剖分只是把树拆成了链真正干活的是线段树。每条重链对应dfs序上的一段连续区间路径上的多个链段就是多个区间。线段树需要支持区间加、区间求和并且带懒标记。这里有个细节模板题要求取模。取模操作要在加法、乘法、懒标记下传时都处理否则中间结果会溢出。很多人第一次写的时候只在最后取模结果WA到怀疑人生。正确的做法是每次加法之后立刻取模懒标记累加时也取模。线段树的写法可以用递归版也可以用非递归版。递归版代码清晰适合模板非递归版常数小但写起来绕。这道题的数据范围是1e5级别递归版完全够用没必要为了那点常数去折磨自己。3. 核心细节解析与实操要点3.1 第一次dfs摸清家底第一次dfs需要算出五个信息父节点fa、深度dep、子树大小siz、重儿子son。这五个信息是后续所有操作的基础。void dfs1(int u, int f) { fa[u] f; dep[u] dep[f] 1; siz[u] 1; int maxson -1; for (int v : g[u]) { if (v f) continue; dfs1(v, u); siz[u] siz[v]; if (siz[v] maxson) { maxson siz[v]; son[u] v; } } }这段代码有几个容易踩的坑。第一maxson初始化为-1而不是0因为子树大小至少是1如果初始化为0当所有儿子子树大小都大于0时逻辑没问题但万一某个儿子子树大小为0不可能或者你想严格比较-1更安全。第二重儿子的判断条件是严格大于不是大于等于。如果有两个儿子子树大小相同选第一个遇到的就行不影响复杂度证明。注意递归深度可能达到1e5在有些平台上会爆栈。如果遇到RE可以把递归改成手动栈或者加编译指令调整栈大小。不过在洛谷上一般没问题。3.2 第二次dfs排座位第二次dfs负责分配dfs序编号同时记录每个节点所在重链的链顶。void dfs2(int u, int topf) { dfn[u] cnt; rk[cnt] u; top[u] topf; if (!son[u]) return; dfs2(son[u], topf); for (int v : g[u]) { if (v fa[u] || v son[u]) continue; dfs2(v, v); } }关键点在于先走重儿子并且重儿子的链顶和当前节点相同再走轻儿子轻儿子的链顶是它自己。rk数组是dfs序到节点编号的映射建线段树的时候要用它来取初始值。这里有个常见错误忘记判断son[u]是否存在就直接递归。叶子节点没有重儿子必须先返回。另外轻儿子的遍历要跳过父节点和重儿子否则会重复访问。3.3 路径操作的跳链逻辑路径操作是树链剖分最核心的部分。以路径求和为例int queryPath(int x, int y) { int res 0; while (top[x] ! top[y]) { if (dep[top[x]] dep[top[y]]) swap(x, y); res (res query(1, dfn[top[x]], dfn[x])) % mod; x fa[top[x]]; } if (dep[x] dep[y]) swap(x, y); res (res query(1, dfn[x], dfn[y])) % mod; return res; }这段代码的逻辑是只要两个节点不在同一条重链上就让链顶深度更大的那个节点往上跳跳到链顶的父节点。最后两个节点在同一条重链上时深度小的那个就是LCA直接查询区间[dfn[x], dfn[y]]。为什么是比较链顶深度而不是节点深度因为你要跳的是整条链链顶深度决定了这条链在树上的位置。如果比较节点深度可能会出现跳过头或者死循环的情况。提示路径修改和路径查询的跳链逻辑完全一样只是把query换成update。建议写成一个函数带个参数或者直接复制粘贴改一下别为了省几行代码搞出bug。3.4 子树操作的区间映射子树操作简单得多因为子树在dfs序里就是连续区间[dfn[u], dfn[u] siz[u] - 1]。void updateSubtree(int u, int val) { update(1, dfn[u], dfn[u] siz[u] - 1, val); } int querySubtree(int u) { return query(1, dfn[u], dfn[u] siz[u] - 1); }这里的关键是理解dfn[u] siz[u] - 1这个右端点。dfs序是从1开始编号的子树大小是siz[u]所以区间长度是siz[u]右端点就是dfn[u] siz[u] - 1。如果你从0开始编号那就是dfn[u] siz[u] - 1一样的。很多人会写成dfn[u] siz[u]然后线段树查询越界或者多查了一个节点。这个off-by-one错误非常隐蔽因为有时候多查的那个节点恰好是0或者不影响结果但大多数时候会WA。4. 完整实操流程与代码实现4.1 线段树部分的实现线段树需要支持区间加和区间求和带懒标记。这里给出递归版实现struct SegTree { int sum[MAXN 2], lazy[MAXN 2]; void pushup(int rt) { sum[rt] (sum[rt 1] sum[rt 1 | 1]) % mod; } void pushdown(int rt, int l, int r) { if (!lazy[rt]) return; int mid (l r) 1; lazy[rt 1] (lazy[rt 1] lazy[rt]) % mod; lazy[rt 1 | 1] (lazy[rt 1 | 1] lazy[rt]) % mod; sum[rt 1] (sum[rt 1] lazy[rt] * (mid - l 1)) % mod; sum[rt 1 | 1] (sum[rt 1 | 1] lazy[rt] * (r - mid)) % mod; lazy[rt] 0; } void build(int rt, int l, int r) { if (l r) { sum[rt] a[rk[l]] % mod; return; } int mid (l r) 1; build(rt 1, l, mid); build(rt 1 | 1, mid 1, r); pushup(rt); } void update(int rt, int l, int r, int ql, int qr, int val) { if (ql l r qr) { sum[rt] (sum[rt] val * (r - l 1)) % mod; lazy[rt] (lazy[rt] val) % mod; return; } pushdown(rt, l, r); int mid (l r) 1; if (ql mid) update(rt 1, l, mid, ql, qr, val); if (qr mid) update(rt 1 | 1, mid 1, r, ql, qr, val); pushup(rt); } int query(int rt, int l, int r, int ql, int qr) { if (ql l r qr) return sum[rt]; pushdown(rt, l, r); int mid (l r) 1, res 0; if (ql mid) res (res query(rt 1, l, mid, ql, qr)) % mod; if (qr mid) res (res query(rt 1 | 1, mid 1, r, ql, qr)) % mod; return res; } };几个关键点pushdown里更新子节点sum时要乘以区间长度build时用rk[l]取原数组的值所有加法都要取模。注意lazy数组也要取模否则多次累加后会溢出。虽然int在累加1e5次1e5后可能不会溢出但加上取模更保险。4.2 主函数与输入输出int main() { scanf(%d%d%d%d, n, m, root, mod); for (int i 1; i n; i) scanf(%d, a[i]); for (int i 1; i n; i) { int u, v; scanf(%d%d, u, v); g[u].push_back(v); g[v].push_back(u); } dfs1(root, 0); dfs2(root, root); build(1, 1, n); while (m--) { int op, x, y, z; scanf(%d, op); if (op 1) { scanf(%d%d%d, x, y, z); updatePath(x, y, z); } else if (op 2) { scanf(%d%d, x, y); printf(%d\n, queryPath(x, y)); } else if (op 3) { scanf(%d%d, x, z); updateSubtree(x, z); } else { scanf(%d, x); printf(%d\n, querySubtree(x)); } } return 0; }输入输出用scanf和printf别用cin和cout这道题的数据量用cin会超时。如果非要用cin记得关同步。4.3 参数选择与边界处理模数mod是输入的不一定是质数所以不能用逆元只能老老实实做加法和乘法。取模的时候注意负数情况不过这道题都是加法不会出现负数。节点编号从1开始dfs序也从1开始线段树区间是[1, n]。建树的时候传参是build(1, 1, n)查询和修改的时候也要保证区间在[1, n]内。根节点是输入的root不一定是1。dfs1和dfs2都要从root开始。这点很多人会忽略默认从1开始结果root不是1的时候就挂了。5. 常见问题与排查技巧实录5.1 调试技巧与常见错误树链剖分的bug通常藏得很深因为涉及两次dfs、线段树、路径跳转多个环节。我总结了一个排查顺序问题现象可能原因排查方法路径查询结果偏小跳链时漏了最后一段检查while循环结束后是否还有一次query子树查询结果偏大区间右端点算错检查是否写成dfn[u]siz[u]修改后查询没变化懒标记没下传检查pushdown是否在query和update开头调用部分节点结果不对dfs序编号错误输出dfn数组检查重链是否连续整体结果全错建树时没用rk数组检查build是否用a[rk[l]]运行超时用了cin或递归太深换scanf或加栈空间答案对但取模后不对中间结果溢出检查每步加法是否取模5.2 独家避坑经验第一个坑重儿子初始值。我见过有人把maxson初始化为0然后重儿子判断用结果遇到子树大小为0的节点不存在或者多个相同大小的儿子时选了最后一个。虽然不影响正确性但会影响重链的划分进而影响复杂度。建议初始化为-1用严格大于。第二个坑dfs2的递归顺序。必须先递归重儿子再递归轻儿子。如果先递归轻儿子重链上的dfs序就不连续了整个剖分就废了。这个错误很隐蔽因为代码看起来没问题但跑出来的结果就是错的。第三个坑路径跳转的终止条件。while循环的条件是top[x] ! top[y]不是x ! y。如果写成x ! y当两个节点在同一条重链上但不同深度时会继续跳导致死循环或者错误结果。第四个坑线段树区间查询的边界。query函数里判断ql mid和qr mid不是ql mid和qr mid。这个off-by-one会导致漏查或多查。第五个坑取模的位置。懒标记累加时要取模sum更新时要取模pushup时要取模查询返回时也要取模。少一个地方取模就可能在大数据下溢出。5.3 复杂度分析与优化建议树链剖分的时间复杂度是O(n m log² n)。两次dfs是O(n)线段树建树是O(n)每次路径操作最多跳O(log n)条链每条链的线段树操作是O(log n)所以是O(log² n)。子树操作是O(log n)。空间复杂度是O(n)线段树开4倍空间邻接表开2倍边数。如果想优化可以把线段树的递归改成非递归常数能小一半左右。但递归版已经能过这道题没必要为了那点常数增加代码复杂度。另外邻接表可以用链式前向星代替vector也能省一点常数。提示如果TLE了先检查是不是用了cin再检查递归深度最后才考虑优化线段树。大多数TLE都是输入输出或者递归爆栈导致的。6. 从模板题到实战的扩展思路6.1 树链剖分能解决哪些实际问题这道模板题只覆盖了最基础的操作但树链剖分的应用远不止于此。路径上的最大值、最小值、区间赋值、区间乘、区间异或都可以通过替换线段树的维护内容来实现。子树操作也可以扩展成子树染色、子树统计等。一个典型的实战场景是维护一棵组织架构树支持查询某个员工到某个上级的路径上的绩效总和以及查询某个部门子树的绩效总和。树链剖分加线段树就能同时搞定这两个需求。另一个场景是维护一棵文件系统树支持修改某个目录下所有文件的大小以及查询某个文件到根目录的路径上的总大小。这也是树链剖分的经典应用。6.2 和其他树上算法的对比树链剖分不是唯一的树上路径算法。LCA可以用倍增或者Tarjan路径查询可以用树上差分或者点分治。但树链剖分的优势在于它同时支持路径修改和子树修改而且是在线的不需要离线处理。倍增法只能求LCA不能做路径修改。树上差分适合离线批量修改但不支持在线查询。点分治适合处理路径计数问题但常数大代码复杂。树链剖分在通用性和复杂度之间取得了很好的平衡。6.3 进阶学习路线啃完这道模板题之后可以尝试以下进阶内容第一把线段树换成树状数组实现单点修改和区间查询的树链剖分。虽然功能受限但代码更短常数更小。第二学习长链剖分理解它和重链剖分的区别以及它在优化dp方面的应用。第三学习动态树Link-Cut Tree它是树链剖分的动态版本支持加边和删边。不过LCT的代码复杂度是树链剖分的两倍以上建议先把树链剖分写熟再碰。第四尝试用树链剖分解决一些经典问题比如“树上路径第k大”“树上路径颜色段数”等。这些问题需要在线段树里维护更复杂的信息是检验你线段树功底的好机会。我个人在实际写题时的体会是树链剖分的代码虽然长但结构非常固定写熟之后就是肌肉记忆。真正难的是调试因为一旦出错你很难一眼看出是dfs错了还是线段树错了。我的建议是分模块调试先输出dfs序检查重链连续性再单独测试线段树的区间操作最后再测路径和子树操作。这样能把bug范围缩小到最小。最后分享一个小技巧写树链剖分的时候把数组名取得有意义一点比如dfn、top、siz、son别用a、b、c。代码长了之后可读性比省那几个字符重要得多。另外把路径操作和子树操作封装成函数主函数里只留输入输出和调用这样逻辑清晰改起来也方便。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →