前置知识:
伸展树(Splay Tree) 是平衡二叉查找树的一种,具有二叉查找树的所有性质;伸展树又称Self-Adjusting Search Trees,即自调整的二叉搜索树 。与普通的二叉查找树相比,其维护更少的节点额外信息,空间性能更优且编程复杂度更低。它由Daniel Sleator 和 Robert Tarjan创造。在伸展树上的一般操作都基于伸展操作。
各种查找树都有各自的优缺点以及适用范围。例如,对于一棵具有n个节点的平衡树,虽然其查找的时间复杂度不超过O(log n),但是如果访问模式不均匀(询问点不随机),平衡树的效率就会受影响,此时我们需要额外的空间记录平衡信息,同时也加大了编程复杂度。
这些查找树的设计目标都是减少最坏情况下单次操作时间,但是如果我们的目标是使一系列查找操作的总时间最少,那我们更好的目标就是降低操作的摊平时间。此处的摊平时间指的是在一系列最坏情况下的操作序列中单次操作的平均时间。而伸展树就是为实现这一目标而设计的。
假设要对一个二叉搜索树执行一系列查找操作,为了使得总时间最小,那么被查找频率高的节点自然就要放在靠近根的位置。于是想到一个简单的设计方案,在每次查找之后对树进行重构,把被查找的条目搬到离树根近一点的位置。 顺着这个思路,splay诞生了。
splay是一种自调整形式的二叉查找树,它会沿着从某个节点到树根之间的路径,通过一系列旋转把该节点搬移到树根,同时使得该条路径上的点尽量靠近树根。
两种可能的重构方法:
注:
上述两个方法是不一样的,一种是查找x后仅交换一次,另一个是将被查询的节点x旋转至树根。
旋转示意图:
其中三角形代表子树,而图示的树也可能是一棵更大的树的子树。
上述两种重构方法的示意图:
其中被查询节点是a。
注:
不幸的是,上述两个重构方法在摊分效率方面表现的都不太好。 如果有很长的随机查询序列,那么上述两种重构方法的查询时间复杂度是O(N)。显然我们需要一种性能更强大的重构方法。
我们采取的重构方法叫做splaying,即伸展,它和上述“搬移至树根”相似。它们都是沿着查询路径 做旋转倒置,将被查询的节点通过此方法移动到根节点。不同的是,伸展操作是按照该结构上的查找顺序成对旋转 。对于伸展树中的一个节点x,我们重复如下操作直到 x 成为树的根节点。
Splaying Step
Case 1(zig): 如果p(x)是x的父节点,并且p(x)是树的根,那么旋转x和p(x)间的边。(此为最终步骤)
Case 2(zig-zig): 如果p(x)不是根节点,并且x和p(x)都是左儿子或者右儿子,那么旋转链接p(x)和g(x)的边,然后旋转链接x和p(x)的边。
Case 3(zig-zag): 如果p(x)不是根,并且x是左儿子 p(x)是右儿子,或者相反,那么旋转链接x和p(x)的边,然后旋转链接x和p(x)的新边。
注:
其中我们假设p(x)为x的父节点,g(x)为p(x)的父节点。下图三个操作a、b、c分别对应上述的zig、zig-zig、zig-zag。
分析:
对于深度为d的节点x做伸展操作,需要花费的时间和d成比例,即和查找x的时间成比例。伸展操作并不仅仅是将x移动到根节点,而是将查找路径上的节点的深度都粗略减少了一半。 如此一来使得伸展树的效率非同凡响。效率证明略,不过我们依然可以根据如下1种常规情况(Figures 4)及2种极端情况(Figures 5)下进行splaying操作后 树的构造来略窥一二。
使用伸展树,我们可以继承标准的二叉搜索树的操作。考虑如下几个操作:
access(i,t): 如果i在树t中,返回一个指向i位置的指针;否则,返回一个指向空节点的指针。
insert(i,t): 向树t中插入一个元素i,假设此前i不存在。
delete(i,t): 从树t中删除元素i,假设i存在。
join(t1,t2): 将树t1和t2合并成一棵新的树并返回新树的树根。该操作假设t1中所有元素都小于t2中的元素。合并后删除t1和t2。
split(i,t): 将t分成两棵子树t1和t2,所有小于等于i的元素在t1,所有大于i的元素在t2。然后删除树t。
access(i,t)实现方法:
我们从树t的根开始,按照二叉搜索树的查找方式向下查找;如果当前节点x包含待查目标i,则算法完成,我们对x进行splaying操作并返回指向x的指针。如果搜索到达了空节点(即待查目标不存在),那么我们对搜索路径的最后一个节点进行splaying操作,并返回一个空指针。如果树为空,则取消splaying操作。(Figures 6)
join(t1,t2)实现方法:
我们首先在t1中执行一次access(i,t1),其中i是t1中最大的元素。于是在access后,t1的根包含i,并且它没有右儿子。于是我们可以将t2作为t1的右儿子连接,并返回处理后的树,join操作完成。(Figures 7)
split(i,t)实现方法:
我们首先执行一次access(i,t),然后断开t和左右儿子的链接,左子树作为t1,右子树作为t2,并且考虑根是否包含等于i的元素。返回t1或t2,split操作完成。(Figures 7)
insert(i,t)实现方法
我们先对t进行一次split(i,t),得到了两个子树t1和t2,这时我们把i作为一个新的根节点,t1作为i的左子树,t2作为其右子树。(Figures 8)
delete(i,t)实现方法
我们先执行一次access(i,t),此时得到根节点t,这个时候对t的两个子树t1和t2执行join(t1,t2),就完成了删除i节点。(Figures 8)
小结:
通过上述操作的实现方法,我们可以发现,实现join和split时用到了access,实现insert和delete时我们又用到了split和join,由此可见我们可以通过已实现的操作来搭建未实现的操作,这样更加简洁且降低了编程难度。
另外,对于insert和delete操作还有另一种稍加优化的方法,这里省略,复杂度分析同样省略,具体可以参照原文[1]。
参考文献:
[1] Daniel D.Sleator , Robert Endre Tarjan, A data structure for dynamic trees.