Splay、K-D树与LCT:三个让算法视野倍增的高级数据结构

发布时间:2026/10/16 11:05:23
Splay、K-D树与LCT:三个让算法视野倍增的高级数据结构 如果你已经能熟练敲出线段树、树状数组、平衡树这些“常规武器”却在面对某些高级题时依然觉得无从下手——比如区间翻转配合动态插入删除、平面上最近点的快速查询、森林中动态连边断边后的路径维护——那你缺的往往不是刷题量而是几件真正有威力的数据结构武器。Splay、K-D树和LCT就是这类武器。它们三个的共同点是难写、难调、难理解但一旦掌握你的题目视野会一下子拓宽好几个层次。本文我用尽量直白的方式把这三个结构从原理到核心代码再到避坑经验完整过一遍适合已经具备基础数据结构能力、想冲击更高级别算法竞赛或复杂工程问题的人。1. Splay伸展树从二叉搜索树到区间操作的王牌很多人第一次接触Splay是在平衡树入门之后觉得它不过又是一个能把树维持平衡的BST变种。这么理解没错但远远不够。Splay真正可怕的地方不在于“平衡”而在于它能把区间操作做到和线段树一样灵活甚至更强。1.1 为什么先学Splay旋转与摊还的哲学二叉搜索树的中序遍历天然对应一个有序序列。如果在这个有序序列上做区间翻转、区间移动、区间加法线段树能处理一部分但碰到“把一段区间的元素裁剪下来再拼接到别处”这种操作就显得很吃力——因为线段树的形态是静态的而Splay可以通过旋转改变树的局部形态却依然保持中序遍历顺序不变。Splay的核心思想是每次访问某个节点后就通过一系列旋转把它推到根。这个操作有两种效果。第一最近访问的节点下次再访问时成本极低第二更重要的是它让整棵树的形态趋于平衡。单次操作可能在最坏情况下是O(n)但是连续执行m次操作的总复杂度是O((mn)log n)也就是摊还复杂度O(log n)。这个性质叫“摊还平衡”它不需要像AVL或红黑树那样严格维护高度而是靠“用过的节点往上提”来保证后续操作效率。我第一次看摊还分析时觉得挺玄学后来明白了一个很俗的类比一个人如果总把最近要用的工具放在手边长期来看他找工具的平均时间就很低。Splay就是这么干的。1.2 核心操作拆解rotate、splay与双旋策略Splay的底层操作只有两个左旋和右旋。围绕一个节点做旋转时本质是把它的某个子节点提升到它的位置同时保持中序遍历不变。你不需要把左旋右旋分开记忆只要抓住一个规律如果当前节点是左孩子就右旋是右孩子就左旋。旋转只会改变三个节点之间的父子关系所以实现非常简单。void rotate(int x) { int y tr[x].f, z tr[y].f; int k (tr[y].ch[1] x); tr[z].ch[tr[z].ch[1] y] x; tr[x].f z; tr[y].ch[k] tr[x].ch[k ^ 1]; tr[tr[x].ch[k ^ 1]].f y; tr[x].ch[k ^ 1] y; tr[y].f x; pushup(y); pushup(x); }这是Splay的基石但要撑起摊还复杂度单靠一次旋转不够必须用“双旋”。“双旋”的策略是这样的如果当前节点的父节点和祖父节点在一条直线上即是同一种方向的孩子先旋转父节点再旋转当前节点。这叫做zig-zig。如果不在一条直线上即当前节点的方向与父节点相对父节点的方向相反则连续两次旋转当前节点。这叫做zig-zag。如果父节点就是根那只需要旋转一次。为什么不能都改成“连续旋转当前节点两次”的简单做法因为存在一个经典的反例假设树是一条链每次都把最深处的节点旋转到根如果只用单旋链的结构在多次操作后依然会退化成某种“拧在一起”的形态摊还复杂度会退化到O(n)。而zig-zig双旋可以把链的长度缩短一截这才保证了后续访问的低成本。很多初学者写Splay时图省事只写单旋遇到极限数据就会莫名其妙超时其实就是这个原因。void splay(int x, int k) { while (tr[x].f ! k) { int y tr[x].f, z tr[y].f; if (z ! k) { if ((tr[y].ch[1] x) ^ (tr[z].ch[1] y)) rotate(x); else rotate(y); } rotate(x); } if (!k) root x; }注意这里的splay(x, k)表示把x旋转到k的儿子位置。如果k0就表示旋转到根。后面写区间操作时我们会经常需要“把某节点旋转到另一个指定节点的下面”而不是一律旋到根所以这个参数k是必须的。1.3 区间操作的魔法懒标记与伸展树的结合Splay能成为区间操作大杀器的核心在于它可以把任意一段连续区间“掏出来”单独处理。具体做法是在序列上建立一棵中序遍历就是原序列的Splay然后区间[l, r]用下面两步提取把序列中第l-1个节点旋转到根。把第r1个节点旋转到根的右儿子。这样根节点的右儿子的左子树恰好就是原序列的[l, r]区间。这就像一个“抽屉”你可以对它整体打懒标记、整体翻转、整体删除、整体移动到别处。以最常见的区间翻转为例。给每个节点维护一个布尔懒标记rev翻转一个区间时只需要在抽屉对应的子树根上打标记并交换它的左右孩子不需要真的递归翻转每一个节点。之后每次用到一个节点时先pushdown把标记下沉给自己的孩子保证路径上的方向正确。这个懒标记思想跟线段树一脉相承但是因为Splay的树形结构会变化所以pushdown的时机更关键在splay之前必须把从根到目标节点的路径上所有祖先的懒标记全部下沉。区间删除和区间移动也不复杂。删除时把抽屉摘下来记为temp然后把它父节点的对应孩子置空即可。移动到另一段位置时先确定目标位置的相邻节点再splay那两个节点然后把temp接进去。这比用vector暴力插入删除不知道高到哪里去了。1.4 Splay实战避坑递归栈溢出与常数优化Splay的坑说多不多但每一个都能让人debug到怀疑人生。第一递归建树时要注意栈深度。如果直接用递归函数建一棵n个节点的平衡树问题不大但如果你递归插入节点最坏情况下Splay本身也可能出现深度较大的临场形态递归操作时容易爆栈。我在某次极限数据下就吃过这个亏后来干脆把所有操作都改写成循环或者直接非递归建树。第二splay操作之前一定要先pushdown路径上的标记。这一点特别容易漏。如果某个区间翻转的懒标记还没有下沉而你把其中一个节点splay上来它的左右孩子方向可能还是反的这时候再pushup维护size或者其它信息就会算错。常见的现象是翻转后极个别节点的左右子树顺序不对或者size虚增减少导致Kth定位错位。第三注意统计信息pushup的时机。旋转之后要立刻更新受影响的y和x区间提取之后要重新计算根节点子树信息。很多初学者只记得在build时算一次size后面就忘记了结果是Kth返回的节点完全错乱整个序列越调越乱。第四插入和删除使用的是哨兵节点。通常在序列两端各放一个虚拟节点避免splay到第0个或第n1个节点时的边界情况。这两个哨兵会参与Kth运算实际序列的下标要偏移一下非常容易搞混。我的习惯是在建树时把下标直接映射到节点的编号同时开一个数组存“第i个位置的节点编号”这样Kth返回时就能直接对照减少出错。2. K-D树把空间切成一棵可搜索的树如果说Splay是“序列之王”那K-D树就是“低维空间之王”。它解决的是多维空间里的两类问题最近邻搜索和区域范围查询。以二维平面为例你手里有10万个点每次问一个坐标希望快速找到离它最近的点或者统计某个矩形区域内有多少个点。这类问题如果暴力做每次都是O(n)数据一大直接爆炸。2.1 K-D树解决的痛点多维最近邻与区域查询K-D树把一个k维空间递归切分成若干个子空间。它的思路跟一维的线段树不太一样更接近二叉搜索树每个非叶子节点代表一个分割超平面把空间分成左右两半每向下一层换一个维度继续切。以二维为例root按x坐标切它的儿子按y坐标切孙子再按x坐标切如此交替轮换。这个结构能做的第一类操作是“矩形区域查询”。给定一个矩形从根开始递归如果节点代表的子树包围盒完全在矩形内部直接取整个子树的统计值如果完全在外直接返回如果部分相交就继续下探。和线段树一样这个查询的复杂度高度依赖结果数量和分割质量。理想情况下一次查询是O(sqrt(n))级别这在很多二维问题里已经够用了。第二类核心操作是最近邻搜索。这个下面单独讲它的剪枝逻辑是所有K-D树题目里最容易写错的地方。2.2 建树过程详解切分维度选择与中位数划分建树的核心是“选择合理的分割维度找中位数”。以二维为例最简单也足够实用的策略是交替按x、y方向分割。每层用nth_element把点按当前维度排序取中位数作为当前节点的点然后递归建左右子树。int build(int l, int r, int dep) { if (l r) return 0; int mid (l r) 1; axis[mid] dep % K; // 当前节点按哪个维度分割 nth_element(p l, p mid, p r 1, cmp); tr[mid].ch[0] build(l, mid - 1, dep 1); tr[mid].ch[1] build(mid 1, r, dep 1); pushup(mid); return mid; }注意这里比较函数必须绑定到当前分割维度用nth_element只需要O(n)的平均时间就能把中位数放到中间位置比sort后取mid再递归省很多常数。建完树后一定要pushup维护每个节点子树对应的包围盒。二维节点通常维护四个值子树中所有点x的最小值、最大值、y的最小值、最大值。这个包围盒是之后所有剪枝的基础每一层的范围信息都要严格向上合并。我之前漏更新了一个维度的最小值导致查询时剪枝过度直接漏掉正确答案找了一整天才发现是pushup写错了。另一个细节是节点本身存储一个真实点而不是只存区间。这和线段树不同K-D树每个节点都对应一个具体的点数据查询时它自己也要参与答案候选。2.3 最近邻搜索的剪枝思路与搜索顺序给一个目标点query要找到树里离它最近的点。朴素做法是遍历整棵树但K-D树可以用包围盒距离做剪枝把搜索空间迅速缩小。搜索逻辑是从根开始递归对当前节点先计算query到它代表的点的距离更新全局答案ans。然后判断应该先搜哪棵子树。这里有个关键优化优先搜索离query更近的那一侧子树回溯后再考虑另一侧。判断方法很朴素——分别计算query到左右子树包围盒的最近距离先走距离小的那一边。另一侧的剪枝条件是一个不等式如果query到该子树包围盒的最近距离已经大于等于当前全局ans那就说明这个子树里不可能有更近的点直接跳过。void query(int x, Point q) { if (!x) return; ans min(ans, dist(tr[x].p, q)); int left tr[x].ch[0], right tr[x].ch[1]; // 注意一定要先计算两个子树的包围盒最近距离 double dl compute_min_dist(left, q); double dr compute_min_dist(right, q); if (dl dr) { if (dl ans) query(left, q); if (dr ans) query(right, q); } else { if (dr ans) query(right, q); if (dl ans) query(left, q); } }这里的compute_min_dist计算的是点到包围盒的最近距离。如果点在包围盒内部距离是0如果在外面就计算到相应边界的距离平方和。通常用平方距离而不是带根号的距离因为开根号既慢又不影响大小比较。这个剪枝看起来简单实际上在数据分布比较好比如随机点)的时候能砍掉绝大多数分支。最坏情况下还是会退化成全树遍历比如所有点都在一个同心圆环上均匀分布且目标点位于圆心时任何子树包围盒的距离都很接近剪枝基本无效。这也是K-D树的缺点——它没有严格的复杂度保证。2.4 K-D树的退化问题与改进方向K-D树最大的隐患是插入新点后可能导致树严重不平衡。如果只用普通插入按分割维度递归插入到叶子的下一层树高会膨胀查询退化成接近O(n)。解决思路有三种。第一种是离线批量建树定期完全重建。适合数据已知且是静态的场景简单可靠。第二种是替罪羊树式的惰性重建给每个节点维护子树大小插入时如果某个子树的失衡度超过阈值就对整棵子树暴力重建。这种动态K-D树写起来复杂一些但在竞赛里非常实用。第三种是使用KD-B树这类变体每个存储节点同时存放多个点牺牲一点查询效率换取插入实现的简洁性工程上偶尔能用。我个人的建议是如果数据允许尽量离线建树。K-D树最惨烈的情况就是动态插入几千个点后树形退化成一串链表然后之前AC的查询代码全部超时。我自己踩过这个坑后养成了一个习惯——任何K-D树题都先看看数据是否允许离线能离线就不碰动态插入。3. LCT动态树维护森林上的路径信息第三种结构是LCT全称Link-Cut Tree中文常叫动态树。它解决的是动态树形结构问题一棵树会在运行过程中被“切断”——把一条边删掉或者在两个节点之间“连接”一条新边同时还需要实时查询任意两点之间路径上的信息比如异或和、最大值、节点数量。树链剖分对静态树是神仙解法可一旦边集变化每条重链剖分方案都得推倒重来递归换链的复杂度完全不可接受。LCT就是为此而生的。3.1 从树链剖分到动态树的必然静态树上查询路径信息最经典的工具是树链剖分线段树。它的原理是把树按照子树大小分成重链用线段树维护链上的数据。动态问题难在重链划分不是静态的你连了边、断了边原先的重链关系瞬间失效。如果每次操作都重新剖分一次那还不如暴力。LCT的核心思路跟树剖完全不同它不在整棵树上固定重链而是每时每刻把树拆成若干条“实链”每条实链用一棵Splay维护。链的划分是动态变化的——当你查询某条路径时LCT会把这条路径涉及的边“激活”成一条实链其它边统统作为虚边挂在旁边。这个“激活”操作就是access它是整个LCT的命门。很多初学者对LCT望而却步是因为它结构上套了两层森林的每一棵树被拆成若干条实链每条实链内部是一棵SplaySplay和Splay之间用虚边连接。要理解LCT得把脑子里“一棵树对应一棵二叉树”的固有印象丢掉换成“一棵树对应若干棵独立的Splay森林”。3.2 Splay之上再套Splay实链剖分的基本思想为什么实链的数据结构偏偏选Splay而不是其它平衡树因为实链需要一种数据结构能方便地“把一个节点到链的一端单独切出来”还能“把两条链无缝拼接”同时支持翻转操作makeroot需要。Splay天生就适合这种动态序列拼接——它的split和merge操作非常灵活而且翻转也只需要打个懒标记。LCT的节点关系和普通Splay不太一样每个节点的ch[0]、ch[1]指向它在实链Splay里的左右孩子fa指针的含义要分情况讨论——如果当前节点是所在Splay的根那么fa指向“这条实链顶端节点在原树中的父节点”否则fa指向Splay里的父节点。这是LCT最绕的地方也是所有bug的源泉。判断一个节点是不是当前Splay的根是LCT里极其常见的一个操作bool isroot(int x) { return tr[tr[x].f].ch[0] ! x tr[tr[x].f].ch[1] ! x; }这个判断的含义是如果x的父节点的左右孩子都不指向x说明x不是这个Splay根的孩子那x就只能是所在Splay的根它的fa自然就指向虚边另一端。这是整个LCT操作的前提很多模板题里你发现rotate以后树的结构混乱大多是isroot判断写错了。3.3 Access与Makeroot的完整推导LCT最核心的操作是access(x)。它的目标是打通从原树根到节点x之间的一条实链使得这条路径上的节点落在同一个Splay里。具体做法很形象把x一路往上走到根每一步都把当前节点的右儿子“替换”成上一轮的Splay同时把原来的右儿子变成虚边。void access(int x) { int last 0; for (int y x; y; y tr[y].f) { splay(y); // 把当前虚链底部的节点旋到根 tr[y].ch[1] last; // 右儿子换成上一段实链 pushup(y); last y; } splay(x); }理解access的关键在于Splay的中序遍历就是实链从上到下的顺序。当前节点y所在Splay的右子树表示的是y下面的那条实链。现在要接入上一轮的实链就把右儿子直接换成last。这个操作做完后last成了y的右孩子代表一条更长的实链然后继续向上传递。有了accessmakeroot就顺理成章。要让x变成原树根只需要先access(x)让x到根的路径变成一条实链然后对整条链打一个翻转标记。为什么旋转因为链的中序遍历现在是“根到x”翻转后变成“x到根”x自然就跑到了链的最顶端成为新的树根。void makeroot(int x) { access(x); rev(x); }这里的rev操作只是对Splay根打懒标记与普通Splay的区间翻转完全一致。一旦理解了access和makeroot后面的link和cut都是它们的简单组合。link(x, y)就是makeroot(x)后让x的fa指向ycut(x, y)就是makeroot(x)后access(y)此时x与y应该在同一个Splay中且y的左子树就是x然后分断即可。值得一提的是LCT的findroot查连通性也是用access实现先makeroot(x)再access(y)然后找到y所在Splay最左侧节点就是原树根。这个过程需要不断pushdown懒标记因为链可能有翻转。3.4 LCT典型应用与常见错误LCT的典型应用一抓一大把动态维护最小生成树边增量加边时如果形成环就替换环上最大边、动态维护树上路径边权/点权的最大值和异或和、判断森林中两点是否连通。后面这个特别适合做并查集的动态替代品——并查集只能加边不能删边LCT连删边都能做。我踩过的LCT坑可以列一个长清单这里挑三个最重要的说。第一个坑是Splay操作前没有把路径上的懒标记全部pushdown。LCT的Splay不是静态的树根经常翻转所以节点上带rev标记的情况很普遍。在旋转前如果不递归找到splay路径上所有祖先并依次下沉标记就会出现左右孩子颠倒是而非的诡异错误。我的习惯是在access和makeroot内部已经splay过一次的前提下对每个要被旋转的节点额外写一个push_path函数先下沉到目标节点的所有祖先链。第二个坑是pushup的位置。LCT里修改右儿子比如access后一定要立刻pushup。很多人写完access后忘了对y更新一次结果后面查最大值时一直用旧数据排查半天才发现是pushup漏了。第三个坑是调试困难。LCT一旦错了树的形态可能已经完全乱掉打印当前Splay看也很难直观发现问题。我的经验是先用最简单的链式数据和暴力对拍把错误数据缩小到几十个操作以内然后手动模拟几遍找出第一个结果不一致的操作。有几次我发现问题出在link时忘记makeroot导致两条实链拼接时顺序颠倒这类问题在暴力对拍下一下就暴露了。4. 三种数据结构横向对比与选型思路学了三种结构最自然的问题是什么情况下用哪个我把它们放在一起对比了一下。4.1 用一张表看清各自适用场景数据结构核心维护对象单次操作摊还/期望复杂度动态修改能力实现难度Splay序列、二叉搜索树、区间操作O(log n)支持插入、删除、翻转、剪切拼接中K-D树低维空间点集、包围盒查询期望O(sqrt(n))~O(log n)静态建树简单动态插入需重构中LCT森林上的路径信息O(log n)支持加边、删边、路径查询、换根高Splay最强的场景是“序列上所有能想到的区间操作”比如文艺平衡树那类题给你一个序列支持区间翻转、区间移动、区间循环位移。K-D树几乎是低维最近邻和区域查询的唯一选择在算法竞赛里通常以二维为主偶尔出三维。LCT则是动态树问题的天花板工具如果你需要同时在森林里连边、删边、查路径信息那基本只有LCT能胜任。4.2 笔试面试与实战中的取舍如果是一般面试或工程场景K-D树对低维空间检索的实用价值最高但很少有人会手写一般调库即可。Splay作为平衡树结构已经被各种现成库覆盖它更大的意义在于理解“旋转与摊还”的底层思想以及在某些需要完全自定义序列操作的实验场景里手工实现。LCT在工业界直接手写的机会更少但它是很多图论算法比如动态最小生成树、离线动态连通性的底层依赖理解它对算法视野的提升是其它结构很难替代的。在竞赛实战里我的选型经验是如果看到“区间翻转”四个字别犹豫Splay看到“二维平面最近点/区域计数”而且点的坐标范围很大需要动态查询K-D树看到“动态加边删边路径查询、最小生成树维护”LCT。如果题目又要求区间操作又要求能任意插入删除元素Splay是唯一选择线段树根本做不了这个。4.3 学习的进阶路线建议这三样东西不建议同时硬啃。我的建议是先彻底搞懂Splay因为LCT内部就嵌着一堆Splay你对旋转、懒标记、splay操作不熟的话直接上LCT等于双重劝退。Splay刷熟两三道区间操作题后试着不看模板手写文艺平衡树能独立写出来才算过关。接着学K-D树。它相对独立代码量也不大核心是建树和剪枝搜索适合作为“第二个新结构”来建立信心。等K-D树刷完两三道最近邻题后再集中精力学LCT。LCT的学习要从access开始死磕把实链剖分和Splay森林的关系在纸上画清楚再写makeroot和link/cut。我见过不少同学直接跳过前两个去学LCT结果吃尽苦头最后连Splay也放弃了。反过来先把Splay的地基打牢LCT的代码其实就是多包了一层壳理解起来会顺很多。老实说这三个结构都不简单但正因如此一旦掌握那种把“不可能维护的数据结构”玩转的爽快感也是普通套路题完全给不了的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询