【数据结构】Splay树
Splay(伸展树)是一种灵活多变的高级数据结构,可以很方便的执行各种动态的区间操作。由丹尼尔·斯立特 Daniel Sleator 和罗伯特·恩卓·塔扬 Robert Endre Tarjan 在 1985 年发明。
Splay 是一颗二叉搜索树,它建立在二叉搜索树(BST)之上,当然也是平衡树的一种,下面简单介绍一下 BST 与平衡树:
首先介绍 BST,也就是所有平衡树的开始,他的名字是二叉查找树.
给定一棵二叉树,每一个节点有一个权值,命名为关键码,而 BST 性质就是,对于树中任何一个节点,都满足一下性质:
- 这个节点的关键码不小于它的左子树上,任意一个节点的关键码
- 这个节点的关键码不大于它的右子树上,任意一个节点的关键码
然后我们就可以发现这棵树的中序遍历,就是一个关键码单调递增的节点序列,说的直白点,就是一个排好序的数组(注意是非严格单调递增序列)。
我们知道,在一颗查找二叉树(BST)中,插入过程的均摊时间复杂度为O(logn),但是在一些特殊情况,BST 树会退化成一条链,导致时间复杂度从O(logn)退化到O(n),这是不可以的,所以产生了一种数据结构——平衡树
。
平衡树最主要的功能就是旋转
,通过等价的旋转,可以最优化 BST 的深度,例如下面的两棵 BST 树的对比,他们实质上是等价的,但是第一颗不平衡,操作时会造成不必要的时间浪费。
当然除此之外,平衡树还有以下功能(来自平衡树 - 维基百科):
- 旋转(rotate):几乎所有平衡树的操作都基于树旋转操作(也有部分基于重构,如替罪羊树),通过旋转操作可以使得树趋于平衡。对一棵查找树(searchtree)进行查询、新增、删除等动作,所花的时间与树的高度成比例,并不与树的容量成比例。如果可以让树维持平衡,也就是让高度维持在O(logn)左右,就可以在O(logn)的复杂度内完成各种基本操作。
- 插入(insert):在树中插入一个新值。
- 删除(delete):在树中删除一个值。
- 查询前驱(predecessor):前驱定义为小于x,且最大的数。
- 查询后继(successor):后继定义为大于x,且最小的数。 如果维护同时节点大小(size),还可以支持以下操作:
- 查询排名(rank):排名定义为比x小的数的个数加一。
- 查询第k大:即排名为k的数。
那么实际上 Splay 树(伸展树)的原理是基于类似程序局部性原理的假设:一个节点在一次被访问后,这个节点很可能不久再次被访问。Splay 树的做法就是在每次一个节点被访问后,我们就把它推到树根的位置。正像程序局部性原理的实际效率被广泛证明一样,Splay 树在实际的搜索效率上也是非常高效的。尽管存在最坏情况下单次操作会花费O(n)的时间,但是这种情况并不是经常发生,而实际证明伸展树能够保证m次连续操作最多花费O(mlogn)的时间。
Splay#
所有平衡树的思路都是维护一颗 BST 树的平衡状态
,也就是不改变中序遍历结果的前提之下,尽可能减少 Splay 树的深度。
前面我们说过了,Splay 的主要思想是:对于查找频率较高的节点,使其处于离根节点相对较近的节点。当然我们统计每一个点的查找次数是不现实的,我们可以理解每一次被查到的节点频率比较高,说白了就是你把每次查找到的点搬到根节点去,这样就可以保证查找的效率。
每次移动的过程就是旋转的过程(rotate),在 Splay 中有两种旋转,分别叫做单旋
与双旋
,我们将他们设计为rotate函数与splay函数。在设计之前,我们需要先定义一颗 BST 树,如下:
因为 OI 多是面向过程设计,下列写法并不在生产环境适用,若要应用于生产环境中,请使用 Class+指针面向对象设计 BST 树。
设计旋转#
单旋(rotate)#
首先考虑一下,我们要把一个点挪到根,那我们首先要知道怎么让一个点挪到它的父节点。单旋函数就是这个功能。
情况一#
考虑下面的情况,我们要把节点 3 移动到它的父亲节点 2 之上(字母节点代表一颗子树):
这时候如果我们让 X 成为 Y 的父亲,只会影响到 3 个点的关系:
Y 与 3,3 与 2,3 与 1 根据二叉排序树的性质 Y 会成为 2 的左儿子 2 会成为 3 的右儿子 3 会成为 1 的儿子(与原来 2 与 1 的情况相同)
经过变换之后,大概是这样:
情况二#
那么反过来,我们把这个时候的节点 2 移到 3 节点之上,与第一个图是相同的,这就是第二种情况。
首先我们写一个get函数,以获取此节点是其父亲的哪一个儿子:
我们假设是从 u->v,设计函数rotate如下:
观察上图,我们发现节点 3 的 Y 子树的父亲更改了,可以发现下面两个规律:
- Y 子树的位置与 3 在 2 中的位置相反
- Y 子树在 2 的目标位置与 3 在 2 中的位置相同
那么我们先获取 Y 子树的位置,然后再把这几个要改变的点相互连接就好了:
双旋(splay)#
splay(u,v)是实现把 u 节点直接搬到 v 节点。
最简单的办法,对于 u 这个节点,每次单旋直到 v。
但是,众所周知出题老师拥有极强的卡时能力,可能会构造数据把单旋卡成n2,具体原因我也不清楚啦,反正不要这样用就好了,下面我们来写splay函数吧~
OI-Wiki 把splay分成了六种情况啊!六种!太麻烦了,实际上就三种啦。
情况一#
我们的目标节点 v 就是 u 的父亲,那么就直接rotate吧!
情况二#
三个点连成了一条线……
这时候先把 2 旋转上去,再把 3 旋转上去就好了
情况三#
三个点歪七扭八,不知道是什么东西……
这时候把 3 旋转两次就好啦
全部代码如下:
功能函数#
Splay 最伟大的两个函数写好了,我们就要实现他作为一颗 BST 树的功能了,一个一个来,我们慢慢讲,因为下面的内容可能还需要修改上面的两个旋转函数。
清空节点#
维护 Size#
我们每一次旋转都会改变节点的 Size 值,因此要在rotate函数的最后添加这两句:
最后完成的rotate函数如下:
插入操作#
插入操作是一个比较复杂的过程,具体步骤如下(假设插入的值为 k):
- 如果树空了,则直接插入根并退出。
- 如果当前节点的权值等于 k 则增加当前节点的大小并更新节点和父亲的信息,将当前节点进行 Splay 操作。
- 否则按照二叉查找树的性质向下找,找到空节点就插入即可(请不要忘记 Splay 操作)。
我们定义一个新变量root
,来代表根节点的位置。
实现代码如下:
同时还要注意每一次 Splay 是把当前节点移动到根,所以要在splay最后更新root
。
查询 k 的排名#
根据二叉查找树的定义和性质,显然可以按照以下步骤查询 k 的排名:
- 如果 k 比当前节点的权值小,向其左子树查找。
- 如果 k 比当前节点的权值大,将答案加上左子树size和当前节点cnt的大小,向其右子树查找。
- 如果 k 与当前节点的权值相同,将答案加1并返回。
实现代码如下:
查询排名 k 的数#
设 k 为剩余排名,具体步骤如下:
- 如果左子树非空且剩余排名 k 不大于左子树的大小 size,那么向左子树查找。
- 否则将 k 减去左子树的和根的大小。如果此时 k 的值小于等于 0,则返回根节点的权值,否则继续向右子树查找。
实现代码如下:
查询前驱#
前驱定义为小于 x 的最大的数,那么查询前驱可以转化为:将 x 插入(此时 x 已经在根的位置了),前驱即为 x 的左子树中最右边的节点,最后将 x 删除即可。
实现代码如下:
查询后继#
后继定义为大于 x 的最小的数,查询方法和前驱类似:x 的右子树中最左边的节点。
实现代码如下:
删除操作#
删除操作也是一个比较复杂的操作,具体步骤如下:
首先将 x 旋转到根的位置。
- 如果 (有不止一个 x),那么将 tree[x].cnt 减 1 并退出。
- 否则,合并它的左右两棵子树即可。
最终 Splay 模板#