【C++】AVL树,平衡二叉树详细解析


前言

前面对map/multimap/set/multiset进行了简单的介绍,在其文档介绍中发现,这几个容器有个共同点是:其底层都是按照二叉搜索树来实现的,但是二叉搜索树有其自身的缺陷,假如往树中插入的元素有序或者接近有序,二叉搜索树就会退化成单支树,时间复杂度会退化成O(N),因此map、set等关联式容器的底层结构是对二叉树进行了平衡处理,即采用平衡二叉树树来实现。

1.AVL树的概念

AVL树,又称平衡二叉搜索树。二叉搜索树虽可以缩短查找的效率,但如果数据有序或接近有序二叉搜索树将退化为单支树,查找元素相当于在顺序表中搜索元素,效率低下。因此,两位俄罗斯的数学家G.M.Adelson-Velskii和E.M.Landis在1962年发明了一种解决上述问题的方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右子树高度之差的绝对值不超过1(需要对树中的结点进行调整),左右子树的高度差被称为平衡因子(平衡因子=右子树高度-左子树高度),即可降低树的高度,从而减少平均搜索长度。

AVL树可以是一棵空树,也可能是具有一下性质的一棵平衡二叉树:

  • 树的左右子树都是一棵AVL树
  • 树的左右子树高度之差(平衡因子)的绝对值不超过1(可以是-1,0,1)

在这里插入图片描述
如果一棵二叉树是高度平衡的,它就是AVL树,如果它有n个结点,其高度可保持在O(logN),搜索时间复杂度为O(NlogN)。

2.AVL树节点的定义

我们需要实现一个KV模型的AVL树,在这里最好定义成三叉链的结构,多引入一个_parent(父节点),方便后序插入等操作。除此之外还要在每个结点中引入平衡因子,由于新构造的结点的左右子树均为空树,所以初始化的时候将平衡因子设置为0就好

为什么要设置平衡因子?为什么要设置成三叉链结构?

由于我们插入结点后需要倒着往上进行平衡因子的更新,所以我们将AVL树结点的结构设置为了三叉链结构,这样我们就可以通过父指针找到其父结点,进而对其平衡因子进行更新。当然,我们也可以不用三叉链结构,可以在插入结点时将路径上的结点存储到一个栈当中,当我们更新平衡因子时也可以通过这个栈来更新祖先结点的平衡因子,但是相对较麻烦。

注意平衡因子不是必须的,它只是AVL树其中一种实现方式,采用其它方法也可以判断左右子树高度差绝对值是否小于等于1,但是我认为引入平衡因子可以给我们更加直观的呈现平衡二叉树的整体特征

//定义AVL树的结点
//三叉链+平衡因子
template<class K,class V>
struct AVLTreeNode
{
	//存储<key,value>数据的结点pair
	pair<K,V> _kv;

	//三叉链
	AVLTreeNode<K, V>* _parent;
	AVLTreeNode<K, V>* _left;
	AVLTreeNode<K, V>* _right;


	int _bf;//平衡因子=右子树高度-左子树高度(-1,0,1)

	//结点的构造函数
	AVLTreeNode(const pair<K,V>& kv)
		:_kv(kv)
		, _parent(nullptr)
		, _left(nullptr)
		, _right(nullptr)
		,_bf(0)
	{}
};

3.AVL树的插入

思路:

  1. 插入数据到平衡二叉树中,我们首先需要一个根
  2. 按照二叉搜索树的插入方法,找到待插入位置。
    待插入结点的key值比当前结点小就插入到该结点的左子树。
    待插入结点的key值比当前结点大就插入到该结点的右子树。
    待插入结点的key值与当前结点的key值相等就插入失败。
  3. 找到待插入位置后,将待插入结点插入到树中。(注意保持三叉链结构)
  4. 更新平衡因子: 一个结点的平衡因子是否需要更新,是取决于该结点的左右子树的高度是否发生了变化,因此插入一个结点后,该结点的祖先结点的平衡因子可能也需要更新。
    新增结点在parent的右边,parent的平衡因子+ + 。
    新增结点在parent的左边,parent的平衡因子− − 。

每更新完一个结点的平衡因子后,还需要再进行以下判断:

如果parent的平衡因子等于-1或者1,表明还需要继续往上更新平衡因子。
如果parent的平衡因子等于0,表明无需继续往上更新平衡因子了。
如果parent的平衡因子等于-2或者2,表明此时以parent结点为根结点的子树已经不平衡了,需要进行旋转处理。

注意: parent的平衡因子在更新前只可能是-1/0/1(AVL树中每个结点的左右子树高度之差的绝对值不超过1)。

跳出循环的条件

在最坏情况下,平衡因子时一路更新到根结点。直到找到与待插入结点的key值相同的结点判定为插入失败或者最终走到空树位置进行结点插入。所以循环的条件是parent不为空,这也是使用三叉链结构的原因,我们可以迭代着往上走。

插入代码如下:

template<class K,class V>
class AVLTree
{
	typedef AVLTreeNode<K, V> Node;
public:
	//插入函数:我们需要将给的pair数据插入到AVL树中。
	bool insert(const pair<K, V>& kv)
	{
		//1.插入数据到平衡二叉树中,我们首先需要一个根
		if (_root == nullptr)
		{
			_root = new Node(kv);
			return true;
		}
		//2.根据二叉搜索树的特性(小的往左走,大的往右走),找到适合插入的位置
		Node* parent = nullptr;
		Node* cur = _root;//从根开始找合适的位置
		while (cur)
		{
			//注意:在平衡二叉搜索树中,键值对pair是按key值进行比较的
			if (cur->_kv.first < kv.first)
			{
				cur = cur->_right;
				parent = cur;
			}
			else if (cur->_kv.first > kv.first)
			{
				cur = cur->_left;
				parent = cur;
			}
			else
			{
				return false;//相等直接返回false
			}
		}

		//3.插入结点,注意保持三叉链的链接
		//此时parent指向待插入结点位置的父节点
		cur = new Node(kv);
		if (parent->_kv.first>kv.first)
		{
			parent->_left = cur;
			cur->_parent = parent;
		}
		else
		{
			parent->_right = cur;
			cur->_parent = parent;
		}
		// 1、更新平衡因子
		while (parent) // parent为空,也就更新到根
		{
			// 新增在右,parent->bf++;
			// 新增在左,parent->bf--;
			if (cur == parent->_left)
			{
				parent->_bf--;
			}
			else
			{
				parent->_bf++;
			}

			// 是否继续更新依据:子树的高度是否变化
			// 1、parent->_bf == 0说明之前parent->_bf是 1 或者 -1
			// 说明之前parent一边高一边低,这次插入填上矮的那边,parent所在子树高度不变,不需要继续往上更新
			// 2、parent->_bf == 1 或 -1 说明之前是parent->_bf == 0,两边一样高,现在插入一边更高了,
			// parent所在子树高度变了,继续往上更新
			// 3、parent->_bf == 2 或 -2,说明之前parent->_bf == 1 或者 -1,现在插入严重不平衡,违反规则
			// 就地处理--旋转

			// 旋转:
			// 1、让这颗子树左右高度不超过1
			// 2、旋转过程中继续保持他是搜索树
			// 3、更新调整孩子节点的平衡因子
			// 4、让这颗子树的高度跟插入前保持一致
			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 && 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)
				{
					RotateRL(parent);
				}
				else
				{
					assert(false);
				}

				break;
			}
			else
			{
				assert(false);
			}
		}
		return true;
	}
private:
	Node* _root = nullptr;
};

4.AVL树的旋转

在AVL树的插入中,我们提到若是在更新平衡因子的过程当中,出现了平衡因子为-2/2的结点,这时我们需要对以该结点为根结点的树进行旋转处理。平衡二叉搜索树的旋转有多种,分别是左单旋,右单旋,左右双旋,右左双旋,我们分别介绍。

旋转的目的:

  • 让这棵树的左右子树高度差的绝对值不超过1
  • 旋转过程中依然要保存AVL树的特性
  • 更新被调整的结点的平衡因子
  • 让这棵子树的高度跟插入当前结点之前的高度一致

左单旋

1.什么情况下进行的是左单旋?

单纯的右边高,当前的parent的平衡因子==2,其右孩子的平衡因子为1,即cur的平衡因子为1的时候进行做单旋。 我们就需要把左边压下来。

if (parent->_bf == 2 && cur->_bf == 1)
{
	RotateL(parent);
}

2.如何进行左单旋?

在这里插入图片描述

为了提高代码的可阅读性,我们进行一下命名:当前parent指向30,subR(右子树)指向60,subRL(右子树的左子树)指向b。

步骤:

  1. SubR的左子树作为parent的右子树(这里要注意完善三叉链的连接,勿忘_parent,所以这里有一个注意事项,就是进行subRL->_parent的时候,需要判断subRL是否为空);
  2. 让parent作为subR的左子树
  3. 让subR作为整个子树的根(注意这里要判断subR是子树的根,还是整棵树的root)
  4. 更新平衡因子。

代码实现:

//左单旋
		void RotateL(Node* parent)
		{
			//命名结点
			Node* subR = parent->_right;
			Node* subRL = subR->_left;
			//若parent是某棵子树,则要保留当前结点的父节点,这样旋转之后subR成为根,才能和这棵树连成整体
			Node* ppNode = parent->_parent;

			//1.将subR的左子树给到parent的右子树
			parent->_right = subRL;
			if (subRL)//注意subRL不为空,才能进行这步,否则会造成对空指针解引用的错误
			{
				//完善三叉链的连接
				subRL->_parent = parent;
			}
			//2.将parent给到subR的左子树,subR变成当前子树的根
			subR->_left = parent;
			parent->_parent = subR;
			//3.将旋转完的子树;连接回去
			if (ppNode == nullptr)
			{
				//如果ppNode为空,则subR就是当前这棵树的根了
				_root = subR;
				subR->_parent = nullptr;
			}
			else
			{
				//如果刚开始parent是左子树
				if (ppNode->_left == parent)
				{
					ppNode->_left = subR;
				}
				else
				{
					ppNode->_right = subR;
				}
				subR->_parent = ppNode;
			}
			//更新平衡因子
			parent->_bf = subR->_bf = 0;
		}

右单旋

1,什么情况下进行右单旋?

单纯的左边高,当前parent的平衡因子== -2,parent左节点的平衡因子等于-1,即cur的平衡因子为-1,我们就需要把右边压下来。

if (parent->_bf == -2 && cur->_bf == -1)
{
	RotateR(parent);
}

2,如何进行右单旋?

在这里插入图片描述

步骤:

  1. 让subL的右子树作为parent的左子树;
  2. 让parent作为subL的右子树;
  3. 让subL作为整个子树的根;
  4. 更新平衡因子。
void RotateR(Node* parent)
		{
			//命名结点
			Node* subL = parent->_left;
			Node* subLR = subL->_right;
			//若parent是某棵子树,则要保留当前结点的父节点,这样旋转之后subL成为根,才能和这棵树连成整体
			Node* ppNode = parent->_parent;

			//1.将subL的右子树给到parent的左子树
			parent->_left = subLR;
			if (subLR)//注意subRL不为空,才能进行这步,否则会造成对空指针解引用的错误
			{
				//完善三叉链的连接
				subLR->_parent = parent;
			}
			//2.将parent给到subL的右子树,subL变成当前子树的根
			subL->_right = parent;
			parent->_parent = subL;
			//3.将旋转完的子树;连接回去
			if (ppNode == nullptr)
			{
				//如果ppNode为空,则subR就是当前这棵树的根了
				_root = subL;
				subL->_parent = nullptr;
			}
			else
			{
				//如果刚开始parent是左子树
				if (ppNode->_left == parent)
				{
					ppNode->_left = subL;
				}
				else
				{
					ppNode->_right = subL;
				}
				subL->_parent = ppNode;
			}
			//更新平衡因子
			parent->_bf = subL->_bf = 0;
		}

左右双旋

1.什么情况下进行左右双旋?

不难发现,前面讲的左单旋和右单旋,它们触发旋转的图形是一条直线,所以当图中出现折线型的路径时,就会触发双旋。
在这里插入图片描述

2.如何进行左右双旋?

步骤:

  1. 以subL为轴点进行左单旋(经过左单旋后,由图可以发现,折线型的路径变成了直线型);
  2. 以parent为轴点进行右单旋(当出现直线型的路径时,再进行一次单旋即可);
  3. 更新平衡因子:这是最复杂的一步,我们画的图是在b处插入新的结点,旋转成功后,subL和subLR的平衡因子更新为0,parent的平衡因子变成了1。如果我们是在c处插入新的结点,那么结果又是不同的(这里大家可以自己尝试着换一下旋转过程图)

左右双旋后,平衡因子的更新随着subLR原始平衡因子的不同分为以下三种情况:
在插入结点后,先记录一下subLR的平衡因子,若平衡因子为-1,则是在b处进行插入,最后subL和subR的平衡因子更新为0,parent的平衡因子变成了1;

若平衡因子为1,则是在c处进行插入,最后subLR和parent的平衡因子更新为0,subL的平衡因子变成了-1;

当subLR原始平衡因子是0时,左右双旋后parent、subL、subLR的平衡因子分别更新为0、0、0。

代码实现:

//左右双旋
		void RotateLR(Node* parent)
		{
			Node* subL = parent->_left;
			Node* subLR = subL->_right;
			int bf = subLR->_bf;

			//以subL为轴点进行左单旋,左边往下压
			RotateL(parent->_left);
			//以parent为轴点进行右单旋,右边往下压
			RotateR(parent);

			//更新平衡因子
			if (bf == 1)//subLR右子树新增结点
			{
				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
			{
				assert(false);
			}
		}

右左双旋

在这里插入图片描述

步骤:

  1. 以subR为旋转点进行右单旋。
  2. 以parent为旋转点进行左单旋。
  3. 更新平衡因子。
//右左双旋
void RotateRL(Node* parent)
{
	Node* subR = parent->_right;
	Node* subRL = subR->_left;
	int bf = subRL->_bf;

	//1、以subR为轴进行右单旋
	RotateR(subR);

	//2、以parent为轴进行左单旋
	RotateL(parent);

	//3、更新平衡因子
	if (bf == 1)
	{
		subRL->_bf = 0;
		parent->_bf = -1;
		subR->_bf = 0;
	}
	else if (bf == -1)
	{
		subRL->_bf = 0;
		parent->_bf = 0;
		subR->_bf = 1;
	}
	else if (bf == 0)
	{
		subRL->_bf = 0;
		parent->_bf = 0;
		subR->_bf = 0;
	}
	else
	{
		assert(false); //在旋转前树的平衡因子就有问题
	}
}

AVL树的验证

AVL树是在二叉搜索树的基础上加入了平衡的限制,因此要验证AVL树,可以分两步:

1.验证其为二叉搜索树
如果中序遍历得到的是一个有序的序列,就说明它是一棵二叉搜索树

2.验证其为平衡树
右子树的高度-左子树的高度绝对值小于等于1
结点的平衡因子是否计算正确

代码实现:

void Inorder()
	{
		_Inorder(_root);
	}

	void _Inorder(Node* root)
	{
		if (root == nullptr)
			return;

		_Inorder(root->_left);
		cout << root->_kv.first << ":" << root->_kv.second << endl;
		_Inorder(root->_right);
	}
	int Height(Node* root)
	{
		if (root == nullptr)
			return 0;
		int lh = Height(root->_left);
		int rh = Height(root->_right);

		return lh > rh ? lh + 1 : rh + 1;
	}
	bool IsBalance()
	{
		return IsBalance(_root);
	}

	bool IsBalance(Node* root)
	{
		if (root == nullptr)
		{
			return true;
		}

		int leftHeight = Height(root->_left);
		int rightHeight = Height(root->_right);

		if (rightHeight - leftHeight != root->_bf)
		{
			cout << root->_kv.first << "平衡因子异常" << endl;
			return false;
		}

		return abs(rightHeight - leftHeight) < 2
			&& IsBalance(root->_left)
			&& IsBalance(root->_right);
	}

AVL树的删除

可按照二叉搜索树的方式将节点删除,然后再更新平衡因子,最差情况下要一直调整到根节点。

具体实现可参考《算法导论》或《数据结构-用面向对象方法与C++描述》殷人昆版。

AVL树的性能

AVL树是一棵绝对平衡的二叉搜索树,其要求每个结点的左右子树高度差的绝对值小于等于1,这样可以保证访问时的时间复杂度,O(logN),但是如果要对AVL树作一些结构修改的操作,性能就会降低。比如:插入时,我们需要维护其平衡,旋转的次数就比较多,在最差的情况下,还有可能要一直旋转到根。因此,如果需要一直查询高效且有序的数据结构,而且数据的个数是不变的,可以考虑AVL树,但如果一个结构经常需要修改,就不适合用AVL树。


  • 18
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 19
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_麦子熟了

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值