目录
在上篇搜索二叉树末尾提到过,得益于搜索二叉树的性质:大于根往右,小于根往左
;在平衡的条件下,查找一个数的效率可以达到惊人的O(logN),但搜索二叉树最致命的缺陷就是无法保证树身的平衡。于是今天的主角AVL树
就为之而生。
本篇对AVL树的插入,查询功能进行模拟实现,学习它旋转调平衡的奥秘,感悟大佬的神奇构思。
AVL树的介绍
二叉搜索树虽可以缩短查找的效率,但如果数据有序或接近有序二叉搜索树将退化为单支树,查找元素相当于在顺序表中搜索元素,效率低下。
因此,两位俄罗斯的数学家G.M.A
delson-V
elskii和E.M.L
andis在1962年发明了一种解决上述问题的方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右子树高度之差的绝对值不超过1(需要对树中的结点进行调整),即可降低树的高度,维持树身的平衡,从而减少平均搜索长度。
AVL树的定义
AVL
树:是空树,或者是具有以下性质的搜索二叉树:
- 左右子树高度之差(简称
平衡因子
)的绝对值不超过1(-1/0/1) - 它的左右子树都是
AVL
树
如果一棵二叉搜索树是高度平衡的,它就是AVL树。如果它有N个结点,其高度可保持在O(logN),搜索的时间复杂度则为O(logN)。
AVL树节点的定义
AVL树也是链式的树型结构,数据都保存在节点当中。
这里我们的数据不再是一个具体的类型,而是存放在一个类模板pair的对象里,pair
也称键值对。pair
中有first
和second
两个成员。键值对用来表示具有一一对应关系的一种结构,该结构中一般只包含两个成员变量key
和value
,key
代表键值,value
表示与key
对应的信息。这也就是我们提到过的key_value模型。
AVL树节点中有:
- 保存数据的键值对
pair
- 三叉链
- 连接左右孩子及父节点
- 平衡因子
有了三叉链,一定程度上可以减少递归的使用,实现起来容易一点;毕竟递归和循环我还是爱循环多一点。增加平衡因子bf
,可以更好判断树身的平衡,但是后面你就知道是需要付出代价的。
对于AVLTreeNode
,只需要一个构造函数初始化节点即可。
template<class K, class V>
struct AVLTreeNode
{
pair<K, V> _kv;//键值对
//三叉链
AVLTreeNode<K, V>* _left;
AVLTreeNode<K, V>* _right;
AVLTreeNode<K, V>* _parent;
int _bf;//平衡因子
AVLTreeNode(const pair<K, V>& kv)
:_kv(kv)
, _left(nullptr)
, _right(nullptr)
, _parent(nullptr)
,_bf(0)
{}
};
认识AVL树的抽象图
在开始介绍AVL树
的插入调平衡前,需要学会看AVL树的抽象图。
在讲解旋转调平衡时,将采用类似下面的抽象图讲解。
- h代表子树的高度
在下图中,树已近不平衡,高度差的绝对值大于1;需要进行左单旋进行调平衡。此时看到的a,b,c
代表的是一颗子树,其高度为h,h的大小不确定,所以子树的树型是无法确认的,有可能是一个节点(h==1),也有可能是一颗颗的子树(h>=2),所以才称下图为抽象图。
那为什么要学会看抽象图呢?画成具体的一棵树不好吗?
通过算一组数来解答这个问题。
假设此时的h==2
,那么子树a,b,c
可能的形状就有x,y,z
三种可能。而为了符合抽象图的形状,子树a,b
可以是x,y,z
三种里的任意一种(因为插入节点在c这颗子树,所以对a,b子树的形状没有要求)
,而子树c只能是形状x才符合抽象图的形状。这样一来,这整棵树的排列组合就有3(x,y,z选一个
)*3(x,y,z选一个
)*4(新节点可以插在x形状的两个节点的左右两个位置之一
)=36种。这才只是当h==2
的情况下,当h
继续增加时也绝不是简单的按倍数增长。
而旋转调平衡的策略也才四种,所以必须将同一类模型的树身归纳于一个抽象图才能清楚知道当树身不平衡时采取四种旋转策略种的哪一种,从而既能维持BSTree
的特性,又能维持树身平衡。所以会看抽象图是很有必要的,是学习旋转调平衡的第一步。
注意:子树c只能是形状x的子树是因为:当子树c的形状是形状z的子树时(y同理),节点插入到子树c时,确实也需要旋转调平衡了,但是却不是我们上面对应的左单旋的抽象图了。如下图。
AVL树的插入
AVL树的插入操作实际上就是BSTree
插入操作的升级:增加了平衡因子对树身平衡的判断以及后续旋转调平衡的操作。对于AVL树的插入,分为以下四步。
插入流程:
- 按
BSTree
的性质插入。 - 更新平衡因子
bf
。 - 使用平衡因子
bf
判断树身是否平衡(bf的绝对值是否大于1)
- 若不平衡,旋转调平衡。
BSTree规则插入
按
BSTree
的性质插入
- 树为空:则直接新增节点,该节点即为
root
。 - 树不为空:按照BST的性质插入:
- 大于根节点,插入到根节点的右子树,直到找到空的位置,插入成功。
- 小于根节点,插入到根节点的左子树,直到找到空的位置,插入成功。
- BST不支持重复数据,若树中已有该值,插入失败。
更新平衡因子
更新平衡因子
bf
当成功完成一次插入后,会影响部分祖先节点的平衡因子。
由于一个结点的平衡因子是否需要更新,是取决于该结点的左右子树的高度是否发生了变化,因此插入一个结点后,该结点的祖先结点的平衡因子可能需要更新。
对于平衡因子的更新,我们规定:右正左负
- 新增节点在右子树上,则新增节点的父节点平衡因子++
- 新增节点在左子树上,则新增节点的父节点平衡因子- -
因此,平衡因子在插入节点后可能出现的值为:-2 -1 0 1 2
对于不同的平衡因子,影响路径上祖先节点的平衡因子是否需要更新。
cur
为新插入的节点或者是在向上更新平衡因子时原来的父节点parent
parent的平衡因子为0
当parent的平衡因子为0时,说明此时parent的左右子树由原来的一边高变为左右等高,也就是说此时parent这棵子树的高度并没有发生变化,也就不需要再向上更新平衡因子。
parent的平衡因子不为0
分两种情况,parent的平衡因子的绝对值为1或者2
parent的平衡因子为1或者-1
这种情况下说明parent
的左右子树由原来的平衡,变为现在的一边高;导致parent
的左或右子树高度增加,这时候就需要向上更新平衡因子。直到parent
的平衡因子变为0,停止更新;或者parent
的平衡因子变为2或者-2,也就是下一种状况。
向上更新方式
cur = parent;
parent = parent->_parent;
parent的平衡因子为2或者-2
当parent的平衡因子变为2或-2,就违反了AVL树的平衡原则(平衡因子的绝对值大小不大于1),这种情况就需要进行旋转调平衡。
而当parent
的平衡因子为-2/2时,cur
的平衡因子必定是-1/1而不会是0。
理由如下:
若cur
的平衡因子是0,那么cur一定是新增节点,而不是上一次更新平衡因子时的parent
,否则在上一次更新平衡因子时,会因为parent
的平衡因子为0而停止继续往上更新。
而cur
是新增结点的话,其父结点的平衡因子更新后一定是-1/0/1,而不可能是-2/2,因为父节点的平衡因子的2/-2一定是由1/-1变来的。
在新增节点插入前,其父结点的状态有以下两种可能:
- 其父节点是一个左右子树均为空的叶子节点,其平衡因子是0,新增节点插入后其平衡因子更新为-1/1。
- 其父节点是一个左子树或右子树为空的节点,其平衡因子是-1/1,新增节点插入到其父节点的空子树当中,使得其父节点左右子树当中较矮的一棵子树增高了,新增节点后其平衡因子更新为0。
- 所以
parent
的平衡因子为2/-2时,只能由上述的情况1演变而来。新增节点插入后parent
的平衡因子更新为-1/1,继续向上更新平衡因子,此时cur
就变为原来的parent
,其平衡因子就为-1或1了;更新后的parent
的平衡因子之后就有可能变为0(停止继续向上更新平衡因子),或者为2或-2(旋转调平衡)
综上所述,当parent的平衡因子为-2/2时,cur的平衡因子必定是-1/1而不会是0。 这也是接下来判断进行何种旋转的判断条件。
平衡因子的判断
根据上面的结论,依据parent
和cur
的平衡因子来判断进行四种旋转调平衡的策略中的一种。
此时parent
和cur
的平衡因子只有以下可能:
parent
的平衡因子为2/-2cur
的平衡因子为1/-1
- 当
parent
的平衡因子为2,cur
的平衡因子为1时,进行左单旋。 - 当
parent
的平衡因子为2,cur
的平衡因子为-1时,进行右左双旋。 - 当
parent
的平衡因子为-2,cur
的平衡因子为-1时,进行右单旋。 - 当
parent
的平衡因子为-2,cur
的平衡因子为1时,进行左右双旋。
- 同号单旋,异号双旋
实现代码:
bool Insert(const pair<K,V>& kv)
{
if (_root == nullptr)//空树的情况
{
_root = new Node(kv);
return true;
}
Node* cur = _root;//遍历
Node* parent = nullptr;
while (cur)
{
if (kv.first > cur->_kv.first)//<K,V>,比较first
{
parent = cur;
cur = cur->_right;
}
else if (kv.first < cur->_kv.first)
{
parent = cur;
cur = cur->_left;
}
else
{
return false;
}
}
//到在这里说明找到位置了,开始插入。
//此时cur已经为nullptr,让其成为新节点
cur = new Node(kv);
if (kv.first > parent->_kv.first)//确认在左还是在右
{
parent->_right = cur;
}
else//不存在相等情况
{
parent->_left = cur;
}
//处理三叉链的最后一环
cur->_parent = parent;
//更新平衡因子
while (parent)//最差情况下会更新到根
{
if (cur == parent->_right)//连接在父节点的右边
{
parent->_bf++;//右减左:右为正
}
else//左边
{
parent->_bf--;
}
//检查是否需要继续更新
if (parent->_bf == 0)
{
break;
}
else if (parent->_bf == -1 || parent->_bf == 1)
{
//需要向上更新
//cur和parent向上爬,所以等会进行什么旋转也是由这两位的平衡因子决定
cur = parent;
parent = parent->_parent;
}
else if (parent->_bf == -2 || parent->_bf == 2)
{
//此时已经不平衡,需要旋转调平衡
//RR:左单旋
if (parent->_bf == 2 && cur->_bf == 1)
{
RotateL(parent);
}
//右单旋
else if(parent->_bf == -2 && cur->_bf == -1)
{
RotateR(parent);
}
//左右双旋
else if (parent->_bf == -2 && cur->_bf == 1)
{
RotateLR(parent);
}
//右左双旋
else if (parent->_bf == 2 && cur->_bf == -1)
{
//RotateLR(parent);//很棒,三小时
RotateRL(parent);
}
break;//旋转后就一定平衡了,无需继续往上更新平衡因子(旋转后树高度变为插入之前了)
}
else
{
//说明平衡因子有问题
assert(false);
}
}
return true;
}
AVL树的旋转
左单旋
新节点插入较高右子树的右侧
右高,左单旋
parent
的平衡因子为2,cur(subR)
的平衡因子为1 :同号单旋
进行实现时,还是按数据结构学习的方法来——画图,对照着图思路会更加清晰。
旋转步骤:
- 列出旋转涉及的节点
subR
subRL
parentparent
- 处理
parent
与subRL
- 让
subRL
变为parent
的右 - 注意:subRL可能为空;若不为空则让
subRL
的parent
指向parent
- 让
- 处理
subR
与parent
- 让
parent
变为subR
的左 parent
的parent
指向subR
- 让
- 处理当前根
subR
与向上的关系- 若
parentparent
为空,说明此时旋转的树就是整棵树,让subR
成为新的根 parentparent
不为空,说明此时旋转的树是一棵子树,处理subR
的向上关系
- 若
- 最后设置平衡因子,此时已经平衡,直接设为0。
实际上所谓旋转,其本质就是改变对应节点的指针指向;有了三叉链,可以减少递归的使用,方便了实现,但是在调平衡需要多维护一个指针,增加了维护成本。
void RotateL(Node* parent)
{
Node* subR = parent->_right;
Node* subRL = subR->_left;
Node* parentparent = parent->_parent;
parent->_right = subRL;
if (subRL)//当h为0时subRL为nullptr
{
subRL->_parent = parent;
}
subR->_left = parent;
parent->_parent = subR;
//上面先考虑当前子树的调整,接下来再向上考虑整体
if (parentparent == nullptr)//说明此次调整的就是根
{
_root = subR;//subR为新根,更新_root
_root->_parent = nullptr;
}
else//调整的是子树
{
//原本的根为左子树
if (parent == parentparent->_left)
{
parentparent->_left = subR;
}
else
{
parentparent->_right = subR;
}
subR->_parent = parentparent;
}
//调整后的这颗树平衡因子都为0,
parent->_bf = subR->_bf = 0;//paret和subR才是在插入节点路径上的
}
右单旋
新节点插入较高左子树的左侧
左高,右单旋
parent
的平衡因子为-2,cur(subL)
的平衡因子为-1 :同号单旋
参照着抽象图,逻辑和左单旋是一致的。
旋转步骤:
- 列出旋转涉及的节点
subL
subLR
parentparent
- 处理
parent
与subLR
- 让
subLR
变为parent
的左 - 注意:subLR可能为空;若不为空则让
subLR
的parent
指向parent
- 让
- 处理
subL
与parent
- 让
parent
变为subL
的右 parent
的parent
指向subR
- 让
- 处理当前根subL与向上的关系
- 若
parentparent
为空,说明此时旋转的树就是整棵树,让subL
成为新的根 parentparent
不为空,说明此时旋转的树是一棵子树,处理subL
的向上关系
- 若
- 最后设置平衡因子,此时已经平衡,直接设为0。
void RotateR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
Node* parentparent = parent->_parent;
parent->_left = subLR;
if (subLR)
{
subLR->_parent = parent;
}
subL->_right = parent;
parent->_parent = subL;
if (parentparent == nullptr)
{
//说明parent为整棵树的根,此时subL为新根,更新
_root = subL;
_root->_parent = nullptr;
}
else
{
if (parent == parentparent->_left)
{
parentparent->_left = subL;
}
else
{
parentparent->_right = subL;
}
subL->_parent = parentparent;
}
subL->_bf = parent->_bf = 0;
}
左右双旋
新节点插入较高左子树的右侧
不是单纯的一边高,需要转化为单旋的情况
parent
的平衡因子为-2,cur(subL)
的平衡因子为1 :异号双旋
此时的树不是纯粹的一边高,仅通过单旋无法达到平衡的效果。
通过多旋转一次将树型转化为单旋的情况。如下面抽象图的情况:可以先进行左单旋转化为纯粹左边高的情况,这时再进行右单旋。
旋转步骤:
- 列出相关的节点
subL
第一次旋转的点,进行树型转化subL
对应的是此时旋转树的根,虽然它的平衡因子没有违反规定,但我们以它为根旋转的目的是树型转化subLR
不是直接旋转相关的点,列出该节点为记录subLR
的平衡因子
- 旋转
- 第一次以
subL
为旋转点左旋 - 第二次以
parent
为旋转点右旋
- 第一次以
- 调节平衡因子
- 两次旋转都是复用前面的单旋,而单旋的平衡因子都会处理成0,这和双旋的情况是不是适用的
- 需要左右双旋是因为新节点插入较高左子树的右侧,但是较高左子树的右侧也有可能有它的左右子树,因此需要分情况讨论
双旋调节平衡因子:以subLR
的平衡因子作为判断条件,如下图:新增节点分别插入在较高左子树的右侧的左右子树上。会有以下三种情况:
subLR
的平衡因子为-1- 这种情况下
subLR
,subL
都为0,parent
为1
- 这种情况下
subLR
的平衡因子为1subLR
,parent
都为0,subL
为-1
- 当
subLR
的平衡因子为0- 这种状况说明
subLR
为空,插入新节点前只有两个节点
- 这种状况说明
具体请对照图看
void RotateLR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
int bf = subLR->_bf;//记录
RotateL(subL);//先左旋,对应parent
RotateR(parent);//再右旋
//左,右旋的平衡因子都会调成0,是不对的。
//这里根据插入位置的父亲节点的平衡因子来调节,有三种情况
if (bf == 0)
{
parent->_bf = 0;
subL->_bf = 0;
subLR->_bf = 0;
}
else if (bf == 1)
{
subL->_bf = -1;
parent->_bf = subLR->_bf = 0;
}
else if (bf == -1)
{
parent->_bf = 1;
subL->_bf = subLR->_bf = 0;
}
else
{
assert(false);
}
}
双旋的第一次旋转实际上是我们主动控制旋转的,其节点的平衡因子并没有违反平衡规则。主动控制旋转的目的也很明确,就是将当前树型转化为左单旋或右单旋的形状,再通过第二次旋转调平衡。其中可能对第一次的旋转会不会破坏搜索二叉树的性质感到疑惑。
这里通过下面这个双旋进行解答:搜索二叉树的中序遍历会得到一个升序序列;所以,一棵树如果通过中序遍历得到一个升序序列,那么它就具有搜索二叉树的特性。从下面例子可以看到中序遍历旋转一次后的二叉树仍为一个升序序列,则说明其具有搜索二叉树的特性。所以双旋的第一次旋转是不会破坏搜索二叉树特性的。这跟搜索二叉树的删除操作中的找人带策略是类似的。
右左双旋
新节点插入较高右子树的左侧
不是单纯的一边高,需要转化为单旋的情况
parent
的平衡因子为2,cur(subR)
的平衡因子为-1 :异号双旋
右左双旋的旋转步骤,逻辑和左右双旋一致
旋转步骤:
- 列出相关的节点
subR
第一次旋转的点,进行树型转化subR
对应的是此时旋转树的根,虽然它的平衡因子没有违反规定,但我们以它为根旋转的目的是树型转化subRL
不是直接旋转相关的点,列出该节点为记录subRL
的平衡因子
- 旋转
- 第一次以
subR
为旋转点右旋 - 第二次以
parent
为旋转点左旋
- 第一次以
- 调节平衡因子
- 参考左右双旋
平衡因子调节的三种情况:
subRL
的平衡因子为1- 这种情况下
subRL
,subR
都为0,parent
为-1
- 这种情况下
subRL
的平衡因子为-1subRL
,parent
都为0,subR
为1
- 当
subRL
的平衡因子为0- 这种状况说明
subRL
为空,插入新节点前只有两个节点
- 这种状况说明
//右左双旋
void RotateRL(Node* parent)
{
Node* subR = parent->_right;
Node* subRL = subR->_left;
int bf = subRL->_bf;
RotateR(parent->_right);
RotateL(parent);
if (bf == 0)
{
parent->_bf = 0;
subR->_bf = 0;
subRL->_bf = 0;
}
else if (bf == 1)
{
parent->_bf = -1;
subR->_bf = 0;
subRL->_bf = 0;
}
else if (bf == -1)
{
subR->_bf = 1;
subRL->_bf = 0;
parent->_bf = 0;
}
else
{
assert(false);
}
}
AVL树的验证
AVL树是在二叉搜索树的基础上加入了平衡性的限制,因此要验证AVL树,可以分两步:
- 验证其为二叉搜索树
- 如果中序遍历可得到一个有序的序列,就说明为二叉搜索树
- 验证其为平衡树
- 每个节点子树高度差的绝对值不超过1
- 节点的平衡因子是否计算正确
二叉树验证
中序遍历,与搜索二叉树的一致
- 此时使用了
pair
,与上篇的搜索二叉树有所区别,但整体逻辑不变。
public:
void InOrder()
{
//嵌套一层,类外不能访问私有成员
_InOrder(_root);
cout << endl;
}
private:
//嵌套一层
void _InOrder(Node* root)//中序遍历搜索二叉树是有序的
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_kv.first << ":" << root->_kv.second << endl;
_InOrder(root->_right);
}
平衡树验证
平衡的验证是通过树高的比对实现的,所以先实现一个求高度的递归函数_Height
;写一个_IsBalanceTree
验证平衡。
_IsBalanceTree
:通过递归,从叶子节点开始一层一层向上验证;验证的点有两个:左右子树的高度差不大于1,当前节点的平衡因子是否正确(左右子树高度差diff
==平衡因子bf
)。
public:
bool IsBalanceTree()
{
return _IsBalanceTree(_root);
}
int Height()
{
return _Height(_root);
}
private:
int _Height(Node* root)
{
if (root == nullptr)
return 0;
//记录左右子树的高度
int lefthight = _Height(root->_left);
int righthight = _Height(root->_right);
//比较高度,返回大的+1(自身)
return lefthight > righthight ? lefthight + 1 : righthight + 1;
}
bool _IsBalanceTree(Node* root)
{
// 空树也是AVL树
if (nullptr == root)
return true;
// 计算pRoot节点的平衡因子:即root左右子树的高度差
int leftHeight = _Height(root->_left);
int rightHeight = _Height(root->_right);
int diff = rightHeight - leftHeight;//平衡树的diff与bf相等
// 如果计算出的平衡因子与root的平衡因子不相等,或者
// root平衡因子的绝对值超过1,则一定不是AVL树
if (abs(diff)>=2||root->_bf!=diff)//abs计算绝对值
return false;
// root的左和右如果都是AVL树,则该树一定是AVL树
return _IsBalanceTree(root->_left) && _IsBalanceTree(root->_right);
}
AVL树的查找
和搜索二叉树的查找是一致的,只不过现在使用的是键值对pair
Node* Find(const pair<K,V>& kv)
{
if (_root == nullptr)
{
return nullptr;
}
Node* cur = _root;
while (cur)
{
if (kv.first > cur->_kv.first)
{
cur = cur->_right;
}
else if (kv.first < cur->_kv.first)
{
cur = cur->_left;
}
else
{
return cur;
}
}
return nullptr;
}
对于AVL树的实现就到此为止,另一个重要的功能——删除会比插入更加麻烦,旋转次数可能会更多,甚至一次删除调整到根的位置。由于本文有点长了,就不介绍了(实际上没学没写)。对删除功能有兴趣的可以自己查阅相关书籍,或者参考龙哥这位大佬的AVL树的博客——AVL树详细实现
AVL树的性能
AVL树是一棵绝对平衡的二叉搜索树,其要求每个节点的左右子树高度差的绝对值都不超过1,这样可以保证查询时高效的时间复杂度,即
l
o
g
(
N
)
log(N)
log(N)。
但是如果要对AVL树做一些结构修改的操作,性能非常低下,比如:插入时要维护其绝对平衡,旋转的次数比较多,更差的是在删除时,有可能一直要让旋转持续到根的位置。因此:如果需要一种查询高效且有序的数据结构,而且数据的个数为静态的(即不会改变),可以考虑AVL树,但一个结构经常修改,就不太适合。AVL树极度追求平衡,如果需要经常修改数据,旋转的消耗是很大的;这时候就不太适用AVL树了;而红黑树在这方面会更胜一筹。