首页  > 百科热搜  > 查找算法 AVL树

查找算法 AVL树

发布时间:2023-07-01 18:03:35     作者:奇幻小鱼k     浏览量:119    

树字的结构

二叉查找树的树高度影响了查找的效率,需要尽量减小树的高度,AVL树正是这样的树。

目前已经有更加完备的代码实现,请参考:https://github.com/hunterhug/gomap 。

树字的结构

AVL树是一棵严格自平衡的二叉查找树,1962年,发明者 Adelson-VelskyLandis 发表了论文,以两个作者的名字命名了该数据结构,这是较早发明的平衡二叉树。

树字的结构

定义如下:

由于树特征定义,我们可以计算出其高度 h 的上界 h<=1.44log(n),也就是最坏情况下,树的高度约等于 1.44log(n)

假设高度 h 的AVL树最少有 f(h) 个节点,因为左右子树的高度差不能大于1,所以左子树和右子树最少节点为: f(h-1)f(h-2)

因此,树根节点加上左右子树的节点,满足公式 f(h) = 1 + f(h-1) + f(h-2),初始条件 f(0)=0,f(1)=1

经过数学的推算可以得出 h<=1.44log(n),由于计算过程超纲了,在此不进行演算。

树的高度被限制于 1.44log(n), 所以查找元素时使用二分查找,最坏查找 1.44log(n) 次,此时最坏时间复杂度为 1.44log(n),去掉常数项,时间复杂度为:log(n)

为了维持AVL树的特征,每次添加和删除元素都需要一次或多次旋转来调整树的平衡。调整的依据来自于二叉树节点的平衡因子:节点的左子树与右子树的高度差称为该节点的平衡因子,约束范围为 [-1,0,1]

平衡二叉查找树比较难以理解的是添加和删除元素时的调整操作,我们将会具体分析。

AVL树的数据结构如下:

其中 Height 表示以该节点作为树的根节点时该树的高度,方便计算平衡因子。

更新树的高度,代码如下:

计算树的平衡因子,也就是左右子树的高度差,代码如下:

添加元素前需要定位到元素的位置,也就是使用二分查找找到该元素需要插入的地方。

插入后,需要满足所有节点的平衡因子在 [-1,0,1] 范围内,如果不在,需要进行旋转调整。

旋转有四种情况:

旋转规律记忆法:单旋和双旋,单旋反方向,双旋同方向。

以下示意图摘自维基百科,阅读代码时可以参考。

在左子树上插上左儿子导致失衡,需要单右旋:

因为红色元素 2 的产生,其最近的父亲节点 Root 失衡了,元素 2 导致了元素 Root=5 的失衡,需要调整。

Pivot=3 代替元素 5 的位置成为新的 Root,然后元素 5 委屈一下成为 3 的右儿子,而 3 的右儿子变成了 5 的左儿子,如上图。

相应调整后树的高度降低了,该失衡消失。我们可以看到红色元素 2 有两个儿子,实际上在添加操作时它是一个新的节点,是没有儿子的,这种有儿子的情况只发生在删除操作。

如果一时难以理解,可以多看几次图好好思考。

代码如下:

在右子树上插上右儿子导致失衡,需要单左旋:

代码如下:

在左子树上插上右儿子导致失衡,先左后右旋:

代码如下:

直接复用了之前左旋和右旋的代码,虽然难以理解,但是画一下图,确实这样调整后树高度降了,不再失衡,一切 perfect。

在右子树上插上左儿子导致失衡,先右后左旋:

代码如下:

四种旋转代码实现后,我们开始进行添加元素操作:

一开始从树根节点开始插入新值:tree.Root = tree.Root.Add(value),因为插入值后会返回新的根节点,也就是说调整过程中树根节点会变化,所以要重新将新根节点赋予老的根节点。

func (node *AVLTreeNode) Add(value int64) 函数中,如果根节点为空,那么需要返回新的根节点:

接着,如果插入的值和节点的值一样,直接更新 Times

否则根据值的大小,旋转插入到左子树或右子树,我们只分析插入右子树的代码:

因为值添加到了右子树,所以转换成了在右子树添加元素:node.Right = node.Right.Add(value),之后要判断根节点的平衡因子是否变化了。

值插入右子树后,要确保树根左子树的高度不能比右子树低一层。当平衡因子 factor == -2 表示右子树的高度变高了,导致 左子树-右子树 的高度从 -1 变成了 -2,所以要旋转。

判断新插入的值是在右子树的左儿子还是右儿子上:

如果在右子树上插上右儿子导致失衡,需要单左旋:LeftRotation(node),如果在右子树上插上左儿子导致失衡,先右后左旋:RightLeftRotation(node)

最后需要更新树根节点的高度,并返回树根(如果曾经旋转,表示树根变了,需要返回新的树根):

添加元素时先要找到元素插入的位置,找到位置后逐层自底向上更新每个子树的树高度,并根据子树平衡是否被破坏,需要进行旋转操作。

由于树的高度最高为 1.44log(n),查找元素插入位置,最坏次数为 1.44log(n) 次。逐层更新子树高度并判断平衡是否被破坏,最坏需要 1.44log(n) 次,因此可以得知添加元素最坏时间复杂度为:2.88log(n),去掉常数项,时间复杂度为:log(n)

关于旋转次数,当插入节点后,某子树不平衡时最多旋转 2次,也就是双旋该子树即可恢复平衡,该调整为局部特征,调整完后其父层不再需要旋转。也就是说,插入操作最坏旋转两次即可。

由于代码的递归实现方式,当某子树旋转过后其父层子树仍然需要判断平衡因子,判断是否需要旋转,该操作是不必要的,因为子树旋转过后全局已经平衡了,不必再判断父层的平衡因子。

对此可以进行代码优化,在左子树或右子树插入元素后,除了返回根节点,还返回其是否旋转过的辅助变量,如:func (node *AVLTreeNode) Add(value int64) (newNode *AVLTreeNode, rotate bool) ,根据返回的辅助变量 rotate,可以:

但此优化意义不大,因为返回辅助变量后仍然需要判断,判断辅助变量和判断平衡因子,时间复杂度一样。

插入元素进行调整后,需要递归向上更新每一棵子树高度,其时间复杂度为 log(n),但可以优化,当两棵子树高度都没有变化时,那么上面的父层子树们都不需要更新树高度,直接退出,由于是递归程序,如何向上传递这个信息,引入了额外空间成本,且不可避免仍然会出现所有层级的父节点都必须更新树高度,优化意义不是很大。

其他操作与二叉查找树通用,代码如下:

查找操作逻辑与通用的二叉查找树一样,并无区别。

删除元素有四种情况:

后面三种情况最后都变成 情况1,就是将删除的节点变成叶子节点,然后可以直接删除该叶子节点,然后看其最近的父亲节点是否失衡,失衡时对树进行平衡。

举个例子,删除叶子节点,如图:

删除节点 24,导致节点 26 的子树不平衡了,这时需要对该子树进行旋转,旋转后如图:

可以发现这时树仍然不平衡,这时是节点 22 的子树不平衡,需要继续旋转,旋转后如图:

实现代码如下:

当删除的值不等于当前节点的值时,在相应的子树中递归删除,递归过程中会自底向上维护AVL树的特征。

因为删除后可能因为旋转调整,导致树根节点变了,这时会返回新的树根,递归删除后需要将返回的新根节点赋予原来的老根节点。

情况1,找到要删除的值时,该值是叶子节点,直接删除该节点即可:

情况2,删除的节点有两棵子树,选择高度更高的子树下的节点来替换被删除的节点:

情况3和情况4,如果被删除的节点只有一个子树,那么该子树一定没有儿子,不然树的高度就大于1了,所以直接替换值后删除该子树节点:

核心在于删除后的旋转调整,如果删除的值不匹配当前节点的值,对当前节点的左右子树进行递归删除,递归删除后该节点为根节点的子树可能不平衡,我们需要判断后决定要不要旋转这棵树。

每次递归都是自底向上,从很小的子树到很大的子树,如果自底向上每棵子树都进行调整,约束在树的高度差不超过1,那么整棵树自然也符合AVL树的平衡规则。

删除元素后,如果子树失衡,需要进行调整操作,主要有两种:删除后左子树比右子树高,删除后右子树比左子树高。

如果删除了右子树的节点,左边比右边高了,不平衡了:

为什么要这么调整呢,看图说话,有两幅图参考:

这幅图可以看到:

所以应该需要右旋:newNode = RightRotation(node)

这幅图可以看到:

所以应该需要先左后右旋:newNode = LeftRightRotation(node)

还有一种特殊情况,和上面的都不一样,如图:

我们如果删除节点 22 或节点 23,这个时候根节点 20 失衡了。

这个时候,无论使用右旋,还是先左旋后右旋都可以使树恢复平衡,我们的 if 判断条件使用了右旋。

如果是先左旋后右旋,那么旋转后恢复平衡,如图对根结点进行旋转:

如果使用右旋也可以,如图对根结点进行旋转:

如果删除了左子树的节点,右边比左边高了,不平衡了:

为什么要这么调整呢,看图说话,有两幅图参考:

这幅图可以看到:

所以应该需要左旋:newNode = LeftRotation(node)

这幅图可以看到:

所以应该需要先右后左旋:newNode = RightLeftRotation(node)

当然,还有另外一种特殊情况,与 5.1 章节类似,使用左旋还是先右旋后左旋都可以,在这里就不阐述了。

进行调整操作后,需要更新该子树的高度。如果没有旋转过,更新之前节点的树高度。如果曾经旋转过,树根变了,更新新的树根节点高度。

删除操作是先找到删除的节点,然后将该节点与一个叶子节点交换,接着删除叶子节点,最后对叶子节点的父层逐层向上旋转调整。

删除操作的时间复杂度和添加操作一样。区别在于,添加操作最多旋转两次就可以达到树的平衡,而删除操作可能会旋转超过两次。

如图是一棵比较糟糕的 AVL 树:

删除节点1,旋转可以一直旋转到根节点,比插入旋转最多旋转两次的次数更多。

如何确保我们的代码实现的就是一棵 AVL 树呢,可以进行验证:

运行请看完整代码。

运行结果:

可以看到,它确实是一棵 AVL 树。

PS:我们的程序是递归程序,如果改写为非递归形式,效率和性能会更好,在此就不实现了,理解AVL树添加和删除的总体思路即可。

AVL 树作为严格平衡的二叉查找树,在 windows 对进程地址空间的管理被使用到。

收藏文章

收藏

文章标签: AVL     查找     算法    
上一篇:华为北斗卫星消息体验,对比上一代升级了什么 下一篇:治国、治政、治军、治学、治水,北宋“第一流人物”的风采(上)