导语
在算法竞赛(ACM/ICPC)中,普通的二叉搜索树、线段树已经无法满足考官的胃口了。如果遇到频繁的区间翻转多维空间的最近点查找,甚至是树的边不断断开又连上的变态场景,你该怎么办?

今天,博主带你一次性拿下算法竞赛界的三座大山:Splay树(伸展树)、K-D Tree(K维树)和 LCT(Link Cut Tree 动态树)

别怕,再难的算法也能拆解成搭积木!建议先收藏,再慢慢啃,这绝对是你能在全网找到的最清晰的通俗解密!


一、Splay树:极其灵活的“区间杂技大师”

很多同学学过Treap或AVL树,为了维持平衡,它们需要复杂的左右旋和高度计算。而 Splay(伸展树)的核心思想非常粗暴且有效:“刚刚被访问过的数据,极有可能再次被访问(局部性原理)。”

所以,Splay 不记录任何优先权或高度,它的唯一法则就是:不管访问了谁,都通过一系列旋转,把它一路“提根”(转到树的根节点)。

1. 核心魔法:双旋(Zig-Zig 与 Zig-Zag)

Splay 的平摊时间复杂度是优雅的 O(log⁡n)O(\log n)O(logn)。但如果只是简单地一层一层单旋,遇到链状树就会退化。Splay 的灵魂在于**“双旋”**。

设当前节点为 xxx,父节点为 fff,祖父节点为 ggg

  • 一字旋 (Zig-Zig)x,f,gx, f, gx,f,g 在一条直线上(比如都是左儿子)。
    核心口诀:先转爹,再转娃! 先旋转 fff,再旋转 xxx。这能极大地减少树的层数。
  • 之字旋 (Zig-Zag)x,f,gx, f, gx,f,g 不在一条直线上(比如一个是左儿子,一个是右儿子)。
    核心口诀:直接连转两次娃! 连续两次旋转 xxx

2. Splay 的杀手锏:区间操作

为什么线段树能做的事,很多还要用 Splay?因为 Splay 特别适合做区间的物理切分与合并

假设我们要提取区间 [L,R][L, R][L,R] 进行操作(比如翻转):

  1. 把排名第 L−1L-1L1 的节点 Splay 到根。
  2. 把排名第 R+1R+1R+1 的节点 Splay 到根的右儿子。
  3. 此时,根的右儿子的左子树,就是完完整整的区间 [L,R][L, R][L,R]
    你可以直接把这棵子树切下来,或者打上 Lazy Tag,简直不要太爽!

** Splay 核心旋转逻辑解析:**

// 核心逻辑:将x旋转到goal的儿子位置
void splay(int x, int goal) {
    if (goal == 0) root = x;
    while (1) {
        int f = t[x].fa, g = t[f].fa;
        if (f == goal) break;
        if (g != goal) {
            // 如果有祖父节点,判断是一字旋还是之字旋
            if (get(x) == get(f)) rotate(f); // 同向(一字旋):先转父节点
            else rotate(x);                  // 异向(之字旋):先转自己
        }
        rotate(x); // 最后转自己
    }
    Update(x);
}

二、K-D Tree:降维打击,多维空间的“导航仪”

现在,题目不在一维的数轴上了,而是给出了平面上的 10510^5105 个坐标点,每次询问距离点 (x,y)(x, y)(x,y) 最近的点是谁。
暴力做是 O(n2)O(n^2)O(n2),直接 TLE 飞起。这时候,K-D Tree (K-Dimensional Tree) 闪亮登场。

1. 核心思想:几何+二分

一维我们用二分法,二维/三维怎么办?轮流切蛋糕!

  • 第一层:按 xxx 坐标找中位数,把平面劈成左右两半;
  • 第二层:按 yyy 坐标找中位数,把平面劈成上下两半;
  • 第三层:再按 xxx 切… 如此交替。

建树后,每个节点不仅是一个点,更代表了平面上的一个矩形区域

2. 剪枝的艺术:最近邻搜索 (NN)

在 K-D Tree 上找最近点,核心在于**“画圆”与“矩形求交”**。

  1. 我们从根节点往下找,先假定一个答案(比如当前距离 rrr)。
  2. 以目标点为圆心,rrr 为半径画一个圆。
  3. 如果目标点在左子树,我们先去左子树搜。
  4. 高能预警(剪枝核心):搜完左子树后,我们要不要去右子树?就看这个圆有没有和右子树代表的“矩形区域”相交! 如果没相交,说明右侧绝对不可能有更近的点,直接放弃右子树(剪枝)!

这种算法能把 O(n)O(n)O(n) 的查询硬生生降到 O(log⁡n)O(\log n)O(logn)O(n)O(\sqrt{n})O(n)级别!

(注:如果频繁插入删除导致树不平衡,通常引入替罪羊树的思想:发现某棵子树太胖了,直接把这棵子树“拍平”,重新建树!)


三、LCT 动态树:数据结构界的“最终Boss”

如果说前面两个只是难,那 LCT (Link Cut Tree) 就是真·天花板
普通的树结构是静态的,但 LCT 要解决的问题是:树的节点在运行过程中,会不断地断开连线(Cut),又不断地重新连线(Link),并且还要查询两点之间的路径信息!

LCT 巧妙地融合了 树链剖分Splay树

1. 实虚交替的“平行宇宙”

LCT 中有两棵树:原树(题目真实的树)和 辅助树(由无数个 Splay 树组成)。

  • 原树的边分两种
    • 实边:每个节点最多只能有一条实边连向儿子。实边连成的路径叫“实链”。
    • 虚边:其余的边。认父不认子(儿子知道父亲是谁,但父亲的 Splay 儿子指针里没有它)。
  • 辅助树的规则
    • 原树中的每一条“实链”,都用一棵 Splay 树来维护。
    • Splay 树的中序遍历结果,严格对应原树中深度从浅到深的节点!

2. LCT 的灵魂函数:access(x)

LCT 所有神级操作的基础,就是 access(x)
它的作用是:在原树中,强行打通一条从根节点到节点 xxx 的实链(高速公路)。

access(x) 的精妙步骤:

  1. 沿着虚边不断向上爬。
  2. 每次把当前节点 Splay 到它所在的辅助树的根。
  3. 强行把它的右儿子(代表深度比它深的节点)换成上一步所在的链(变虚为实)。
  4. 更新信息。

3. 神奇的 makeroot(x)

很多题目没有固定的根,怎么办?makeroot(x) 能把 xxx 变成整棵原树的根!
步骤:

  1. access(x):打通根到 xxx 的路。此时 xxx 在原树的实链底端(深度最深)。
  2. splay(x):把 xxx 转到 Splay 树的根。此时 xxx 没有右儿子。
  3. reverse(x):最骚的一步!给 xxx 打上 Splay 的翻转 Tag。原树中的深度关系瞬间倒转,xxx 变成了深度最浅的,即变成了根!

** LCT 的 Link 和 Cut 操作简直像艺术:**

// 连边操作:在 x 和 y 之间连一条边
void link(int x, int y) {
    makeroot(x);       // 把 x 变成原树的根
    if (findroot(y) != x)  // 如果它们不在同一棵树里
        t[x].fa = y;   // x 认 y 作父(连一条虚边)
}

// 断边操作:断开 x 和 y
void cut(int x, int y) {
    makeroot(x);       // 把 x 变成原树的根
    access(y);         // 打通 x 到 y 的实链
    splay(y);          // 把 y 转到辅助树的根
    // 断绝父子关系
    if (t[y].ls == x && !t[x].rs) {
        t[y].ls = t[x].fa = 0;
        pushup(y);
    }
}

总结

  • Splay:通过无休止的旋转实现自平衡,利用“双旋”保证性能,是动态区间操作的王。
  • K-D Tree:巧妙融合空间分割与二分思想,配合剪枝大法,是多维坐标查询的利器。
  • LCT 动态树:把静态的重链剖分动态化,用 Splay 维护实链,实虚边灵活切换,是动态图/森林连通性问题的终极武器。

当你能手撕这三个数据结构时,恭喜你,你的代码能力、对指针的理解、对递归与分治的掌握,已经超越了 95% 的同龄人!


** 博主有话说:**
这篇文章拆解这三大高级数据结构,画图、码字不易!如果你觉得这篇文章打通了你的任督二脉,求点赞、收藏、评论一键三连!

你有在比赛或面试中被哪些数据结构坑过吗?欢迎在评论区吐槽交流,我们下期见!

更多推荐