数据结构之“艾薇儿”树
封面画师:ツチヤ 封面ID:79898339
本文参考视频:小马哥教育(SEEMYGO) 2019年 恋上数据结构与算法(第一季)
源码仓库:mofan212/data-structure-and-algorithm (github.com)
辅助学习网址:数据结构和算法动态可视化
0. 前言
二叉搜索树的复杂度分析
如果是按照7、4、9、2、5、8、11的顺序添加节点:
如果我们想要添加一个节点 12 ,那么我们需要先和根节点 7 进行比较,然后和其子节点 8 进行比较,最后在和 8 的子节点 11 进行比较,确定插入位置。同理,如果我们要添加节点 1 或 6 也是同样的道理。
不难发现,二叉搜索树的复杂度与元素(节点)的个数无关,与树的高度有关。
因此,上述二叉搜索树(满二叉树)的复杂度为: O(h) = O(logn)
,可以看出二叉搜索树的添加、查找效率很高。
❗但是!
如果我们更换添加节点的顺序,将添加节点的顺序变成从小到大添加:
这时,这棵二叉搜索树的复杂度为: O(h) = O(n)
,二叉搜索树退化成链表 。
我们发现,当二叉树的节点数量 n 较大时,两者的性能差异比较大。
比如 n = 1000000 时,二叉搜索树的最低高度是 20 ,但最大高度是 1000000 ,这两种情况很极端,会导致其搜索效率出现很大的差别。
添加节点顺序不当可能会造成二叉搜索树退化成链表,删除节点时,也可能导致二叉搜索树退化成链表。比如我删除最开始的二叉树中的 2 、8、11、9 四个节点时:
综上可知,添加、删除节点时,都可能会导致二叉搜索树退化成链表。
那么,我们有什么办法防止二叉搜索树退化成链表吗? 让添加、删除、搜索的复杂度维持在O(logn)
。
这时,又一个“天降猛男”登场了 —— 平衡二叉搜索树。💪
1 平衡二叉搜索树
1.1 平衡
平衡(Balance): 当节点数量固定时,左右子树的高度越接近,这棵二叉树就越平衡(高度越低)。
上图所示的二叉树越来越平衡。
这里又可以引入理想平衡
的概念:最理想的平衡,就是像完全二叉树、满二叉树一样,高度是最小的。
1.2 改进二叉搜索树
前面已经分析:节点的添加、删除可能会导致BST退化成链表,但是因为节点的添加、删除是无限制的,基本是随机的,因此我们并不能在节点的添加和删除上天机限制。
但是我们依然有改进方案: 在节点的添加、删除之后,想办法让二叉搜索树恢复平衡(减小树的高度)。
我们还可以继续调整节点的位置,并完全可以达到理想平衡,但是付出的代价可能会比较大。由于增加了调整的次数,反而会增加时间复杂度,甚至超过链表的时间复杂度而得不偿失。
总的来说,我们不能一昧地调整,我们用尽量少的调整次数让BST达到适度的平衡即可 。
一棵达到适度平衡的BST可以称之为: 平衡二叉搜索树。
1.3 平衡二叉搜索树
平衡二叉搜索树,Balanced Binary Search Tree
,简称:BBST
。经典的平衡二叉搜索树有:
- AVL树
- 在Windows NT内核中广泛使用
- 红黑树
- C++ STL (比如map、set等)
- Java中 的 TreeMap、TreeSet、HashMap、HashSet
- Linux 的进程调度
- Nginx 的 timer 管理
一般也称它们为: 自平衡的二叉搜索树 (Self-balancing Binary Search Tree
)
2. AVL树
2.1 基本概念
AVL树是最早发明的自平衡二叉搜索树,那么AVL是什么意思? AVL 取名于两位发明者的名字:
G. M. A
delson-V
elsky 和 E. M. L
andis ,这是两位来自苏联的科学家(“精苏”狂喜😜)
AVL ? 艾薇儿? 艾薇儿树? 嗯! 可以!😂
平衡因子
平衡因子(Balance Factor):某节点的左右子树高度差 (左子树的高度减右子树的高度)。
比如:
对于AVL树来说,AVL树每个节点的平衡因子只可能是 1、0、-1(绝对值小于等于1,如果超过1,称之为“失衡”),换句话说,AVL树每个节点的左右子树高度差不超过 1 。搜索、添加、删除的时间复杂度是O(logn)
。
AVL树示例:
2.2 代码重构
为了便于代码复用,我们可以对编写的二叉搜索树代码进行重构,即:编写一个BinaryTree
类,用来表示所有二叉树都具有的一些公共方法和成员变量;将原来表示二叉搜索树的类BinarySearchTree
改写为BST
,同时使用BST
类继承BinaryTree
。
BinaryTree
中包含的一些方法和成员变量(省略方法的实现,具体实现参考 二叉搜索树 中给出的代码):
1 | package com.yang.Tree; |
BST
中包含的一些方法和成员变量(省略方法的实现,具体实现参考 二叉搜索树 中给出的代码):
1 | package com.yang.Tree; |
AVL树是自平衡二叉搜索树,是一种特殊的二叉搜索树,因此在编写AVL树的代码时,我们可以让表示AVL树的类AVLTree
继承BST
。可以初步得到以下代码:
1 | public class AVLTree<E> extends BST<E>{ |
2.3 添加失衡
比如向下面这棵子树中添加元素 13 :
可以看到,原本这棵子树是平衡的,添加元素 13 后而变成了失衡。最坏情况下,甚至可能导致添加节点的所有祖先节点都失衡,但添加节点的父节点(节点 12 )、非祖先节点(节点 4、6、8)不会失衡。
那么怎么解决因为添加而导致的失衡呢? 需要使用到旋转。 👇
2.4 添加旋转
2.4.1 LL - 右旋转(单旋)
右旋转示意图:
右旋转动态图:
在上述示意图中,n 表示节点, p(parent)表示父节点,g(grandparent)表示祖父节点;T0、T1、T2、T3分别表示节点下的子树,其中 T0、T1 的高度相等,T2、T3 的高度相等。
最开始时,整棵子树是平衡的,当我们在 T0 子树出处插入一个节点(图中红色矩形)时,节点 g 处于失衡状态,我们需要对其进行操作,让它恢复平衡。
这时,需要对 g 用到右旋转 。右旋转步骤如下:
g.left = p.right
p.right = g
- 让 p 成为这棵子树的根节点
- 旋转后仍然是一棵二叉搜索树: T0 < n < T1 < p < T2 < g < T3
- 整棵树都达到平衡
旋转结果如上图的右侧部分。明明是右旋转,为什么要叫LL呢?因为失去平衡的节点是由其左子树的左子树下的节点造成的。
并不是旋转了就完事了,我们还需要注意维护的内容:
- T2、p、g 的
parent
属性 - 先后更新 g 、p 的高度
2.4.2 RR - 左旋转(单旋)
左旋转示意图:
左旋转动态图:
在上述示意图中,n 表示节点, p(parent)表示父节点,g(grandparent)表示祖父节点;T0、T1、T2、T3分别表示节点下的子树,其中 T0、T1 的高度相等,T2、T3 的高度相等。
最开始时,整棵子树是平衡的,当我们在 T3 子树出处插入一个节点(图中红色矩形)时,节点 g 处于失衡状态,我们需要对其进行操作,让它恢复平衡。
这时,需要对 g 用到左旋转 。左旋转步骤如下:
g.right = p.left
p.left = g
- 让 p 成为这棵子树的根节点
- 仍然是一棵二叉搜索树: T0 < g < T1 < p < T2 < n < T3
- 整棵树都达到平衡
旋转结果如上图的右侧部分。
并不是旋转了就完事了,我们还需要注意维护的内容:
- T1、p、g 的
parent
属性 - 先后更新 g、p 的高度
2.4.3 双旋
LR
遇上 LR 的情况(向T1、T2处添加节点),需要先对 p 进行 RR 左旋转,再对 g 进行 LL 右旋转(双旋)。过程如下:
RL
遇上 RL 的情况(向T1、T2处添加节点),需要先对 p 进行 LL 右旋转,再对 g 进行 RR 左旋转(双旋)。过程如下:
2.5 计算平衡因子
由于AVL树的特性,我们在添加元素时需要计算某一节点的平衡因子,并对失衡节点进行旋转,让其再次达到平衡状态,而这一步骤的前提就是需要计算平衡因子。
平衡因子就是某一节点左子树的高度减去右子树的高度 ,明白了这个道理后就很好计算了。
需要找到一种表示节点高度的方法,首先想到的就是在BinaryTree
类中的静态嵌套类Node
中添加表示高度的成员变量height
。但是这么做有一个问题,如果我创建的不是AVL树,只是普通的BST,那么这个时候也会开辟一段内存空间用来存储height
,而BST中并没有用到它,造成空间被白白浪费。
我们可以在AVLTree
类中编写表示AVL树自己节点的类AVLNode
,让这个类继承Node
并添加成员变量height
,这样就可以做到创建AVL树或创建BST时,都有满足自身要求的节点。最后,就可以根据height
来计算平衡因子并判断某一节点是否平衡:
1 | // 判断节点是否平衡 |
AVLTree
继承了BST
,它俩使用相同的节点添加方法 ,但是我们知道AVL树和BST添加的节点是不一样的,AVL树添加的节点中有height
表示高度,而BST是没有的。因为这个差异,我们需要对代码进行修改:
先在BinaryTree
中编写节点添加方法:
1 | // 访问修饰符 protected 让该类的子类可以使用 |
修改BST
中的添加方法,使用createNode()
方法创建节点,同时编写afterAdd()
方法(该方法为空,在AVLTree
进行重写)表示添加节点后需要做的调整:
1 | public void add(E element) { |
最后在AVLTree
中重写createNode()
方法:
1 |
|
2.6 更新高度
前一步需要计算平衡因子,但是计算平衡因子需要某一节点左右子树的高度差,换句话说就是需要左右子树的高度。在前一步中,我们仅仅是设置了表示高度的成员变量height
,当我们给AVL树添加节点后,需要更新其父节点(祖先节点)的高度以便计算平衡因子。
我们可以在AVLNode
内部类中编写更新AVL树节点高度的方法updateHeight()
:
1 | private static class AVLNode<E> extends Node<E> { |
在前一节中,我们计划编写一个afterAdd(Node<E> node)
方法,用于表示添加节点后需要做出的调整,同时该方法传入参数类型为Node
,而在AVLTree
类中节点类型为AVLNode
,我们在afterAdd(Node<E> node)
方法中需要对参数 node 进行强制类型转换,为了避免大量的类型转换代码,我们可以将节点高度更新的方法再次进行封装。在AVLTree
类中再次封装节点高度更新方法:
1 | // 节点高度更新封装 |
2.7 旋转准备
接下来就是旋转前的最后准备了,前面已经分析过两大种单旋—— LL 和 RR (双旋由单旋组合而成),而旋转的重点就是要知道到底是哪种失衡,因此我们最后的准备就是判断是哪种情况导致的失衡。
在此之前,我们需要在AVLTree
类中重写afterAdd(Node<E> node)
,在这个方法中编写添加节点后需要做的操作。
关于afterAdd(Node<E> node)
,有以下几点需要分析:
- 参数 node 表示新添加的节点
- 这个方法主要用来更新高度和修复平衡
- 我们可以沿着插入节点的父节点一直向上寻找,查看有无失衡或直到根节点
- 当前节点处于平衡状态时,我们需要更新它的高度(因为插入了新节点,新节点的祖先节点高度会发生改变)
- 当前节点处于失衡状态时,我们需要让其恢复平衡,最后跳出循环
1 |
|
在上述代码中,更新节点高度的方法updateHeight()
已经编写好了,但是恢复平衡的方法rebalance()
还没有编写,接下来就是他的主场! 😏
首先我们知道,节点失衡是一系列的,所谓一系列就是指当一个节点发生失衡后,最坏可能其祖先节点都失衡。因此,要想恢复平衡就是要针对高度最低的失衡点进行修复。在上述afterAdd()
代码中已经成功获取到了高度最低的失衡点:
接下来要做的就是针对这个节点判断失衡方式并进行恢复。判断节点失衡反思是针对失衡点的子节点进行判断的,如果失衡节点的左子节点的左子节点位置插入了节点,并导致AVL树失衡,我们称这种失衡方式为 LL 。同理可得其他三种失衡方式。
还需要判断失衡点的子节点是左子节点还是右子节点,而之所以失衡是因为左右子树高度差大于了 1 ,因此我们判断失衡方式是需要获取当前节点高度最高的子节点,因为高度最高的子节点(或其子节点)就是插入节点的位置。
在获取高度最高的子节点时,需要比较左右子树的高度,比较结果有三种: 大于、小于、等于。大于和小于很好处理,而针对等于的情况可以选择左子节点返回,也可以选择右子节点返回,我们规定返回当前节点的位置。即:如果当前节点是其父节点的左节点,那么就返回左子节点;反之返回右子节点。
针对这种情况,我们在BinaryTree
类中的静态嵌套类Node
中添加方法,以判断当前节点相对于父节点的位置 :
1 | // 一个节点就是一个Node |
然后在AVLTree
类中的静态嵌套类AVLNode
中编写获取当前节点高度最高的子节点的方法:
1 | private static class AVLNode<E> extends Node<E> { |
最后在AVLTree
类中编写恢复平衡的方法rebalance(Node<E> grand)
:
1 | /** |
上述代码中右旋方法rotateRight(Node<E> node)
和左旋方法rotateLeft(Node<E> node)
尚未编写,我们在下节进行重点分析 !
1 | // 左旋转 |
2.8 实现旋转
在 2.4 旋转 一节中已经分析了旋转的基本原理,接下来就是要使用代码实现旋转。
代码内容分为两部分:
- 旋转逻辑
- 旋转后的处理 (更新父节点、更新高度)
通过比较左旋转和右旋转的旋转逻辑我们可以发现,两种旋转方式除了旋转逻辑之外,旋转后的处理是类似(或者说相同的),为了减少重复代码,我们可以将旋转后的处理操作代码封装成一个方法: afterRotate()
,即:
1 | private void afterRotate(Node<E> grand, Node<E> parent, Node<E> child){ |
左旋转与右旋转逻辑:
1 | // 左旋转 |
2.9 旋转统一
在实现旋转时,我们编写了右旋转rotateRight(Node<E> grand)
和左旋转rotateLeft(Node<E> grand)
对应的方法,但是通过上面的图可以发现旋转是可以使用一个方法来完成的。
a、c、e、g表示子树,可以为空;b、d、f表示节点。它们的高度顺序为: a < b < c < d < e < f < g。 四种情况下最终得到的旋转后的子树都很类似,仅仅是插入节点位置不同。
利用这个原理,可以编写以下用来统一旋转方法rotate()
的代码:
1 | private void rotate( |
然后改写一下恢复平衡的方法rebalance()
:
1 | /** |
我们从最开始给出的示意图可以看到,a
、g
在旋转前后是没有改变的,针对AVL树,我们是可以不用处理这两个地方的。但为了泛用,我依旧将它们进行处理。
2.10 删除失衡
原本这棵子树是平衡的,但当我们删除这棵子树中的元素 16 时,可能会导致父节点或祖先节点失衡(只有一个节点会失衡), 其他节点都不可能失衡 。
针对父节点失衡:删除节点后,删除节点的父节点可能失衡,也可能不失衡。
假设节点 15 的左子树为空, 右子节点只有一个 16 ,当我们删除 16 时,虽然节点 15 的高度发生了改变,但是并没有失衡。
针对上述子树不做任何变化,直接删除节点 16 ,那么节点 15 会失衡,且只有节点 15 会失衡。因为删除节点 16 后,节点 15 的平衡因子发生了变化,但是其高度并没有变化,因此不会影响到其他节点。
同时,我们还可以得出一个结论: 如果删除一个节点导致其父节点失衡,那么删除节点一定位于其父节点高度较小的子树中 。 除此之外,节点恢复平衡后,还可能导致更高层祖先节点失衡。
2.11 删除旋转
LL - 右旋转(单旋)
原本子树是平衡的,当我们删除 红色节点 后,子树处于失衡状态,且是节点 g 处于失衡,因此需要进行恢复平衡。进一步可以得出,失衡的原因是 LL ,因此进行右旋转即可。
旋转后整棵子树恢复平衡了,但是并不代表整棵树恢复了平衡。如果旋转前后子树的高度发生了变化,即: 绿色节点不存在,这棵子树的高度减一。未删除节点前,恰逢这棵子树的父节点的另外一棵子树的高度比这棵子树大 1 ,如果这时候绿色节点不存在,那么导致高度差成为 2 ,这棵子树的父节点失衡。
因此,如果绿色节点不存在,更高层的祖先节点可能也会失衡,需要再次恢复平衡,然后有可能导致更高层的祖先节点失衡…
极端情况下,所有祖先节点都需要进行恢复平衡,共 O(logn)
次调整。
RR - 左旋转(单旋)
LR - RR 左旋转,LL 右旋转(双旋)
遇上 LR 的情况,需要先对 p 进行 RR 左旋转,再对 g 进行 LL 右旋转(双旋)。过程如下:
RL - LL 右旋转,RR 左旋转(双旋)
遇上 RL 的情况,需要先对 p 进行 LL 右旋转,再对 g 进行 RR 左旋转(双旋)。过程如下:
绿色节点为删除节点后子树高度变化的标志,如果绿色节点不存在,那么整棵子树的高度减一,极有可能导致这棵子树的父节点失衡,然后需要再次恢复平衡,然后又可能导致这棵子树父节点的父节点(祖先节点)失衡,而陷入一个循环…
2.12 实现删除
与添加节点类似,我们同样需要在BST
类中添加一个方法afterRemove(Node<E> node)
,表示删除节点后需要进行的调整。
1 | /** |
afterRemove(Node<E> node)
在BST
类中为空,表明该方法在二叉搜索树中的删除方法不用被实现,在需要进行调整的地方进行重写(比如:AVL树中的节点删除操作)。
然后修改BST
类中删除节点的方法remove(Node<E> node)
,在这个方法中合适的位置添加afterRemove()
方法:
1 | // 删除某一节点 |
那么什么地方是合适的位置?我们知道在BST中,删除节点有时并不是真的删除(地址)了需要被删除的节点,可能删除的是它的前驱或后继节点,然后还需要进行覆盖。覆盖时会改变节点的左右子树、父节点,afterRemove()
方法就可以放在改变后,表示在节点真正被删除后对完整的树进行平衡恢复,而不是一删除就进行恢复。
PS:在AVL树中,我们可以看出afterRemove()
方法都是在执行完覆盖调用的,那么可以将它直接写在最后吗?答案是可以的,但是为了后续 红黑树 的实现,不建议这样做,因为红黑树的删除后的调整在不同的位置执行不同。
添加失衡和删除失衡所用的旋转逻辑是一样,代码是一样,可以直接复用。
最后在AVLTree
中重写afterRemove()
方法:
1 |
|
这样,删除的实现就完成了! 🎉 🎊
Q&A
afterRemove()
方法与afterAdd()
方法仅是一个break
的差别,这是为什么?
两者找寻失衡节点、更新高度、恢复平衡的逻辑是一样的,因此两者的打码大致一样。添加失衡只需要修复高度最低的失衡节点就能让整棵树恢复平衡,因此使用break
跳出循环;但对于删除节点是不行的,因为节点恢复平衡后,还可能导致更高层的“祖宗”节点失衡,因此需要一直遍历,直到根节点,故不使用break
。
- 在
afterRemove()
方法中使用node = node.parent
进行循环遍历不会出现空指针异常吗?
不会出现。从BST的节点删除方法中可以看到:我们虽然删除了选中的节点,但只是取消了其他节点对被删除节点的应用,而被删除节点对其父节点的引用并没有取消,因此是不会出现空指针异常的。
2.13 AVL树总结
添加
- 可能会导致所有祖先节点都失衡
- 只要让高度最低的失衡节点恢复平衡,整棵树就恢复了平衡【仅需
O(1)
次调整】
删除
- 只可能会导致父节点或祖先节点失衡(只有一个节点会失衡)
- 让节点恢复平衡后,可能会导致更高层的祖先节点失衡【最多需要
O(logn)
次调整】
平均时间复杂度
- 搜索: O(logn)
- 添加: O(logn),仅需O(1)次的旋转操作(并不是说只操作 1 次,而是说旋转次数是常数级别的)
- 删除: O(logn),最多需要O(logn)次的旋转操作