C++学习记录(15)AVL树
前言
之前我们说过,自己实现的二叉排序树有一个非常坏的极端情况,那就是插入的数据基本有序,很容易造成二叉树退化成单链,这样logN的查找效率直接就没了。
这样比链表复杂,没链表方便,用起来有什么用?
因此,在二叉排序树的基础上进一步加以限定升级得到AVL树。
一、AVL树的概念
面对普通二叉排序树的缺点两位苏联数学家G.M. Adelson-Velsky和E.M.Landis在1962年共同提出了一种巧妙的解决方案,后来用他们名字的首字母命名为AVL树。
AVL的核心思想是通过控制每个结点左右子树高度差严格控制平衡。
在普通二叉排序树基础上:
- AVL树是一棵空树
- AVL也可以是一个左右子树高度差不超过1的二叉搜索树,且左右字数均属于AVL树
在AVL树中我们引入一个平衡因子的概念,即每个结点都有平衡因子,平衡因子 = 右子树高度 - 左子树高度,不难推导出AVL树的平衡因子的取值只可能是-1//0/1,平衡因子只是一个辅助,辅助我们在插入删除等操作时保证AVL树的特性。
除了上面的概念,我还想介绍以下几点:
为什么AVL不规定高度差为0是平衡二叉排序树?
有图有真相,某些情况下,最好的就是高度差为1。
平衡因子的加入起到的作用
静止状态下倒是看不出来,假如你往上挂数据:
是不是一下就看出来哪里不符合AVL树了,原因就是你AVL树的取值只有-1/0/1。
不要觉得没用:
如果直接甩你脸上个这,你还真不一定能看出来不符合AVL树。
AVL树结构
由于实现了AVL的平衡,因此二叉树往往呈现出“遍地开花”而非“一支独大”的行为,类似于完全二叉树,因此高度稳稳控制在logN左右,为以AVL树为底层的相关操作提供了保障。
二、AVL树的结点
template <class K, class V>
struct AVLTreeNode
{AVLTreeNode(const K& key, const V& value):_kv(key,value), _left(nullptr), _right(nullptr), _parent(nullptr), _bf(0){}pair<K, V> _kv;struct AVLTreeNode* _left;struct AVLTreeNode* _right;struct AVLTreeNode* _parent;//预示着以三叉链表的形式存储二叉树结点int _bf;//记录结点平衡因子
};
为了方便维护数据,模仿map里,直接把key-value放到一个pair里;
_bf是用来记录结点的平衡因子的,balance factor嘛;
至于_parent是为了后续操作的方便,到后面自然就知道干嘛的了。
三、AVL树的插入操作
其实正常插入的逻辑仍旧是不变的,想想就是啊,插入个pair,找位置的逻辑还是一句key,只不过new的时候带上value:
bool Insert(const pair<K,V>& kv){if (_root == nullptr){_root = new Node(kv);return true;}
1.AVL树为空,那么就得new一个结点
但是我们传过来的是pair对象,所以干脆直接:
AVLTreeNode(const pair<K,V>& kv):_kv(kv), _left(nullptr), _right(nullptr), _parent(nullptr), _bf(0){}
一不做二不休,直接把Node的底层的pair的初始化改成拷贝构造。
2.AVL树不为空,那么就得查找应该插入的位置
同样道理,还是比较key,那么key < _key就该:
parent = cur;
cur = cur -> _left;
key > _key就该:
parent = cur;
cur = cur->_right;
Node* cur = _root;Node* parent = nullptr;while (cur){if (kv.first < cur->_kv.first){parent = cur;cur = cur->_left;}else if (kv.first > cur->_kv.first){parent = cur;cur = cur->_right;}elsereturn false;}cur = new Node(kv);if (cur->_kv.first < parent->_kv.first)parent->_left = cur; elseparent->_right = cur;return true;}
查找逻辑还是根据key,需要改变的就是key为kv.first,原因也很简单,接口由key-value变为kv,key被pair隐藏到first里,必须指定访问。
另外就是new的时候也得改一下。
3.检查平衡因子
插入的逻辑基本没变,但是由于AVL树的特殊性,插入结点以后并不意味着就结束任务了,比如:
或者:
插入以后直接把某些结点的平衡因子干成2/-2那不炸了。
所以插入以后的第一要务是检查所有平衡因子,但是需要遍历整棵树吗?
可以观察到,其实只有子树被插入的结点的平衡结点的值收到了影响:
而插入的结点一定是叶子结点,如果是叶子结点,那么它的平衡因子就是0,故而插入结点需要检查的平衡因子是插入结点的所有祖先。
经考虑,调整后的情况有以下几种:
- 0->-1;0->-1
这种情况出现在:
挂左子树0->-1 挂右子树0->1
注意昂,千万别就把这当成只有邻近的结点之间适用了,这是推广,只不过这个图最简单。
只要是在其左子树右子树挂就有可能造成这样的变化:
不一定非得紧挨着,拿着辈分来说就是,不一定说儿子变爸爸才因其以上变化,爷爷孙子也行啊。
只要是属于被插入结点的子树的祖先就一定被影响。
- -1->0;1->0
一目了然吧。
- -1->-2;1->2
总结规律
- 插入结点的所有祖先的平衡因子都可能受影响而改变,因此最坏的情况应该从插入结点的父亲一直检查到根结点
- 如果插入后存在-1->0,1->0的情况,则此结点的祖先无需再检测
10的左侧插入一个结点,导致10完全平衡,说明10的左右子树的高度一样,那么以8为根结点的树的右子树的高度其实仍未变化。
怎么形容这个事呢,我的评价是:
假如说有一个人的高跷是这样的,那么补齐短的那边使得与长段齐平,其实高度仍未变化。
- 如果检测到某一点平衡因子为-1/1,则说明此结点所在二叉树没有问题,但仍需继续向上排查
- 如果检测到某一点平衡因子为2/-2,则说明二叉树需要调整,我们采取的方法是旋转
4.旋转
第一个问题:什么时候旋转?
看着这张图很容易解决什么时候旋转的问题。检查插入结点的祖先,而我们的插入逻辑中已经保留插入结点的父结点parent,配上结点里的_parent即可实现祖先的平衡因子的检查。
while (){if (parent->_bf == 0)break;else if (parent->_bf == -1 || parent->_bf == 1)parent = parent->_parent;else if (parent->_bf == -2 || parent->_bf == 2){//不符合平衡二叉树规则,旋转}else//插入后所有的都没匹配,匹配到这说明在这次插入前就已经不是平衡二叉树//既然没有意义,那就直接报错assert(false);}
不难写出内部逻辑,但是while条件还是写不出来,根据分析,最坏的结果就是检查到根结点:
如果根结点的平衡因子是0,直接break,对while的条件根本说不上影响;
如果根结点的平衡因子是-1/1,那么parent会继续更新成nullptr,此时插入结点所有祖先遍历完毕,因此parent不为空时可以检测该结点平衡因子,为空时循环结束。
while (parent){//先更新,再判断if (cur->_kv.first < parent->_kv.first)parent->_bf--;elseparent->_bf++;if (parent->_bf == 0)break;else if (parent->_bf == -1 || parent->_bf == 1)parent = parent->_parent;else if (parent->_bf == -2 || parent->_bf == 2){//不符合平衡二叉树规则,旋转}else//插入后所有的都没匹配,匹配到这说明在这次插入前就已经不是平衡二叉树//既然没有意义,那就直接报错assert(false);}
当然,对于需要旋转的分支我先提前说一下结果,那就是旋转以后可以使插入后需要旋转的子树的高度变化为原来的高度
比如这里的绿色部分可以使得高度由2变回1,所以再往上的祖先的平衡因子不变,就没必要再检查了,也就是旋转的逻辑走完也有break。
所以唯一对while有影响的就是_bf == -1||_bf == 1。
第二个问题,怎么旋转?
直接说情况吧,旋转总结下来分为四种情景:
右单旋、左单旋、左右双旋、右左双旋。
右单旋
抽象成这样的情况,右单旋发生在往a这个抽象子树上插入结点导致a子树高度由h变为h+1从而导致此时平衡因子发生如下变化:
解决方案就是:
其中利用的就是1的右子树b一定是符合1 < b < 2,因此可以将b子树作为2的左子树。
看起来好像是把2压下去了一样,其实核心就是把高的往上举,低的往下压,降降高度,可以看到图上包括真正的效果其实都是把子树高度降低,还原到插入结点前的高度。
当然,我们上述给出来的可能就是一个完整的树,只不过把子树抽象了起来,也可能是某个树的一部分。
比如:
这种是具象化的一个例子,此时不难观察到a = b = c = 0,如果往a插入:
如果按照上面的方法来旋转那么容易得到:
再多就不再执行了,毕竟说句实话,相对高度不变情况下,abc三棵子树可以不断变换高度,每次变换高度都会产生很多种情况,右单旋的抽象图可以无限制的画下去,但那不是吃力不讨好嘛,其实记住这个图然后进行相关操作才是关键,因此直接大致看看需要执行的操作是什么:
看着图写代码噢:
旋转发生的前提条件是发生了插入,并且引起平衡因子的异常,因此先搞出来前提条件:
插入的逻辑和检查平衡因子的逻辑不用多说了,现在直接上判断是不是右单旋即可。
然后就是完善有单选的逻辑:
说高级点确实是旋转,但是实质上还是考验我们怎么改变树的结构也就是指针朝向来实现平衡因子的合法。
不难对照图看出来,只需要改变1 2 b子树根结点三者的指针指向,即可实现右单旋。另外说一句,下面的所有步骤一定需要建立在看着图的前提下才能进行,不然真可以说是抓瞎。
都记录下来用起来也方便,parent就是产生不正常平衡因子的结点我们例子中的2;subL就是parent的左孩子,也就是例子中的1;subLR是parent左孩子的右孩子,也就是例子中的b子树的根结点。
改左右孩子结点完成。
别忘了还需要改_parent结点:
而且这玩意超级讲究,subL的父亲变成parent的父亲,subLR的父亲变成parent,parent的父亲变成subL,一定注意一定要在parent父亲结点修改之前修改subL的父亲,不然干成自指向了。
这些完了以后还有非常隐含的一个点,因为改孩子指向改父亲指向其实还是挺好想到的,但是:
我们上面说了,我们给出来旋转的图可能就是一颗完整的子树,那么最开始的parent,现在的subL就是树的根结点,那么旋转以后需要对_root进行修改。
也有可能是:
一棵树的一部分,画成两个结点是因为,这棵子树可能是一个结点的左子树也可能是右子树。
不管怎么说,旋转完的树总得和原来的树产生关联(哪怕parent传过来的时候是根),因此写成:
如果根结点那就得改变树的根结点,不是根结点要跟原来的树产生关联。
最终代码:
void RotateR(Node* parent){Node* subL = parent->_left;Node* subLR = subL->_right;//parent subL subLR//childparent->_left = subLR;subL->_right = parent;//parentsubL->_parent = parent->_parent;subLR->_parent = parent;parent->_parent = subL;//_root?if (subL->_parent == nullptr)_root = subL;//left or right?else if (subL->_kv.first < subL->_parent->_kv.first)subL->_parent->_left = subL;elsesubL->_parent->_right = subL;//更新平衡因子subL->_bf = parent->_bf = 0;}
旋转完以后平衡因子肯定就变了,这个不多说。
调试一下:
尝试最简单的右旋场景,那就是插入三个结点,一直往最左边插,结果就发现空指针的解引用问题,细细一想,这种最简单的情况刚好是a = b = c = 0的情况,那么subLR可不就是空指针嘛,空指针那必定没有什么_parent之说。
由这个问题我又细细的想了subL不可能为空,parent不可能为空,能够进到右单旋的情况下只有subLR可能为空,当然,parent->_parent也可能为空,但是我们不是注意了嘛。
所以修改一下代码:
void RotateR(Node* parent){Node* subL = parent->_left;Node* subLR = subL->_right;//parent subL subLR//childparent->_left = subLR;subL->_right = parent;//parentsubL->_parent = parent->_parent;if(subLR)subLR->_parent = parent;parent->_parent = subL;//_root?if (subL->_parent == nullptr)_root = subL;//left or right?else if (subL->_kv.first < subL->_parent->_kv.first)subL->_parent->_left = subL;elsesubL->_parent->_right = subL;//更新平衡因子subL->_bf = parent->_bf = 0;}
多if了一下,防止空指针解引用问题。
再次调试没啥毛病。
左单旋
左单旋其实可以说是右单旋的一个镜像,其抽象图为:
有了右单旋的经验,我就不再画图了,直接进行代码的编写,当然,肯定还得看着图才能写好。
void RotateL(Node* parent){Node* subR = parent->_right;Node* subRL = subR->_left;//childparent->_right = subRL;subR->_left = parent;//parentsubR->_parent = parent->_parent;if (subRL)subRL->_parent = parent;parent->_parent = subR;if (_root == parent)_root = subR;else if (subR->_kv.first < subR->_parent->_kv.first)subR->_parent->_left = subR;elsesubR->_parent->_right = subR;subR->_bf = parent->_bf = 0;}
调试基本没问题。
左右双旋
双旋就是旋转两次,原因始于插入结点:
如果是经典的右单旋场景,如果往a子树插入结点:
站在1的视角看是左子树高,站在2的视角也是左子树高。
此时把b挂到2的左子树,2挂到1的右子树解决问题。
但是如果往b子树插入结点:
站在1视角看是右子树高,站在2视角看是左子树高,如果仍旧以右单旋解决问题:
不难看出来问题仍未解决。
解决这个问题的思路说难也简单,那就是转换成纯右单旋场景利用右单旋即可解决问题。
转换纯右旋场景
从插入b子树开始:
如果想要用纯右旋,那么5所在的子树必须也是左子树高,那么就得对于5所在的子树操作。
当然,千万不敢想直接swap(a,b),我刚开始就这么想的,后来想了想,我怎么敢的,二叉查找树的左右子树有严格的大小之分,如果直接swap高度的问题是解决了,但是根本不符合二叉查找树的大小关系啊。
所以还得老老实实的左旋:
我们学的左旋肯定得右边挂着俩子树,所以干脆再拆一下b子树:
这是还未插入结点的图,因为我想了想,一次就插入一个结点,如果往上挂的话,可能挂到e也可能挂到f上,还得分类讨论。
往子树e上插入
在整棵树上:
聚焦于5这个左子树:
对于这个情况,左旋的结果是:
感觉像是没啥问题,用我们之前的左旋即可完成左子树高。
回到整棵树:
直接右旋的结果是:
非常没毛病。
最终需要改变的平衡因子如图所示。
往子树f上插入
先左旋:
再右旋:
查漏补缺
还有个问题,那就是ef是从b里面拆出来的,在上面两种情况中,我们不止一次看到了h-1的抽象子树,究其根本,b不一定就是高度>=1的子树,也就是说,可能就是个空结点。
左旋:
右旋:
总结
如果b!=0,那么插入结点毕竟导致高度差出现,最终旋转后平衡因子不同;如果b=0,那么插入结点不造成高度差。
三种情况都是先左旋再右旋,到时候复用代码即可,问题是我们如何分辨到底是哪种情况,也就是最终平衡因子设置成什么呢?拉过来对比对比有啥区别:
观察容易得知:
- 左右双旋需要的前提是parent == -2&&subL == 1
- 分类最终平衡因子归属是属于例子里的7也就是subLR的值
最终代码:
else if (parent->_bf == -2 && parent->_left->_bf == 1)RotateLR(parent);
//....void RotateLR(Node* parent){Node* subL = parent->_left;Node* subLR = parent->_left->_right;int bf = subLR->_bf;//parent subL subLRRotateL(subL);RotateR(parent);if (bf == 0){parent->_bf = 0;subL->_bf = 0;subLR->_bf = 0;}else if (bf == -1){parent->_bf = 1;subL->_bf = 0;subLR->_bf = 0;}else if (bf == 1){parent->_bf = 0;subL->_bf = -1;subLR->_bf = 0;}elseassert(false);}
调试基本没问题。
右左双旋
由左右双旋类比,出发点应该是:
还是分类讨论:
b=0
e插入
f插入
最终代码:
else if (parent->_bf == 2 && parent->_right->_bf == -1)RotateRL(parent);
//...void RotateRL(Node* parent){Node* subR = parent->_right;Node* subRL = subR->_left;int bf = subRL->_bf;//parent subR subRLRotateR(subR);RotateL(parent);if (bf == 0){parent->_bf = 0;subR->_bf = 0;subRL->_bf = 0;}else if (bf == -1){parent->_bf = 0;subR->_bf = 1;subRL->_bf = 0;}else if (bf == 1){parent->_bf = -1;subR->_bf = 0;subRL->_bf = 0;}elseassert(false);}
5.测试代码
最终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 (kv.first < cur->_kv.first){parent = cur;cur = cur->_left;}else if (kv.first > cur->_kv.first){parent = cur;cur = cur->_right;}elsereturn false;}cur = new Node(kv);//parent cur//_bf = right - leftif (cur->_kv.first < parent->_kv.first)parent->_left = cur; elseparent->_right = cur;cur->_parent = parent;//检测平衡因子while (parent){//先更新,再判断if (cur->_kv.first < parent->_kv.first)parent->_bf--;elseparent->_bf++;if (parent->_bf == 0)break;else if (parent->_bf == -1 || parent->_bf == 1){cur = parent;parent = parent->_parent;}else if (parent->_bf == -2 || parent->_bf == 2){//旋转if (parent->_bf == -2 && parent->_left->_bf == -1)RotateR(parent);//符合右单旋else if (parent->_bf == 2 && parent->_right->_bf == 1)RotateL(parent);else if (parent->_bf == -2 && parent->_left->_bf == 1)RotateLR(parent);else if (parent->_bf == 2 && parent->_right->_bf == -1)RotateRL(parent);elseassert(false);break;}elseassert(false);}return true;}
测试insert就顺手写个递归的中序遍历用:
void testAVLTree1()
{//int arr[] = { 40, 20, 60, 10, 30, 50, 70, 5 };int arr[] = { 50, 30, 70, 20, 40, 60, 80, 35 };AVLTree<int, int> t;for (auto e : arr){t.Insert({ e,e });}t.InOrder();cout << endl;
}int main()
{testAVLTree1();return 0;
}
ai生成的序列,分别测试了单旋和双旋。
简单样例没问题不妨再测试随机值插入:
经测试没啥问题:
#define N 1000000
void testAVLTree1()
{//int arr[] = { 40, 20, 60, 10, 30, 50, 70, 5 };//int arr[] = { 50, 30, 70, 20, 40, 60, 80, 35 };AVLTree<int, int> t;//for (auto e : arr)//{// t.Insert({ e,e });//}srand(time(nullptr));for (size_t i = 0; i < N; i++){t.Insert({ rand()+i,rand()+i });}t.InOrder();cout << endl;
}
而且时间上:
releasex86下基本150ms左右可以插入这么多个数据,为了更方便观察插入的效率:
public:int Height(){return _Height(_root);}int Size(){return _Size(_root);}
private:int _Height(Node* root){if (root == nullptr)return 0;return 1 + max(_Height(root->_left), _Height(root->_right));}int _Size(Node* root){if (root == nullptr)return 0;return 1 + _Size(root->_left) + _Size(root->_right);}
当然,这俩函数效率不咋地,重点是测试多少个样例和树的高度来量化效率。
63w数据,插入用时150ms左右。
四、AVL树的查找操作
道理其实跟普通二叉查找树的一样,毕竟只看key:
Node* Find(const K& key){if (_root == nullptr)return nullptr;Node* cur = _root;while (cur){if (key < cur->_kv.first)cur = cur->_left;else if (key > cur->_kv.first)cur = cur->_right;elsereturn cur;}return nullptr;}
我还调成debug,结果Find的速度超级快,所以这就是为什么非要废了八劲的搞个自平衡二叉查找树,你就说Find的效率快不快吧。
五、AVL是否平衡操作
肯定不能说遍历整个树看看到底平衡因子对不对,因为你insert搞的就是对的平衡因子,因此换个方法验证:
public:bool IsBalanceTree(){return _IsBalanceTree(_root);}
private:bool _IsBalanceTree(Node* root){//空树也是AVLif (root == nullptr)return true;int lefth = _Height(root->_left);int righth = _Height(root->_right);int bf = righth - lefth;if (abs(root->_bf) >= 2 || bf != root->_bf){cout << root->_kv.first << "平衡因子失效:" << root->_bf << endl;return false;}return _IsBalanceTree(root->_left) && _IsBalanceTree(root->_right);}