赞
踩
曾经介绍过AVL树,这是一种典型适度平衡的二叉搜索树。你应该还记得,为此我们需要为其中的每一个节点都定义并且引入一个名为平衡因子的指标,并且要求其中所有节点的平衡因子的数值必须介于-1到+1之间。尽管相对于理想平衡而言,这种形式的适度平衡,已经在条件上略有放松,但依然显得有些过于苛刻。如果我们注意到,我们还需要在AVL树的动态调整过程中保持这种特性,这种苛刻就更是显而易见了。
如果比喻作人,AVL树就犹如那种,时时小心处处谨慎的类型,那么能否成为那类更为潇洒的人呢?也就是说,我们可否秉承一种更为宽松的准则,同时又从长远,从整体来看,依然不失某种意义的平衡性呢?答案是肯定的,答案就是我们这节的主角——伸展树。
实际上,引入伸展树的最初动机非常的基本和原始。具体来说,也就是试图利用所谓的局部性,那么什么是局部性呢?
所谓局部性是实际的计算机环境中普遍存在的一种规律或现象。具体来说,也就是刚被访问过的数据极有可能很快的被再次访问到。
在实际的生活中,你应该也有类似的经历。比如,你有可能在机场上偶遇某位时隔五年甚至十年的老朋友,但在紧接着下来的第二天,你们或许可能又会在另一个场合,比如某个会议上在次碰面。
这样一种现象或者规律在信息处理的过程中是普遍存在屡见不鲜的。比如,我们这里讨论的BST就是一个典型的例子,对于BST来说,这一规律可以具体描述为:我们每次刚刚访问过的某一个节点都有可能很快的会再次被我们访问到,或者推而广之,我么接下来访问的那个节点即便不是你刚访问过的那个节点,也不会离它太远,所谓的虽不中,亦不远矣。
通过此前的学习,我们应该知道:对于AVL树而言,每一次查找所需的时间都是logn,因此任意的连续m次查找,所需要的累计时间无非就是O(m*logn),这无可厚非。
那么进一步的,如果我们对数据的访问的确具有上述的局部性,那么我们对数据集的访问效率能否做到更高呢?
不妨来考察一个简单的实例,也就是典型的线性结构——列表。我们知道在列表结构中所有元素都按照它们之间的逻辑关系,可以排列成一个线性的序列,相邻的元素通过引用确立前驱和后继关系。
我们知道在这样的一个结构中对任何一个元素的访问效率将主要取决于它在这个序列中所具有的秩。
具体来说,秩越小,也就是越靠前的元素,访问的效率越高。秩越大,也就是越靠后的元素,访问的效率越低。
如果对于各元素的访问是完全理想随机的,我们恐怕只得如此,而不能奢望有什么实质的改进。
然而如我们刚才所说的,如果对这个集合的元素的访问具有局部性,甚至有极强的局部性,我们就可以通过简明的策略来实现对整个数据集合的更高效访问。比如一种行之有效的办法是一旦有某个元素刚刚接受访问,我们就立即将它移动到这个序列的最前端。这种技巧背后的策略不难理解。
因为根据局部性,我们接下来将要访问的元素很可能就是我们刚刚访问的那个元素,而这个元素就在此前刚刚被送到这个序列的最前端,而对于这样一个最前端的元素而言,我们的访问是唾手可得最为便捷的。
从整个数据结构的生命周期而言,这样一个列表结构,即便最初是完全随机分布的,那么在经过足够长时间的使用之后,在某一段时间内,被集中访问的那些元素都会不约而同的集中到这个列表的前端去。我们已经知道,这个区域的访问效率是相应更高的。因此我们就可以在一个足够长的时间跨度之内获得比此前更高的访问效率。
好了,现在我们可以回到我们的二叉查找树,为了便于对比,我们不妨将BST的画法旋转90度,具体来说,也就是将树的顶部和树的底部分别与列表的头部与尾部对应起来。这种对应是有道理的,因为在BST中位于顶部的元素相对于位于其他位置,尤其是底部的元素而言访问效率要高得多。因此如果我们希望借助局部性对BST的访问效率做进一步的优化,就不妨参照列表的这种技巧,将在某一段时间内,经常要访问到的元素,通过某种方式尽可能的移送到更加接近树根的位置,也就是等效的说,要尽可能的降低他们的深度。
那么这样一种构思是否真正的可行呢?如果可行的话,具体的方法又是如何的呢?
根据刚才的分析,下一个将被访问的节点很有可能就是刚被访问的节点,因此为了提高下一次访问操作的效率,一种最直接的办法莫过于将刚被访问的那个节点直接移送到根节点的位置。因此我们就得到了一个简明的策略。
也就是说,任何一个节点,一旦被访问,我们都应随即将它转移至树根的位置。
为了实现这样一个目标,我们所能够借助的手段,依然无非是此前所介绍过的等价变换(zig zag)。
具体来说,就是zig和zag变换,如果节点V在当前这一层是左孩子,我们就通过对它的父亲做一次zig旋转,使得二者在高度上彼此互换。对称的,如果节点V当前是右孩子,就对它的父节点P做一次zag旋转,同样地,经过这样的一个调整之后,节点V和它的父亲将在高度上互换位置。
总而言之,无论是经过一次zig旋转还是zag旋转,对应的节点V都可以在高度上上升一层。
既然如此,我们不妨反复的使用这一技巧,使V的高度逐层上升,知道最终抵达树根。
来看具体的实例,这是一棵BST。
假设我们要访问333,那么按照刚才的策略,我们需要在此后通过一系列的zig和zag旋转,将它转移到树根的位置。
我们看看具体的过程
概括而言,对某个特定节点的伸展调整过程无非就是,自下而上,逐渐做单选调整。
非常有意思的是,如果借用《蜗牛》那首歌中一句歌词来形容这个过程是再贴切不过的了,在这首歌里,我们会唱到,我要一步一步往上爬,描述的难道不正是这样一个过程嘛。
按照刚才所设定的策略,也就是任何节点一经访问,就随即被推送至树根,我们已经可以得到某种意义上的伸展树。
然而不幸的是,尽管蜗牛那首歌的旋律和歌词都非常优美,但是就效率而言,那句一步一步向上爬所暗示的策略,并非是最佳的选择,原因在于它有最坏情况。
这棵树有do,re,mi,fa,so,la,si类似于七个音符所构成的一个逐级上升的音阶。请注意,这棵树当前的形状可以形容为是汉字中的一撇。接下来,不妨假设作为一个周期,我们就按照do,re,mi,fa,so,la,si的次序来访问这个树中的各个节点。
刚才那样的访问周期,可以类似于此的一系列快照来描述
可以看到,如果这棵树的规模是n的话,那么第一个节点所需要的访问成本应该大致为n,第二个节点呢,大致为n-1,而第三个呢,是n-2,以及n-3,以及n-4,以及最终的1。
概括而言,整个周期中各自访问所对应的计算成本是按算术级数变化的,整个周期的长度为n,因此根据我们在第一章已掌握的技巧,可以推断出,整个周期内所需要的计算成本累计因该是
n
2
n^2
n2量级。如果将这个计算成本分摊到整个周期内的n次操作,我们就会发现,每次操作的分摊成本居然高达Ω(n)。
这个量不仅与AVL树的logn相去甚远,而且反过来不难发现,它已经退化成了最原始的线性序列。比如说,list或者vector的水平,因此,这样一种效率以及在背后决定这一效率的伸展策略,都是不能令我们满意的,然而好消息是问题的根源并不在于伸展本身,而是在于我们没有把它运用好。
那么我们又应该如何在原有的伸展策略基础上进行改进呢?我们马上就可以看到,从量上讲,这种改进并不大,只需要做一处非常非常微妙的改进。这就犹如画龙点睛那个故事一样,作为一条龙,我们目前的伸展策略实际上已经相当完备,它所缺乏的只是能够体现其灵魂的那样一只眼睛,没错,不是一双眼睛,而仅仅是一只。
为伸展树这条龙点上那只传神之眼的是著名计算机科学家Tarjan。
他的这一点睛之笔可以概括为不再是逐层的去进行伸展,而是将它改变为两层两层的进行。
具体来说,我们需要反复考察,当前正在伸展的节点V、它的父亲p以及祖父g,并且根据这祖孙三代的相对位置,经过至多两次旋转,使得V能够一次性的上升两层。当然,他们可能的相对位置无非上图四种,也就是zig-zig,zag-zag,以及zig-zag和zag-zig。
我们先来看所谓之字形的情况,也就是zig-zag或者是zag-zig。
比如上图(左一)就是一种,具体来说,节点V是左孩子,而他的父亲p却是右孩子。此时,我们的改进策略是:
可以看到,最终的效果是节点v抵达了此前祖父的高度。因此,整个过程的实质调整效果可以理解为是V上升了两层。
然而经过仔细观察,细心的你或许会发现,这样一种调整方法并没有什么稀罕之处,因为它与我们此前所介绍的AVL树的双旋调整是完全等效的。一个zig再加上一个zag,无论是过程还是最终的结果。
而且进一步的对比你会发现,这样一个调整过程,实际上与刚才的逐层调整,也没有任何实质的区别。
首先对父节点做旋转,进而对祖父节点进行旋转,难道这不就是逐层伸展的另一种等效形式嘛,换汤不换药。
如果你能够观察到这些现象,那非常好,因为这的确就是事实。那条龙已经具有一只眼睛,而真正的差别在于另一只眼。
实际上,那另一只神奇的眼睛只有在zig-zig或zag-zag的情况下才会发光。
不妨只考虑其中一种,比如zig-zig情况,也就是节点v和他的父亲p都是左孩子的情况。在这里为了便于对比,不妨将这祖孙三代逐层伸展调整结果先画出来,也就是v首先经过父节点p的一次zig旋转上升一层,进而再通过祖父节点g的一次zig旋转再上升一层。我们已经对此非常熟悉,整个过程平淡无奇。我们刚才也讲过,它会导致最坏情况。
我们再来看看,Tarjan所点出的那只眼睛,或者说他所建议的调整方法。这里关键中的关键是我们需要首先越级,从祖父而不是父节点来开始旋转。
将新的这种调整方法的结果与此前的逐层调整方法的结果做一对比,坦诚的讲第一次看到Tarjan为这条龙所点上的这只眼睛的时候,我并没有察觉到它有什么与众不同之处,现在你,或许也是这样,没错,各种的奥妙的确不易察觉。因为从效果来看,二者无非都是将v这个节点提升了两层而已,然而他们毕竟在局部拓扑结构上还是有微妙的差异,更重要的是,这种局部的微妙差异,将导致全局的不同,而那种不同将是根本性的,颠覆性的。
为了切实的感受和领会这只眼睛的神奇之处,我们不妨来看这样一个实例。
依然是汉字中的一撇,只不过为了更好的体现效果,我们这里取了更长的一撇。接下来我们不妨恶意的来访问其中最深的那个节点,因为这样的话,所消耗的时间将会最多。
- 那么按照Tarjan建议,此时我们不仅要关心节点1的父亲,也就是2,同时也要关注它的祖父,也就是3,我们的第一次旋转,应该在祖父而不是父亲的位置上进行,因此,针对这种情况,我们首先对节点3做一次顺时针的zig。
- 接下来才轮到对原先的父亲也就是2来做一次zig,从而形成这样一种局面。v还是v,只不过上升了两层。
- 节点v,也就是001,拥有了新的一个父亲和新的一个祖父。
继续采用Tarjan建议的方法,具体来说,要对新的祖父也就是5做一次zig旋转。
然后再对新的这个父亲4做一次zig旋转。
- 那么接下来继续采用Tarjan的建议,将会通过怎样的一系列双层调整,最终使目标节点1,伸展到树根呢?连续的看下这个过程。 一次双层调整
又一次双层调整
以及再一次
再一次
以及最终再一次,来看下最终这棵树的全貌。
看到效果了吗?不出意外,节点1被伸展到了树根,然而这只是最基本的一个任务。
我们注意到,作为这样一个调整过程的副产品,整棵树的树高已经有了本质的变化。
你应该记得,我们此前针对逐层伸展所举的那个最坏的例子。没错,那个例子之所以很坏,是因为每次尽管它能够将目标节点调整到树根,但是整棵树的高度却会不得不按照算数级数,亦步亦趋的小步的变化。于是呢?使得恶意者每次都可以去试图访问它最深的那个节点,从而导致累计的平方量级的时间复杂度,以及分摊意义上的线性时间。
而现在呢?我们至少可以看出那种恶意的方法将会失效,因为我们这棵树的形态得到了有效的优化。
具体来说,树的高度大致缩减为原先的一半,接下来如果我们继续恶意的试图去访问它的新的这个最低点,也就是003,我们就会发现,它会继续的优化调整。来看下,调整的过程以及结果。
可以看到,树的高度在此前的基础上又进而缩减了一半。
为了更好的看到树高的变化效果,我们不妨来看一棵更大的伸展树。
这是一棵由31个节点所构成的高度为30的伸展树。 我们继续试图恶意的来访问其中最深的也是访问成本最高的那个节点1。请留意在访问这个节点之后,对它进行的双层调整过程。尤其是观察,在每一局部,按照Tarjan所建议的方式进行调整之后,对于这棵树的整体所产生的效果。我们来看一下这棵调整之后伸展树的拓扑结构。
不出我们的意料,全树的高度大致也缩减了一半。接下来如果我们继续试图恶意的访问其中最深的那个节点,那么在访问完这个节点之后,将同样的通过一系列的双层伸展,将这个节点推送至树根,而且最重要的一个特性是,这棵树的高度会因此继续缩减一半。
通过刚才的那个实例不难发现,Tarjan所建议的这种新方法,具有某种意义上的路径折叠效果。
具体来说,包括最坏节点在内的任何一个节点,一旦经过访问,再经过此后的双层调整之后,这个节点所对应的那条路径长度就会随即折减一半。
我们甚至可以说,这种效果具有某种意义上的智能。
既然在一棵BST中,我们非常忌讳很深的节点,那么这种折叠效果,自然就会具有对坏节点的修复作用。这就犹如含羞草那样,一旦它感受到威胁,就会通过迅速的收缩,将自己的弱点隐藏起来。
因此在采用Tarjan所建议的这种新的策略之后,刚才所举的那种最坏情况,将不至持续的发生。实际上可以严格的证明,按照新的策略,就分摊意义而言,单趟伸展操作所需要的时间都不会超过logn。
也就是说,我们现在不仅足以应对此前所涉及的那种最坏情况,而且也不会有任何其他的最坏情况,这是一个最好不过的消息了。
当然从完整性角度来看,还有一个细微之处需要补充。也就是如果v的深度是奇数而不是偶数,那么在最终必然会发生一种情况,也就是v只有父亲而没有祖父。此时节点v的父亲就应该是全树的根。
我们说这个问题并不严重,因为这种情况只可能在整个伸展的过程中出现一次,而且只会出现在最后。如果真出这种情况,我们就可以视具体的形态,也就是v此时究竟是左孩子还是右孩子相应的做一次zig或者是zag旋转。
既然这种情况在整个伸展的过程中只会出现一次,因此从渐进的意义而言并不会实质的影响整个调整过程的复杂度。
至此,我们应该能够理解Tarjan所建议的这种双层调整的策略不仅是完备的而且是行之有效的,那么这样一种双层调整的策略,又当如何具体实现呢?
这里再次采用模板类的形式给出伸展树的接口定义。可以看到,作为BBST的又一变种,它自然可以在我们此前已经设计并实现的BST类的基础上通过派生直接得到。
具体的与AVL树一样,我们也需要重写其中的insert和remove这样两个动态的操作接口。而与AVL树不同的是,在这里我们还需要重写search接口。
回顾一下,在其他的众多变种中,search接口都可以视作是静态的,也就是说,它并不会导致树中节点之间拓扑连接关系的变化。然而在伸展树中,正如我们此前已经看到的,这是个例外。即便是查找操作,也会引起全树的结构变化,因此,这个接口在这里也需要重写。
然而正如我们马上就要看到的,这个接口对于伸展树而言举足轻重。这个接口内部的实质功能无非是完成伸展,因此在这里我们也设计并提供了一个保护类型的splay接口,用以实现这个功能。
这个居于核心位置的伸展功能,大致可以实现如下。
其原理如此前我们所介绍的。
那么这里所分出的4种情况,又当如何具体的分别应对呢?
我们不妨来看其中的一种,也就是zig-zig的情况。
你应该记得,在这种情况下,v以及p都是左孩子。我们也知道,按照Tarjan的策略,应该就局部这棵子树(上左图)调整为这个样子(上右图)。具体来说,也就是g将成为p的右孩子,而p要成为v的右孩子。
~
请注意,这样的变换依然是一个等价变换,局部的这3个节点,以及他们下属的4棵对应的子树,在中序遍历意义上的次序将保持不变。将他们沿垂直方向都投影到水平线上,就可以验证这一点。
这里采用的方法与此前的3+4重构非常类似,我们不妨忽略具体的旋转过程,而代之以直接拼接的方式。
attachAsLChild(g,p->rc)
完成。attachAsLChild(p,v->rc)
呢?它的作用是将v的右孩子,也就是这个X转作p的左孩子。attachAsRChild(p,g)
呢?其效果是将g作为p的右孩子。attachAsRChild(v,p)
的效果雷同。类外的3种情况与上述雷同。
再看伸展树的查找接口应当如何实现,这里给出一种可能的实现方法。
首先与常规的BST一样,也需要调用searchIn这个接口,在以root为根的BST中,查找数值为e的节点。
我们知道,查找可能成功,也可能不成功。
所以概括而言,无论是成功或者失败,我们都会在树根处获得一个相等或者近似的节点,不难理解这种处理手法的原理正是为了充分利用我们此前所介绍的局部性。需要强调的是,既然在内部需要调用splay算法调整树的拓扑结构,所以对于伸展树而言,search接口将不再是一个静态的操作。这也是伸展树区别其他同类BBST的最本质特点。
那么伸展树的另外两个动态接口,也就是insert和remove又当如何实现呢?
先来考察插入算法。
按照直观的思维,调用BST标准的插入算法,然后再按照伸展树的规则,将新节点伸展至树根的位置。
然而我们发现,这种实现方法在这里显得过于迂回曲折。
因为无论如何在真正实施插入操作之前,我们必然已经调用过一次search接口,而我们刚刚也已重写过的search接口,实际上已经集成了一个splay操作。也就是说,即便查找可能失败,在此之后,根节点也必然是_hot节点。
你应该记得,我们的新节点本来就应该作为_hot的左或右孩子接入树中的,既然_hot已经在根节点处唾手可得,我们为什么还要费尽周折的去做更复杂的操作呢?沿着这一思路,我们不妨按照上面这种图给出的流程,来完成伸展树的插入算法。
纵观整个过程以及最终的结果,我们不难发现,其效果与通常的插入完全一样。具体来说,不仅使得一个新节点得以顺利插入树中,而且同样使它处于树根的位置,就像它曾经被推送上去。
再考察节点删除算法
同样的,按照直观的思维,我们或许会首先按照BST的标准删除算法实施删除,再按照伸展树的约定,将与之领进的节点,比如_hot伸展到树根的位置。同样的,这种方法本来也无可厚非,但是在此时,也依然显得有些迂回。
这背后的原因也是类似的,因为不失一般性,如果在删除操作之前的search操作是成功的,那么在查找之后,待删除的目标节点必然已经被推送到了树根的位置。既然如此,我们为何不随即就在树根的位置附近来完成这样一次删除操作呢?具体过程如上图所示。
于是从逻辑上看这个节点的左右子树就变得彼此分离,剩下的任务是,如何将他们重新合并起来。这里有很多种方法。
而这样一个过程的整体效果呢?同样是我们所期望的,具体来说,所需要的删除的节点确实被删除了 ,而且在此之后,作为树根的节点,是一个与此前那个节点非常临近的一个。也就是说,在此后局部性将可以继续得到充分的利用。
最后我们来对伸展树的性能和特点做一个综合的回顾。
首先需要再次强调的是相对此前的AVL树,伸展树显得更为灵活和变通,它不需要再记录节点的高度或者平衡因子之类的附加信息,因此从编程实现的角度来看会更为简便易行。而另一方面,就时间复杂度而言,依然可以确保是在logn的范围内,这也与此前的AVL树相当。
请注意,这样一个复杂度界限并没有任何更多的先决条件,包括我们最初所介绍的局部性条件。
事实上,当局部性存在的时候,伸展树的性能还会更高。
不妨考虑一种极端的情况,也就是局部性非常强的时候,在这个时候,即便数据集的规模能够达到n,在相当长的一段时间之内,我们所访问的数据可能只是其中很少的一部分,比如只有远远小于n的k个,而我们的操作次数呢?却要远远大于n。
~
比如你用电脑的过程往往就是这样,尽管你的硬盘上所存放的数据文件等等是数以万计甚至几百万计,但是在很短时间内,你所经常使用的数据往往只是其中非常低的一个百分比。比如当你在online上苦苦做题的几天之内,你所访问的数据很可能是你的硬盘中屈指可数的那么几个文件,是不是。
~
也就是说,这类情况的特点,可以概括为尽管我们所存放的数据非常的多,但是在相当长的时间内,我们所访问的只是其中非常小的一部分,如果我们把这个数据集组织为一棵BST,并且采用伸展树的策略进行自我调整,那么可想而知的是,经过一段时间的使用之后,我们最常用的那部分数据都会逐步的集中到树根的附近,以至于其他部分的数据几乎可以忽略掉,他们存在与否,已经不甚重要了,也就是说,我们完全可以认为数据集无非就是一个规模为k而不是n的一棵BBST。
~
这样一棵规模为k的BBST其访问的效率自然就应该接近于每次是logk而不是logn。因此对于任何一个足够长的操作序列而言,其中每一次操作的均摊时间复杂度也就大致是logk,当然在达到这种状态之前,我们大致要经过常规的n次操作,每次操作的时间复杂度是logn。
~
你使用电脑的经历也应该能够验证这样一个规律,难道不是吗?你的每一台新安装的电脑,不都是在经过一段时间的应用之后,就会变得非常顺手,使得在接下来的足够长的时间之内都能够飞速的运转。是的,这是因为通常的操作系统都充分利用了数据访问的局部性,从而使得缓存的命中率能够达到经可能的高。
当然,尽管具有以上诸多优点,我们不得不说,伸展树也并非十全十美,比如尽管它的分摊效率很好,但它依然不能够保证杜绝单次最坏情况的出现。
实际上,在此前已经看到伸展树的形状在任何时刻通常都是不平衡,甚至是极其不平衡。
因此我们完全可能在某一个不幸的时刻需要访问一个足够深甚至是最深的节点。尽管伸展树在此后会随即将这条路径缩短近一半,但是在此前的这次查找过程中你不得不仍需付出沉重的代价。因此在对单次操作效率十分敏感的场合,伸展树依然是不能适用和胜任的。
这类例子虽然不多,但也的确存在,比如在手术室里,控制手术器械的程序断乎不能采用伸展树这一类的数据结构。
当然也正如我们所看到的伸展树的复杂度分析是一个非常复杂的课题,在这里我们只是以形象的方式通过举例进行了一定的验证,而严格的证明却要更加复杂。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。