本节涉及到的全部代码如下,欢迎参考指正!
我们学习的map/multimap/set/multiset等容器有个共同点是:其底层都是按照二叉搜索树来实现的,但是二叉搜索树有其自身的缺陷,假如往树中插入的元素有序或者接近有序,二叉搜索树就会退化成单支树,时间复杂度会退化成O(N),因此map、set等关联式容器的底层结构是对二叉树进行了平衡处理,即采用平衡树来实现。
AVL树
AVL树的概念:
二叉搜索树虽可以缩短查找的效率,但如果数据有序或接近有序二叉搜索树将退化为单支树,查找元素相当于在顺序表中搜索元素,效率低下。因此,两位俄罗斯的数学家G.M.Adelson-Velskii和E.M.Landis在1962年发明了一种解决上述问题的方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右子树高度之差的绝对值不超过1(需要对树中的结点进行调整),即可降低树的高度,从而减少平均搜索长度。一棵AVL树是具有以下性质的二叉搜索树:
- 它的左右子树都是AVL树
- 左右子树高度之差(简称平衡因子)的绝对值不超过1(-1/0/1)
注意:引入平衡因子的概念只是平衡二叉树的其中一种实现方式,这种方式更易于对于我们后续深入理解其工作原理并模拟实现,因此还有其它的实现方式,但无论是哪种方式,都要满足AVL树的基本性质。
空树也可以认为是AVL树。如下,就是一颗AVL树:
如果一棵二叉搜索树是高度平衡的,它就是AVL树。如果它有n个结点,其高度可保持在O(log_2 n),搜索时间复杂度O(log_2 n)
AVL树的模拟实现:
AVL树结点的定义:
#pragma once
#include<iostream>
using namespace std;
//直接来实现KV键值对类型的AVL数,这个会了,K型自然就会了
template<class K,class V>
class AVLTreeNode
{
public:
AVLTreeNode(const pair<K, V>& kv)
:_left(nullptr)
, _right(nullptr)
, _parent(nullptr)
,_kv(kv)
, bf(0)
{}
private:
AVLTreeNode<K,V>* _left;
AVLTreeNode<K, V>* _right;
AVLTreeNode<K, V>* _parent;//这里定义一个父节点会为我们后续调整树结构时提供很大便利
pair<K, V> _kv;
int _bf;//balance factor平衡因子
};
注意:定义结点时相较于普通的二叉搜索树我们给它增加了父节点指针、平衡因子,后续实现我们利用父节点指针能很方便的完成调整,但同样要注意,多一个成员变量,就要多一份维护,每次对一个结点做完调整后,一定不能忘记更新它的父节点以及平衡因子,否则后面会有大坑!!
AVL树的定义:
template<class K, class V>
class AVLTree
{
typedef AVLTreeNode<K, V> Node;//对复杂结点类型重命名为Node
private:
Node* _root=nullptr;//AVL树的成员变量为整棵树的根节点,这里给出缺省值nullptr
};
AVL树的Insert():
相信有了搜索二叉树的基础我们写出插入的逻辑并不是大问题,真正有问题的是插入过后将整棵树调整为AVL树,因此整个AVL的Insert()函数包括:插入+调整【旋转调整】,两部分,插入部分代码直接给出,如下:【考虑到递归的栈溢出问题,且此处用循环很好控制的原则,我们本篇提供的代码只使用循环思想实现,递归的方法可自行实现】
按照二叉搜索树性质插入结点:
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 (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->_right = cur;
}
else if (parent->_kv.first > kv.first)
{
parent->_left = cur;
}
cur->_parent = parent;
//到此插入的工作完成,且更新了插入结点的父节点,接下来我们要进行的就是从下往上调整平衡因子
}
到此,当前结点完成了插入也处理了其父节点,插入部分代码完成,新节点插入后,AVL树的平衡性可能会遭到破坏,此时就需要更新平衡因子,并检测是否破坏了AVL树的平衡性,若没有破坏则直接插入成功,返回true,若破坏了则要进入调整部分。
更新并插入新结点后判断父结点的平衡因子判断是否需要调整:
通过观察我们发现,插入一个新结点,一定会改变其父节点,因此parent结点的平衡因子一定需要更新:
插入前,parent 的平衡因子分为三种情况:-1,0, 1, 插入分以下两种情况:1. 如果cur插入到parent的左侧,只需给pParent的平衡因子-1即可2. 如果cur插入到parent的右侧,只需给pParent的平衡因子+1即可此时:parent的平衡因子可能有三种情况:0,正负1, 正负2图解如下:![]()
针对情况一,要继续向上更新父节点的平衡因子,图解如下:
平衡因子的更新和判断代码如下:
//更新平衡因子 while (parent)//循环结束条件为cur为根节点,即cur的parent为nullptr { //更新parent的平衡因子 if (cur = parent->right) { parent->_bf++; } else { parent->_bf--; } //如果更新后parent的_bf为0,则说明不用调整,直接跳出循环,插入成功 if (parent->_bf == 0) { break; } //如果更新后parent的_bf为1或-1,则向上更新parent和cur,再次判断 else if (parent->_bf == 1 || parent->_bf == -1) { parent = parent->_parent; cur = cur->_parent; } //如果更新后parent的_bf为2或-2,则说明这棵树已经不平衡了,需要先将当前树旋转调整为平衡树 else if (parent->_bf == 2 || parent->_bf == -2) { //旋转调整 } //走到这里说明_bf出现了问题,这里不写死易于后续找到问题所在 else { assert(false); }
到此我们只需要完成当父结点平衡因子为2或-2时旋转调整当前树为AVL树即可,而这剩下的旋转调整部分则是我们学习的核心内容,我们会详细分析各种旋转情况,用画图的方式更加直观的体会旋转的具体操作,并最终将图转化为有效的代码。
如果在一棵原本是平衡的AVL树中插入一个新节点,可能造成不平衡,此时必须调整树的结构,使之平衡化。根据节点插入位置的不同,AVL树的旋转分为四种:![]()
下面我们具象化来对每种情况进行分析1. 新节点插入较高左子树的左侧---左左:右单旋![]()
由上图分析可知,右单旋的方法如下:
插入前,AVL树是平衡的,新节点插入到30的左子树(注意:此处不是左孩子)中,30左子树增加了一层,导致以60为根的二叉树不平衡,要让60平衡,只能将60左子树的高度减少一层,右子树增加一层,即,将左子树往上提,这样60转下来,因为60比30大,只能将其放在30的右子树,而如果30有右子树,右子树根的值一定大于30,小于60,只能将其放在60的左子树,旋转完成后,更新节点的平衡因子即可。在旋转过程中,有以下几种情况需要考虑:1.图中为了可读性我们在旋转后,只更新了平衡因子,但我们不能忘记,每个节点还有一个指向父亲的父指针,改变后还要更新父指针,否则会出现很多指向错误。2. 30节点的右孩子可能存在,也可能不存在(h==0时),记得要判断一下,否则在更新1指出的父指针时可能会出现空指针的解引用问题。3. 60可能是根节点,也可能是子树,如果是根节点,旋转完成后,要更新根节点,如果是子树,可能是某个节点的左子树,也可能是右子树,需判断后正确更新。
综合分析及各个注意事项,我们接下来就可以用代码来实现了
右单旋代码如下【一定要看注释把每一步细节琢磨清楚,非常容易出错】:
//右单旋 if (parent->_bf == -2 && cur->_bf == -1) { RotateR(parent); } void RotateR(Node* parent) { //定义两个变量分别标记将作为替补根的父节点的左,以及后面要改变父亲的替补结点的右 Node* SubL = parent->_left; Node* SubLR = SubL->_right;//就是分析过程中的b树 //为了和上层的树链接,还要先记录一下当前parent的父亲 Node* pparent = parent->_parent; //按照分析改变链接关系 SubL->_right = parent; parent->_parent = SubL; parent->_left = SubLR; //b树不是空树,则更新SubLR的父指针 if (SubLR) { SubLR->_parent = parent; } //如果来之前parent就是根节点了,那直接将根节点更新为SubL,并让其父亲指向空 if (parent == _root) { _root = SubL; _root->_parent = nullptr; } //否则就让pparent指向parent的指针指向SubL else { if (parent == pparent->_left) { pparent->_left = SubL; } else { pparent->_right = SubL; } } //完成链接后记得更新平衡因子!!!! SubL->_bf = parent->_bf = 0; }
2. 新节点插入较高右子树的右侧---右右:左单旋【这种情况的分析方法和右单旋的方法一样,可自行照猫画虎推导,不再画图分析】左单旋代码如下://左单旋 else if (parent->_bf == 2 && cur->_bf == 1) { RotateL(parent); } void RotateL(Node* parent) { Node* SubR = parent->_right; Node* SubRL = SubR->_left; Node* pparent = parent->_parent; //改链接关系 SubR->_left = parent; parent->_parent = SubR; parent->_right = SubRL; if (SubRL) { SubRL->_parent = parent; } if (parent == _root) { _root = SubR; _root->_parent = nullptr; } else { if (pparent->_left == parent) { pparent->_left = SubR; } else { pparent->_right = SubR; } SubR->_parent = pparent; } SubR->_bf = parent->_bf = 0; }
3. 新节点插入较高左子树的右侧---左右:先左单旋再右单旋
由上图分析可知,先左单旋再右单旋的方法如下:
旋转之前,保存pSubLR的平衡因子,旋转完成之后,需要根据该平衡因子来调整其他节点的平衡因子,先对30进行左单旋,再对90进行右单旋, 旋转之前,60的平衡因子可是-1/0/1,旋转完成之后,根据情况对其他节点的平衡因子进 行调整 ,上图刚好是对应的三种情况。综合分析及各个注意事项,我们接下来就可以用代码来实现了先左单旋再右单旋代码如下:
//先左旋后右旋 else if (parent->_bf== - 2 && cur->_bf == 1) { RotateLR(parent); } void RotateLR(Node* parent) { //这里主要是为了得到有插入操作的当前结点左子树的右子树的稳定因子 Node* SubL = parent->_left; Node* SubLR = subL->_right; int bf = subLR->_bf; //记录好起关键作用的稳定因子,直接复用左单旋和右单旋即可 RotateL(parent->_left); RotateR(parent); //旋转完成后根据之前记录的bf对各个结点的_bf进行修改 if (bf == 1) { parent->_bf = 0; SubL->_bf = -1; SubLR->_bf = 0; } else if (bf == -1) { parent->_bf = 1; SubL->_bf = 0; SubLR->_bf=0 } else if (bf == 0) { parent->_bf = 0; SubL->_bf = 0; SubLR->_bf = 0 } else { //这里不要写死,万一bf不等于0或1或-1也好及时报错 assert(false); } }
4. 新节点插入较高右子树的左侧---右左:先右单旋再左单旋【这种情况的分析方法和先左单旋再右单旋的方法一样,可自行照猫画虎推导,不再画图分析】先右单旋再左单旋代码如下://先右单旋后左单旋 else if (parent->_bf == 2 && cur->_bf == -1) { RotateRL(parent); } void RotateRL(Node * parent) { //这里主要是为了得到有插入操作的当前结点左子树的右子树的稳定因子 Node* SubR = parent->_right; Node* SubRL = subR->_left; int bf = subRL->_bf; //记录好起关键作用的稳定因子,直接复用左单旋和右单旋即可 RotateR(parent->_right); RotateL(parent); //旋转完成后根据之前记录的bf对各个结点的_bf进行修改 if (bf == 1) { parent->_bf = -1; SubR->_bf = 0; SubRL->_bf = 0; } else if (bf == -1) { parent->_bf = 0; SubR->_bf = 1; SubRL->_bf = 0 } else if (bf == 0) { parent->_bf = 0; SubR->_bf = 0; SubRL->_bf = 0 } else { //这里不要写死,万一bf不等于0或1或-1也好及时报错 assert(false); } }
等到插入并需要调整的调整完成之后,就说明插入成功,出来直接return true即可,插入部分完整代码如下:
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 (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->_right = cur; } else if (parent->_kv.first > kv.first) { parent->_left = cur; } cur->_parent = parent; //到此插入的工作完成,且更新了插入结点的父节点,接下来我们要进行的就是调整插入后每个结点的平衡因子 //更新平衡因子 while (parent) { //更新parent的平衡因子 if (cur ==parent->_right) { parent->_bf++; } else { parent->_bf--; } //如果更新后parent的_bf为0,则说明不用调整,直接跳出循环,插入成功 if (parent->_bf == 0) { break; } //如果更新后parent的_bf为1或-1,则向上更新parent和cur,再次判断 else if (parent->_bf == 1 || parent->_bf == -1) { parent = parent->_parent; cur = cur->_parent; } //如果更新后parent的_bf为2或-2,则说明这棵树已经不平衡了,需要先将当前树旋转调整为平衡树 else if (parent->_bf == 2 || parent->_bf == -2) { //旋转调整 //右单旋 if (parent->_bf == -2 && cur->_bf == -1) { RotateR(parent); } //左单旋 else if (parent->_bf == 2 && cur->_bf == 1) { RotateL(parent); } //先左旋后右旋 else if (parent->_bf== - 2 && cur->_bf == 1) { RotateLR(parent); } //先右单旋后左单旋 else if (parent->_bf == 2 && cur->_bf == -1) { RotateRL(parent); } //bf值出现问题,及时返回报错 else { assert(false); } //能走到这里,说明当前parent位置以下的树调整好了,不用再继续往上调,直接break即可 break; } else { //走到这里说明_bf的值不是0、1、-1、2、-2,此时直接断言报错 assert(false); } } //插入并调整完成 return true; }
AVL树的中序遍历Inorder():
上面我们依据分析完成了AVL树插入部分的代码,但是到底对不对呢?如何能验证?最简单办法当然就是插入一些值然后中序打印这棵AVL树,如果打印结果是升序,说明首先搜索二叉树插入逻辑是没有问题的,因此我们先来比对搜索二叉树写一个中序遍历打印函数,代码如下:
public: void Inorder() { _Inorder(_root); cout << endl; } private: void _Inorder(Node*& root) { if (root == nullptr) { return; } _Inorder(root->_left); cout << root->_kv.first << " "; _Inorder(root->_right); }
测试结果如下:
判断是否为平衡树 IsBalance()以及求树的高度Hight():
中序遍历结果是升序,只能说明是搜索二叉树没错,但是并不能证明是平衡树,怎么证明是平衡树呢?当然是证明以每个结点为根的树都是平衡树, 递归去检查每个结点左子树高度与右子树高度差绝对值是否小于2,若满足,则平衡的控制逻辑没有错,否则就有问题【注意此处我们不能用_bf来验证,因为_bf是我们自己定义的成员变量,本身可能就存在问题,因此这里我们直观的用计算高度来判断】
代码如下:
int _Hight(Node* root) { if (root == nullptr) { return 0; } int RHight = _Hight(root->_right); int LHight = _Hight(root->_left); return RHight > LHight ? RHight + 1 : LHight + 1; } bool _Isbalance(Node* root) { if (root == nullptr) { return true; } int RHight = _Hight(root->_right); int LHight = _Hight(root->_left); if (RHight - LHight != root->_bf) { cout << root->_kv.first << "平衡因子异常" << endl; return false; } //如果当前结点左子树右子树高度差绝对值小于2且左子树和右子树均是平衡树,则是平衡树返回真,否则返回假 return abs(LHight -RHight ) < 2//别忘了这个判断绝对值的条件,因为_bf本身就可能有问题可能导致高度检查的时候有问题 && _Isbalance(root->_left) && _Isbalance(root->_right); }
测试结果如下:
这步测试完成后,就能判断我们插入数据后的树是一颗平衡搜索二叉树!
插入随机值检验
上面的两组测试用例可能存在一些巧合,接下来我们生成一些随机值插入再看看有没有问题,测试代码如下:
void AVLTreeTest2() { srand(time(0)); size_t N = 100000; AVLTree<int, int> t1; for (int i = 0; i < N; i++) { int x = rand() + i; t1.Insert(make_pair(x, x)); } cout << t1.Isbalance() << endl; cout << t1.Hight() << endl; }
测试结果如下:
到此我们AVL树的插入检验的就差不多了,如果能走到这一步,说明Insert()函数大概率是没什么问题的!!!
AVL树的删除、查找等其它成员函数【简单了解一下就行,实际中会用就行】:
因为AVL树也是二叉搜索树,可按照二叉搜索树的方式查找、删除,然后再更新平衡因子,只不过与插入不同的时,删除节点后的平衡因子更新,最差情况下一直要调整到根节点的位置,我们不需要具体去详细的学习底层,实际上像AVL这样的结构,库里面已经非常的完善了,工作中我们一般不需要自己去写一个这样的结构出来,能够熟练使用现成的就够用了,本人认为学习它最重要的其实就是理解它的旋转调整部分的逻辑,这部分对于我们提升思维有很大的帮助,值得深究。
AVL树的性能
AVL树是一棵绝对平衡的二叉搜索树,其要求每个节点的左右子树高度差的绝对值都不超过1,这样可以保证查询时高效的时间复杂度,即log_2 (N)。但是如果要对AVL树做一些结构修改的操作,性能非常低下,比如:插入时要维护其绝对平衡,旋转的次数比较多,更差的是在删除时,有可能一直要让旋转持续到根的位置。因此:如果需要一种查询高效且有序的数据结构,而且数据的个数为静态的(即不会改变),可以考虑AVL树,但一个结构经常修改,就不太适合,实际中我们用的也不是很多,真正经常高频使用到的实际上是另一种近似平衡的结构,红黑树,后续也会详细总结!