伸展树(Splay)理论-笔记

简介

前置知识:

  • 树->二叉搜索树->平衡树->AVL树->Treap->伸展树
  • 左单(双)旋、右单(双)旋、左右双旋

伸展树(Splay Tree) 是平衡二叉查找树的一种,具有二叉查找树的所有性质;伸展树又称Self-Adjusting Search Trees,即自调整的二叉搜索树 。与普通的二叉查找树相比,其维护更少的节点额外信息,空间性能更优且编程复杂度更低。它由Daniel Sleator 和 Robert Tarjan创造。在伸展树上的一般操作都基于伸展操作

需要伸展树的原因:

各种查找树都有各自的优缺点以及适用范围。例如,对于一棵具有n个节点的平衡树,虽然其查找的时间复杂度不超过O(log n),但是如果访问模式不均匀(询问点不随机),平衡树的效率就会受影响,此时我们需要额外的空间记录平衡信息,同时也加大了编程复杂度。
这些查找树的设计目标都是减少最坏情况下单次操作时间,但是如果我们的目标是使一系列查找操作的总时间最少,那我们更好的目标就是降低操作的摊平时间。此处的摊平时间指的是在一系列最坏情况下的操作序列中单次操作的平均时间。而伸展树就是为实现这一目标而设计的。

和其他平衡树或具有明确限制的数据结构相比,伸展树的优点:

  • 从摊平角度讲,它们忽略常量因子,因此绝对不会比有明确限制的数据结构差。而且它们可以依据适用情况进行调整,于是在使用模式不均匀的情况下更加有效。
  • 由于无需存储限制信息,它们所需空间更小,实现起来也更加简洁。
  • 它们的查找和更新算法概念简单,易于实现。

潜在的两个缺点:

  • 它们需要更多的局部调整,尤其是在查找期间。(而其他有明确限制的查找树仅需要在更新期间进行调整,查找期间则不用)
  • 一系列查找操作中的某一个可能会耗时较长。这在实际应用中需要作为是否选用的参考依据。

什么是伸展树:

假设要对一个二叉搜索树执行一系列查找操作,为了使得总时间最小,那么被查找频率高的节点自然就要放在靠近根的位置。于是想到一个简单的设计方案,在每次查找之后对树进行重构,把被查找的条目搬到离树根近一点的位置。 顺着这个思路,splay诞生了。
splay是一种自调整形式的二叉查找树,它会沿着从某个节点到树根之间的路径,通过一系列旋转把该节点搬移到树根,同时使得该条路径上的点尽量靠近树根。

构建方法

两种可能的重构方法:

  • 单旋:在查找完位于节点x中的条目之后,旋转链接x和其父节点的边。(除非x是根)
  • 搬移至树根:在查找完位于节点x中的条目后,旋转链接x和其父节点的边,然后重复这个操作直至x成为树根。

注:
上述两个方法是不一样的,一种是查找x后仅交换一次,另一个是将被查询的节点x旋转至树根。
旋转示意图:
其中三角形代表子树,而图示的树也可能是一棵更大的树的子树。
伸展树(Splay)理论-笔记_第1张图片
上述两种重构方法的示意图:
其中被查询节点是a。
伸展树(Splay)理论-笔记_第2张图片
注:

  • 若x为p(x)的左孩子,交换x和p(x)的位置,称为右旋。
  • 若x为p(x)的右孩子,交换x和p(x)的位置,称为左旋。

不幸的是,上述两个重构方法在摊分效率方面表现的都不太好。 如果有很长的随机查询序列,那么上述两种重构方法的查询时间复杂度是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。

伸展树(Splay)理论-笔记_第3张图片

分析:
对于深度为d的节点x做伸展操作,需要花费的时间和d成比例,即和查找x的时间成比例。伸展操作并不仅仅是将x移动到根节点,而是将查找路径上的节点的深度都粗略减少了一半。 如此一来使得伸展树的效率非同凡响。效率证明略,不过我们依然可以根据如下1种常规情况(Figures 4)及2种极端情况(Figures 5)下进行splaying操作后 树的构造来略窥一二。

伸展树(Splay)理论-笔记_第4张图片

伸展树(Splay)理论-笔记_第5张图片

伸展树上的更新操作

使用伸展树,我们可以继承标准的二叉搜索树的操作。考虑如下几个操作:
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,由此可见我们可以通过已实现的操作来搭建未实现的操作,这样更加简洁且降低了编程难度。

伸展树(Splay)理论-笔记_第6张图片
伸展树(Splay)理论-笔记_第7张图片
另外,对于insert和delete操作还有另一种稍加优化的方法,这里省略,复杂度分析同样省略,具体可以参照原文[1]。

参考文献:

[1] Daniel D.Sleator , Robert Endre Tarjan, A data structure for dynamic trees.

你可能感兴趣的:(数据结构)