文章目录
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树本身只考虑左右子树的高度,所以只要所有节点能够满足全局的单调性即可,并不需要关心,它们的具体数值是多少,所以这里不妨不再将关键码加亮显示,而把关注里更多集中于各个节点的平衡因子。
当然从这个例子可以看出,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)之间满足这样一个叠加关系。
这个递归式是我们所有分析的核心,而以下只不过是一些简单的数学技巧而已。为此,我们不妨对它做一个等价变换,也就是在左右各加一个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。
总而言之,在一棵AVL树中,插入一个节点之后,有可能会导致若干个祖先失衡。
当然你大可放心,除了祖先之外的其他节点是不可能失衡的。
- 再来看另一个方向的删除操作,假设在原先的这棵AVL树中,我们删除了某一个节点,比如Y。
因为对于删除操作来说,在摘除节点之后的瞬间,至多只有一个节点会失衡。 这背后是什么原因呢?
所以概括而言,如果在一棵AVL树中,删除某个节点之后,的确引起祖先的失衡,那么这种失衡的祖先充其量不过只有一个。
没错,在某个节点删除之后的瞬间,至多只有一个节点失衡,而反过来,我们却刚刚看到,一个节点的插入,却有可能引起几乎所有的祖先同时失衡。
那么我们是否可以说:相对而言,AVL树的删除操作要比插入操作更为简单呢?实际情况恰恰相反。
因此相对而言,插入操作要更为简便一些,而删除操作要复杂不少,因此接下来,我们不妨从插入操作入手。
2. 插入
2.1 单旋
首先来考察插入操作的第一种情景
我们在某个节点(g)原本已经更高的分支(p)插入了一个新的节点,这个分支的高度继续上升一层,从而导致节点g的平衡因子从-1变成-2,突破了AVL树的底线。
而且我们假设g是所有因此而发生失衡的祖先中最深的那个,那么从g出发沿着这个新增长的分支,我们可以找到它的孩子节点,以及孙子节点,将它们分别命名为v、p和g,分别暗示是一个节点v以及它的父亲parent以及祖父grandparent。根据这样的命名方式,我们也不难理解,尽管一个节点的插入有可能会导致多个祖先的失衡,其中最低的那个也不会低于它的祖父辈。
那么既然此处已经发生了失衡,又当如何令它重新恢复平衡呢?
为了更清楚地看到平衡化之后的效果,不妨对上中图稍事整理,如上右图,不难验证,局部的这棵子树的确已经恢复了平衡。然而好消息还不止于此。实际上,如果在此前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,在这种情况下,我们要进一轮共两次的等价变换,也称这种组合为双旋。通过下面步骤来看一下,这种情况下双旋的执行过程。
这样我们实际上已经完成了这一局部的重平衡化,为了能够更清楚地看到这一点,我们不妨同样地对这一结果稍事整理。
现在应该看的很清楚了,在此局部的这棵子树的确已经恢复了平衡。
那么同样地,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的左孩子,那么我们就可以通过一次单旋来恢复局部的平衡。
不难验证,经过这样一个调整之后,在此局部,的确恢复了平衡,那么故事就此终结了吗?我们说有时候是,有时候不是。这里的关键在于 T 2 T_2 T2这棵子树底层节点是否存在。
请注意,这个节点在我们调整之前,原本是平衡的,而在它下属的后代恢复平衡之后,它却有可能进而失衡。我们也可以等效地认为这个节点的失衡是由于为了消除它后代的失衡进而引发的,这样一种失衡逐渐向上层传播的现象,也是删除操作所特有的。
当然从算法而言这并不是什么了不起的事情,因为对于这个新的失衡祖先,我们完全可以套用整个调整算法继续使它复衡。当然,有可能又会引发更高层祖先的失衡,极端的情况下,我们有可能会在每一层都进行一次调整。累计而言,这种调整有可能会多达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。
在这种情况下,我们要先后做两次旋转调整。
所以同样的,充其量至多经过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棵子树按照这样一个拓扑关系直接地拼接起来(如上图)。
在此,不妨稍作暂停,并对照此前所介绍的各种情况以及相应的调整算法,你应该会发现,无论是插入还是删除,无论是单旋还是双旋,最终的效果都应该是这样一种形式。
按照这样一个思路,我们可以更为概括而且更为深入地来理解并且记忆以上各种情况的处理手法,而更好消息是,按照这样一种理解,我们也可以更加简明更加高效而且更加安全鲁棒地来实现相应的重构算法
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都是固定的,我们甚至可以把这些情况汇总为一张表,我们不妨来考察这四种情况中的两种。
另外两种情况完全对称。
4.4 综合评价
最后我们来对AVL树的性能和特点做一个总体的评价
首选我们注意到AVL树具有极高的理论价值,因为它正面的告诉我们的确存在这样一种数据结构,可以在渐进logn的复杂度意义下兼顾所有的静态和动态操作,而且为此,我们的存储负担也不会有实质的增加。
当然AVL树的缺点也是非常明显的,这也将成为我们的动力,促使我们去不断地改进并提出更高更高效的数据结构。
如果说以上的两个缺点都属于鸡蛋里挑骨头,那么第三个缺点却的确是致命的。因为我们已经看到,对于AVL树而言,它的插入操作和删除操作是非常不对等的,这种不对等就集中体现在每次操作之后所涉及的旋转调整次数。具体来说,每次插入操作之后,最多只需一轮调整,也就是常数次O(1),而在删除操作之后,为了使得全树重新恢复平衡,正如我们已经看到的,在最坏情况下,我们需要做多达logn次旋转调整。因此就全树中各节点之间的拓扑连接关系而言,在插入操作之后,可以保证变化量保持在常数的范围,而删除操作却未必能做到这样。
实际上,在很多高级的数据结构和算法中都对这种拓扑结构的变化量有严格的要求,具体来说,我们这里高达logn的变化量绝对是不能满足要求的。我们希望将它们控制到更低,比如在下一章将要介绍的红黑树,则可以将这个变化量严格控制在每次不超过常数0(1),无论对于插入操作还是删除操作都是如此。