和大多数的平衡树一样,splay 本质上也是二叉查找树,只是增加了翻转(rotate)操作来保证复杂度。
借用 OI Wiki 的图片,左旋和右旋的目的是为了尽可能降低平衡树的深度。
图中进行的就是 splay 操作的右旋,可以发现不论怎么旋转,保证整棵树的中序遍历顺序不变是最重要的前提条件,观察图中节点,圈号表示平衡树中的节点,方框框中的表示一颗子树,观察不能发现图中变化的主要有 x,p 和 x 的右子树 B。
所以左旋进行的操作如下:
- 将 \(p\) 的左子树改为 \(x\) 的右子树,同时将 \(x\) 的右子树改为 \(p\)
- 将 \(p\) 原来父亲的左子树改为 \(x\)
- 将 \(x\) 的父亲改为 \(p\) 原来的父亲,\(p\) 的父亲改为 \(x\)
右旋的操作与左旋对称,所以可以将两个操作写成一个函数。
旋转操作在平衡树中并不少见,但是 splay 依靠的是一种特殊的旋转——“双旋”
双旋的方式主要有两种
- zig-zig
如果 \(x,p,g\) 三点共线,那么先旋转 \(p,g\) 之间的边再旋转 \(x,p\) 之间的边。
- zig-zag
如果 \(x,p,g\) 不共线,那么两次旋转都只转 \(x,p\) 之间的边
其余的 splay 操作大多基于二叉查找树本身的性质:
- 一个点的左子树中的值都比这个点的值小
- 一个点的右子树中的值都比这个点的值大
只需在一般的二叉查找树操作的最后加上将操作过的节点转到根上即可,因为先前经过的点后面经过的概率大于先前没经过的点,这也是 splay 看上去每次都要旋转复杂度很高但是均摊复杂度仍然优秀的原因。
几个区别如下:
- 合并操作一般情况下直接将左子树最右边的节点定为根,根据二叉查找树的性质,这个节点一定大于所有的左子树节点并且小于所有的右子树节点,满足根的性质。如果左子树右子树中有一棵是空树直接返回不空的那棵树的根节点,如果左右字数都是空树则返回空树。
- 在删除节点时,splay 先将要删除的节点找出来旋转到根上然后合并两棵子树
- 在查询 \(x\) 的前驱和后继时,splay 先在树中插入 \(x\),然后将 \(x\) 转到根上,这时候左子树最右边的就是 \(x\) 的前驱,而右子树最左边的就是 \(x\) 的后继。