高级数据结构-03:伸展树
预备知识
二叉树枚举
给定 个节点,可以通过栈操作序列枚举不同拓扑结构的二叉树。在深度优先遍历或树的动态生成过程中,每一次向左下方深入可视为一次 Push,每一次向父节点或右子树回溯可视为一次 Pop。包含 个节点的二叉树,其生成过程包含 次 Push 和 次 Pop。
不同的 Push 与 Pop 组合对应不同的二叉树形态。针对特定节点数,可以记录每种二叉树形态对应的有效操作序列:

上述序列满足一个基本约束:任意前缀中,Pop 次数不能超过 Push 次数。否则相当于访问空树的父节点,对应非法树结构。带有这种前缀非负约束的序列生成问题,与多种经典组合计数问题同构。
卡塔兰数
网格路径问题
上述栈排列问题可以抽象为网格路径问题:在一个 的二维网格中,寻找从左下角到右上角的最短路径。每一步只能向右(对应 Push)或向上(对应 Pop)。

若不加约束,最短路径数为 ,即在 步中选择 步向右。为了满足前缀非负约束,即任意时刻向上的步数不能超过向右的步数,需要限制路径不能越过主对角线,只能在对角线及以下区域移动。
如路径示意图所示,一条合法的路径可以被等价编码为不同的符号串:

-
字符序列表示:
OCOOOCCOCOCC(其中O代表横向的 Open/Push,C代表纵向的 Close/Pop)。 -
括号嵌套表示:
()((( ))()())。其中左括号(对应向右一步,右括号)对应向上一步。对角线不被穿越,等价于括号字符串中任意前缀的右括号数量不超过左括号数量。
计算合法路径数量时,可以使用反射原理。若一条路径越过主对角线,则它必然与对角线上方一单位的直线 相交。

找到该路径与 的第一个交点,并将交点之后的路径部分关于这条直线对称翻转。翻转前,原路径终点是 ;翻转后,终点变为 。由于从 到 的任意单调路径都必然穿过 ,于是得到一个双射:从 到 的所有非法路径,与从 到 的所有无约束路径一一对应。
到达 的路径数为 或 。因此,合法路径总数等于总路径数减去非法路径数,得到卡塔兰数的减法定义式:
帕斯卡三角形中的中心列分布
卡塔兰数的减法公式 可以从帕斯卡三角形的中央列分布中观察到。中心列数据 减去相邻列数据 ,得到卡塔兰数序列:

| 深度 2n | 中心列项 (n2n) | 相邻列项 (n+12n) | 相减差值 (Cn) |
|---|---|---|---|
| 2 | 2 | 1 | |
| 4 | 6 | 4 | |
| 6 | 20 | 15 | |
| 8 | 70 | 56 | |
| 10 | 252 | 210 |
括号匹配与矩阵乘
除树形枚举外,卡塔兰数还出现在以下问题中:
-
括号匹配问题: 合法安排 对括号时,字符串必须满足开闭括号数量相等,且任意前缀的右括号数量不超过左括号数量。例如,
()()()、(()())、()(())合法,而)())((非法。当 时,存在 种合法序列。 -
矩阵链乘优化: 计算 个矩阵乘积 时,矩阵乘法满足结合律但不满足交换律,不同加括号方式会影响标量乘法总量。这等价于统计给定前序遍历序列 的二叉树能够产生多少种中序遍历排列。设 为 个矩阵乘积的不同加括号方式数量,则有:
-
: 以及 ,共 种。
-
: 共 种。通项满足卡塔兰卷积: (其中 )。
-
卡塔兰数说明二叉树形态数量很大,其中包含高度为 的退化结构,因此需要引入树平衡机制。
AVL 树与 B-树
AVL 树
AVL 树对平衡因子施加约束:任意节点的左右子树高度差绝对值不得超过 1。

对比 时的树形可知,随机生成的 BST 可能出现较深的偏斜分支;AVL 树通过平衡约束保持接近平衡的层级结构,将高度限制在 。
局部失衡调整
在 AVL 树中,插入或删除节点可能破坏高度平衡。按照失衡路径中节点的相对位置,传统重平衡分为四种情况:
- Left Left Case (LL):需要进行一次单次右旋。
- Right Right Case (RR):需要进行一次单次左旋。
- Left Right Case (LR):需要先做一次左旋,再做一次右旋。
- Right Left Case (RL):需要先做一次右旋,再做一次左旋。

这种按情况处理的方式有效,但实现中需要较多条件分支。
3+4 重构
为了简化传统旋转逻辑,可以引入 3+4 重构。该方法通过重新组织局部节点与子树完成平衡调整。
设 为插入或删除后发现的最低失衡节点。考察失衡路径上的祖孙三代节点:(祖父)、(父亲)、(孙子)。由于三者属于同一棵二叉搜索树,它们具有确定的相对大小。按照中序遍历次序,将这三个节点重新命名为 ,使得 。
这三个节点作为内部骨架,向外连接四棵互不相交且可能为空的子树。按照中序遍历次序,将四棵子树命名为 。由于中序遍历保持单调递增,原始树满足:
算法将原先以 为根的子树 替换为新子树 ,构建规则如下:
-
将居中的 提升为新子树根节点,即 。
-
将 作为 的左孩子(),将 作为 的右孩子()。
-
按顺序挂接四棵子树: 的左子树为 (),右子树为 (); 的左子树为 (),右子树为 ()。

通过一次 的局部重组,失衡被消除,且新树的中序遍历仍为 。该过程覆盖传统的 LL、RR、LR、RL 四种情形。
3+4 重构不是在改变这几个关键码的大小关系,而是在保持中序序列完全不变的前提下重新安排局部高度。读这类图时可以先忽略指针方向,只看排序骨架:三个节点必然能排成 ,四棵子树必然能嵌入五个间隔中。只要重构后仍满足 ,二叉搜索树的不变量就没有被破坏。
B-树及其变体
当搜索树规模大到需要存储在磁盘等外存设备中时,二叉树的层级结构会导致过多页面 I/O。B 树通过允许节点拥有多个分支来降低树高。
阶数为 的 B 树允许节点拥有至多 个分支。低阶特例如下:
-
时,即为 2-3-树。各内部节点的分支数只能是 2 或 3,含有的 key 数量为 1 或 2。
-
时,为 2-3-4-树。分支数可能为 2、3 或 4,key 数量为 1、2 或 3。红黑树可以视为一棵等价 2-3-4 树的二叉实现,其中红色节点表示与其父节点在 B 树中处于同一个逻辑节点内,而颜色翻转机制对应 B 树中的节点分裂。
伸展树
AVL 树和红黑树提供了 最坏情况边界,但需要维护额外状态位(平衡因子或颜色),并承担重平衡开销。另一方面,严格平衡结构不直接利用数据访问局部性:刚被访问过的数据及其附近数据,通常更可能再次被访问。

对传统 AVL 树进行连续 次相关查找(),总时间仍为 。伸展树利用局部性进行自适应调整:它不维护显式平衡约束,而规定节点一旦被访问,就将其调整到树根。
逐层单旋
“将目标节点推至根”的具体方式会影响分摊复杂度。早期直观方法是逐层伸展:当节点 被访问后,反复对其与父节点 执行单旋。若 是左孩子,执行 zig(p);若是右孩子,执行 zag(p),直到 到达根。

逐层调整在某些情况下效率较低。考虑一个最坏情况:初始结构是一条向左倾斜的单链(例如插入顺序为 )。此时顺序执行 search(1), search(2), search(3), search(4), search(5):
-
当
search(1)时,1 通过逐层zig被推到树根,但其余节点仍保留较长路径。
-
连续从深层节点查找会产生 次旋转,每一周期的分摊时间可达 。因此,需要引入新的伸展策略。
双层伸展
为改进逐层伸展,规则改为:每次向上处理两层。设 为当前访问节点,,。根据三者拓扑关系,算法执行成对旋转,使 上升两层并成为局部子树根。
情况 1:zig-zag / zag-zig。 如果 是左孩子的右孩子(zig-zag),或者右孩子的左孩子(zag-zig),则 在中序遍历次序中居中。

- 此时算法执行一次底层旋转(例如对 和 ),紧接着再对 和 执行一次旋转。
- 这种调整与连续两次逐层调整效果相同,将 推到祖父位置,并重新分布局部子树。
- 这种情况的调整相当于做了一次 3+4 重构。
情况 2:zig-zig / zag-zag。 这是伸展策略改进逐层单旋最坏行为的关键。如果 是左孩子的左孩子,或者右孩子的右孩子:

-
传统逐层调整会先转 和 。而在伸展树中,必须先转 和 ,然后再转 和 。
-
即先执行
zig(g),再执行zig(p)(或全为 zag)。 -
折叠效应: 按这种顺序旋转后,原本位于同一侧的长链结构会被压缩。由于优先旋转上层祖父节点,访问路径长度会缩短。当对单链底部节点执行
search(1)后,双层调整会重新分布退化链条。访问路径持续被压缩,是伸展操作分摊时间达到 的直观原因。
zig-zig 的旋转顺序是伸展树最容易读错的地方。它不是单纯为了把 提高两层,而是顺手把访问路径上的一段长链压短;如果仍按逐层单旋的顺序操作,目标节点虽然也能到根,但其余节点不会获得足够的折叠收益,后续访问仍可能反复走长路径。
情况 3:zig / zag。 如果 上升到最后只剩父节点 ,且没有祖父 (即 已是整棵树根),则只需一次单旋 zig(r) 或 zag(r) 将 推到根部。该情况在每次伸展中至多出现一次。

(还有一种名为半伸展(Semi-splaying)的变体。在 zig-zig 情况下,旋转一次后 并不继续上升,而是让焦点转移到其父节点,仅把 推进一半深度。这减少了重构的操作量且保持了渐进效率,但实际实现复杂度的增加以及合并分离操作的困难,使得全伸展依然是主流方案 )。
算法实现
为复用二叉搜索树逻辑,伸展树 Splay<T> 由标准二叉搜索树模板 BST<T> 派生:
1 | // Splay 复用 BST 的基本节点与查找框架,但会重写会改变拓扑的操作。 |
splay 实现
splay 方法以 while 循环实现。只要当前节点仍有祖父节点,就反复自下而上执行双层伸展。
1 | template <typename T> |
在 zig-zig 分支中,代码先将 的右子树接到 左侧,将 的右子树接到 左侧,再让 成为 的右孩子, 成为 的右孩子,从而完成同向路径上的双层旋转。
接口重写与 hot 机制
伸展树的查找 search 不是只读操作,它会修改树结构。在 search 内部,先调用基类的 BST::search(e) 定位节点。无论查找成功还是失败,BST 中记录最后访问位置的 _hot 都会用于后续调整。
1 | template <typename T> |
由于每次搜索都会将相关节点移至根,伸展树的插入与删除不必沿用 BST 的叶端操作,而是利用根部的树分裂与树合并。
伸展树里的 search 不是普通意义上的只读查询。成功时,它把命中的节点放到根;失败时,它把最后接近目标值的 _hot 节点放到根。这样一来,插入和删除都可以围绕根节点完成,避免“先一路走到底,再一路修回来”的双重路径成本。
插入重构(Split): 传统插入需要查找到叶子后创建节点。由于 Splay::search() 已集成 splay(),若查找失败,_hot 节点(待插入位置的前驱或后继)已被提升至根部,此时可以在根附近接入新节点。以新节点 为新根,将原树按大小关系从根部分裂为左子树 和右子树 ,再将 与 分别作为 的左右子树。

删除重构(Join): 当 search(e) 命中时,目标节点被上浮到根。释放该节点后,原树分裂为左右两棵子树 和 。随后执行合并:在 中寻找最大值元素 ,该查找同样触发伸展,使 成为 的新根。由于 是 中最大者,它没有右子树,因此可直接将 挂接为 的右孩子。

Split/Join 的安全性仍然来自二叉搜索树的大小边界。插入时,新节点左边的整棵树都小于它,右边的整棵树都大于它;删除时,左树最大节点 被伸展为左树根后没有右孩子,因此把原右树挂到 的右侧不会破坏中序顺序。
复杂度分析
为进行分摊分析,给树中每个节点 赋予权重 (通常为 1),并定义:
- 大小函数 :表示以 为根的整棵子树中所有节点权重的总和。
- 秩函数 :定义为大小的对数,即 。
- 系统总势能 :全树所有节点秩的总和 。
势能 是分摊分析中的辅助函数。对于高度不平衡的退化树,向下移动时 下降较慢,总势能较高;对于平衡树,各节点的 下降较快,总势能较低。一次树操作的分摊成本 定义为实际时间开销 加上势能变化量 。若旋转使树更平衡,势能下降可以抵消部分实际开销;反之则增加分摊成本。
设单次旋转的实际时间为 1。分析每一次 splay step(令旋转后的秩为 ):
- 对于 zig 步骤,由于仅 和 的秩改变,其分摊成本被放缩并绑定为 。
- 对于 zig-zig 步骤,涉及到 三者的变化,通过放缩推导(利用 和 秩的置换以及对数函数的凸性),其分摊成本可绑定为:(注意这里去掉了 )。
将一次完整伸展操作中的所有步骤相加时,中间节点的 项相互抵消,得到:
由于叶子节点秩至少为 0,整棵树根的秩为 ,单次伸展操作的分摊耗时不超过 ,因此伸展树具有 分摊时间复杂度。





