在了解伸展树前,我建议大家先了解一下AVL树,这会有助于理解伸展树的很大一部分,毕竟伸展树也是从AVL上生长出来的。 AVL树:跟我一起动手种一棵生长树吧
现在我们来描述一种相对与AVL树更简单的数据结构,它叫伸展树,它保证从空树开始连续任意M次操作最多花费O(MlogN)时间。虽然这种保证并不排除任一次操作花费的时间为O(N),但是我们关注结果。
伸展树是基于这样的事实:对于二叉查找树来说,每次操作的最坏时间为O(N),并不坏,只要它不时常发生就行。
伸展树的基本想法是:考虑到局部性原理(刚被访问的内容下次可能仍会被访问,查找次数多的内容可能下一次会被访问),为了使整个查找时间更小,被查频率高的那些节点应当经常处于靠近树根的位置。这样,很容易得想到以下这个方案:每次查找节点之后对树进行重构,把被查找的节点搬移到树根,这种自调整形式的二叉查找树就是伸展树。每次对伸展树进行操作后,它均会通过旋转的方法把被访问节点旋转到树根的位置。
实施上面描述的重新构造的一种方法是执行单旋转,这意味着我们将在访问路径上的每一个节点和它们的父节点进行旋转。 作为栗子,考虑在下面的树中对k1进行一次访问之后所发生的情况:
相信大家都看过上面那篇AVL树了,或者至少对AVL树有一定的了解,这里就不过多介绍那些旋转方式。
一次旋转之后:
k1还没到根,继续转:
转,再转:
好,转完了。
可以看到,本来一棵长树变成了近乎平衡的树。 这些旋转的效果是将k1不断推向树根,使得k1的进一步访问很容易(没被再推走之前)。 不过缺点也很明显,原先的带头大哥k3,百分之九十九也是刚刚坐上头把交椅,屁股还没坐热就让k1给踢到小角落去了。
展开的思路类似于前面介绍的旋转的想法,不过在旋转如何实施上我们稍微有一点选择的余地。我们仍然从底部向上沿着访问路径旋转。 令X是在访问路径上的一个非根节点,我们将在这个路径上实施旋转操作。如果X的父节点是根节点,那么我们只需要旋转X和树根。否则,X就有父亲§和祖父(G),存在以上两种情况以及对称的情形要考虑(跟AVL树差不多)。
也就是AVL树里那俩要双旋的。
也就是AVL树里那俩只需要单旋的。
来看个栗子,还是上面那个,k1
展开的第一步是在k1,显然是一个之字型,因此我们用k1,k2,k3执行一次标准的AVL双旋转,得到如下的树:
注意和上面的旋转进行比对。
这一步转完之后,迎接k1的是一个一字型,因此我们用k1,k4,k5来做一次一字型旋转,注意看:
注意甄别这次旋转和之前旋转的不同,更要看清楚和标准AVL单旋的差别。 这一次一字型旋转,其中包含了两次的AVL单旋。
虽然从一些小栗子上很难看出来,但是展开操作不仅讲访问节点移动到根处,而且还把访问路径上的大部分节点深度大致减少一半的效果(某些浅的节点最多向后推两个层次)。
这个删除略微抽象了一点,简而言之,就是:
上面的操作,需要一次自顶向下的一次遍历,而后自底向上的一次遍历。这可以通过备忘录模式来实现(自底向上需要沿途保存节点),不过这需要大量的开销,而且也需要处理许多特殊情况。那么,接下来我们来讲一下如何在初始访问路径上施行一些旋转,结果得到在实践中更快的过程,只用到O(1)的额外空间,但却保持了O(logN)的摊还时间界。
自顶向下伸展操作将伸展树分为三部分:
左树:包含所有已经知道比待查节点 X小的节点。
右树:包含所有已经知道比待查节点 X大的节点。
中树:包含所有其它节点。
在中树自根向下进行节点查找(每次向下比较两个节点),根据查找情况将中树中的节 点移动(此处的移动是指将节点和中树的连接断开,而将节点连接到左或右树的适当位置。)到左树或右树(如有必要则会先对中树进行旋转再进行节点移动)。 初始状态时,左树和右树都为空,而中树为整个原伸展树。随着查找的进行,左树和右树会因节点的逐渐移入变大,中树会因节点的逐渐移出变小。最后查找结束(找到或遇到空 节点)时组合左中右树并是伸展树自顶向下伸展方法的最终结果。
如图:如果旋转时一次单旋转,那么根在Y的子树就将成为中间树的新根,X和子树B连接成为R中最小项的左儿子,X的做儿子逻辑上成为NULL。 特别要注意,为使单旋转情形适用,Y不一定必须是树叶。
对于一字型,看图说话咯:
一切尽在不言中:
等下还有场座谈会,明天,明天一起来动手种一棵伸展树,看看它能否茁壮生长咯。 今天还是先把理论弄明白咯。