搞了几年C++和算法之后,回头看这三样东西——Splay、K-D树、LCT动态树,依然是最容易劝退的一批数据结构。Splay靠旋转维护序列,K-D树靠空间划分处理多维数据,LCT靠虚实链剖分在森林上做动态操作。它们的共同特点是:思路不复杂,但实现里全是细节,任何一个变量写错,整个程序的表现都可能是错的。如果你正准备进阶,或者读了不少模板却始终没吃透,这篇文章会按我的理解把这三棵树拆开、揉碎,讲清楚为什么这么做、代码里每一步到底在干什么。
这三样东西适合什么人群?我认为有两类。第一类是打算法竞赛的选手,Splay和LCT几乎是非做不可的硬骨头;第二类是工程研发中频繁处理区间、多维检索或树形拓扑结构变化的从业者,K-D树在空间检索里尤其常见。理解了它们的底层逻辑,再看很多现成库的实现,也不会再觉得那是黑盒。
1. 它们到底在攻什么难题
1.1 从三个经典场景说起
先把问题摆出来。你有一排盒子,要支持把一个区间整体翻转,还要知道任何时候第k个盒子里装的是什么。数组上做区间翻转是O(n)的,线段树下传标记能做部分区间修改,但它本身不擅长“把某一段拿出来单独处理”这件事。Splay的出现,就是为了把“区间操作”变成“把一个节点的整棵子树搬走”,再复杂一点,某个区间要先切割、再合并、再翻转,Splay也能用同样一套旋转移树来实现。
你有一个二维平面上的点集,要反复询问离某个坐标最近的点。朴素做法是O(n)扫一遍。数据一大就吃力了,K-D树把二维空间按照坐标中位数切成一块块超矩形,查询时利用“当前最优解半径”剪掉整个不可能包含答案的区域,大多数场景下能把查询复杂度从O(n)降到接近对数级,虽然最坏情况还是能退化,但实现简单、常数小,是工程上很常见的多维度检索手段。
你要维护一片森林,支持连接两个不连通的节点、切断一条边、随时查询树上某条路径上的最大值或和。树链剖分可以做到O(log^2 n),但它要求树形态是静态的,连边删边之后就不好维护了。LCT利用Splay作为辅助树,只维护“当前被使用的实链”,把access、makeroot、link、cut这四板斧练熟之后,动态树问题就有了通用解法。
1.2 为什么说它们是“天花板”
说句实话,这三个结构并不是谁难到无法理解,而是它们把“自平衡”和“懒操作”两个概念延伸到了极致。理解层次是这样的:
| 结构 | 解决的核心矛盾 | 底层手段 | 复杂度 |
|---|---|---|---|
| Splay | 区间切割、合并、翻转 | 单旋/双旋把访问节点顶到根 | 均摊O(log n) |
| K-D树 | 多维最近邻与范围检索 | 维度交替、中位数划分、超矩形剪枝 | 期望O(log n),最坏O(n) |
| LCT | 动态森林上的路径维护 | 虚实链剖分 + Splay辅助树 + 懒标记 | 均摊O(log n) |
这里我想多一句:所谓“天花板”,不是搞不出来更复杂的东西,而是这三棵树的背后已经连成一条线——K-D树教你“剪枝”,Splay教你“旋转重构”,LCT教你“把一棵树拆成多条链,每条链又是一颗平衡树”。学完它们之后,你再看Treap、红黑树、树套树,会感觉思路是相通的。
2. Splay伸展树:线段树之外的另一条路
Splay最容易被误解的一点是:它看起来只是把最近访问的节点旋转到树根,为什么会这么快?答案是均摊。一个节点被访问后离根越近,下次访问它的代价就越小,局部性好的操作序列摊到每次操作上就是对数级别的。这很像缓存里的热数据思想:常用数据放到最上层,整体效率就上来了。
2.1 核心思想与结构约定
写Splay前必须约定清楚几个东西。第一,它的中序遍历就是当前维护的序列,左子树里的元素在序列里一定在根节点左边,右子树一定在右边。第二,所有旋转和翻转操作都不改变中序顺序,rev标记只是临时“交换左右子树的访问顺序”,真正下传到节点时才会让局部中序翻转。第三,数组模拟时ch[x][0]表示左孩子,ch[x][1]表示右孩子,fa[x]是父指针。
2.2 rotate与splay的完整实现
旋转是Splay的一切。一次旋转把一个节点提到它父亲的位置,同时保持二叉搜索树的中序顺序不变。核心在于:如果x是y的左孩子,那么x的右孩子就变成y的左孩子,y变成x的右孩子;对称的情况同理。这里最容易错的地方是更新顺序:必须先处理“x和y的父子关系”,再处理“祖父z和x的关系”,最后统一更新sz,顺序错了会直接漏更新甚至把指针指飞。
写了几年这一类树结构,我依然觉得Splay的rotate是后面所有操作的基础,所以这里把完整实现贴出来,方便对照后面的解释:
const int N = 200005; int ch[N][2], fa[N], sz[N], rev[N], val[N]; int root, tot; int isrs(int x) { return ch[fa[x]][1] == x; } void pushup(int x) { sz[x] = sz[ch[x][0]] + sz[ch[x][1]] + 1; } void pushdown(int x) { if (!rev[x]) return; swap(ch[x][0], ch[x][1]); if (ch[x][0]) rev[ch[x][0]] ^= 1; if (ch[x][1]) rev[ch[x][1]] ^= 1; rev[x] = 0; } void rotate(int x) { int y = fa[x], z = fa[y]; int k = isrs(x); int b = ch[x][k ^ 1]; if (z) ch[z][isrs(y)] = x; fa[x] = z; ch[x][k ^ 1] = y; fa[y] = x; ch[y][k] = b; if (b) fa[b] = y; pushup(y); pushup(x); }这里有一个很多教程不会强调的点:只有当z是0,也就是x的祖父不存在时,我才把root换成x;如果z存在,我直接用isrs(y)把x接到z对应的孩子位。很多第一次写的人会把“如果是左孩子就ch[z][0]=x”写成固定ch[z][0]=x,一遇到右孩子就彻底崩了。
2.3 双旋还是单旋
splay(x, goal)的作用是把x旋转到goal的直接孩子,如果goal为0就旋到树根。这个过程不是单纯一直单旋就完事,而是要先判断父、子、祖三个节点的相对关系。如果x和它的父亲都是各自父亲的左孩子(或者都是右孩子),先旋转父亲,再旋转x,这叫zig-zig;如果x的位置和父亲不一致,就叫zig-zag,先旋转x,再旋转x。这个差异是我当年第一次写Splay时最没搞懂的地方。
为什么一定要区分zig-zig和zig-zag?因为全用单旋的话,一条链上最坏的访问会出现均摊O(n)的退化,双旋才能保证splay操作本身也是均摊O(log n)。打个比方:你整理一叠旧书,每一次都只把最上面那本换个位置,和每次把整叠从前到后重新排一遍,成本完全不同。
void splay(int x, int goal) { while (fa[x] != goal) { int y = fa[x]; int z = fa[y]; if (z != goal) { if (isrs(x) == isrs(y)) rotate(y); else rotate(x); } rotate(x); } if (!goal) root = x; pushup(x); }写完splay后建议立刻测试:插入1到5,反复查询节点5,然后中序遍历看是否还是1 2 3 4 5。中序遍历不变,是验证旋转是否正确最简单的办法。
2.4 用Splay做区间翻转
真正的区间操作,需要先让目标区间变成一个节点的子树。做法是在序列两端各放一个哨兵节点,通过“找第k个元素”把左端点的前一个节点旋到根,再把右端点的后一个节点旋到根的右孩子,那么右端点的左子树就正好是我们要操作的区间。以翻转[l, r]为例:
- 在序列最前面加一个虚拟节点,所以原数组第i个元素在Splay中对应下标i+1。
- getkth(l)返回当前序列中下标为l的节点。
- splay(getkth(l), 0),让第l-1个节点成为根。
- splay(getkth(r + 2), root),让第r+1个节点成为根的右孩子。
- 给这个右孩子的左子树打上rev标记。
getkth和普通二叉搜索树的第k小查找逻辑基本一样,但要注意在向下走的过程中不断pushdown,否则左右子树可能已经处于“逻辑翻转”状态,而你还在拿旧的拓扑结构去判断方向。区间翻转的板子在一些编辑器或文本处理里也有实际应用,比如维护一份需要反复剪切粘贴的长文本。
2.5 Splay的坑与经验
Splay我实际踩得最多的坑有三个。第一是pushup的位置:rotate里必须先后pushup(y)再pushup(x),因为旋转后y在下、x在上,x的sz依赖y的新sz,顺序反了会得到旧值。第二是splay过程中不能漏pushdown:旋转前如果子树有rev标记没下传,左右子树可能已经被swap过了,但ch数组还是旧顺序,旋转之后中序就乱了。第三是数组内存:Splay一般开两倍节点数再加哨兵,tot从2开始初始化,避免最终下标冲突。
还有一个经验:写区间操作时不要直接用节点本身的val去定位,要用sz去定位。因为你要操作的不是某个特定值,而是序列中某个位置,这依赖的是子树大小的累计。如果脑子一热用值去搜,一旦序列里有重复值就会出现定位错误。
3. K-D树:把二分查找推广到多维
如果说Splay是“序列之王”,那K-D树就是多维空间的常规武器。它不像四叉树那样要求空间均匀,也不需要你知道整个数据范围再建格点网格,它完全基于现有数据点做递归划分。
3.1 从“一维排序”到“多维划分”
一维最近邻很简单,把数排序后用二分找位置即可。到了二维,你要同时考虑横纵两个坐标,单排序就失效了。K-D树的思路是:每一层选一个维度,在该维度上取中位数作为切分点,小于中位数的点进左子树,大于的进右子树。下一层换下一个维度继续切。这样整棵树把平面切成了一个个嵌套的超矩形,每个节点代表一个点,节点的左右子树分别代表两个半空间。
假设是二维树,第一层按x切,第二层按y切,第三层再按x切。建树完成后,任意节点的坐标是它所在超矩形的分界线,而mn[0]/mx[0]/mn[1]/mx[1]记录的是子树所有点在该维度的范围。这些边界信息是后面剪枝的命根子,少了它们,K-D树的查询就退化成遍历。
3.2 建树:维度选择与nth_element
构建K-D树的经典写法是按深度交替换维度,但交替法不是最优的。更好的做法是统计当前区间内每个维度的方差,选方差最大的维度作为切分维度。因为方差大意味着数据在该维度分散,用这个维度切分能更好地减小下一步搜索范围。实际竞赛里为了省常数,用“dep % K”轮流切也很常见,尤其点是均匀随机时两者差别不大。
找中位数也别写快排,C++标准库里有nth_element,一次调用就能把第mid大的数放到mid位置,左边都不大于它,右边都不小于它,平均O(n)完成,比sort整体排序省时间。建树时递归两半,维护好当前节点的mn和mx,这里需要把左右子树的mn/mx合并上来,类似线段树的pushup。
struct Node { int ch[2]; double x, y; double mn[2], mx[2]; } tr[N]; int pt[N], n; bool cmpX(int a, int b) { return tr[a].x < tr[b].x; } bool cmpY(int a, int b) { return tr[a].y < tr[b].y; } void pushup(int u) { tr[u].mn[0] = tr[u].mx[0] = tr[u].x; tr[u].mn[1] = tr[u].mx[1] = tr[u].y; for (int d = 0; d < 2; ++d) { if (tr[u].ch[0]) { tr[u].mn[d] = min(tr[u].mn[d], tr[tr[u].ch[0]].mn[d]); tr[u].mx[d] = max(tr[u].mx[d], tr[tr[u].ch[0]].mx[d]); } if (tr[u].ch[1]) { tr[u].mn[d] = min(tr[u].mn[d], tr[tr[u].ch[1]].mn[d]); tr[u].mx[d] = max(tr[u].mx[d], tr[tr[u].ch[1]].mx[d]); } } } int build(int l, int r, int dep) { if (l > r) return 0; int mid = (l + r) / 2; nth_element(pt + l, pt + mid, pt + r + 1, dep % 2 == 0 ? cmpX : cmpY); int u = pt[mid]; tr[u].ch[0] = build(l, mid - 1, dep + 1); tr[u].ch[1] = build(mid + 1, r, dep + 1); pushup(u); return u; }注意pt数组中存的是点编号,cmp比较的是编号对应的实际坐标。建树递归深度在随机数据下大约是log n,如果数据点分布均匀,深度也在log n级别;如果出现大量共线或同一维度位置相等,K-D树会偏向退化,下面会讲如何处理。
3.3 最近邻搜索与剪枝
查询一个点o的最近邻,递归地访问树。到每个节点先算o与当前节点坐标的距离,更新当前最优答案,然后决定先搜左子树还是右子树。通常先搜“离查询点更近的一侧”,也就是当前节点所在维度上,查询坐标在分界点左边就先搜左子树。搜完一边回来后,评估另一边是否可能包含更优解。
这里的关键是剪枝条件。对另一边子树,我计算o到该子树包围盒的最近距离,如果这个最近距离已经不小于当前最优答案,那该子树里不可能有更优的点,直接跳过。注意“包围盒最近距离”要按每个维度分别算,不是简单拿中位线距离替代。
double distToRect(int u, double x, double y) { double dx = 0, dy = 0; if (x < tr[u].mn[0]) dx = tr[u].mn[0] - x; else if (x > tr[u].mx[0]) dx = x - tr[u].mx[0]; if (y < tr[u].mn[1]) dy = tr[u].mn[1] - y; else if (y > tr[u].mx[1]) dy = y - tr[u].mx[1]; return sqrt(dx * dx + dy * dy); } void query(int u, double x, double y) { if (!u) return; double d = sqrt((tr[u].x - x) * (tr[u].x - x) + (tr[u].y - y) * (tr[u].y - y)); if (d < ans) ans = d; int l = tr[u].ch[0], r = tr[u].ch[1]; double dl = l ? distToRect(l, x, y) : 1e18; double dr = r ? distToRect(r, x, y) : 1e18; if (dl > dr) swap(l, r), swap(dl, dr); if (dl < ans) query(l, x, y); if (dr < ans) query(r, x, y); }这个剪枝看起来简单,实际效果非常强。二维随机点集下,一次最近邻查询通常只需要访问树里几个到几十个节点,和O(n)扫全量完全不是一个量级。如果查询点的分布恰好总是踩在边界上,剪枝会失效,最坏情况确实会变成O(n),这也是K-D树在超高位维度上不如那些专门设计的高维索引的原因。但到二十维以内,它依然是实践里最简单好用的方案。
3.4 K-D树的动态插入与退化处理
静态建树很快,但一旦要动态插点,直接照静态方法插会导致树严重不平衡:新点一路落到叶子,树变成一条链。最简单的补救是配合定期重建:插入时沿树走,每次走到空位就挂上去;当整棵树节点数超过阈值或者不平衡度达到某个比例时,把所有点重新build一遍。这种“暴力重建”的思路和替罪羊树不谋而合,实现简单,实测效果也够用。
另外一个容易被忽略的点:mn/mx要在插入后沿路径重算。如果你只插点不更新包围盒,查询剪枝会用过期范围,结果就是该剪的都不剪,性能直线下降。我见过不止一个人在这种问题上调试大半天,最后发现mn/mx是对新建节点初始化错了。
4. LCT动态树:Splay的巅峰用法
前面铺垫了这么多,LCT终于要登场了。LCT全称Link-Cut Tree,直译就是“连边-断边树”。它的出现把树链剖分从“静态拓扑”推广到了“动态森林”,而且用的是完全不同的框架:实链剖分 + Splay辅助树。
4.1 虚实链剖分和辅助树
静态树链剖分根据子树大小把边分成重边和轻边,LCT的思路是动态决定哪些边是实边、哪些边是虚边。每条实链用一棵Splay维护,Splay的中序是链上从浅到深的顺序。每个节点最多有一个实儿子,其他虚儿子通过fa指针连到上层节点,但这个fa指针不是Splay上的父指针,而是“虚边”的归属关系,所以一个节点可以同时是某棵Splay上某点的儿子,又通过fa虚指向上层。
我接触LCT时最大的障碍就在这里:fa[x]有时候代表Splay里的父亲,有时候代表原树上连向父亲的虚边,必须靠isroot(x)判断。isroot(x)的写法不是看fa[x]是否为0,而是看fa[x]是否存在且fa[x]的左、右孩子是否指向x。如果都不是,说明x是它所在Splay的根节点,fa[x]是虚边的指向。
4.2 access与makeroot 到底在干什么
LCT的所有操作几乎都建立在access和makeroot之上。access(x)的作用是“把x到原树根路径上的所有边变成实边,形成一条实链”。实现上不是递归,而是循环:每一步先把当前点x旋到它所在Splay的根,然后把x的右孩子换成上一次循环得到的链,接着通过fa[x]向上跳。这个右孩子替换的操作,本质上是把原来的实链断掉,接上新的实链。
bool isroot(int x) { return ch[fa[x]][0] != x && ch[fa[x]][1] != x; } void access(int x) { for (int last = 0; x; last = x, x = fa[x]) { splay(x); ch[x][1] = last; pushup(x); } }注意到没有?access循环里的x一直在变,每轮先把当前x旋到Splay根,然后通过fa向上跳。这里的fa[x]经过splay(x)后,指的不再是Splay里的父节点,而是沿虚边指向原树上的父节点,这正是循环能逐层向上打通整条链的原因。很多初学者没有想通这一点,导致对access稍微改了流程就完全看不懂。
makeroot(x)的作用是把x变成整棵原树的根。做法很经典:先access(x),把根到x的路径全部变成一条实链,再把x splay到辅助树根,最后给x打上翻转标记。为什么翻转会换根?因为这条实链在Splay里按深度递增存放,x是链最深的点,反转整条链后,x变成链最浅的点,也就是新根。这个rev标记和Splay区间翻转的rev完全同款,所以LCT的板子里会大量复用Splay的pushdown和rotate。
void makeroot(int x) { access(x); splay(x); rev[x] ^= 1; }4.3 路径查询、link与cut
要查询原树上x到y路径上的信息,标准流程是:makeroot(x)把x变成根,access(y)把根到y变成实链,splay(y)把y旋到辅助树根,这时y的整棵子树就是x到y的路径。路径和、最大值、最小值的维护就交给Splay上的pushup,和普通Splay一模一样。
连接两个点link(x, y)的做法是:先makeroot(x),把x变成它所在树的根,然后如果findroot(y)不等于x(说明x和y还不连通),直接把fa[x]设为y即可。这里的fa[x]是虚边设置,不需要在Splay中建立左右孩子关系,所以复杂度比想象里低很多。
断开一条边cut(x, y)的做法是:先makeroot(x),再access(y),然后splay(y)。此时如果x和y在原树上相邻,x一定是y的左孩子,而且x不能有右孩子,因为x深度比y浅,如果x有右孩子说明两者之间夹着别的节点,绝不是直连边。满足这些条件才把x从y的左孩子处摘掉。
void link(int x, int y) { makeroot(x); if (findroot(y) != x) fa[x] = y; } void cut(int x, int y) { makeroot(x); access(y); splay(y); if (ch[y][0] == x && ch[x][1] == 0) { ch[y][0] = 0; fa[x] = 0; pushup(y); } } int findroot(int x) { access(x); splay(x); while (ch[x][0]) { pushdown(x); x = ch[x][0]; } splay(x); return x; }findroot这里也要补一句:因为辅助树维护的是实链,要找原树树根,就要一直沿左孩子向下走到最浅点,也就是中序遍历的第一个点。走的过程中如果不pushdown,rev标记会让方向判断失效,最后可能停在错误的节点。
4.4 LCT的应用切面
LCT最经典的场景是动态维护最小生成树:在图上不断加边,每加一条边就用LCT把成环路径上的最大边找出来并删掉;也能用于维护动态树的连通性、路径的异或和、最大值等问题。实际项目中处理“树形态变化+路径查询”时,LCT比每次重跑树链剖分快得多。
当然LCT也有明显的短板。第一是常数大,同样的操作量比静态树剖分慢不少;第二是调试困难,一旦fa语义混淆,错误很难通过打印一个节点的左右孩子看出来。所以如果你只是维护一个静态树,别用LCT去炫技;但如果你真的处理动态连边删边,LCT就是几乎唯一的大方向。
5. 常见问题与调试实录
这部分我结合自己写这三类数据结构时踩过的坑来聊。真实调试中,错误往往不是“算法设计错了”,而是某个细节没对齐,结果整个程序的形态都失控了。
5.1 问题现象速查表
我把高频问题整理成了表格,排查时直接照着看:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Splay旋转后中序遍历变 | rotate里更新顺序错误、没先pushup(y)再pushup(x) | 打印中序序列对比 |
| Splay区间翻转后序列错位 | 定位时没用sz、没在getkth中pushdown | 检查getkth的递归路线 |
| K-D树查询总返回第一个点 | mn/mx没维护、剪枝条件写成了<= | 打印包围盒与实际点范围 |
| K-D树插入后性能崩 | 只插点没更新mn/mx、树退化 | 检查插入路径的pushup |
| LCT操作后findroot错误 | 缺少makeroot前置、rev标记未下传 | 在findroot中每步都pushdown |
| LCT的cut后树结构乱 | 没判断x是否真是y的直接左孩子 | 打印fa和ch关系 |
5.2 调试三板斧
第一招是暴力对拍。写任何高级数据结构前,先写一个非常朴素的暴力版,比如Splay就用一个vector维护序列,区间翻转就倒序reverse,LCT就每次查询走一遍DFS。然后随机小数据反复生成操作,两个实现逐行对比输出,一旦不一致就缩小到最小用例。这个方法看起来笨,但真的比人眼盯代码有效得多。
第二招是打印树结构。不要只打印数组值,要打印每个节点的fa、左右孩子和rev。Splay错乱时,中序遍历是最直观的验证;LCT错乱时,打印isroot为真的节点集合,看看辅助树是否是若干条链,每条链的中序是否按原树深度排列。
第三招是写最小测试。每次只测一个操作,例如单独测rotate、单独测access、单独测makeroot,不要一上来就跑完整流程。LCT尤其适合这样拆:先验证access后一条实链的对错,再验证makeroot更换根是否正确,最后才整合成link/cut。
5.3 关于“模板背下来就行”的误解
网上有很多LCT的模板,几乎都长一个样子。我不赞成直接背,因为每个人的场景不同,有的要维护最大值,有的要维护异或和,有的要处理边权和点权转换,直接往板子里硬套只会越套越乱。更好的办法是先把最核心的rotate、splay、access、makeroot理解成一个完整故事,再按需求填存储字段。比如边权转点权,通常把每条边的权值挂到较深端点上,然后在access路径查询时忽略一个LCA点,这个逻辑不改核心代码很难讲清楚。
6. 怎么练才有回报
如果之前完全没接触过这几个结构,我的建议是按“Splay → K-D树 → LCT”的顺序走。Splay是区间操作标杆,同时也是LCT的基石,先把中序、sz、rev这一套玩熟。K-D树独立性强,可以作为第二个突破点,它的剪枝逻辑对思维是很好的训练。LCT放到最后,因为它的难点不是某一步,而是把整个Splay代码和虚实链剖分的场景彻底融合。
6.1 边界测试与数据规模
练题时一定要刻意制造几个极端数据。链状树测LCT,因为链会让所有深度差都变成悬念;点全部落在一条竖线上的数据测K-D树,观察会不会退化;全是重复值的序列测Splay,检查getkth是否还能稳定返回正确位置。各类数据规模最好从n=10开始,小数据能手动验证,检验通过后直接上n=100000级别的随机数据看常数和稳定性。
6.2 从模板到工程化的差距
工程里写这些结构,还需要考虑内存池、迭代版消除递归栈溢出、多线程下的懒标记隔离。竞赛代码可以用全局数组,工程代码最好封装成类,把ch、fa、rev这些数组作为成员变量,通过构造函数按需分配。实测下来,输入量特别大的时候,用int代替long long存节点编号能省不少内存,维护点的坐标时再用double;Splay中sz上限超过2^31的极端情况很少,但真要处理百亿级节点,就要换long long。
最后分享一个小技巧:所有包含rev标记的树结构,我习惯在pushdown后顺手把rev[x]清0,然后把当前节点的左右孩子swap,而不是等到访问时再swap。这样做的好处是,打印树结构时看到的拓扑永远是“逻辑真实”的,不容易把自己绕晕。踩过几次坑之后,我越发觉得这些数据结构的难度不在“懂不懂思想”,而在“细节是否一次写对”,把调试习惯建立起来,才能真正从会读模板变成会写结构。