之前的文章中我们学习过二叉搜索树,学习完该部分之后,在进行OJ的练习和思考中会发现如果一颗搜索树由于初始结点选择的不好这棵树就会变成成一颗歪脖子树,这样搜索的效率反而会变的不是很理想。那么在今天的文章中我们就要来介绍一种基于搜索树的树 -- AVL树。
AVL树的概念
二叉搜索树虽可以缩短查找的效率,但如果数据有序或接近有序二叉搜索树将退化为单支树,查
找元素相当于在顺序表中搜索元素,效率低下。因此,两位俄罗斯的数学家G.M.Adelson-Velskii
和E.M.Landis在1962年发明了一种解决上述问题的方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右子树高度之差的绝对值不超过1(需要对树中的结点进行调整),即可降低树的高度,从而减少平均搜索长度。
一棵AVL树或者是空树,或者是具有以下性质的二叉搜索树:
- 它的左右子树都是AVL树
- 左右子树的高度之差(简称平衡因子)的绝对值不超过1(-1/1/0)
下图就是一个典型的AVL树,在这里我们讲述的AVL树使用的是平衡因子来进行的平衡。其中每个结点的平衡因子表示其左右子树的高度之差,以8结点为例子就是右子树的高度减去左子树的高度的差即1。
如果一棵二叉搜索树是高度平衡的,它就是AVL树。如果它有n个结点,其高度可保持在
O(log_2 n),搜索时间复杂度O(log_2 n)。
AVL树节点的定义
在定义AVL树的结点的时候,与搜索树不同的地方是,在这里我们定义了一个父亲节点,有了这个节点之后再插入的时候,会带来一些便捷,但也会有一些复杂。此外,还定义了平衡因子。
template<class K, class V>
class AVLTreeNode
{
AVLTreeNode<K, V>* _left;
AVLTreeNode<K, V>* _right;
AVLTreeNode<K, V>* _parent;
pair<K, V> _kv;
int _bf; // balance factor
AVLTreeNode(const pair<K, V>& kv)
:_left(nullptr)
, _right(nullptr)
, _parent(nullptr)
, _kv(kv)
, _bf(0)
{}
};
AVL树的插入
因为AVL树就是在二叉搜索树的基础上引入了平衡因子,因此AVL树的插入与搜索树是类似的。AVL树的插入过程可以分为两步:
- 按照二叉搜索树的方式插入新节点
- 调整结点的平衡因子
bool Insert(const pair<K, V>& kv) {
if (_root == nullptr){
_root = new Node(kv);
return true;
}
Node* parent = nullptr;
Node* cur = _root;
while (cur) {
if (cur->_kv.first < kv.first) {
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first) {
parent = cur;
cur = cur->_left;
}
else {
return false;
}
}
cur = new Node(kv);
if (parent->_kv.first > kv.first) {
parent->_left = cur;
}
else {
parent->_right = cur;
}
cur->_parent = parent;
// 更新平衡因子
return true;
}
在插入中需要注意的就是,由于我们新添增了父亲结点,因此需要在插入时将父亲结点链接。
由于插入之后的可能性会有很多,现在就让我们来分析一下,下面就是一些插入时候可能会发生的情况:
从图中可以看出这样的信息:
- 如果更新完之后,平衡没有出现问题(|bf| <= 1),平衡结构没有受到影响,不需要处理。
- 如果更新完之后,平衡出现问题(|bf| > 1),平衡结构受到影响,需要处理(旋转)
- 插入新增节点,会影响祖先的平因子(全部或者部分)
- 祖先结点的变化方式为:cur == parent->right parent->bf++; cur == parent->left parent->bf--;
然后我们对parent的平衡因子进行分析:
- parent == 1 || parent == -1,parent的子树高度发生了变化,需要继续往上更新,因为1和-1是从0转换过来的,插入之前是parent->bf == 0,说明左右两边高度相等,现在有一边高1,说明parent有一边高一边低,高度发生了变化;
- parent == 0,parent的子树高度不变,不用继续往上更新,这一插入结束。因为0是从1和-1转换过来的,插入之前是parent->bf == 1 || parent->bf == -1,说明插入之前一边高一边低,插入节点填上矮的那边,高度不变。
- parent == 1 || parent == -1,parent所在子树不平衡,需要处理这棵子树(旋转处理)
下面我们就来详细的介绍一下AVL树的旋转:
AVL树的旋转
根据前文中AVL树的插入所示,祖先结点的最终变化一定为2或者-2,那么它一定有一个孩子结点的平衡因子是1和-1,有了高度的变化才会引起祖先结点的变化。
新节点插入较高左子树的左侧 -- 右单旋
右单旋情况的介绍:
这个是一个左单旋的示意图。a/b/c分别代表着一棵高度为h的子树。新插入的结点在a子树中,下面我们先进行一些假设:
假设abc为空,在3号结点的左子树位置新增一个结点,然后对平衡因子进行修正可得;
假设abc为一棵高度为1的子树,在a树随意一个孩子结点上新增一个节点,然后对平衡因子进行修正可得;
假设abc为一颗高度为2的子树,由于高度为2的子树有三种不同情况,但是我们这里要保证a子树一定是完全二叉树,其余的bc两棵子树随意什么形状都可以。这样是因为如果a子树不是一颗满树,那么在插入的时候就有可能不会对祖先结点的平衡因子造成影响,有可能会正好插入在空缺的那一个位置上。
......随着abc树高度的变化,还有着很多的情况,但是这些情况都有着一个特点就是,在新增结点之后祖先的平衡因子一定会变为-2,其左孩子的结点一定会变为-1。下图中就是上面的三种假设的抽象图。
h == 2,新增结点的情况:
不论abc的子树高度为多少,只要我们在a子树中新增一个结点就会造成需要做右单旋处理。
下面我们来进行旋转:
从图中可以看出经过旋转平衡因子已经变为了紫色的0,整棵树已经平衡。
我们让b变成6的左边,让6变为3的右边,这样就完成了右单旋。
下面是右单旋的代码实现:
void RotateR(Node* parent) // 传入需要进行旋转的父结点(parent)即平衡因子为-2的接地那
{
Node* subL = parent->_left; // 父结点的左孩子(subL)节点即平衡因子为-1的结点
Node* subLR = subL->_right; // 需要进行旋转的平衡因子为-1的右孩子(subLR)
parent->_left = subLR; // 让父结点的左子树变为其原来左子树的右子树
if (subLR) // 这里需要注意父结点的左子树的右子树(subLR)是可能为空的,需要进行额外的判断
{
subLR->_parent = parent; // 链接subLR结点的parent指针
}
Node* ppNode = parent->_parent; // 这里需要将parent结点的父结点进行保存
subL->_right = parent;
parent->_parent = subL;
if (ppNode == nullptr) // 这里需要注意的是,parent结点是有可能为root结点的,如果为根节点,之前我们保存的ppNode结点就是为空,需要重新赋根结点,如果不是就需要进行重新的链接,重新链接时需要进行比较确定是ppNode的左子树还是右子树。然后将subL与ppNode互相赋值。
{
_root = subL;
subL->_parent = nullptr;
}
else
{
if (ppNode->_left == parent)
{
ppNode->_left = subL;
}
else // ppNode->_right = parent
{
ppNode->_right = subL;
}
subL->_parent = ppNode;
}
parent->_bf = subL->_bf = 0; // 这里就需要将parent、subL的平衡因子进行修正
}
新节点插入较高右子树的右侧 -- 左单旋
左单旋与右单旋是类似的这里就不再过多的介绍:
左单旋的旋转方式为将b变为3的右边,将3变为6的左边。
下面是左单旋的代码实现:
void RotateL(Node* parent) {
Node* subR = parent->_right;
Node* subRL = subR->_left;
parent->_right = sub_RL;
if (subRL) {
subRL->_parent = parent->_parent;
}
Node* ppNode = parent->_parent;
subR->_left = parent;
parent->_parent = subR;
if (ppNode = nullptr) {
_root = subR;
subR->_parent = nullptr;
}
else {
if (ppNode->_left == parent) {
ppNode->_left = subR;
}
else {// ppNode->_right = parent
ppNode->_right = subR;
}
subR->_parent = ppNode;
}
parent->_bf = subR->_bf = 0;
}
新节点插入较高左子树的右侧 -- 先左单旋再右单旋
下面我们再来展示一种左右双旋的抽象图:
在这个抽象图中ad是高度为h的子树,bc是高度为h-1的子树,新插入的结点在bc这两棵树中的任意一棵。同样下面我们来进行一些假设:
假设6号结点就是新增的结点,对平衡因子进行修正;
假设bc为空树,ad为只有一个结点的树,在6号结点的位置任意一颗子树新增节点,对平衡因子进行修正;
假设ad为一颗高度为2的子树,bc为高度为1的子树在6号结点的任意子树新增一个节点,对平衡因子进行调整;
......还有很多种的情况,但是同样这些的情况都有着这样的一个特点,就是平衡因子调整到最后一定会变为-2,其左孩子的平衡因子一定为1;下面就为这几种情况的模拟图:
下面我们来进行旋转:
在这种情况下单一的左旋或者右旋已经无法起作用了,因此在这里需要我们进行左右双旋,此时的旋转可以对左单旋与右单旋进行复用,但是需要注意的是在旋转结束之后平衡因子的变化已经不再是全部变为0,需要重新进行赋值,下面分别是h==0、h==1、以及更加普遍的情况。
从上图中 可以看出当h == 0时平衡因子都修正为0,其余的情况当新增的子树时6号结点左子树中的结点,那么旋转之后的右子树的平衡因子就会变成1;当新增的子树时6号结点右子树中的结点,那么旋转之后的左子树的平衡因子就会变成-1;因此就需要将6号结点的平衡因子进行记录,方便处理旋转之后的平衡因子。
下面是左右双旋的代码实现:
void RotateLR(Node* parent) {
Node* subL = parent->_left;
Node* subLR = parent->_left->_right;
int bf = subLR->_bf; // 这里需要记录subLR的平衡因子,用来确定后面旋转之后的平衡因子变化。
RotateL(parent->_left);
RotateR(parent);
if (bf == 1) {
subL->_bf = -1;
parent->_bf = 0;
subLR->_bf = 0;
}
else if (bf == -1) {
parent->_bf = 1;
subL->_bf = 0;
subLR->_bf = 0;
}
else {
assert(false);
}
新节点插入较高右子树的左侧 -- 先右单旋再左单旋
右左双旋与左右双旋跟之前的单旋一样也是类似的。下面就展示一下右左双旋的示意图,代码也是与左右双旋类似的:
当我们处于左右双旋的时候我们是在b或c的子树出进行结点的插入操作,假如我们在a子树进行插入的时候就会变为下图的形式:这与我们前面讲述的右旋是一致的,这样就只要使用之前我们学习的右旋即可,还有其他的情况也是可以互相转化的。因此我们就得到了四种旋转的情况。
总结:
假如以pParent为根的子树不平衡,即pParent的平衡因子为2或者-2,分以下情况考虑
1. pParent的平衡因子为2,说明pParent的右子树高,设pParent的右子树的根为pSubR
当pSubR的平衡因子为1时,执行左单旋
当pSubR的平衡因子为-1时,执行右左双旋
2. pParent的平衡因子为-2,说明pParent的左子树高,设pParent的左子树的根为pSubL
当pSubL的平衡因子为-1是,执行右单旋
当pSubL的平衡因子为1时,执行左右双旋
旋转完成后,原pParent为根的子树个高度降低,已经平衡,不需要再向上更新。