C++笔记-AVL树(包括单旋和双旋等)
一.AVL树概念
AVL树是最先发明的自平衡二叉查找树,AVL是一颗空树,或者具备下列性质的二叉搜索树:它的左右子树都是AVL树,且左右子树的高度差的绝对值不超过1。AVL树是一颗高度平衡搜索二叉树,通过控制高度差去控制平衡。
AVL树得名于它的发明者G.M.Adelson-Velsky和E.M.Landis是两个前苏联的科学家,他们在1962年的论文《An algorithm for the organization of information》中发表了它。
这就是一个简单的AVL树。
在AVL树这里我们引入一个平衡因子(balance factor)的概念,每个结点都有一个平衡因子,任何结点的平衡因子等于右子树的高度减去左子树的高度,也就是说任何结点的平衡因子等于0/1/-1, AVL树并不是必须要平衡因子,但是有了平衡因子可以更方便我们去进行观察和控制树是否平衡,就像一个风向标一样。
这里平衡因子的计算方法不一定是右子树高度减去左子树高度,当然也可以反过来,这里我是用第一种方式来计算平衡因子。
现在思考一下为什么AVL树是高度平衡搜索二叉树,要求高度差不超过1,而不是高度差是0呢?0不是更好的平衡吗?画画图分析我们发现,不是不想这样设计,而是有些情况是做不到高度差是0的。比如一棵树是2个结点,4个结点等情况下,高度差最好就是1,无法做到高度差是0。
就像这里的2个结点和4个结点,就无法做到高度差为0。
AVL树整体结点数量和分布和完全二叉树类似,高度可以控制在logN,那么增删查改的效率也可以控制在O(logN),相比二叉搜索树有了本质的提升。
二.AVL树的的实现
2.1AVL树的初始化结构
这里和之前的二叉搜索树差不多,不过我们这里采用的是key/value的结构,并且其中多了这节新加的变量:平衡因子和parent变量,后面都会用到。
2.2AVL树的插入
AVL树的插入依旧要遵循二叉搜索树的插入规则,所以前面这部分和二叉搜索树的插入没什么区别,唯一要注意的是我们此时是key/value的结构,所以比较的是pair中的first元素。
并且我这里最后写了要维护parent,每插入一个数据,都要维护其parent变量,便于后面的操作。
上面插入的操作完成后,我们就要进行更新平衡因子的操作,因为我们新插入了数据,高度差就会发生变化,所以此时要更新结点中的平衡因子。
我们思考一个问题:所有的结点中的平衡因子都要被修改吗?
就比如我现在插入一个9,会影响左边那部分的平衡因子吗?
显然是不会影响到左边的,所以我们更新平衡因子最多就到根结点即可。
而此时我们定义的parent就发挥作用了,我们在插入后,父亲的平衡因子就会发生改变,而我们要找到父亲,就要利用parent变量。
下面我们要思考的问题是:要怎样更新平衡因子呢?
更新原则:
1.平衡因子=右子树高度-左子树高度
2.只有子树高度变化才会影响当前结点平衡因子
3.插入结点,会增加高度,所以新增结点是parent的右子树,parent的平衡因子++,新增结点是parent的左子树,parent的平衡因子--
4.parent所在子树的高度是否变化决定了是否会继续向上更新
我们根据上面的更新原则写出以上代码。
此时我们思考一个问题:更新停止的条件是什么?是一直向上更新直到根结点吗?
所以这里又要引出更新停止的条件:
1.更新后parent的平衡因子等于0,更新中parent的平衡因子变化为-1->0或者1->0,说明更新前 parent子树一边高一边低,新增的结点插入在低的那边,插入后parent所在的子树高度不变,不会影响parent的父亲结点的平衡因子,更新结束。
2.更新后parent的平衡因子等于1或-1,更新前更新中parent的平衡因子变化为0->1或者0->-1,说明更新前parent子树两边一样高,新增的插入结点后,parent所在的子树一边高一边低,parent所在的子树符合平衡要求,但是高度增加了1,会影响parent的父亲结点的平衡因子,所以要继续向上更新。
3.更新后parent的平衡因子等于2或-2,更新前更新中parent的平衡因子变化为1->2或者-1->-2,说明更新前parent子树一边高一边低,新增的插入结点在高的那边,parent所在的子树高的那边更高了,破坏了平衡,parent所在的子树不符合平衡要求,需要旋转处理,旋转的目标有两个:1、把 parent子树旋转平衡。2、降低parent子树的高度,恢复到插入结点以前的高度。所以旋转后也不需要继续往上更新,插入结束。
4.不断更新,更新到根,根的平衡因子是1或-1也停止了。
我们先说前两种情况:
第一种呢很好理解,当更新之后如果parent的平衡因子为0,就说明此时这棵树左右已经平衡了,就没必要再继续向上更新了,所以就可以break停止循环。这里可以看上面插入9那种情况,插入9之后10的左右两边就平衡了,10的平衡因子就变为0.
而第二种就是插入过后导致左边高了或者右边高了,此时就需要接着向上调整,直到parent的平衡因子为0或者更新到根结点后,根结点的平衡因子为1或者-1才能停止。
而第三种情况就最为复杂:如果此时parent中的平衡因子为2或者-2,说明此时这棵树已经不构成AVL树,而我们要想它重新变为AVL树,就需要“旋转”来调整这颗树的结构。
就如这张图所示,在插入13后,10的平衡因子就变为2,此时我们观察到以10为根结点的这颗子树就不是AVL树,左子树的高度为0,右子树的高度为2,此时就要旋转。
在讲解旋转之前我们先来了解一下旋转的规则:
1.保持搜索树的原则
2.让旋转的树从不平衡变平衡,其次降低旋转树的高度
旋转总共分为四种:左单旋/右单旋/左右双旋/右左双旋
2.2.1右单旋
右单旋的旋转原则就如上图所示。
本图展示的是10为根的树,有a/b/c抽象为三棵高度为h的子树(h>=0),a/b/c均符合AVL树的要求。10可能是整棵树的根,也可能是一个整棵树中局部的子树的根。这里a/b/c是高度为h的子树,是一种概括抽象表示,他代表了所有右单旋的场景,实际右单旋形态有很多种。
在a子树中插入一个新结点,导致a子树的高度从h变成h+1,不断向上更新平衡因子,导致10的平衡因子从-1变成-2,10为根的树左右高度差超过1,违反平衡规则。10为根的树左边太高了,需要往右边旋转,控制两棵树的平衡。
旋转核心步骤,因为5<b子树的值<10,将b变成10的左子树,10变成5的右子树,5变成这棵树新的根,符合搜索树的规则,控制了平衡,同时这棵的高度恢复到了插入之前的h+2,符合旋转原则。如果插入之前10整棵树的一个局部子树,旋转后不会再影响上一层,插入结束了。
这里有人可能疑惑为什么要这样旋转?
原因呢就是这是发明AVL树的大佬研究出来的规则,所以我们不必深究这样旋转的原因,记着即可。
这里可能不理解为什么这里要把a/b/c抽象成这种方式,我们看几种情况:
第一种情况呢a/b/c可能都是nullptr,高度都为0。这种情况还比较简单,我们接着往下看。
第二种情况,此时a/b/c的高度都为2,并且b/c可以是x/y/z中的任何一种,但是a不能是y/z,因为如果是y/z的话,可能就不会产生旋转,可能a自身就会发生旋转,为了演示整个过程,a只能为x的情况。
a插入节点会引发10节点旋转的插入位置有4种。
所以这里合计3*3*4=36种情况,这里看这情况还不算很多对吧,接着问往下看。
第三种情况,此时a/b/c的高度为3,并且b/c可以是两个中的任何各一个,注意第二个下面的叶子结点可以是1个,可以是2个,可以是3个,也可以是4个,所以就b/c而言,就有15*15种情况。
而a可以是x也可以是y-c,如果是x,那么就有8种情况,如果是y-c,保留三个叶子结点有4种情况,保留两个叶子结点也有四种情况。
那么合计15*15*(8+4*4)=6120种情况,可以看出只是高度+1而已,就多了这么多种情况。因为右单旋的情况太多了我们列举不完的,所以我们才用抽象的方式来表示a/b/c。
这里大家也不要纠结到底是怎么算出来这么多种情况的,没必要,我们掌握右单旋的规则之后直接用即可。
下面我们来实现右单旋的代码:
第二个if语句之前的代码就是按照我们上面所写的规则来写的,但是我们上面举的例子也不完全,而缺少的部分就是第二个if中所写的情况。
缺少的正是我们并不知道parent到底是根结点还是树中间的一个结点,所以我们分为两种情况来写:
1.parent是根结点
2.parent不是根结点
针对第一种是根结点的情况就比较简单,直接让根结点转为subL即可,并更新其_parent。
第二种情况就是判断parent是ppnode的左子树还是右子树,相对应的改变即可,同时也要更新_parent。
最后呢,经过旋转过后我们可以发现此时以5和10为根结点的两颗树都平衡了,所以还要更新两个的平衡因子。
注意:右单旋中的维护_parent的操作不可避免,可以看出在右单旋中多处用到了_parent,如果不维护,下次在使用时就会出问题。
并且我们自己下去写代码时,可以根据我上面所示的图来写,这样更便于我们理解,也能更好的写出来。
2.2.2左单旋
上面我们讲了右单旋,左单旋和右单旋无非就是旋转的方向不一样,里面逻辑是一样的,所以这里我就不过多赘述,给大家展示左单旋的示意图:
正如我上面所说的,和右单旋的图很相似,旋转的逻辑是一样的。
左单旋代码演示:
2.2.3左右双旋
看到这里,我们思考一个问题:仅仅靠两个单旋能解锁所有需要旋转的情况吗?
可能有人觉得能,有人觉得不能,我们来看一个简单的例子:
我们来看这种情况,此时这种插入使得5的平衡因子变为1,而10的平衡因子变为-2,这与我们上面的右单旋或者左单旋情况是不一样的,并且由上图可以看出我们如果依旧使用右单旋,并不能解决问题,无非就是反过来而已,这时候靠左单旋或者右单旋就不能解决问题,需要使用双旋来解决。
何为双旋呢?
简而言之就是通过两次旋转来完成平衡的操作,同时和单旋一样,同样可以更新平衡因子。
上面的例子比较简单,我们再来看复杂一点的情况:
这幅图的情况和上面类似,只不过比上面复杂,这种情况就需要我们利用左右双旋来解决,也就是先左单旋再右单旋。
我们先对以5为根结点的树进行左单旋。
再对以10为根结点的树进行右单旋,此时经过两次旋转过后,整棵树就再次变得平衡,下面就是更新平衡因子。
我们通过上图可以看出,虽然经过两次旋转,但是并不和单旋的情况一样,5,8,10这些根结点的平衡因子并不一定是0,所以我们要分情况来更新他们的平衡因子。
由上面的距离我们可以得出在面临需要双旋的情况下,我们需要把b展开来看,而我们把展开的情况分为上面三种。
前面两种我们可以清晰看到通过左单旋和右单旋后subL和parent的平衡因子不一定为0,需要根据插入新数据的位置是在e的位置还是在f的位置而判断它们的平衡因子。
而第三种情况就是我们看的最简单的情况,b本身就是新插入的数据,此时经过双旋过后各个结点的平衡因子都为0。
所以我们根据以上三种情况来写代码:
前半部分就是根据我们的思路去进行双旋,最后要根据插入新数据的位置来更新三个根结点的平衡因子。
最后的assert断言其实可写可不写,因为不会走到这,但是我们以防万一代码出了什么bug,在这里我加上了。
2.2.4右左双旋
右左双旋和左右双旋的情况也是反过来的:
这是需要进行左右双旋的情况。
而这是需要进行右左双旋的情况,我们来判断是否是左右双旋还是右左双旋可以这样记:左右双旋是右边高的左边高,而右左双旋是左边高的右边高,第一个高指的是新插入数据的parent。
右左双旋同样也分为三种情况:
这里的图省略了第一次旋转的过程,大家下去可以自己画画中间的过程,逻辑和左右双旋是一样的。
下面是代码演示:
旋转呢就是上面所述的四种情况,我们来对旋转做一个总结:
旋转无非就是上图所示的情况,不过后面两种是最简单的情况,经过旋转的四种情况过后,我们来把insert的代码给补全:
要注意在经过旋转过后就要break停止循环,不然程序会进入死循环。
2.2.5Find查找的实现
查找和上节课讲的的一样,和insert的前半段差不多,找到了就返回响应的位置即可,未找到返回nullptr。
2.2.6Height高度和Size结点个数的实现
求一棵树结点的个数和树的高度我们在二叉树那一章节就讲过,利用递归即可,这里就不过多赘述了。
2.2.7检验树是否平衡
这个函数就是检测我们构建的二叉树在不断插入新的数据的过程中是否能够保持平衡,这个代码我就不过多讲解,大家看注释即可,只是给大家作为检测自己写的代码所构建的AVL树是否是合格的而已。
其实AVL树还有一个内容我没有讲解,就是AVL树的删除,这个内容为什么我不讲呢?
因为这个内容说实话比之Insert还要更麻烦一点,但是Insert已经足够我们了解AVL树在插入数据时维持平衡的原理,AVL树我们主要掌握的就是各种旋转的情况和如何进行旋转,感兴趣的可以自己下去找一些书籍看看。
以上就是AVL树的内容。