本文阅读大概需要45分钟,独立编程需要两天,建议预留充足的时间和咖啡。
(友情提示,笔者基本未参考网络资料,边思考边写代码,100%干货,学AVL看这一篇就够了)
学树的顺序,一般来说是:二叉树->二叉查找树->AVL树->2-3-4树->红黑树。它们的难度依次递增。不得不说的是,树是计算机科学最重要研究课题之一。在算法类面试当中,树的考察也是不可或缺的。
先简单回顾一下二叉查找树:
一棵空树,或者是具有下列性质的二叉树:
(1)若左子树不空,则左子树上所有结点的值均小于它的根结点的值;
(2)若右子树不空,则右子树上所有结点的值均大于它的根结点的值;
(3)左、右子树也分别为二叉排序树;
(4)没有键值相等的结点。
让我们介绍一种全新的自平衡二叉树(斜体加粗表示这玩意很酷炫)。相信在你学完并且理解之后。会觉得之前学的树都太low了。
你可能觉得AVL是某些niubilious(自造词,牛批的意思)的英文缩写。事实是,AVL树的名字来源于它的发明作者G.M. Adelson-Velsky 和 E.M. Landis。AVL树是最先发明的自平衡二叉查找树(Self-Balancing Binary Search Tree,简称平衡二叉树)。
它的特点是:左右子树高度差(平衡因子)的绝对值小于等于1. (红色加粗的字体表明你应该记住这句话)
下面邀请插画师turtle配合演示,事实上,整个AVL树的插入和删除操作我们都会作图演示,保证你能看懂:
感谢turtle先生,你今天的粉色画笔非常好看!
数字代表val,字母代表payload。你可以借助字典中的key和value来理解。最下面的数字,我们称为平衡因子(balance factor,bf)。只有一棵树所有节点平衡因子在1,0,-1之间,这棵树才是平衡的。 所谓的树的高度,就是垂直方向,从当前节点到根节点的最大深度。平衡因子等于左右子树高度差,叶子节点平衡因子一定为0。
为什么要满足这个条件呢?(斜体表示你问了一个问题)因为我们希望一棵树尽量对称均匀,看起来漂亮(删除线表示这是错误的理解)我们先区分两个概念
- 满二叉树:除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树为满二叉树(国内定义)。
- 完全二叉树:若设二叉树的深度为k,除第 k 层外,其它各层 (1~k-1) 的结点数都达到最大个数,第k 层所有的结点都连续集中在最左边,这就是完全二叉树。
AVL树是一种完全二叉树,因为这个特性,我们发现它的插入,删除操作最好最坏的情况都是log(n)。
这是怎么得来的? 你如果只是死记而不理解的话,这会很糟糕,你可能会记混淆。一棵完全二叉树的深度设为k , 那么前k-1层一共有2^(k-1)-1个节点,最后一层节点数最少为0,最多为2^(k-1)。设节点总数为N。那么2^(k-1)-1<=N<=2^(k-1)-1+2^(k-1)=2^k-1。解得: k-1<Log2(N+1)<k,按照层数递归的思想,我们的时间复杂度就是O(logN)量级的。
由于之前的博客已经讲解了二叉查找树。下面让我们思考,在插入过程如何保持二叉树动态平衡。
希望你能保持足够的耐心,关闭音乐,集中注意力,只要不是咖啡撒到键盘上就行。
def put (self, val, payload):
if self.root:
self._put(val, payload,self.root)
else:
self.root = BSTNode(val,payload)
def _put(self,val,payload,currentNode):
if val <= currentNode.val:
if not currentNode.left_child:
left_new = BSTNode(val,payload,parent=currentNode)
currentNode.left_child = left_new
else:
self._put(val,payload,currentNode.left_child)
else:
if not currentNode.right_child:
right_new = BSTNode(val,payload,parent=currentNode)
currentNode.right_child = right_new
else:
self._put(val,payload,currentNode.right_child)
这是BST插入的相关代码。下面介绍添加节点时的平衡操作:
Put方法实现
- 我们先考虑当前节点bf的变化,如果当前节点的bf大于1或小于-1,说明我们需要进行旋转操作,下面我会详细介绍;否则在当前节点为左子节点时,我们将其父节点的bf加 1,当前节点为右子节点,其父节点减1. 这里其实是一个递归操作,每当子节点改变,其上层的所有父节点需要改变,除非某个父节点的平衡因子为0.
- 我们先介绍几种平衡方法:左旋(L_Rot),右旋(R_Rot),LR双旋(LR_doubRot),RL双旋(RL_doubRot)。它们是你前所未见的炫操作。
1.左旋(感谢Sun_TTTT博客精彩配图)
简而言之就是某个节点的父节点变成了其左子节点,对于原来的左子节点,变成原来父节点的右节点。
2.右旋
简而言之就是某个节点的父节点变成了其右子节点,对于原来的右子节点,变成原来父节点的左节点。
3.LR双旋
它是左旋和右旋的结合版。
4.RL双旋
LR双旋的相反版本。
至于AVL是如何设计出这样的结构,这不是人类考虑的问题了。但是笔者理解是,为了保持节点平衡,尽可能降低树的高度,在红黑树中也有类似的操作。
好了,介绍了这几种旋转方法,我猜你已经晕头转向了。但是好戏刚刚开始。
- 关于旋转:什么时候进行什么样的旋转?我们需要一点想象力(Ignite your Imagination)。
- 对于直线型结构和回旋镖型结构,如下图所示。在当前节点的平衡因子小于-1且左子节点不存在并且当前节点的右子节点的左子节点存在,那么表示我们需要进行RL双旋,否则进行左旋;在当前节点的平衡因子大于1且右子节点不存在并且当前节点的左子节点的右子节点存在,那么表示我们需要进行LR双旋,否则进行右旋。
代码如下,供参考:
def renew_balance_factor(self,currNode:AVLNode):
if currNode.balance_factor>1 or currNode.balance_factor<-1:
if self.isrebalance: self.rebalance(currNode)
return
if currNode.isLeftChild():
currNode.parent.balance_factor+=1
elif currNode.isRightChild():
currNode.parent.balance_factor-=1
if currNode.parent!=None and currNode.parent.balance_factor != 0 :
self.renew_balance_factor(currNode.parent)
def rebalance(self,currNode:AVLNode):
if currNode.balance_factor>1:# left-heavy sub tree
if not currNode.right_child and currNode.left_child.right_child:
self.LR_doubRot(currNode)
return
else:
self.R_Rot(currNode)
elif currNode.balance_factor<-1:# right-heavy sub tree
if not currNode.left_child and currNode.right_child.left_child:
self.RL_doubRot(currNode)
else:
self.L_Rot(currNode)
- 旋转后平衡因子如何更新
我们在BST中插入节点,如果发现某个节点的平很因子大于1或小于-1。就表示我们该进行自平衡操作了。进行R单旋的情况是直线型结构:
我们只需要将当前节点16绕7旋转至其右节点即可。其他节点不作改变。我们旋转的同时也必须考虑平衡因子的变化,这里会涉及
到比较复杂的数学推导,不过没必要紧张,你完全可以画图理解。
我们只需要更新A和B的平衡因子即可。
上面的new old 分别表示新老节点,h(·)表示节点高度。你看到这可能已经跃跃欲试了,数学可是我的强项呀! L单旋同理。
很不幸的是,对于大部分人,包括笔者,数学都不是我们的强项!所以我们需要调动程序员思维。正所谓“车到山前必有路”。
思考替代方案中。。。
很棒,你已经有了思路,我可以直接获取节点的高度啊!只需要编写一个get_node_bf()函数就行了。需要注意的是,我们需要得到的是该节点左右子树中高度的最大值。所以我们必须进行递归左右子树,左右子树的“路径”,个数我们不知道:按照h(Node) = 1 + max(h(left_child),h(right_child)). 因此我们需要用两个列表path_level_l 和 path_level_r 来分别存储左右子树各个路径的高度。最后套用公式就是该节点的高度。
我们稍微整理一下思路写出代码:
def get_level(node,depth,path_level):
if not node: return 0
if node.isLeaf(): path_level.append(depth);return
depth+=1
if node.left_child:
get_level(node.left_child,depth,path_level)
if node.right_child:
get_level(node.right_child,depth,path_level)
如果是叶子节点其高度为1,bf为0,我们再写一个求节点bf的函数:
def get_node_bf(self,currNode:AVLNode)->int:
# obtain current node's bf
max_l = max_r =0
path_level_l = []
path_level_r = []
if currNode.isLeaf():
return (max_l-max_r)
else:
get_level(currNode.left_child,1,path_level_l)
get_level(currNode.right_child,1,path_level_r)
if not path_level_l: max_l = 0
else:max_l = max(path_level_l)
if not path_level_r: max_r = 0
else: max_r = max(path_level_r)
return (max_l - max_r)
- 进行LR单旋的是“回旋镖型结构”:
我们同样进行平衡因子的动态更新,不过这次只用调用get_node_bf函数即可。注意C先变为B的父节点,B为C左子节点,A再变为C的右子节点,C的父节点改为A的父节点。A的父节点改为C是不是非常简单呢?
- 下面我们将上述过程可视化:
以下面数据为例:
data = {16:'A',3:'B',7:'C',11:'D',9:'E',26:'F',18:'G',14:'H',15:'I'}
大家可以自行验证另一个完全相反的过程,检查自己的代码有无纰漏。
好了,非常高兴你能坚持看到这,如果你觉得困的话,可以明天再看删除操作:
Del 方法实现
你已经喝完了咖啡,是否还感觉困呢?咖啡不要放太多糖。
Del 方法比put方法稍微复杂一点,但是我们有了put的相关方法,因而不会太麻烦。我们构建了上述二叉树,现在尝试依次删除150,130,160,140,155,120,157。
删除有三种情况:
- 删除叶子节点;
- 删除节点只有一个子节点;
- 删除节点有两个子节点。
如果你认真看了我关于二叉查找树的博客的话,会发现万变不离其宗。我们只需要在每次删除节点后更新bf即可。为此我们设计函数update_del_bf()。如果我们发现当前节点不平衡,就将其通过旋转的方式平衡,继续更新它的父节点;如果节点平衡,我们依次更新父节点,直到父节点为None止。
代码如下:
def update_del_bf(self,currNode:AVLNode):
if not currNode:return
currNode.balance_factor = self.get_node_bf(currNode)
if currNode.balance_factor>1 or currNode.balance_factor<-1:
if self.isrebalance: self.rebalance(currNode)
self.update_del_bf(currNode.parent)
return
if currNode.parent:#update all parent bf
self.update_del_bf(currNode.parent)
好了删除操作我们也完成了,下面以图片的形式展示整个删除过程。
上面就是,整个插入和删除的过程,相信你一定会有许多收获或者疑问,欢迎留言或者email durant2019@sina.com。
下一期介绍红黑树*