【数据结构】自平衡二叉搜索树各种旋转算法原理解析及AVL树的C++实现

前言:上一篇文章我们实现的搜索二叉树虽然平均效率还不错,但是在比较极端的情况下会退化成一条单支树,这时候原本看起来很快的查找也会变成O(N)。为了解决这个问题,这篇文章就继续往后走,看看AVL树是怎么通过平衡因子和旋转把树的高度控制下来的,并用C++完成一棵以插入和平衡调整为核心的AVL树。

本篇完成的是插入版AVL树,删除结点后的再平衡不在这里展开, 因为我也不会,说实话我写一个AVL树刚写完时报错一堆,后面才慢慢把BUG改完,快力竭了都后面如果有时间精力再去补充吧,先这样了。


1.AVL树的概念


1.1 为什么需要AVL树

普通搜索二叉树只要求左子树中的key小于根,右子树中的key大于根,却没有限制左右两边的高度。如果依次插入1、2、3、4、5,每个新结点都会落到上一个结点的右边,最后整棵树就会退化成单支结构。

AVL树仍然是一棵二叉搜索树,所以原来的查找规则并没有改变。它只是在此基础上又增加了平衡要求:树中任意一个结点的左右子树高度差都不能超过1。一旦插入新结点破坏了这个条件,就通过旋转重新恢复平衡。

需要注意的是,AVL树不一定是一棵完全二叉树,它只是把每个结点左右两边的高度差限制在1以内。这样就能避免树高随着结点数量线性增长,使整棵树的高度保持在O(log N)

1.2 平衡因子

为了判断一个结点是否平衡,我这里给每个结点额外保存一个平衡因子_bf,并统一采用下面的定义:

text 复制代码
平衡因子 = 右子树高度 - 左子树高度

所以平衡因子的正负所表达的含义为:

平衡因子 结点状态
-1 左子树比右子树高1
0 左右子树一样高
1 右子树比左子树高1
-22 当前结点已经失衡,需要旋转

一棵空树可以看成AVL树;对于非空树,它首先要满足二叉搜索树的性质,其次左右子树本身也要是AVL树,并且每个结点的平衡因子只能是-1、0、1中的一个。

由于AVL树的高度是O(log N),所以查找和插入的最坏时间复杂度都是O(log N)。一次旋转只调整固定数量的指针,本身是O(1),真正需要沿树向上走的是插入后的平衡因子更新过程。

2.AVL树的插入与平衡因子更新

2.1 AVL树的结点结构

AVL树结点是在普通搜索二叉树结点的基础上增加_parent_bf_parent方便我们从新插入的结点一路向上更新祖先,_bf则记录当前结点左右子树的高度差。

cpp 复制代码
template<typename K, typename V>
struct TreeNode
{
	pair<K, V> _kv;
	TreeNode<K, V>* _left;
	TreeNode<K, V>* _right;
	TreeNode<K, V>* _parent;

	int _bf;

	TreeNode(const pair<K, V>& kv)
		:_kv(kv)
		, _left(nullptr)
		, _right(nullptr)
		, _parent(nullptr)
		, _bf(0)
	{ }
};

新结点刚插入时没有左右孩子,因此平衡因子初始化为0。这里保存的是pair<K, V>,比较时使用first中的key,second中保存与key对应的value。

下面展示的函数都属于AVLTree<K, V>类,其中NodeTreeNode<K, V>的类型别名,_root指向整棵树的根结点。后面旋转代码中的parentsubLsubR等变量保存的都是Node*

2.2 插入的大致过程

AVL树的插入可以分成两步:

  1. 先按照普通搜索二叉树的规则找到空位置并插入新结点。
  2. 从新结点的父结点开始向上更新平衡因子,遇到失衡结点时选择对应的旋转。

第一步并不复杂。新key小于当前结点就向左走,大于当前结点就向右走;如果key已经存在就返回false。找到空位置以后除了连接左右孩子,还要把新结点的_parent指向父结点。

真正容易绕的是第二步。假设当前变高的子树根结点为cur,它的父结点为parent

  • curparent的左孩子,说明左子树变高,parent->_bf--
  • curparent的右孩子,说明右子树变高,parent->_bf++

更新以后并不是每次都要继续走到根,接下来只会出现三类结果。

如果parent->_bf变成0,说明插入前这个结点一边高、另一边低,新结点正好补在较低的一边。当前子树的整体高度没有发生变化,更上层结点的平衡因子也不会受影响,所以可以直接停止。

如果parent->_bf变成1-1,说明当前子树的整体高度增加了1,还要把curparent同时向上移动一层,继续更新祖先。

如果parent->_bf变成2-2,当前结点已经失衡。此时再结合较高孩子cur的平衡因子,就能判断应该进行哪一种旋转:

parent->_bf cur->_bf 失衡类型 调整方式
-2 -1 LL,左左型 右单旋
2 1 RR,右右型 左单旋
-2 1 LR,左右型 先左旋再右旋
2 -1 RL,右左型 先右旋再左旋

这里的LL、RR、LR、RL描述的是从失衡结点向新增结点走时的方向,并不是旋转方向。比如LL型表示新增结点落在左孩子的左侧,真正处理时反而要向右旋。

插入导致第一次失衡时,较高孩子cur的平衡因子不会是0,所以这里正好对应上面四种组合;孩子平衡因子为0的失衡情况主要会在删除调整中出现。

下面就是插入部分的完整逻辑:

cpp 复制代码
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.first > kv.first)
		{
			parent = cur;
			cur = cur->_left;
		}
		else
		{
			return false;
		}
	}


	// 插入新结点
	Node* newnode = new Node(kv);
	newnode->_parent = parent;

	if (kv.first < parent->_kv.first)
	{
		parent->_left = newnode;
	}
	else
	{
		parent->_right = newnode;
	}

	cur = newnode;

	// 维护平衡因子
	// 维护平衡
	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) // 需要旋转
		{
			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)
			{
				// patent 左高,cur 右高
				RotateLR(parent);
			}
			else if (parent->_bf == 2 && cur->_bf == -1)
			{
				RotateRL(parent);
			}
				
			break;
		}
		else
		{
			// 非法情况,用断言检查
			assert(false);
		}


	}

	return true;
}

插入场景中,第一次失衡经过正确旋转以后,这棵局部子树的高度会恢复到插入之前,因此这里旋转完成后可以直接break。这个结论是针对本篇的插入调整,AVL树删除时的高度传播和平衡因子情况会更加复杂,不能直接套用这里的停止条件。

3.四种旋转算法的原理与实现

AVL树真正麻烦的地方其实不是插入,因为插入还是按照搜索二叉树的规则来走,比较绕的是发现不平衡以后怎样调整指针。这里先把旋转共同需要遵守的原则交代一下:

  • 旋转前后必须保持二叉搜索树的中序次序不变。
  • 旋转点内部转移出去的子树,要重新连接到正确的父结点上。
  • 局部子树的新根要重新接回原来的上一层;如果旋转点原本就是根,还要更新_root
  • 结点位置变化以后,所有受影响的_parent和平衡因子都要同步修改。

只要把"局部结构、上一层连接、平衡因子"这三部分分开看,四种旋转就没有表面上那么乱。

3.1 右单旋------LL型失衡

parent->_bf == -2并且较高的左孩子cur->_bf == -1时,说明新增结点落在了左孩子的左侧。此时以parent为旋转点进行右单旋,让subL成为这棵局部子树的新根。

图中的subLRsubL原来的右子树。根据二叉搜索树的大小关系,它里面的所有key都大于subL、小于parent,所以旋转后应该把它交给parent作为左子树。这样中序次序仍然是A -> subL -> B -> parent -> C

cpp 复制代码
void RotateR(Node* parent)
{
	Node* subL = parent->_left;
	Node* subLR = subL->_right;

	parent->_left = subLR;

	// subLR 有可能为空
	if (subLR)
		subLR->_parent = parent;

	subL->_right = parent;

	Node* pparent = parent->_parent;
	parent->_parent = subL;

	// 处理 parent 为根结点的边界情况
	if (parent == _root)
	{
		_root = subL;
		subL->_parent = nullptr;
	}
	else if (pparent->_right == parent)
	{
		pparent->_right = subL;
		subL->_parent = pparent;
	}
	else if (pparent->_left == parent)
	{
		pparent->_left = subL;
		subL->_parent = pparent;
	}
	else
	{
		assert(false);
	}

	// 更新平衡因子
	subL->_bf = 0;
	parent->_bf = 0;
}

这里最容易漏掉的是两处父指针:subLR不为空时,它的新父结点要改成parentsubL成为局部新根以后,它的父结点要接到原来的pparent。如果parent就是整棵树的根,则直接让_root指向subL,并把subL->_parent置空。

3.2 左单旋------RR型失衡

左单旋与右单旋正好对称。当parent->_bf == 2并且右孩子cur->_bf == 1时,新增结点落在右孩子的右侧,需要让subR向上成为局部新根。

这次需要转移的是subR原来的左子树subRL。它的key大于parent又小于subR,所以左旋以后应该成为parent的右子树。

cpp 复制代码
void RotateL(Node* parent)
{
	Node* subR = parent->_right;
	Node* subRL = subR->_left;

	parent->_right = subRL;
	if (subRL)
		subRL->_parent = parent;

	subR->_left = parent;

	Node* pparent = parent->_parent;
	parent->_parent = subR;

	if (parent == _root)
	{
		_root = subR;
		subR->_parent = nullptr;
	}
	else if (pparent->_right == parent)
	{
		pparent->_right = subR;
		subR->_parent = pparent;
	}
	else if (pparent->_left == parent)
	{
		pparent->_left = subR;
		subR->_parent = pparent;
	}
	else
	{
		assert(false);
	}

	parent->_bf = 0;
	subR->_bf = 0;
}

在本文的插入场景中,LL和RR型单旋完成后,原来的parent与新的局部根平衡因子都会恢复为0,所以可以直接置0。不过这个更新方式不要不加条件地搬到AVL删除中,因为删除产生单旋时,较高孩子还可能出现平衡因子为0的情况。

3.3 左右双旋------LR型失衡

parent->_bf == -2而左孩子cur->_bf == 1时,parent左边高,但新增结点落在了左孩子的右侧。这个形状不能只靠一次右旋解决,需要先把弯折的结构拉直:

  1. 先对subL做左单旋,让subLR上移。
  2. 再对parent做右单旋,让subLR成为这棵局部子树的新根。

双旋的指针连接可以直接复用前面的两个单旋函数,但是平衡因子的更新不能简单地认为三个结点一定都变成0。它取决于旋转前中间结点subLR的平衡因子,所以要在调用两个单旋之前先把这个值保存下来。

cpp 复制代码
void RotateLR(Node* parent)
{
	Node* subL = parent->_left;
	Node* subLR = subL->_right;
	int bf = subLR->_bf;

	RotateL(subL);
	RotateR(parent);

	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;
	}

}

三种平衡因子更新可以整理成下面这张表:

旋转前subLR->_bf 旋转后parent->_bf 旋转后subL->_bf 旋转后subLR->_bf
1 0 -1 0
-1 1 0 0
0 0 0 0

之所以要先保存bf,是因为RotateLRotateR会按照单旋场景把相关结点的平衡因子清零。如果旋转以后再读取subLR->_bf,原来的方向信息就已经丢失了。

3.4 右左双旋------RL型失衡

RL型与LR型完全对称。当parent->_bf == 2而右孩子cur->_bf == -1时,新增结点落在右孩子的左侧,需要先对subR右旋,再对parent左旋。

cpp 复制代码
void RotateRL(Node* parent)
{
	Node* subR = parent->_right;
	Node* subRL = subR->_left;
	int bf = subRL->_bf;
	RotateR(subR);
	RotateL(parent);

	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;
	}
}

对应的平衡因子变化为:

旋转前subRL->_bf 旋转后parent->_bf 旋转后subR->_bf 旋转后subRL->_bf
1 -1 0 0
-1 0 1 0
0 0 0 0

到这里四种情况就都串起来了:外侧过高使用一次反方向单旋,内侧过高先旋转孩子把它变成外侧结构,再旋转失衡结点。旋转以后中序次序始终没有改变,所以AVL树仍然保留二叉搜索树的性质。

4.AVL树的其他接口

4.1 查找和中序遍历

旋转只改变局部结构,不会破坏key之间的大小关系,因此查找逻辑和普通搜索二叉树完全相同。当前结点的key小于待查key就向右走,当前结点的key大于待查key就向左走,找到相等的key时返回对应结点。

cpp 复制代码
Node* Find(const K& key)
{
	Node* cur = _root;
	while (cur)
	{
		if (cur->_kv.first < key)
		{
			cur = cur->_right;
		}
		else if (cur->_kv.first > key)
		{
			cur = cur->_left;
		}
		else
		{
			return cur;
		}
	}

	return nullptr;
}

因为树高能够保持在O(log N),所以Find的最坏时间复杂度也是O(log N)。中序遍历仍然可以按照升序输出所有key:

cpp 复制代码
void InOrder()
{
	_InOrder(_root);
	std::cout << std::endl;
}

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

	_InOrder(root->_left);
	std::cout << root->_kv.first << "->" << root->_kv.second << std::endl;
	_InOrder(root->_right);
}

4.2 检查实际高度和平衡因子

旋转代码里的指针和平衡因子比较多,只看中序遍历有序还不够,因为一棵普通搜索二叉树即使已经失衡,中序结果仍然是有序的。这里通过后序递归同时计算左右子树高度,并核对两件事:

  • 实际算出的rightHeight - leftHeight是否等于结点中保存的_bf
  • 实际高度差是否落在[-1, 1]之内。
cpp 复制代码
bool IsBalanceTree()
{
	bool ret = true;
	_CheckHeight(_root, ret);
	return ret;
}

int _CheckHeight(Node* root, bool& ret)
{
	if (root == nullptr)
	{
		return 0;
	}

	int leftHeight = _CheckHeight(root->_left, ret);
	int rightHeight = _CheckHeight(root->_right, ret);

	int bf = rightHeight - leftHeight;

	if (bf != root->_bf)
	{
		cout << "平衡因子异常:"
			<< root->_kv.first
			<< " 实际bf = " << bf
			<< " 保存bf = " << root->_bf
			<< endl;

		ret = false;
	}


	if (bf < -1 || bf > 1)
	{
		cout << "结点失衡:"
			<< root->_kv.first
			<< " bf = " << bf
			<< endl;

		ret = false;
	}

	return leftHeight > rightHeight
		? leftHeight + 1
		: rightHeight + 1;
}

每个结点只会被访问一次,因此这次检查的时间复杂度为O(N)。它主要验证实际高度差和保存的平衡因子是否一致,并不等价于把key顺序、所有父指针等结构关系也全部检查一遍。

4.3 拷贝和资源释放

AVL树结点是动态申请的,所以还需要处理深拷贝和资源释放。拷贝结点时除了复制键值对与平衡因子,还要让复制出来的左右孩子重新指向新的父结点,不能保留原树中的父指针。

cpp 复制代码
AVLTree() = default;

AVLTree(const AVLTree& T)
{
	_root = Copy(T._root);
}

AVLTree& operator=(AVLTree T)
{
	std::swap(_root, T._root);
	return *this;
}

~AVLTree()
{
	Destroy(_root);
}

赋值运算符采用传值加交换的写法,形参T先得到一份副本,再与当前对象交换根指针。函数结束时,T会释放当前对象原来管理的结点。

cpp 复制代码
Node* Copy(const Node* cur)
{
	if (cur == nullptr)
	{
		return nullptr;
	}

	Node* newNode = new Node(cur->_kv);
	newNode->_bf = cur->_bf;


	newNode->_left = Copy(cur->_left);
	if (newNode->_left)
	{
		newNode->_left->_parent = newNode;
	}

	newNode->_right = Copy(cur->_right);
	if (newNode->_right)
	{
		newNode->_right->_parent = newNode;
	}

	return newNode;
}

Node* Destroy(Node* cur)
{
	if (cur == nullptr)
	{
		return nullptr;
	}

	cur->_left = Destroy(cur->_left);
	cur->_right = Destroy(cur->_right);
	delete cur;
	return nullptr;
}

相关推荐
lucas_AI1 小时前
1.2B 小模型赢过 235B 大模型:NaviDC-OCR 把文档解析卷明白了
人工智能·深度学习·算法
Augustzero1 小时前
为什么线程不能说睡就睡?看懂等待与唤醒机制
c++·后端
程序员与背包客_CoderZ1 小时前
Linux D-Bus通信协议详解:从入门到C/C++编码实战
linux·服务器·c语言·c++·嵌入式硬件·嵌入式软件·dbus
冻柠檬飞冰走茶2 小时前
PTA基础编程题目集 7-35有理数均值(C++语言实现)
开发语言·数据结构·c++·算法·均值算法
wangchen_02 小时前
C++正则表达式
开发语言·c++·正则表达式
民乐团扒谱机2 小时前
【微实验】倒谱算法(Cepstrum)深度解析:原理、数学推导与代码实现
人工智能·算法·语音识别
Nil2083 小时前
leetcode 189轮转数组
数据结构·算法·leetcode
-dzk-3 小时前
【动态规划】LC 118.杨辉三角
算法·动态规划
恣逍信点3 小时前
《凌微经》静态自悖——变化之自因
人工智能·科技·算法·机器学习·业界资讯·拓扑学·哲学