文章目录
一、引言
二、AVL树的概念
三、AVL树的插入
3、1 AVL树的简单插入
3、2 旋转分类
3、2、1 新节点插入较高右子树的右侧:左单旋
3、2、2 新节点插入较高左子树的左侧:右单旋
3、2、3 新节点插入较高左子树的右侧:左右双旋(先左后右)
3、2、4 新节点插入较高右子树的左侧:右左双旋(先右后左)
四、检查AVL树
4、1 高度差与平衡因子对比
4、2 不同的测试用例进行测试
五、性能分析
4.1 查找操作的复杂度
4.2 插入操作的复杂度
六、总结
🙋♂️ 作者:@Ggggggtm 🙋♂️
👀 专栏:C++、数据结构 👀
💥 标题:AVL树💥
❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️
一、引言
二叉搜索树 虽可以缩短查找的效率,但如果数据有序或接近有序二叉搜索树将退化为单支树,查找元素相当于在顺序表中搜索元素,效率低下。因此,两位俄罗斯的数学家G.M.Adelson-Velskii和E.M.Landis在1962年发明了一种解决上述问题的方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右子树高度之差的绝对值不超过1(需要对树中的结点进行调整),即可降低树的高度,从而减少平均搜索长度。因此就诞生出了AVL树。
AVL树是一种自平衡二叉搜索树,它在查找、插入和删除操作中都能保持较好的性能。它的命名来自于它的发明者,Adelson-Velsky和Landis。AVL树的特点是通过旋转操作来保持平衡,使得任何一个节点的左右子树高度差不超过1。本文将介绍AVL树的概念、实现以及性能分析。首先,我们将解释AVL树的结构和基本概念。然后,我们将详细讨论如何实现AVL树,并提供C++语言的示例代码。最后,我们将对AVL树的性能进行分析。
二、AVL树的概念
AVL树是一种二叉搜索树,它的每个节点包含一个关键字(键值对)、左右孩子指针和父结点指针。每个节点还包含一个平衡因子(balance factor),用于表示该节点的左右子树高度差(平衡因子=左子树高度−右子树高度)。
template<class K,class V> struct AVLTreeNode { AVLTreeNode<K, V>* _left; AVLTreeNode<K, V>* _right; AVLTreeNode<K, V>* _parent; pair<K, V> _kv; //key-value int _bf; //balance factor AVLTreeNode(const pair<K,V>& kv) :_left(nullptr) ,_right(nullptr) ,_parent(nullptr) ,_bf(0) ,_kv(kv) {} };
一棵AVL树或者是空树,或者是具有以下性质的二叉搜索树:
它的左右子树都是AVL树。 左右子树高度之差(简称平衡因子)的绝对值不超过1(-1/0/1)。AVL树的平衡性规则是指任何一个节点的左右子树的高度差不超过1。即对于任意节点,其左子树高度和右子树高度的差绝对值不大于1。
三、AVL树的插入
本篇文章重点介绍AVL树的插入操作。因为在插入操作中包含了所有重要操作。
3、1 AVL树的简单插入
插入一个新节点到AVL树中,需要进行以下几个步骤:
- 执行二叉搜索树的插入操作,找到新节点应该插入的位置。
- 更新相关节点的平衡因子。
- 检查是否破坏了平衡性规则,如果破坏了,则进行相应的旋转操作来恢复平衡。
接着,在新节点插入后,我们需要检查树的平衡性。
- 插入新节点后的平衡因子调整:由于插入可能导致从插入节点到根节点的路径上的某些节点的平衡被破坏,我们需要从新插入的节点开始向上遍历,更新所有受影响节点的平衡因子。
- 平衡调整:一旦检测到某个节点的平衡因子不在范围[-1, 1]之间,就需要对其进行平衡调整,以确保树的平衡性。根据插入节点所在的子树的情况,可以进行四种类型的旋转操作:左旋、右旋、左右旋和右左旋。
- 继续向上检查:在执行旋转操作后,可能会影响到更高层次的节点的平衡性。因此,我们需要继续向上检查,直到根节点为止,以确保整个树的平衡性。
当我们找到合适位置进行插入后,将该节点进行连接,在更新其父节点及相关节点的平衡因子。如下图例子:
假设我们在上图中的值为6的节点左孩子插入一个节点。新插入的节点的平衡因子为0。但是其父节点(值为6的节点和值为7的节点)的平衡因子都发生了变化:6的节点和值为7的节点的平衡因子分别变为-1和0。为什么呢?这就与更新平衡因子的规则有关了。
更新平衡因子的规则:
- 新增在右,parent-> bf++;新增在左,parent-> bf--;
- 更新后,parent->bf ==1or-1,说明parent插入前的平衡因子是0,说明左右子树高度相等,插入后有一边高,parent高度变了,需要继续往上更新;
- 更新后,parent->bf == 0,说明parent插入前的平衡因子是1 or -1,说明左右子树一边高一边低,插入后两边一样高,插入填上了矮了那边,parent所在子树高度不变,不需要继续往上更新;
- 更新后,parent->bf == 2 or -2,说明parent插入前的平衡因子是1 or -1,已经平衡临界值,插入变成2 or -2,打破平衡,parent所在子树需要旋转处理。
- 更新后,parent->bf 大于 2 或者 小于 -2的值,不可能,如果存在,则说明插入前就不是AVL树,需要去检查之前操作的问题。
通过对上述的理解,我们可写出如下代码:
template<class K, class V> class AVLTree { typedef AVLTreeNode<K, V> Node; public: 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.second > kv.second) { parent = cur; cur = cur->_left; } else { return false; } } cur = new Node(kv); if (parent->_kv.first < kv.first) { parent->_right = cur; } else { parent->_left = cur; } cur->_parent = parent; while (parent) { if (parent->_right == cur) { parent->_bf++; } else { parent->_bf--; } if (parent->_bf == 0) { break; } else if (abs(parent->_bf) == 1) { parent = parent->_parent; cur = cur->_parent; } else if (abs(parent->_bf) == 2) { break; } else { assert(false); } } return true; } private: Node* _root = nullptr; };
3、2 旋转分类
旋转也是有其自己的规则的:
- 旋转后需要成为平衡树;
- 旋转不能破环搜索树的规则。
我们先看如下图的情况:
我们在值为9的节点右边进行插入,我们发现其父节点和祖宗节点的平衡因子都发生了变化。此时我们是需要进行旋转调节的。但是我们也可以把该节点插入到其他节点下面,然后也可能会需要进行旋转。需要旋转的情况太多,所以我们需要进行分类。
为了保持树的平衡,AVL树可能需要执行以下四种旋转操作之一:
- 左旋(LL旋转)
- 右旋(RR旋转)
- 先左后右旋(LR旋转)
- 先右后左旋(RL旋转)
下面我们来一一解释各种旋转的原理。
3、2、1 新节点插入较高右子树的右侧:左单旋
左旋是在一个节点的右子树高度大于左子树高度时进行的旋转操作。通过左旋,可以将右子树的高度降低,使得整棵树重新恢复平衡。
左旋的依据是,右子树的高度大于左子树的高度,因此需要将右子树中的某个节点旋转到根节点的位置,使得该节点成为新的根节点,并将原来的根节点作为新根节点的左子节点。
具体需要左旋的情况如下:
左旋是怎么实现的呢?如下图:
上图就是把父节点中右树的左孩子给父节点,父节点连接到右树的左边。通俗解释,把subRL赋给parent->_right(parent->_right=subRL),然后再将parent连接到subR的左(subR->_left=parent)。
我们再分析是否符合旋转的规则。首先旋转后的高度是平衡的。其次符合搜索树的规则。但是我们不仅仅要更换节点,还要更新变换的父节点和平衡因子。我们改变了parent这棵树和子树subR。所以我们还需更新他们的父节点和平衡因子。我们看左旋的代码实现:
void RotateL(Node* parent) { Node* subR = parent->_right; Node* subRL = subR->_left; // 旋转 Node* ppNode = parent->_parent; subR->_left = parent; parent->_right = subRL; // 更新父节点 if (subRL) // h=0的情况下,subRL为nullptr subRL->_parent = parent; parent->_parent = subR; if (_root == parent) { subR->_parent = nullptr; _root = subR; } else { if (ppNode->_left == parent) { ppNode->_left = subR; } else { ppNode->_right = subR; } subR->_parent = ppNode; } // 更新平衡因子 subR->_bf = parent->_bf = 0; }
3、2、2 新节点插入较高左子树的左侧:右单旋
右旋是在一个节点的左子树高度大于右子树高度时进行的旋转操作。通过右旋,可以将左子树的高度降低,使得整棵树重新恢复平衡。
右旋的依据是,左子树的高度大于右子树的高度,因此需要将左子树中的某个节点旋转到根节点的位置,使得该节点成为新的根节点,并将原来的根节点作为新根节点的右子节点。
右旋的情况如下如:
我们再根据下图具体分析一下:
右旋与左旋转很类似,我们都需要借助 parent 来维护该树的平衡。首先是要把subLR子树移动到parent的左树上。然后,再把parent移动到subL的右树上。操作完这两部后,树就平衡了。 我们可结合下图理解:
不要忘记旋转后,要更新所变动节点的父节点和平衡因子,我们看代码:
void RotateLR(Node* parent) { Node* subL = parent->_left; Node* subLR = subL->_right; int bf = subLR->_bf; RotateL(parent->_left); RotateR(parent); subLR->_bf = 0; if (bf == 1) { parent->_bf = 0; subL->_bf = -1; } else if (bf == -1) { parent->_bf = 1; subL->_bf = 0; } else if (bf == 0) { parent->_bf = 0; subL->_bf = 0; } else { assert(false); } }
3、2、3 新节点插入较高左子树的右侧:左右双旋(先左后右)
左右旋是在一个节点的左子树的右子树高度大于左子树高度时进行的旋转操作。左右旋可以通过先进行一次左旋,再进行一次右旋来实现树的平衡。
左右旋的依据是,左子树的右子树高度大于左子树的高度,导致左子树整体比右子树要高。为了保持树的平衡,需要对左子树进行左旋,然后再对原父节点进行右旋。
我们已经讲述了如下两种情况:
那下种情况该怎么旋转呢?我们不妨先自己想一下看看怎么旋转。
由于旋转的原因,我们还需再次展开子树b。上述情况又分为如下三种情况:
上述三种情况只不过是更新平衡因子有所不同,旋转的方法还是相同的。都是先对值为30的子树进行左单旋,然后再对值为90的子树进行右单旋,旋转完成后再 考虑平衡因子的更新。整体的需安装过程如下:
我们可以再结合着代码理解一下:
void RotateLR(Node* parent) { Node* subL = parent->_left; Node* subLR = subL->_right; int bf = subLR->_bf; RotateL(parent->_left); RotateR(parent); subLR->_bf = 0; if (bf == 1) { parent->_bf = 0; subL->_bf = -1; } else if (bf == -1) { parent->_bf = 1; subL->_bf = 0; } else if (bf == 0) { parent->_bf = 0; subL->_bf = 0; } else { assert(false); } }
3、2、4 新节点插入较高右子树的左侧:右左双旋(先右后左)
右左双旋与左右双旋大同小异,只不过是在不同的情况下进行了不同顺序的旋转。旋转的思路和情况分类是一模一样的。我们先看一下需要右左双旋的情况:
同样可按照旋转的需求,划分三种情况(与左右双旋类似)。这里就不在具体画出。具体的旋转过程如下:
再对不同情况下的平衡因子进行更新,代码如下:
void RotateRL(Node* parent) { Node* subR = parent->_right; Node* subRL = subR->_left; int bf = subRL->_bf; RotateR(parent->_right); RotateL(parent); subRL->_bf = 0; if (bf == 1) { subR->_bf = 0; parent->_bf = -1; } else if (bf == -1) { subR->_bf = 1; parent->_bf = 0; } else if (bf == 0) { parent->_bf = 0; subR->_bf = 0; } else { assert(false); } }
四、检查AVL树
当我们自己完成AVL树代码后,我们还需要检查一下是否正确。通过什么方式检查呢?
4、1 高度差与平衡因子对比
我们首先想到的是通过中序遍历打印出来,看其是否是有序的。这样就算验证完成了吗?并不是!!!
我们不仅仅要验证是否为搜索树,话要看其是否为平衡树。但是验证是否是平衡树时,又该怎么检查是否平衡呢?可不可以通过遍历整棵树,看其平衡因子绝对值是否小于等于1呢?怎么就感觉好像又可以,好像又不太行呢?平衡因子是我们自己更新的,还需要我们自己去检查吗?当然不用!!!我们需要求出该子树的高度差与平衡因子比较看是否相同,这样检验才是比较合理的。
有了上述的思路,我们在看代码是怎么实现的,代码如下:
public: void InOrder() { _InOrder(_root); cout << endl; } bool IsBalance() { return _IsBalance(_root); } private: void _InOrder(Node* root) { if (root == nullptr) return; _InOrder(root->_left); cout << root->_kv.first << " " << root->_kv.second << endl; _InOrder(root->_right); } bool _IsBalance(Node* root) { if (root == nullptr) { return true; } int leftHigh = TreeHeight(root->_left); int rightHigh = TreeHeight(root->_right); int subHigh = rightHigh - leftHigh; if (root->_bf != subHigh) { cout << "平衡因子有误:" << root->_kv.first << endl; return false; } return abs(subHigh) < 2 && _IsBalance(root->_left) && _IsBalance(root->_right); } int TreeHeight(Node* root) { if (root == nullptr) { return 0; } return max(TreeHeight(root->_left), TreeHeight(root->_right)) + 1; }
4、2 不同的测试用例进行测试
我们先来看一段特殊的测试代码:
void TestAVLTree1() { int a[] = { 4, 2, 6, 1, 3, 5, 15, 7, 16, 14 }; // 测试双旋平衡因子调节 AVLTree<int, int> t1; for (auto e : a) { t1.Insert(make_pair(e, e)); } t1.InOrder(); cout << "IsBalance:" << t1.IsBalance() << endl; }
先看一下屏蔽掉右左双旋更新平衡因子代码的结果,输出结果如下:
结果是有序的,但是平衡因子是有问题的!我们再把这段代码放开再看其输出结果:
只有一组数据并不能很好的说明问题,我们再随机生成数据进行多次测试,代码如下:
void TestAVLTree2() { size_t N = 10000; srand(time(0)); AVLTree<int, int> t1; for (size_t i = 0; i < N; ++i) { int x = rand(); t1.Insert(make_pair(x, i)); } cout << "IsBalance:" << t1.IsBalance() << endl; }
五、性能分析
4.1 查找操作的复杂度
AVL树的查找操作与普通的二叉搜索树相同,都是按照比较大小的规则在树中查找特定的元素。由于AVL树是平衡二叉搜索树,它保持了左右子树高度的平衡,因此查找操作的平均时间复杂度为O(log n),其中n是树中节点的数量。
在平衡状态下,每一层的节点数大约是前一层的两倍,这保证了树的高度始终保持在logarithmic级别。这种高度平衡的特性使得查找操作的时间复杂度始终保持在O(log n)的范围内,使得AVL树在大量数据的情况下依然能够快速有效地进行查找。
总的来说,AVL树的查找操作性能非常出色,是其设计的主要优势之一。
4.2 插入操作的复杂度
在AVL树中进行插入操作时,需要首先执行标准的二叉搜索树的插入操作,将新节点插入到合适的位置。然后,需要进行平衡调整,以确保插入操作不会破坏树的平衡性。
插入操作的时间复杂度包括两个部分:插入节点的时间复杂度和平衡调整的时间复杂度。插入节点的时间复杂度是O(log n),其中n是树中节点的数量,这是因为AVL树保持了树的高度平衡,使得每一层的节点数大约是前一层的两倍。
平衡调整的时间复杂度取决于进行的旋转操作次数。最坏情况下,插入一个节点可能需要执行O(log n)次旋转操作,但平均情况下,插入节点的时间复杂度仍然是O(log n)。这是因为在绝大多数情况下,只需要进行少数次旋转操作即可将树恢复平衡。
总体来说,AVL树的插入操作的平均时间复杂度是O(log n),使得在插入新元素时仍然能够保持高效的性能。
六、总结
AVL树是一种自平衡的二叉搜索树,其平衡性质使得在查找、插入和删除操作上都能保持较高的性能。通过限制树的平衡因子在一定范围内,AVL树能够在任何时刻保持树的高度平衡,从而避免了退化成链表的情况。
在AVL树的操作中,插入和删除操作可能会导致树的平衡性被破坏,因此需要进行相应的平衡调整操作,包括左旋、右旋、左右旋和右左旋等。这些操作能够使树保持平衡,继续提供高效的性能。文章来源:https://www.toymoban.com/news/detail-636120.html
AVL树的性能分析表明,查找、插入和删除操作的平均时间复杂度均为O(log n),其中n为树中节点的数量。虽然平衡调整可能会增加一些开销,但总体上AVL树仍然能够保持高效的操作性能。文章来源地址https://www.toymoban.com/news/detail-636120.html
到了这里,关于【C++】AVL(平衡二叉搜索树)树的原理及实现的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!