AVL树(包括其代码实现以及思想过程)


前言

AVL树又名自平衡的二叉搜索树,本文将逐步介绍其形成过程和原理思想


``

一、二叉搜索树

1.1 二叉搜索树的概念

⼆叉搜索树⼜称⼆叉排序树,它或者是⼀棵空树,或者是具有以下性质的⼆叉树:
• 若它的左⼦树不为空,则左⼦树上所有结点的值都⼩于等于根结点的值
• 若它的右⼦树不为空,则右⼦树上所有结点的值都⼤于等于根结点的值
• 它的左右⼦树也分别为⼆叉搜索树
• ⼆叉搜索树中可以⽀持插⼊相等的值,也可以不⽀持插⼊相等的值,具体看使⽤场景定义

在这里插入图片描述

二、AVL树

1.定义与特点

当我们遇到如下情况的二叉树搜索树的时候
在这里插入图片描述
可以看出当我们需要遍历或者访问里面的数据变量时,它的时间复杂度就从O(log N)变成了O( N ),从树结构的操作变成了链表操作。
这时候就需要用一种方式来控制其高度上的变化来维持这段树结构。

两位苏联数学家G.M.Adelson-Velsky和E.M.Landis在1962年发明了AVL树(又名自平衡的二叉搜索树)。它时最先被发明的一种自平衡二叉查找树。

2. 平衡因子与高度

让我们来看一下其概念

• AVL树实现这⾥我们引⼊⼀个平衡因⼦(balance_factor)的概念,每个结点都有⼀个平衡因⼦,任何结点的平衡因⼦等于右⼦树的⾼度减去左⼦树的⾼度,也就是说任何结点的平衡因⼦等于0/1/-1,AVL树并不必须要平衡因⼦,但是有了平衡因⼦可以更⽅便我们去进⾏观察和控制树是否平衡,就像⼀个⻛向标⼀样。
• 思考⼀下为什么AVL树是⾼度平衡搜索⼆叉树,要求⾼度差不超过1,⽽不是⾼度差是0呢?0不是更好的平衡吗?画画图分析我们发现,不是不想这样设计,⽽是有些情况是做不到⾼度差是0的。⽐如⼀棵树是2个结点,4个结点等情况下,⾼度差最好就是1,⽆法作为⾼度差是0。
• AVL树整体结点数量和分布和完全⼆叉树类似,⾼度可以控制在 log N ,那么增删查改的效率也可以控制在O(log N) ,相⽐⼆叉搜索树有了本质的提升。

3. AVL树的结构

struct AVLTreeNode
{
 // 需要parent指针,后续更新平衡因⼦可以看到 
 pair<K, V> _kv;
 AVLTreeNode<K, V>* _left;
 AVLTreeNode<K, V>* _right;
 AVLTreeNode<K, V>* _parent;
 int _bf; // balance factor
 AVLTreeNode(const pair<K, V>& kv)
 :_kv(kv)
 , _left(nullptr)
 , _right(nullptr)
 , _parent(nullptr)
 ,_bf(0)
 {}
};

结点变量功能解释:_kv是用来存储结点需要被存储的数据,_left _right _parent 分别是指向左子树 右子树 父节点的指针,_bf是平衡因子用来控制树的高度差

template<class K, class V>
class AVLTree
{
 typedef AVLTreeNode<K, V> Node;
public:
 //...
private:
 Node* _root = nullptr;
};

这片代码块便是AVL树的插入删除等功能实现区域,其中变量_root是一颗AVL树的根结点。

4. 插入操作

4.1插入操作的大致流程

  1. 插⼊⼀个值按⼆叉搜索树规则进⾏插⼊。
void insert(const pair<K, V> kv) {
	Node newnode = new Node(kv);
	if (_root == nullptr) {
		_root = newnode;
	}
	
		Node cur = _root;
		Node parent = nullptr;
		while (cur) {
			if (cur->_kv.first< kv.first) {
				parent = cur;
				cur = cur->_right;
			}
			else {
				parent = cur;
				cur = cur->_left;
			}
		}
		cur = newnode;
		if (parent->_kv.first<kv.first) {
			parent->right = newnode;
		}
		else {
			parent->_left = cur;
		}
		// 链接父亲
		cur->_parent = cur;

		// 控制平衡
		// 更新平衡因子
}

代码思想解释:首先new一个节点对象newnode,判断根节点是否为空,不为空则直接让根节点为新开辟的newnode即可。若不为空,则遍历找到树的空指针,往下遍历的时候需要根据二叉搜索树的规则进行,即定义两个节点类型cur(开始指向根节点)和parent分别记录当前节点和它的父亲,如果需要插入的值kv大于当前节点的话,则让parent指向cur,而cur指向它的右节点(如果小于当前节点则指向左节点)直到cur为空,就到了需要插入的空指针,此时parent的作用就发挥出来了,因为我们只找到需要插入的空指针,但不知道它是在左节点还是右节点,所以我们拿待插入的值kv和parent的_kv进行二叉搜索树规则的比较进行插入即可。

4.2 平衡因⼦更新

进行完上一步更新过后,接下来就是平衡因子的更新

更新原则:
• 平衡因⼦=右⼦树⾼度-左⼦树⾼度
• 只有⼦树⾼度变化才会影响当前结点平衡因⼦。
• 插⼊结点,会增加⾼度,所以新增结点在parent的右⼦树,parent的平衡因⼦++,新增结点在parent的左⼦树,parent平衡因⼦- -
• parent所在⼦树的⾼度是否变化决定了是否会继续往上更新

更新停⽌条件:
• 更新后parent的平衡因⼦等于0,更新中parent的平衡因⼦变化为-1->0或者1->0,说明更新前parent⼦树⼀边⾼⼀边低,新增的结点插⼊在低的那边,插⼊后parent所在的⼦树⾼度不变,不会影响parent的⽗亲结点的平衡因⼦,更新结束。
• 更新后parent的平衡因⼦等于1或-1,更新前更新中parent的平衡因⼦变化为0->1或者0->-1,说明更新parent⼦树两边⼀样⾼,新增的插⼊结点后,parent所在的⼦树⼀边⾼⼀边低,parent所在的⼦树符合平衡要求,但是⾼度增加了1,会影响arent的⽗亲结点的平衡因⼦,所以要继续向上更新。
• 更新后parent的平衡因⼦等于2或-2,更新前更新中parent的平衡因⼦变化为1->2或者-1->-2,说明更新前parent⼦树⼀边⾼⼀边低,新增的插⼊结点在⾼的那边,parent所在的⼦树⾼的那边更⾼了,破坏了平衡,parent所在的⼦树不符合平衡要求,需要旋转处理,旋转的⽬标有两个:1、把parent⼦树旋转平衡。2、降低parent⼦树的⾼度,恢复到插⼊结点以前的⾼度。所以旋转后也不需要继续往上更新,插⼊结束。

接下来的三种情况来帮助我们理解上面一大段文字:

更新到10结点,平衡因⼦为2,10所在的⼦树已经不平衡,需要旋转处理

在这里插入图片描述
更新到中间结点,3为根的⼦树⾼度不变,不会影响上⼀层,更新结束

在这里插入图片描述
最坏更新到根停⽌

在这里插入图片描述

bool insert(const pair<K, V> kv) {
	Node newnode = new Node(kv);
	if (_root == nullptr) {
		_root = newnode;
	}
	
		Node cur = _root;
		Node parent = nullptr;
		while (cur) {
			if (cur->_kv.first< kv.first) {
				parent = cur;
				cur = cur->_right;
			}
			else {
				parent = cur;
				cur = cur->_left;
			}
		}
		cur = newnode;
		if (parent->_kv.first<kv.first) {
			parent->right = newnode;
		}
		else {
			parent->_left = cur;
		}
		// 链接父亲
		cur->_parent = cur;

		// 控制平衡
		// 更新平衡因子
		while (parent)
		{
			if (cur == parent->_left)
				parent->_bf--;
			else
				parent->_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)
			{
				// 旋转

				break;
			}
			else
			{
				assert(false);
			}
		}
		return true;
}

4.3 平衡因子的更新 旋转操作

让我们先来看一下《STL源码剖析》中对需要旋转的情况说明:
在这里插入图片描述
在这里插入图片描述

  1. 保持搜索树的规则
  2. 让旋转的树从不满⾜变平衡,其次降低旋转树的⾼度
    旋转总共分为四种,左单旋/右单旋/左右双旋/右左双旋。

4.4 右单旋

• 本图展⽰的是10为根的树,有a/b/c抽象为三棵⾼度为h的⼦树(h>=0),a/b/c均符合AVL树的要
求。10可能是整棵树的根,也可能是⼀个整棵树中局部的⼦树的根。这⾥a/b/c是⾼度为h的⼦树,
是⼀种概括抽象表⽰,他代表了所有右单旋的场景,实际右单旋形态有很多种,具体图2/图3/图4/
图5进⾏了详细描述。
• 在a⼦树中插⼊⼀个新结点,导致a⼦树的⾼度从h变成h+1,不断向上更新平衡因⼦,导致10的平
衡因⼦从-1变成-2,10为根的树左右⾼度差超过1,违反平衡规则。10为根的树左边太⾼了,需要
往右边旋转,控制两棵树的平衡。
• 旋转核⼼步骤,因为5<b⼦树的值<10,将b变成10的左⼦树,10变成5的右⼦树,5变成这棵树新
的根,符合搜索树的规则,控制了平衡,同时这棵的⾼度恢复到了插⼊之前的h+2,符合旋转原
则。如果插⼊之前10整棵树的⼀个局部⼦树,旋转后不会再影响上⼀层,插⼊结束了。
在这里插入图片描述
可以看出大致过程是根据二叉搜索树的规则进行的,即:当一个树parent的结点的平衡因子为-2时,进行右单旋,及定义其左孩子为subL,左孩子的右孩子为subLR。让subLR做parent的左节点,parent做subL的右孩子。虽然步骤很简单,但是其中的代码步骤细节还是很麻烦的需要考虑到每个节点的指向和指向这个节点的节点

4.4.1 右单旋代码实现
void RotateR(Node* parent)
{
 Node* subL = parent->_left;
 Node* subLR = subL->_right;
 // 需要注意除了要修改孩⼦指针指向,还是修改⽗亲 
 parent->_left = subLR;
 if (subLR)
 subLR->_parent = parent;
 Node* parentParent = parent->_parent;
 subL->_right = parent;
 parent->_parent = subL;
 
 // parent有可能是整棵树的根,也可能是局部的⼦树 
 // 如果是整棵树的根,要修改_root 
 // 如果是局部的指针要跟上⼀层链接 
 if (parentParent == nullptr)
 {
 _root = subL;
 subL->_parent = nullptr;
 }
 else
 {
 if (parent == parentParent->_left)
 {
 parentParent->_left = subL;
 }
 else
 {
 parentParent->_right = subL;
 }
 subL->_parent = parentParent;
 }
 parent->_bf = subL->_bf = 0;
}

在这里插入图片描述

代码思想解释:首先定义节点指针变量subL,subLR,两者的作用以及在上面解释过了。首先调整subLR,让parent的左边指向它,subLR的成员变量_parent指向parent,但是需要注意subLR的高度h如果h是0,则代表它是空指针,所以需要加入if(subLR)判断过后再访问,避免对空指针的引用。接着调整parent,同样考虑指向它的和它指向的,让subL的右边指向它,parent的成员变量_parent指向subL,但是在改变parent的成员变量_parent之前我们需要用一个变量pParent来记录改变前的_parent,目的是最后的subL的指向改变。如果parentParent == nullptr,说明parent是根节点,这时候就直接让subL为_root,subL的_parent为空即可。但如果不是就需要根据上面subLR和parent的改变方式去改变即可。

最后让parent和subL的平衡因子都变为0就完成了右单旋。

4.4.2 右单旋的情况(仅供了解)

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

4.5 左单旋

• 本图6展⽰的是10为根的树,有a/b/c抽象为三棵⾼度为h的⼦树(h>=0),a/b/c均符合AVL树的要
求。10可能是整棵树的根,也可能是⼀个整棵树中局部的⼦树的根。这⾥a/b/c是⾼度为h的⼦树,
是⼀种概括抽象表⽰,他代表了所有左单旋的场景,实际左单旋形态有很多种,具体跟上⾯右旋类
似。
• 在a⼦树中插⼊⼀个新结点,导致a⼦树的⾼度从h变成h+1,不断向上更新平衡因⼦,导致10的平
衡因⼦从1变成2,10为根的树左右⾼度差超过1,违反平衡规则。10为根的树右边太⾼了,需要往
左边旋转,控制两棵树的平衡。
• 旋转核⼼步骤,因为10<b⼦树的值<15,将b变成10的右⼦树,10变成15的左⼦树,15变成这棵
树新的根,符合搜索树的规则,控制了平衡,同时这棵的⾼度恢复到了插⼊之前的h+2,符合旋转
原则。如果插⼊之前10整棵树的⼀个局部⼦树,旋转后不会再影响上⼀层,插⼊结束了。
在这里插入图片描述

4.5.1 左单旋代码实现

其实现规则过程和右单旋别无二致。

void RotateL(Node* parent)
{
 Node* subR = parent->_right;
 Node* subRL = subR->_left;
 parent->_right = subRL;
 if(subRL)
 subRL->_parent = parent;
 Node* parentParent = parent->_parent;
 subR->_left = parent;
 parent->_parent = subR;
 
 if (parentParent == nullptr)
 {
 _root = subR;
 subR->_parent = nullptr;
 }
 else
 {
 if (parent == parentParent->_left)
 {
 parentParent->_left = subR;
 }
 else
 {
 parentParent->_right = subR;
 }
 subR->_parent = parentParent;
 }
 parent->_bf = subR->_bf = 0;
}

4.6 左右双旋

当我们遇到内侧插入时是无法仅靠单旋转来处理平衡的,此时我们需要通过 对不同的节点进行左单旋过后再右单旋来处理。其中双旋的重点是平衡因子的更新

通过下图可以看到,左边⾼时,如果插⼊位置不是在a⼦树,⽽是插⼊在b⼦树,b⼦树⾼度从h变
成h+1,引发旋转,右单旋⽆法解决问题,右单旋后,我们的树依旧不平衡。右单旋解决的纯粹的左边
⾼,但是插⼊在b⼦树中,10为跟的⼦树不再是单纯的左边⾼,对于10是左边⾼,但是对于5是右边
⾼,需要⽤两次旋转才能解决,以5为旋转点进⾏⼀个左单旋,以10为旋转点进⾏⼀个右单旋,这棵树
这棵树就平衡了。
在这里插入图片描述
在这里插入图片描述
具体旋转方法便是将其先旋转为单旋转可旋转状态。
上面两张图片分别为左右双旋中h为0和h为1具体场景分析,下⾯我们将a/b/c⼦树抽象为⾼度h的AVL
⼦树进⾏分析,另外我们需要把b⼦树的细节进⼀步展开为情况2和左⼦树⾼度为h-1的e和f⼦树,因为
我们要对b的⽗亲5为旋转点进⾏左单旋,左单旋需要动b树中的左⼦树。b⼦树中新增结点的位置
不同,平衡因⼦更新的细节也不同,通过观察情况2的平衡因⼦不同,这⾥我们要分三个场景讨论。
• 场景1:h>=1时,新增结点插⼊在e⼦树,e⼦树⾼度从h-1并为h并不断更新8->5->10平衡因⼦,
引发旋转,其中8的平衡因⼦为-1,旋转后8和5平衡因⼦为0,10平衡因⼦为1。
• 场景2:h>=1时,新增结点插⼊在f⼦树,f⼦树⾼度从h-1变为h并不断更新8->5->10平衡因⼦,引
发旋转,其中8的平衡因⼦为1,旋转后8和10平衡因⼦为0,5平衡因⼦为-1。
• 场景3:h为0时,a/b/c都是空树,b⾃⼰就是⼀个新增结点,不断更新5->10平衡因⼦,引发旋
转,其中8的平衡因⼦为0,旋转后8和10和5平衡因⼦均为0。

在这里插入图片描述

4.6.1左右双旋代码
void RotateLR(Node* parent)
{
 Node* subL = parent->_left;
 Node* subLR = subL->_right;
 int bf = subLR->_bf;
 RotateL(parent->_left);
 RotateR(parent);
 if (bf == 0)
 {
 subL->_bf = 0;
 subLR->_bf = 0;
 parent->_bf = 0;
 }
 else if (bf == -1)
 {
 subL->_bf = 0;
 subLR->_bf = 0;
 parent->_bf = 1;
 }
 else if(bf == 1)
 {
 subL->_bf = -1;
 subLR->_bf = 0;
 parent->_bf = 0;
 }
 else
 {
 assert(false);
 }
}

其中的三种情况分别对应上面图片中三种插入方式的平衡因子的改变,请借助图片理解

4.6.2 右左双旋

• 跟左右双旋类似,下⾯我们将a/b/c⼦树抽象为⾼度h的AVL⼦树进⾏分析,另外我们需要把b⼦树的
细节进⼀步展开为12和左⼦树⾼度为h-1的e和f⼦树,因为我们要对b的⽗亲15为旋转点进⾏右单
旋,右单旋需要动b树中的右⼦树。b⼦树中新增结点的位置不同,平衡因⼦更新的细节也不同,通
过观察12的平衡因⼦不同,这⾥我们要分三个场景讨论。
• 场景1:h>=1时,新增结点插⼊在e⼦树,e⼦树⾼度从h-1变为h并不断更新12->15->10平衡因
⼦,引发旋转,其中12的平衡因⼦为-1,旋转后10和12平衡因⼦为0,15平衡因⼦为1。
• 场景2:h>=1时,新增结点插⼊在f⼦树,f⼦树⾼度从h-1变为h并不断更新12->15->10平衡因⼦,
引发旋转,其中12的平衡因⼦为1,旋转后15和12平衡因⼦为0,10平衡因⼦为-1。
• 场景3:h==0时,a/b/c都是空树,b⾃⼰就是⼀个新增结点,不断更新15->10平衡因⼦,引发旋
转,其中12的平衡因⼦为0,旋转后10和12和15平衡因⼦均为0。
在这里插入图片描述

4.6.1 右左双旋代码实现
void RotateRL(Node* parent)
{
 Node* subR = parent->_right;
 Node* subRL = subR->_left;
 int bf = subRL->_bf;
 RotateR(parent->_right);
 RotateL(parent);
 if (bf == 0)
 {
 subR->_bf = 0;
 subRL->_bf = 0;
 parent->_bf = 0;
 }
 else if (bf == 1)
 {
 subR->_bf = 0;
 subRL->_bf = 0;
 parent->_bf = -1;
 }
 else if (bf == -1)
 {
 subR->_bf = 1;
 subRL->_bf = 0;
 parent->_bf = 0;
 }
 else
 {
 assert(false);
 }
}

5. 优缺点与应用场景

优点:
1,保证了查找、插入和删除的时间复杂度为O(log n)。
2.适合需要频繁查找的应用场景。
缺点:
1.需要额外的空间来存储平衡因子。
2.插入和删除操作可能需要多次旋转,增加了维护成本。

应用场景:AVL树适用于需要频繁查找操作但插入和删除操作相对较少的场景,如数据库索引、文件系统等


总结

AVL的相关代码建议各位同学自己敲一遍,不要照葫芦画瓢,看明白规则之后,代码方面仍然是会有缺陷的(反正本文作者是如此的,并且本文仍有诸多不足之处),尽量自己带着想法敲一遍,目的是巩固和最重要的查漏补缺。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值