文章目录
- 1.重平衡
-
- [1.1 AVL= BBST](#1.1 AVL= BBST)
- [1.2 平衡因子](#1.2 平衡因子)
- [1.3 适度平衡](#1.3 适度平衡)
- [1.4 接口](#1.4 接口)
- [1.5 失衡 + 复衡](#1.5 失衡 + 复衡)
- [2. 插入](#2. 插入)
-
- [2.1 单旋](#2.1 单旋)
- [2.2 双旋](#2.2 双旋)
- [2.3 实现](#2.3 实现)
- [3. 删除](#3. 删除)
-
- [3.1 单旋](#3.1 单旋)
- [3.2 双旋](#3.2 双旋)
- [3.3 实现](#3.3 实现)
- [4. (3 + 4)-重构](#4. (3 + 4)-重构)
-
- [4.1 "3+4"重构](#4.1 "3+4"重构)
- [4.2 "3+4"实现](#4.2 "3+4"实现)
- [4.3 rotateAt](#4.3 rotateAt)
- [4.4 综合评价](#4.4 综合评价)
1.重平衡
1.1 AVL= BBST
作为二叉搜索树这章最后一节,我们来介绍最为经典的一种平衡二叉搜索树,也就是AVL树。
回顾此前的几节,首先介绍的是二叉查找树BST,然而我们也看到,尽管同时兼顾高效的静态操作和动态操作的角度讲,BST相对此前简单的向量和列表已经具有某种优势和潜质,但是毕竟它不能保证这一点,其原因在于,它的高度无论是从平均情况还是最坏情况都不能保证做到足够的低,具体来说,也就是做到 logn 以下。
当然在BST中的确存在这么一种特殊的类型,也就是所谓的Complete Binary Tree 完全二叉树。它的高度可以达到严格的最小,也就是logn。然而,相对于整体的BST,这类BST的数量极少。而且我们如果需要将任何一棵树转化为一棵完全二叉树,所需要的成本也太高。也正因为此,我们建议是或许应该适当地放松所谓平衡地标准。也就是说,只需考察某一类在渐进意义下不超过O(log n)高度的树即可。而这样一类树也就是我们所说的平衡二叉搜索树Balanced Binary Search Tree ------BBST。
比如这节将要介绍的AVL树,就是在这种意义下的一种BBST。以AVL树为代表的这些BBST,首先并没有放弃渐进意义logn 的复杂度底线,同时正因为它已经适度地放松了平衡的标准,所以通过精巧地设计,它们都可以具有这样一种属性。
具体来说,对于任何一棵这样意义下的BBST,在其生命期内,即便在某次操作之后,它不再满足BBST的条件,也就说游离到BBST这个范畴之外,也可以通过之前介绍的等价变化,迅速地将其转化为一棵等价的BBST。也就是说,可以通过极小的代价,就使之重新归入BBST的范畴。
而这种极小的代价是多少呢?不出意料,依然是不超过logn ,令刚刚失衡的搜索树重新恢复为一棵BBST的过程,也称作重平衡rebalance。
而对于包括AVL树在内的各种BBST而言,其核心技巧,无非两条。第一,如何界定一种适度的平衡标准,其次,则是一整套重平衡的技巧和算法。
以下就以AVL树为例,具体地讲解如何完成这两项任务。
1.2 平衡因子
首先,给出在AVL意义下什么叫做适度的平衡,凭借什么来判断一棵树是否是在AVL意义下的适度平衡。
需要用到这样一个指标,实际上,对于二叉树中的任何一个节点V,都可以定义它的所谓平衡因子 balanced factor。具体来说,也就是它的左子树高度与右子树高度之差,那么根据AVL树发明者的定义,所谓AVL树就是其中所有节点的平衡因子都不超过1,也不小于-1。
比如,不难验证这样一棵BST(上图),其实就是一棵AVL树。当然,AVL树本身只考虑左右子树的高度,所以只要所有节点能够满足全局的单调性即可,并不需要关心,它们的具体数值是多少,所以这里不妨不再将关键码加亮显示,而把关注里更多集中于各个节点的平衡因子。
我们来校验下,
- 对于第一个叶节点1而言,它的左右子树都是空,高度均为-1,所以这个节点平衡因子自然就是-1减-1 零。
- 再来看第二个节2点,它的左子树高度为零,而右子树高度为-1,所以它的平衡因子应该为正1。
- 同样地,对于节点6来说,它的左子树高度为-1,而右子树高度为0,所以这个节点的平衡因子应该为-1。
- 类似地,对于节点11而言,它的左子树高度为1,而右子树高度为0,两项之差为正1。
- 最后,对于根节点 3 来说,它的左子树高度为1,而右子树高度为2,二者之差为-1。
~
因此,对于这样一棵BST来说,的确,平衡因子在处处都上下不超过正负1,因此,它的确是一棵名副其实的AVL树。
当然从这个例子可以看出,AVL树未必是完全二叉树,也就是说,它未必是理想平衡。那么反过来,如此定义的AVL树是否的确是适度平衡的呢?
1.3 适度平衡
可以证明AVL树的确是适度平衡的,也就是说,一棵规模为n的AVL树,其高度在渐进意义下是不超过logn的。
height( AVL ) = O(log n)
实际上,为了证明规模固定的AVL树,其高度不会超过某个上限。我们可以等价证明,在高度固定的情况下,一棵AVL树的节点也不至于太少。
n = Ω( 2 h e i g h t ( A V L ) 2 ^{height(AVL)} 2height(AVL) )
具体来说,我们可以证明这样一个事实。
对于高度固定为h的AVL树,其中所包含的点数至少是与h呈Fibonacci数关系,为此需要借助递推式。具体来说,可以证明这样一个递推式
S(h) = 1 + S(h - 1) + S(h - 2)
也就是说,如果我们将高度为h的AVL树的规模下限定义为S(h)的话,那么S(h)与S(h - 1)以及S(h - 2)之间满足这样一个叠加关系。
为此我们来考察那棵高度为h,同时规模达到最小的AVL树(上图)。既然它的规模要达到最少,所以它的左子树和右子树的规模也应该尽可能少,那么在AVL树的定义下,可变化的余地充其量不过其中一棵子树比另一棵子树高一层。不失一般性,假设左子树比右子树高出一层,因为它的高度为h - 1,所以它的规模下限自然也就是S(h - 1),同理,作为高度为h - 2的右子树,它的规模下限自然也就是S(h- 2),当然,还不要忘了这里的树根节点,这也就是为什么,我们还要附加上一个单位1。
这个递归式是我们所有分析的核心,而以下只不过是一些简单的数学技巧而已。为此,我们不妨对它做一个等价变换,也就是在左右各加一个1。
S(h) + 1 = [ S(h - 1) + 1 ] + [ S(h - 2) + 1 ]
左侧添加了一个1,右侧这块也添加了一个1,以及此前原本已有的一个1。接下来,如果我们将S(h)+ 1 定义为一个新的函数T(h),就会发现这个递推式的右侧会变成T(h - 1)再加上T(h - 2),这种形式是Fibonacci数所特有的递推形式,所以我们可以断定。它应该是等于Fibonacci的某一项。那么具体是从h前后位移多少项呢?
S(h) + 1 = [ S(h - 1) + 1 ] + [ S(h - 2) + 1 ]
T(h) = T(h - 1) + T(h - 2) = fib(h + ?)
我们只需考察对应的边界情况即可。
h n T(h)
0 1 2 = fib(3)
1 2 3 = fib(4)
首先考察规模为1,高度为0的AVL树,此时的T(h)应该等于1 加 1也就是2,我们知道这个是Fibonacci树的第三项 fib(3)。再来考察高度为1的AVL树,其规模最小也不至低于2,也就是左子树为一个节点,右子树为空的AVL树。此时的T(h)应该等于 2 + 1,也就是3。我们知道这个是Fibonacci数的第四项 fib(4)。由此可见,这里的T(h)只不过是Fibonacci数向前位移了三位。
我们知道Fibonacci数大致是呈 ϕ h \phi ^h ϕh的指数形式增长,由此我们也得到了n关于高度h的一个下届n = Ω \Omega Ω( ϕ h \phi ^h ϕh),因此反过来等价地n的对数也就构成了h的一个上界 h = O(log n),而这一点正式BBST所谓适度平衡的要求,这就以为这我们的AVL树的确是适度平衡的。
好了,至此也就完成了第一项使命,也就是给出AVL意义下的适度平衡标准。那么接下来,在着手完成第二项使命,也就是给出具体的重平衡算法之前,特许应该首先以C++语言的形式明确给出AVL树各种操作接口的规范。
1.4 接口
接下来,首先就将AVL树关于适度平衡的标准以及它作为数据结构所应该提供的各种接口以C++语言的形式明确定义下来。
首先是什么叫做平衡,可以看到,所谓的理想平衡就是左右子树的高度完全相等。
而所谓的平衡因子呢?在这里也严格地按照AVL树的定义,取作左子树的高度与右子树的高度之差
那么AVL树所谓的适度平衡标准,也就可以转译为平衡因子最大不过1最小不过-1
我们也可以依然采用模板类的形式,由标准的BST派生出AVL类。因此,包括search在内的很多公用标准接口都可以直接沿用。而作为派生类,这里需要重写的无非就是涉及重平衡的动态操作,也就是插入以及删除。
那么在这两种动态操作之后AVL树的失衡现象具体是什么样的呢?究竟有多严重呢?在给出具体的重平衡算法之前,或许应该首先获得一些感性上的体会。
1.5 失衡 + 复衡
考察这样一个实例
首先请关注中间这棵BST,不难发现,它其实就是在开篇所举的那个AVL树实例,只不过在这里我们将数字的关键码统一替换为了字母。
- 接下来,假设需要加入M。
那么按照BST的常规算法,经过适当的搜索,可以确定应该将M作为K的右孩子接入到这棵树中,然而随后会发现:M的接入,虽然不致引起它的父亲K的失衡,却导致它的祖父N因此失衡,更糟糕的是它的曾祖父(也就是R)也会因为它的插入而导致失衡。而作为一个极端的例子,这里使得它的更高层祖先也会因为它的插入而失衡。
总而言之,在一棵AVL树中,插入一个节点之后,有可能会导致若干个祖先失衡。
当然你大可放心,除了祖先之外的其他节点是不可能失衡的。
其背后原因在于,对于非新插入节点祖先的那些节点而言,无论是它们的高度,还是它们孩子的高度,都不会因为新节点的插入而有所变化,所以它们各自的平衡因子也都将维持原状,如果此前是平衡的,那么它们就不可能变成失衡。
- 再来看另一个方向的删除操作,假设在原先的这棵AVL树中,我们删除了某一个节点,比如Y。
那么类似地,我们也会发现:Y的删除会导致它此前的那些父节点R发生失衡。你会发现:除了R之外,Y此前的其余祖先,比如说G,并未失衡。这只是一种巧合或者是我们没有考虑到最坏的情况吗?我们说不是这样的。
因为对于删除操作来说,在摘除节点之后的瞬间,至多只有一个节点会失衡。 这背后是什么原因呢?
不妨假设,在某个节点被摘除之后,的确会引起它的某个甚至某些祖先发生失衡。可以证明,其实其中只有一个祖先会失衡。
~为此我们不妨考察其中高度最低的那个失衡祖先。我们会发现:这个祖先R尽管失衡了,它的高度却必然保持原样。这背后的原因在于,如果这个节点的失衡的确是因为它的某个后代被摘除了,那么这个后代在此前也必然属于它那个相对更短的分支,而它的高度则是由它相对更长的那些分支所决定的,因此这个节点的删除并不致于引起这个祖先高度的变化,而既然这个祖先的高度不致于变化,那么相对于更高的祖先而言,它们在计算平衡因子时,结果也应该与未删除节点之前是一样的,换而言之,它们必然是平衡的。
所以概括而言,如果在一棵AVL树中,删除某个节点之后,的确引起祖先的失衡,那么这种失衡的祖先充其量不过只有一个。
没错,在某个节点删除之后的瞬间,至多只有一个节点失衡,而反过来,我们却刚刚看到,一个节点的插入,却有可能引起几乎所有的祖先同时失衡。
那么我们是否可以说:相对而言,AVL树的删除操作要比插入操作更为简单呢?实际情况恰恰相反。
如果我们将插入操作和删除操作比喻为孩子,那么插入操作是这样一种孩子:他有可能在某个时候会闯下一连串的祸,但这个孩子还至少是个好孩子,因为他能够痛改前非,我们很快就能看到,一旦他能够改正其中一个错误,那么其他的一连串错误也都会自然地烟消云散。而反过来删除操作呢?虽然不能称作是个坏孩子,但是他至少是一个不长记性,不吸取教训的孩子,我们很快就会看到,尽管这个孩子在每一次只会闯下一个祸,但是每当你帮助他改正了这个错误之后,他转眼就会忘掉这件事,并且很快就会在另一个位置犯下同一样的错误,而且这个孩子的记忆力糟糕之极,即便你有足够的耐心帮助他改正下一个错误,接下来转眼之间,他有可能在另一个位置再次犯下同样的错误。
因此相对而言,插入操作要更为简便一些,而删除操作要复杂不少,因此接下来,我们不妨从插入操作入手。
2. 插入
2.1 单旋
首先来考察插入操作的第一种情景
我们在某个节点(g)原本已经更高的分支(p)插入了一个新的节点,这个分支的高度继续上升一层,从而导致节点g的平衡因子从-1变成-2,突破了AVL树的底线。
请注意,新的节点可能插入在 T 2 T_2 T2下面也可能插入在 T 3 T_3 T3下面,我们在它们之间引入一条虚线,表示二者只能取其中之一。
而且我们假设g是所有因此而发生失衡的祖先中最深的那个,那么从g出发沿着这个新增长的分支,我们可以找到它的孩子节点,以及孙子节点,将它们分别命名为v、p和g,分别暗示是一个节点v以及它的父亲parent以及祖父grandparent。根据这样的命名方式,我们也不难理解,尽管一个节点的插入有可能会导致多个祖先的失衡,其中最低的那个也不会低于它的祖父辈。
那么既然此处已经发生了失衡,又当如何令它重新恢复平衡呢?
实际上,我们能做的无非是上一节所介绍的等价变换,也就是zig或者是zag旋转。在此处,我们只需要做一次旋转,也就是所谓的单旋。通过上图理解整个调整过程:
从我们刚才失衡的局部出发,接下来围绕着失衡的节点g做一次逆时针的zag旋转,这样的旋转可以由接下来几步组合完成。
- 首先引入一个临时引用rc指向节点p,接下来,我们要令p的左子树 T 1 T_1 T1成为g的右子树,为此只需这样调整。
- 再接下来,要令g成为p的左孩子,因此需要做这样的调整。
- 再接下来,要将局部子树的根由g替换为p,也就是说要做这样的调整。
- 此时的临时引用也完成了历史使命,它可以退出了,而旋转操作也同时宣告完成,至此重平衡化已经完成。
为了更清楚地看到平衡化之后的效果,不妨对上中图稍事整理,如上右图,不难验证,局部的这棵子树的确已经恢复了平衡。然而好消息还不止于此。实际上,如果在此前g以上还有其他的祖先同时发生失衡,那么在这个局部重新恢复平衡之后,也会同时一揽子地重新获得平衡,你能看出这背后的原因吗?在此不妨暂停片刻,就这个问题做一思考。
在这里除了平衡因子以外,局部子树还有一个重要指标就是它的高度,那么它的高度在哪呢?请留意这里所设置的三条基准线。不难发现,在插入新节点之前,原先这个子树的高度应该是以中间这条水平线为基准,然而对照重新平衡后的这棵树,我们会发现它的高度又重新回到了这样一条基准线。这棵局部子树的高度能够复原,又意味着什么呢?我们说这个意义非常重大,这意味着它的所有祖先在计算平衡因子时所得的结果也将与插入新节点之前完全一样。换而言之,在局部子树高度复原之后,所有祖先也必然会统一地恢复平衡,而全树呢?也将因此恢复平衡。
请注意,对于这种情况,我们无非是做了一次zag旋转,这种旋转只涉及到局部的常数个节点,因此它所对应的时间消耗应该是O(1)的,这个结果也在好不过了。当然这种情况只是所有情况中的一种,其特点是刚才所定义的gpv这连续三代的节点在方向上是朝向一致的,比如这里它们同时向右,所以我们也相应地称为zag-zag。不难理解,对于对称的情况,也就是它们一致向左的情况,同样可以参照这个方法予以处理,那种情况我们也称作zig-zig。
那么如果它们朝向并不一致,而是呈所谓的之字形形式呢?
2.2 双旋
比如这就是祖孙三代呈现一种之字形形式的可能。具体来说,节点p是节点g的右孩子,而节点v却是节点p的左孩子,这样一种情况也称作zig-zag,当然还有对称的zag-zig,其方法和过程完全对称。
我们这里不妨依然以zig-zag为例,那么,请注意,这里我们所谓的g依然是所有失衡祖先中最低的那个,而且节点g的高度也不致于太低,它至少是新插入节点x的祖父,当然,x本身有可能就是v,在这种情况下,我们要进一轮共两次的等价变换,也称这种组合为双旋。通过下面步骤来看一下,这种情况下双旋的执行过程。
- 首先,要围绕着节点p做一次顺时针的zig,整个过程与刚才相仿,来重新温习一遍。
至此,zig旋转即告完成。- 接下来,还需要围绕着节点g做一次逆时针的zag旋转,就单独这次旋转来说,与我们刚才的过程完全一样,来重温一遍。
至此,zag旋转即告完成。
这样我们实际上已经完成了这一局部的重平衡化,为了能够更清楚地看到这一点,我们不妨同样地对这一结果稍事整理。
现在应该看的很清楚了,在此局部的这棵子树的确已经恢复了平衡。
那么同样地,g以上有可能之前也是失衡的那些祖先呢?我们说它们依然会一揽子地统一恢复平衡,其背后的原因,与刚才单旋的情况如出一辙。最后正如刚才已经指出的这两种情况以及它的对称情况完全覆盖了插入操作失衡调整的所有情况,因此,就算法而言,已经做了足够的分析和交代。
那么这样一组调整的算法如何具体兑现为代码呢?
2.3 实现
在这里我们给出AVL树插入算法,一种可能的实现
- 可以看到,开始的几步操作无非是常规的BST插入算法,也就是说,我们需要首先进行查找定位并且不妨假设这个节点的确还不存在,接下来我们需要创建一个新的节点并且将它接入到刚才的BST中。
接下来,才是AVL规则的相应处理
- 具体来说,我们将从新节点的父亲开始,不断地逐一枚举它的历代祖先,即便这个祖先还是平衡的,我们依然有必要考虑更新它的高度。
- 而一旦抵达一个不平衡的祖先,根据我们这里枚举各代祖先的次序,这个祖先必然是所有失衡祖先中的最低者,因此按照刚才所介绍的算法,需要在此局部作旋转调整,而此局部所涉及的三个顶点,无非是g、以及它更高的孩子tallerChild(g)也就是p,以及再更高的孙子tallerChild(tallerChild(g))也就是v。请注意,一旦发现了这样一个节点并且随即完成了局部的重平衡,就可以直接退出这样一个循环,并且直接退出整个算法。
应该知道这正是由我们刚才所指出的那个特性所保证的,也就是说,尽管在某个节点刚刚插入的瞬间,可能同时有多个祖先都是处于失衡的状态,但是一旦我们令其中最低者恢复平衡,那么所有的失衡祖先都必然会统一地恢复平衡。
3. 删除
再来考察AVL树节点删除算法
3.1 单旋
比如上左图就是在节点删除之后引起失衡的一种情况,如果我们同样地将失衡的那个祖先命名为g,那么它之所以在此时会失衡,是因为此前所摘除的那个节点恰好处于它原本就更短的那个分支,比如 T 3 T_3 T3底部,也就是说,它的平衡因子将由此前的+1变成现在的+2,从而违规。
请注意,这里子树 T 0 T_0 T0和 T 1 T_1 T1的底部应该至少有一个节点,而 T 2 T_2 T2底部的这个节点有可能存在,也有可能不存在。
请注意,与插入的情况不同,在这里,失衡节点g有可能恰好就是刚刚被删除节点的父亲,然而无论如何,只要gpv这祖孙三代节点是朝一个方向排列的,比如,p是g的左孩子,v也是p的左孩子,那么我们就可以通过一次单旋来恢复局部的平衡。
具体来说,也就是围绕这个失衡节点g做一次顺时针的zig旋转,这个旋转操作的细节在此前插入算法中已经给出详细的步骤,所以这里不妨直接给出旋转调整之后的结果(上右图)。可以看到,局部的子树树根由原先的g替换成了p,而节点v和g则分别成为了节点p的左右孩子。
不难验证,经过这样一个调整之后,在此局部,的确恢复了平衡,那么故事就此终结了吗?我们说有时候是,有时候不是。这里的关键在于 T 2 T_2 T2这棵子树底层节点是否存在。
如果它存在,那么我们会发现:经过这样调整后,子树的新高度与此前原树高度是一样的,因此,与插入算法同理,在这种情况下,我们可以保证:在这棵子树上的每一个祖先它们的高度及平衡因子都会继续保持原状,因此不会发生新的失衡现象。
~然而问题在于 T 2 T_2 T2的这个底层节点有可能根本就不存在,在这样的情况下,相对于原树的高度,调整之后新树的高度会缩短一个单位。此时不妨设想这样一个场景,也就是某一个祖先,它的另一个分支可能会更高,换而言之,它此前的平衡因子已经是-1,因此在原本就更短的左侧分支进一步缩短之后,它的平衡因子将进一步下降到-2,从而超标。
请注意,这个节点在我们调整之前,原本是平衡的,而在它下属的后代恢复平衡之后,它却有可能进而失衡。我们也可以等效地认为这个节点的失衡是由于为了消除它后代的失衡进而引发的,这样一种失衡逐渐向上层传播的现象,也是删除操作所特有的。
当然从算法而言这并不是什么了不起的事情,因为对于这个新的失衡祖先,我们完全可以套用整个调整算法继续使它复衡。当然,有可能又会引发更高层祖先的失衡,极端的情况下,我们有可能会在每一层都进行一次调整。累计而言,这种调整有可能会多达logn次。
需要指出的是这样一种估计既不是杞人忧天更不是危言耸听,我们的确可以构造出这样的反例。当然与插入算法一样,我们还需要考虑另外一种情况,也就是gpv这样连续三代节点未必是朝一个方向排列的,如果它们是按照所谓的之字形排列呢?
3.2 双旋
我们只需考虑其中一种也就是所谓的zag-zig的情况。另一种zig-zag的情况完全对称。
在这种情况下,v是p的右孩子,而p是g的左孩子。此时我们依然只可能至多有一个失衡节点,不难理解如果g果然是这个失衡节点,那此前所删除的必然是 T 3 T_3 T3这棵树的某一底层节点,而且因为这个底层节点的删除,导致 T 3 T_3 T3整体的高度收缩一层,从而使得节点g的平衡因子由此前的+1变成超标的+2。
在这种情况下,我们要先后做两次旋转调整。
- 具体来说,首先需要围绕着节点p做一次逆时针的zag旋转,同样地,假定对基本旋转非常熟悉了,所以在这里直接给出旋转之后的结果(上中图),我们可以看到,它等效于将此前的zag-zig转为了现在的zig-zig,恰好就是我们刚刚介绍过的单旋操作。
- 接下来,只需要围绕着节点g做一次zig,就可以使得这棵子树重新恢复平衡。
~
尽管如此,还是特别提醒你留意,这棵局部子树在调整前后的高度变化。我们知道在 T 1 T_1 T1和 T 2 T_2 T2这两棵子树的底层至少有一个节点存在,这就意味着,在旋转调整之前,这棵局部子树的高度应该是由最下面这条水平基本线来确定的,而在调整完成之后,这棵局部子树的高度,将由第二条基准线确定。
~
两相比较,高度收缩了一层,这意味着什么呢?
~
没错,这意味着在这棵局部子树以上的每一个祖先此时都存在由原先的平衡转为失衡的可能,也就是说,同样会发生失衡的向上传播。当然,果真如此,我们可以依然套用这里所给的算法,好在失衡传播的方向是单调的一直朝上。
所以同样的,充其量至多经过logn次这样的传播,迟早会抵达某个不再失衡的节点或者抵达树根。
至此,节点删除可能的几种情况都已论及,那么这些处理算法又当如何落实为具体代码呢?
3.3 实现
这里就给出AVL树节点删除算法一种可能的实现
开始的几步只不过是BST常规的节点删除算法,具体来说,我们进行一次搜索,并且不妨假设目标节点的确存在。于是我们调用removeAt例程,将这个节点物理地摘除掉。
接下来,我们依然是通过一个for循环遍历被删除节点地历代祖先。
请特别注意,我们的起点是被删除节点的父亲,而不是像插入操作那样,可以直接从它的祖父开始。
那么在整个遍历过程中,我们每发现一个失衡的祖先g,都要对这个祖先做一轮适当地旋转调整,而旋转所涉及到的三个节点依然是g、以及它更高孩子p、以及再往下更高的那个孙子v。而且无论是否失衡或者做过旋转调整,我们都有必要调整这个祖先的高度。
可以看出在最坏情况下,的确需要做logn次。
不妨将此前插入操作的控制逻辑与此处做一对比。
的确,这里没有可以提前终止遍历过程的窍门,因为在最坏情况下,我们的确需要无一遗漏地处理所有的各代祖先。此后,才能够顺利地结束算法并且返回。
至此,你可能会发现,对于这里的旋转操作,我们还没有给出它的具体实现方法,它的确是按照我们刚才所推荐的单选和双旋那种方式来实现的吗?很有趣的是,答案是否定的。
4. (3 + 4)-重构
4.1 "3+4"重构
实际上,以上针对AVL树插入操作和删除操作所介绍的单旋式和双旋式调整技巧无非是为了帮助你形成对算法的理解,而在真正的实现时,我们大可不必机械地如此理解。
这样一个过程可以比喻为玩魔方,是的,你需要在规则的允许下,通过巧妙的旋转组合,使之转入某种特定状态,比如六面各自同色。那么你是否去过魔方的组装车间?你会发现那里的工人可不是按照你这样的规则在那进行这样的旋转。实际上,它们无非是将魔方的一个又一个组件直接地拼接为一个魔方,工人们之所以这么做,是因为它们最大也是唯一目标是尽快地以最高的效率完成魔方的组装。
我们这里呢,也不妨借助这一策略,因为对于AVL树的重平衡化而言,我们最终在乎的并不是所谓的技巧,而是在于这个过程的效率,我们来看一下,如何将魔方组装工人的那种策略用到我们这个问题上。
具体来说,我们依然假设g就是当前最低的那个失衡祖先,并且同样地沿着那个最长的分支去考察gpv这祖孙三代,以下我们并不急于对它们进行旋转,而是首先做重命名,也就是说,按照它们在中序遍历序列中的次序自小到大重新命名为ab以及c。
对照我们此前所讲的各种情况,无论是zig-zag、zag-zig、zig-zig或者是zag-zag,你会发现在它们以下无非是最多4棵子树 ,那么我们也需要对这4 棵子树做重命名,而且命名的规则同样是参照中序遍历的次序,也就是 T 0 T_0 T0是最小的那棵树、 T 1 T_1 T1是次小的、 T 2 T_2 T2是较大的、 T 3 T_3 T3是最大的。
此时,如果我们依然按照中序遍历的次序将这两个序列混合起来就可以得到一个长度为7的序列,在这个序列中,三个节点abc必然是镶嵌于这4棵子树之间,实际上,无论是哪种具体的情况,讲过这样的重命名之后,按照中序遍历的次序,必然是从 T 0 T_0 T0到a,再从a到 T 1 T_1 T1、再从 T 1 T_1 T1到b、然后从b到 T 2 T_2 T2、再从 T 2 T_2 T2到c,最终由c到 T 3 T_3 T3。你应该不会觉得奇怪,因为这恰恰就是BST所谓的单调性。
在这样一棵局部子树的具体体现,在调整之前,即便这棵子树是失衡的,它也依然是一棵BST,所以这个单调性应该自然满足,而在调整之后,尽管它已经恢复了平衡,但是这个单调性也依然是需要保持,因此,我么可以统一地将这三个顶点abc以及这4棵子树按照这样一个拓扑关系直接地拼接起来(如上图)。
具体来说,以a和c分别作为b的左和右孩子,而 T 0 T_0 T0和 T 1 T_1 T1将作为a的左和右子树, T 2 T_2 T2和 T 3 T_3 T3分别作为c的左和右子树,这样一种拼接是针对于三个节点以及下属4棵子树而言的,所以也称作3+4重构。
在此,不妨稍作暂停,并对照此前所介绍的各种情况以及相应的调整算法,你应该会发现,无论是插入还是删除,无论是单旋还是双旋,最终的效果都应该是这样一种形式。
这也犹如无论魔方的最终状态如何,也无论你所设计的旋转方案具体如何,最终必然应该达到你心目中早已设计好的一个结局,对于魔方而言,一般都是六面各自同色,而对于我们的BBST而言,则是在此局部的重新平衡。
按照这样一个思路,我们可以更为概括而且更为深入地来理解并且记忆以上各种情况的处理手法,而更好消息是,按照这样一种理解,我们也可以更加简明更加高效而且更加安全鲁棒地来实现相应的重构算法
4.2 "3+4"实现
在这里,给出3+4重构算法的一种实现方式
作为这个算法的输入,我们的确需要提供三个节点abc以及4棵子树 T 0 T_0 T0 T 1 T_1 T1 T 2 T_2 T2和 T 3 T_3 T3,以下我们可以通过这样一段非常规整的代码完成这3个顶点以及4棵子树之间的连接。可以看到,这样一种实现的思路非常的简明清晰,而且因为这段代码非常的规整,所以更加便于编写、调试以及维护和重用,出现错误的概率也会降到最低。
那么接下来需要解释的一个问题就是在以上各种情况下,我们如何来完成对3个节点以及4棵子树的重命名,从而以正确的参数形式转交给这样一个connect34例程呢?
4.3 rotateAt
现在这个曾经多次出现并引起我们非常好奇的rotateAt算法,终于到了可以揭开它神秘面纱的时候了。
它的传入参数只有一个,也就是在我们所关心的祖孙三代中作为孙辈的那个节点v,所以通过父亲引用,我们可以很便捷地找到p以及g。
于是接下来,我们只需分别判断p和v究竟是左孩子还是右孩子,就可以正确区分zig-zig,zig-zag以及zag-zig和zag-zag各种情况。而在每一种具体情况下,vpg究竟应该如何命名为abc以及它们属下的4棵子树究竟应该如何命名为 T 0 T_0 T0 T 1 T_1 T1 T 2 T_2 T2和 T 3 T_3 T3都是固定的,我们甚至可以把这些情况汇总为一张表,我们不妨来考察这四种情况中的两种。
- 第一种也就是所谓的zig-zig的状态,无论v还是p都是左孩子,在这样一种情况下,你不难理解,它们的中序遍历序列自然应该是v-a、p-b和g-c,而4棵子树呢,也相应地应该是v的左子树、v的右子树、以及p的右子树和g的右子树,就像上图所画的那样。
- 再看所谓的zig-zag情况,也就是说,v是右孩子,而p却是左孩子,在这种情况下,同样按照中序遍历次序,不难确定,应该是p-a、v-b和g-c,而4棵子树呢,也自然应该是p的左子树、v的左子树、v的右子树以及g的右子树,在上图中,我们可以更清晰地看出这一点,也就是p的左子树、v的左子树、v的右子树以及g的右子树。
另外两种情况完全对称。
4.4 综合评价
最后我们来对AVL树的性能和特点做一个总体的评价
首选我们注意到AVL树具有极高的理论价值,因为它正面的告诉我们的确存在这样一种数据结构,可以在渐进logn的复杂度意义下兼顾所有的静态和动态操作,而且为此,我们的存储负担也不会有实质的增加。
当然AVL树的缺点也是非常明显的,这也将成为我们的动力,促使我们去不断地改进并提出更高更高效的数据结构。
那么AVL树的第一个缺点就在于它人为的引入了一个所谓的平衡因子概念,并且要求在这个数据结构中隐式地或者显示地存放,为此我们往往需要去改造数据项原有地基本结构,或者再做额外的封装,无论如何这一要求都过于做作,显得不是那么自然。
~那么针对这一问题,在下一章将首先引入所谓的伸展树,我们将会看到伸展树将无需记录和维护任何诸如平衡因子的指标,无论是显式的还是隐式的。
~另外,AVL树的实测性能与它的理论性能之间存在着较大的差距。尤其是正如我们所看到的它的删除操作要更为复杂,尽管knuth曾经指出这种最坏的情况以及较坏的情况出现的概率其实很低。平均而言,大概每五次操作才会引起一次旋转。
如果说以上的两个缺点都属于鸡蛋里挑骨头,那么第三个缺点却的确是致命的。因为我们已经看到,对于AVL树而言,它的插入操作和删除操作是非常不对等的,这种不对等就集中体现在每次操作之后所涉及的旋转调整次数。具体来说,每次插入操作之后,最多只需一轮调整,也就是常数次O(1),而在删除操作之后,为了使得全树重新恢复平衡,正如我们已经看到的,在最坏情况下,我们需要做多达logn次旋转调整。因此就全树中各节点之间的拓扑连接关系而言,在插入操作之后,可以保证变化量保持在常数的范围,而删除操作却未必能做到这样。
实际上,在很多高级的数据结构和算法中都对这种拓扑结构的变化量有严格的要求,具体来说,我们这里高达logn的变化量绝对是不能满足要求的。我们希望将它们控制到更低,比如在下一章将要介绍的红黑树,则可以将这个变化量严格控制在每次不超过常数0(1),无论对于插入操作还是删除操作都是如此。