☰
Splay、K-D树与LCT:三个让算法视野倍增的高级数据结构
2026/10/16 11:05:14 网站建设 项目流程

如果你已经能熟练敲出线段树、树状数组、平衡树这些“常规武器”,却在面对某些高级题时依然觉得无从下手——比如区间翻转配合动态插入删除、平面上最近点的快速查询、森林中动态连边断边后的路径维护——那你缺的往往不是刷题量,而是几件真正有威力的数据结构武器。

Splay、K-D树和LCT就是这类武器。它们三个的共同点是:难写、难调、难理解,但一旦掌握,你的题目视野会一下子拓宽好几个层次。本文我用尽量直白的方式,把这三个结构从原理到核心代码再到避坑经验完整过一遍,适合已经具备基础数据结构能力、想冲击更高级别算法竞赛或复杂工程问题的人。

1. Splay伸展树:从二叉搜索树到区间操作的王牌

很多人第一次接触Splay是在平衡树入门之后,觉得它不过又是一个能把树维持平衡的BST变种。这么理解没错,但远远不够。Splay真正可怕的地方不在于“平衡”,而在于它能把区间操作做到和线段树一样灵活,甚至更强。

1.1 为什么先学Splay:旋转与摊还的哲学

二叉搜索树的中序遍历天然对应一个有序序列。如果在这个有序序列上做区间翻转、区间移动、区间加法,线段树能处理一部分,但碰到“把一段区间的元素裁剪下来再拼接到别处”这种操作就显得很吃力——因为线段树的形态是静态的,而Splay可以通过旋转改变树的局部形态,却依然保持中序遍历顺序不变。

Splay的核心思想是:每次访问某个节点后,就通过一系列旋转把它推到根。这个操作有两种效果。第一,最近访问的节点下次再访问时成本极低;第二,更重要的是,它让整棵树的形态趋于平衡。单次操作可能在最坏情况下是O(n),但是连续执行m次操作的总复杂度是O((m+n)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的儿子位置。如果k=0就表示旋转到根。后面写区间操作时,我们会经常需要“把某节点旋转到另一个指定节点的下面”,而不是一律旋到根,所以这个参数k是必须的。

1.3 区间操作的魔法:懒标记与伸展树的结合

Splay能成为区间操作大杀器的核心在于:它可以把任意一段连续区间“掏出来”单独处理。具体做法是在序列上建立一棵中序遍历就是原序列的Splay,然后区间[l, r]用下面两步提取:

  1. 把序列中第l-1个节点旋转到根。
  2. 把第r+1个节点旋转到根的右儿子。

这样,根节点的右儿子的左子树,恰好就是原序列的[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个或第n+1个节点时的边界情况。这两个哨兵会参与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望而却步,是因为它结构上套了两层:森林的每一棵树,被拆成若干条实链,每条实链内部是一棵Splay;Splay和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的右孩子,代表一条更长的实链,然后继续向上传递。

有了access,makeroot就顺理成章。要让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指向y;cut(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的代码其实就是多包了一层壳,理解起来会顺很多。老实说,这三个结构都不简单,但正因如此,一旦掌握,那种把“不可能维护的数据结构”玩转的爽快感也是普通套路题完全给不了的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询