多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Self-Adjusting Top Tree:统一路径与子树操作的动态树框架

Self-Adjusting Top Tree:统一路径与子树操作的动态树框架 最近在啃动态树资料时Self-Adjusting Top Tree 这个词反复出现。一开始我以为它只是 Link-Cut Tree 换了个皮真正把簇分解和自调整的细节过了一遍之后才发现它把“路径”和“子树”两类问题统一进了同一个框架抽象度比 LCT 的实虚链剖分高不少。理解它之后再回头看很多动态树论文里的 rake、compress、边界点这些术语会有一种“原来之前卡在哪儿”的顿悟感。这篇文章想聊的就是 Self-Adjusting Top Tree 到底是什么、它的簇模型怎么运转、实现时哪些地方最容易出事。适合已经会 LCT、想深入动态树内在机制的选手也适合被 LCT 维护虚子树信息烦到想换个思路的朋友。1. 动态树问题为什么需要比 LCT 更底层的模型1.1 动态树问题的普适性与痛点“动态树”这个词在算法圈被用得很宽泛。狭义上它就是指一张点数为 n、边会增删的森林支持 link、cut以及查询两点路径上的某种聚合信息比如路径和、最大值、gcd甚至是可以合并的链信息。比赛里最经典的做法是 LCT很多人在模板题通过后就把 LCT 当成了动态树的标准答案。但用着用着会发现三个痛点。第一LCT 把树剖成虚实链路径上天然是一棵 splay可这棵 splay 上的节点顺序是“按深度排列的路径顺序”。想维护子树信息就要额外维护每个节点的虚儿子集合而且这个集合必须用可重集或者另一个平衡树来组织代码复杂度直接起飞。第二LCT 的 access 操作会把虚边变实边、把实链结构调整遇到边权绑定在儿子或父亲节点上的情形写起来要想很久搞反了方向就是经典的旋转爆炸。第三有些问题需要在同一棵树上同时维护路径信息和子树信息LCT 需要同时维护实链聚合和虚子树聚合两套东西边界情况极多。这些痛点不是 LCT 独有的而是“实虚链剖分”这个模型本身的局限。实虚链剖分把树横向切成了若干条链路径操作天然高效但子树操作必须借助额外的“虚儿子列表”来补齐因为被切掉的虚子树没有参与当前链的排列。如果树是静态的可以用树剖加重链顶点的办法绕开可树是动态的链的划分一变虚子树列表也全乱了。所以在 LCT 之上很多人转向更底层、更对称的“簇”模型也就是 Top Tree。1.2 Top Tree 如何用簇统一路径与子树Top Tree 的核心概念是“簇”。一个簇是原树的一个连通边集并且它和原树的其余部分只通过至多两个顶点相连这两个顶点叫边界点。为什么要限制边界点因为只有边界点暴露给外部合并簇的时候才不用关心簇内部长什么样就像你不会拆开一个黑盒只需要知道它的两个接口引脚。簇与簇之间的合并有两种基本方式compress 和 rake。compress 把两个共享一个边界点的簇沿着一条路径方向串起来得到的新簇边界点正是两个旧簇另一头的端点这天然对应路径上的拼接。rake 则是把其中一个边界点“吸收”掉把若干个子树簇并到另一个簇上所以它对应子树、叶子的合并。两种操作交替使用最终整棵树可以合并为一个根簇。如果把合并过程画出来每个中间簇是一个节点父节点是合并它的动作这棵“簇树”就是 Top Tree。有了这棵簇树路径查询就是把路径两端点之间的若干簇用 compress 串起来子树查询则把对应子树整个 rake 进来。两类操作在同一个结构里变得对称这是 LCT 做不到的。LCT 的实链天然只对路径友好子树信息要靠额外结构。明白这一点就能理解为什么很多更高级的动态树论文喜欢用 Top Tree 当基础框架而不是 LCT。2. Self-Adjusting Top Tree 的核心机制2.1 簇分解与两种基本合并的直观理解想真正实现 Self-Adjusting Top Tree得先把静态的 rake/compress 想明白。我们可以从叶子簇开始看。原树上的每条边单独构成一个簇这层簇有两个边界点就是边的两个端点。接下来相邻的叶子簇开始合并。如果两个簇共享一个端点并且合并后仍然只有两个边界点那么它们既可以走 compress 也可以走 rake取决于我们想把这条“路径”延长还是想把一个分支“收入”主体。举个具体的例子。一棵树上有路径 u-v 和分支 v-w那么簇 A (u,v)簇 B (v,w)。A 和 B 共享端点 v。如果做 compress合并后的簇变成 (u,w)内部端点 v 被隐藏了这时簇表达的是“从 u 到 w 的路径”。如果做 rake合并后的簇仍然是 (u,v) 或者 (u,w) 作为边界取决于把哪个端点吸收掉这时表达的是“以某个端点为根的子树分支”。换句话说compress 管链rake 管枝两种操作一个负责延长路径一个负责收拢子树。Top Tree 的“形状”并不唯一。同样一棵树你可以用不同的 rake/compress 顺序构建出不同的簇树。这给 Self-Adjusting 创造了空间既然形状不唯一那就让访问模式来决定形状。这也是它和静态线段树式 Top Tree 最大的区别。2.2 Splay 式自调整把常用簇抬起来Self-Adjusting Top Tree 的关键在于它不维护一棵固定的簇树而是每次访问一个目标簇或一条路径时沿着簇树做类似 splay 的旋转把目标簇旋转到根同时通过 rake/compress 把和它相邻的簇重组。这里的旋转发生在簇树的节点之间。每个内部节点代表一次 rake/compress 合并经过旋转后代表“当前查询路径”的簇成为根簇整棵簇树的形状会根据访问模式自动调整而不是固定不变。这其实就是把 splay 树“move-to-root”的思想搬到了簇树上。静态 Top Tree 做一次路径查询需要先定位两个叶子簇在簇树中的位置然后从它们向上走到根收集一路上的合并信息。而 Self-Adjusting Top Tree 把“找 LCA 再收集”变成了“从目标叶子向上 splay”过程中用边界点是否匹配来做旋转决策。于是频繁访问的簇会被抬到根附近后续再访问它或者它附近的簇会更快这就是“自调整”三个字的含义。有人可能会问这不是和 splay 树差不多吗确实旋转机制类似。但不同点在于splay 树维护的是有序序列而簇树维护的是原树上具有任意拓扑关系的簇。旋转到根之后根簇的边界点可能和原树的根、原树的叶子没有直接对应关系它只是当前操作需要的那个子结构。所以每次 expose 完聚合值必须重新计算边界点顺序必须重新校准这个更新成本也被算进了均摊复杂度。2.3 均摊复杂度背后的直觉严格证明 Self-Adjusting Top Tree 的操作均摊 O(log n) 需要势能函数完整证明可以参考 Tarjan 和 Werneck 的论文。这里只说直觉。每次操作的代价和簇树中目标簇的深度成正比。splay 会把深层节点旋转上去相当于把势能释放掉所以总的均摊成本被限制在 O(log n)。但这里有个重要前提如果只机械地做旋转不一定会得到 O(log n)。还需要在 expose 过程中对路径上的虚簇做 rake类似 LCT 的 access 在虚实切换时做的标记更新。因为在自调整过程中簇树的边界点集合一直在变只有把不再需要的边界点通过 rake 吸收掉后续的 splay 才能正确地沿着父指针走。另一个直觉来源是“全局平衡”和“自调整”的对比。全局平衡树把树平衡到每个点深度 O(log n)但树一旦更新重构代价很高。自调整树不保证任意时刻的深度都小只保证频繁访问的节点深度小访问少的部分偶尔很深也没关系。这个权衡在动态环境下非常划算因为动态问题里查询通常有局部性。3. 实现时绕不开的细节3.1 簇节点的数据结构设计真正写代码之前先要把“原树顶点”和“簇树节点”分清。原树顶点就是题目里给的 1..n簇树节点是我们自己 new 出来表示一个个簇的结构体。一个簇可能有多个边界点一般是 0、1、2 个。为了统一我习惯上用一个无符号整数记录边界点数量然后用一个大小为 2 的数组存边界点编号。下面是一个很简洁的节点定义struct Cluster { Cluster* ch[2]; Cluster* fa; int boundaryCnt; // 边界点数量 int boundary[2]; // 边界点编号0 表示不存在 int info; // 簇自身维护的额外信息 int sum; // 整簇聚合值 int lazy; // 加法/覆盖标记 bool rev; // 翻转标记用于改变边界顺序 };实际写的时候还需要一个映射关系给定一个原树顶点找到所有以它为边界点的簇。因为同一个顶点可能出现在很多簇的边界点中。比较省事的做法是给每个原树顶点维护一个vectorCluster*邻接表记录当前还有哪些簇的边界点是它。合并操作时通过这个邻接表快速找到可以合并的相邻簇。很多第一次写的人容易在这里懵以为一个顶点只属于一个簇导致 rake 的时候找不到相邻簇最后聚合值不对。3.2 expose 的核心流程拆解Self-Adjusting Top Tree 最核心的操作是 expose它把目标簇变成根簇并且让根簇表达出当前查询想要的子结构。伪代码可以写成这样void expose(Cluster* c) { // 第一步先沿着父指针做一次完整 splay把 c 旋转到根 splay(c); // 第二步查看根簇当前的两个边界点反复 rake/compress while (true) { bool changed false; for (int i 0; i root-boundaryCnt; i) { int v root-boundary[i]; for (Cluster* other : adj[v]) { if (other root) continue; // 根据 other 与 root 的相对关系选择 rake 或 compress if (需要压缩路径边界) { root compress(root, other); } else { root rake(root, other); } changed true; break; } } if (!changed) break; } // 第三步把新的根簇再 splay 一次保证均摊复杂度 splay(root); }这个伪代码省略了很多细节比如compress和rake的具体实现、边界点顺序更新、聚合值的 pushup。想强调的有两点一是每次合并完必须重新扫描根簇的边界点因为合并后边界点集合变了可能有新的可合并邻居二是第二步里的 while 循环不能无限执行每次合并都会减少可用的边界点数量最多 O(n) 次但配合 splay 的均摊分析后总体成本可控。真正实现时expose 的退出条件不是“边界点空了”而是“当前根簇已经覆盖了查询所需的子结构”。比如路径查询时根簇边界应该是路径的两个端点子树查询时根簇边界一般是一个端点。所以最好把 expose 拆成expose(edge)和expose(path)两个变体避免每次把整棵树 rake 成一个点。3.3 查询和更新怎么挂到簇树上有了 expose剩下的操作就顺了。路径查询找到覆盖路径两端的一条基础边簇对它做 expose使路径两端点成为根簇的边界点然后读root-sum。子树查询找到子树根到父节点的那条边簇对它做 expose使得子树的边界点只有一个然后读root-sum。更新操作类似只是把读值改成打标记。比如路径加一void pathAdd(int u, int v, int delta) { Cluster* c getClusterByEdge(u, v); expose(c); root-lazy delta; pushup(root); }需要注意的是pushup不只是把左右子簇的 sum 加起来。对于 rake 进来的子树簇它贡献的可能是一整块子树信息对于 compress 出来的路径簇它贡献的是路径拼接信息。所以聚合函数的语义必须和操作类型对应。很多错误的源头都在这里把路径聚合和子树聚合混在同一个 pushup 里结果两个查询都错。4. 调优与排错实录4.1 递归爆栈与迭代化改造自调整树涉及簇树旋转和标记下传。如果沿用递归写法当数据是链时簇树高度可能暂时达到 O(n)递归深度直接超限。我的建议是先把 splay 的 rotate 写成迭代版本用栈记录从目标节点到根的所有祖先先统一 pushdown再逐层旋转。一个典型的错误写法是在 splay 的 while 循环里直接调用递归 pushdown这可能把祖先的 rev 标记漏下传。正确做法是先拿到祖先链void splay(Cluster* c) { vectorCluster* path; Cluster* cur c; while (cur) { path.push_back(cur); cur cur-fa; } for (int i path.size() - 1; i 0; i--) { pushdown(path[i]); } while (c-fa) { rotate(c); } pushup(c); }这段代码的复杂度在单次旋转时可能较高但均摊下来没问题。注意 rotate 之后要按顺序 pushup先更新旧父节点再更新新节点顺序反了聚合值就错。4.2 懒标记、翻转标记的顺序陷阱Top Tree 的簇节点本身是一棵二叉树的节点但它表达的不只是路径顺序还包含 rake/compress 的语义。rev 翻转标记不仅要交换左右子簇还要交换 boundary 数组里的边界点顺序。比如一个路径簇边界是 (u, v)翻转后应该变成 (v, u)否则后续 compress 的方向就会错。如果代码里同时有加法和翻转标记pushdown 顺序必须仔细设计。我习惯让翻转标记优先于加法标记因为翻转不影响加法值的存储位置。顺序反了可能在一次路径更新后出现“部分节点加了两次部分节点没加”的诡异情况。调试这类问题没有捷径只能写一个能打印整个簇树的小工具输出每个簇的边界点顺序和聚合值对着暴力结果逐层比对。4.3 常见运行错误速查现象可能原因排查方向旋转后父指针成环rotate 里忘记修改左右子节点的 fa打印 fa 链检查每轮旋转前的父子关系查询值少了一部分rake 合并时没有把所有子簇的 sum 更新到父簇检查 rake 后是否对父簇调用了 pushup路径方向不对rev 翻转时没有交换 boundary 数组顺序打印 boundary 顺序确认翻转后端点互换递归爆栈splay 前没有把祖先标记全部 pushdown或 rotate 用了递归改成迭代版 rotate统一从根到目标 pushdown多测样例 TLEexpose 的 while 循环里重复扫描了无关簇检查邻接表是否在合并后更新旧簇是否残留4.4 自调拓扑树调优心得除了上面那些正确性问题性能调优也值得说一句。Self-Adjusting Top Tree 的复杂度依赖 splay 的势能分析所以任何破坏“访问模式”的操作都要小心。比如每次 expose 都去遍历一个顶点关联的所有邻簇看起来只是个常数因子但如果这个顶点度数很大操作次数可能退化。我自己的做法是让邻接表只存“当前仍是接口”的簇每次合并后立即从邻接表里删除被吸收掉的旧簇这样 while 循环的扫描范围就是真实需要调整的簇不会扫到一堆已经被合并完的废簇。5. 应用场景与替代方案对比5.1 什么场景值得付出实现成本Self-Adjusting Top Tree 的实现成本几乎是 LCT 的两倍所以不是所有动态树问题都适合用它。我目前看到的常见场景有三类第一类是需要同时维护路径和子树信息的问题LCT 的虚子树维护会让人崩溃而簇模型天然统一。第二类是维护边权更新和路径最值簇模型允许聚合函数是半群且不要求方向性避开了 LCT makeroot 后边权转点权的别扭写法。第三类是做研究工作很多动态树论文基于 top tree理解后能快速看懂更复杂的结构。如果你只是打算法竞赛或者面对的题目是 LCT 模板题的经典路径求和那还是 LCT 更合适。Self-Adjusting Top Tree 的代码量和理解成本决定了它是一个“重型武器”不要为了炫技强行使用。5.2 和 LCT、Euler Tour Tree 放在一起怎么选结构路径操作子树操作实现难度均摊复杂度LCT强需额外虚子树中O(log n)Euler Tour Tree弱强中O(log n)Self-Adjusting Top Tree强强高O(log n)Euler Tour Tree 基于括号序列维护子树加法和子树查询极其方便但路径查询很弱。LCT 是路径问题的首选。所以选型时可以这样判断如果只需要路径操作用 LCT如果只需要子树操作用 ETT如果两者都要且你能承担实现成本再考虑 Self-Adjusting Top Tree。我个人实际做项目时的体会是不要一开始就试图把论文里的所有细节都复现出来。先实现一个静态 Top Tree用暴力数据验证 rake/compress 的边界点更新逻辑再把 splay 的旋转挂上去加上自调整。每加一层都跑到随机数据上对拍。这个结构最大的坑不在某个神秘定理而在你“以为已经理解但没理解”的边界点语义。等你真正在一个动态图上成功跑通一次完全正确的 expose再回头看 LCT 的 access会发现很多原来靠背模板才能记住的细节现在都变得理所当然了。这也是我一直觉得 Self-Adjusting Top Tree 值得花时间啃一遍的原因。
返回列表