C++ AVL 树深度剖析:平衡二叉搜索树原理、源码与面试考点

AVL 树是历史上第一个自平衡二叉搜索树,解决普通二叉搜索树有序插入退化为链表、最坏 O (N) 的致命缺陷,保证增删查稳定(O(logN))。本文基于AVLTree.hTest.cpp完整源码,逐行解析节点结构、插入逻辑、四种旋转、平衡因子更新、校验工具,同时补充底层细节、删除难点、AVL 与红黑树对比,覆盖全部代码出现的知识点。

一、AVL 树基础概念

1.1 定义

AVL 树由前苏联科学家 Adelson‑Velsky 和 Landis 于 1962 年提出,它要么是空树,要么满足两个条件:

  1. 左右子树本身也都是 AVL 树;
  2. 任意节点左右子树高度差绝对值不超过 1

为什么不要求高度差必须等于 0? 部分节点数量无法做到完全等高,例如 2 个节点、4 个节点,最优高度差只能是 1,强制 0 是做不到的。

1.2 平衡因子(balance factor,bf)

定义:bf = 右子树高度 − 左子树高度 合法取值只能是 -1、0、1

  • bf = 0:左右子树高度相等;
  • bf = 1:右子树比左子树高 1;
  • bf = -1:左子树比右子树高 1;
  • bf = 2 / -2:树失衡,必须旋转修复平衡。

注意:部分教材定义bf=左−右,符号会完全相反,做题要看定义。本套源码统一使用右减左
补充:平衡因子不是 AVL 树必需,也可以每个节点存子树高度;平衡因子只是传感器,不需要递归计算高度,O (1) 读取,提升效率。

1.3 性能对比普通 BST

  • 普通 BST:最优(O(logN)),有序插入退化链表最坏(O(N));
  • AVL 树:严格限制高度,树高上界约(1.44*log_2N),增删查最坏稳定(O(logN))

二、AVL 树节点与类框架源码解析

cpp 复制代码
template<class K, class V>
struct AVLTreeNode
{
	pair<K, V> _kv;               // key‑value键值对
	AVLTreeNode<K, V>* _left;     // 左孩子
	AVLTreeNode<K, V>* _right;    // 右孩子
	AVLTreeNode<K, V>* _parent;   // ⭐父指针!核心设计
	int _bf;                       // 平衡因子

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

template<class K, class V>
class AVLTree
{
	typedef AVLTreeNode<K, V> Node;
public:
	bool Insert(const pair<K, V>& kv); // 插入
	Node* Find(const K& key);          // 查找
	void InOrder();                    // 中序遍历
	bool IsBalanceTree();              // 校验是否AVL树
	int Height();                      // 获取树高度
	int Size();                        // 统计节点数量
private:
	// 四大旋转私有函数
	void RotateR(Node* parent);   // 右单旋 LL
	void RotateL(Node* parent);   // 左单旋 RR
	void RotateLR(Node* parent);  // 左右双旋 LR
	void RotateRL(Node* parent);  // 右左双旋 RL

	// 递归辅助函数
	int _Height(Node* root);
	int _Size(Node* root);
	bool _IsBalanceTree(Node* root);
	void _InOrder(Node* root);

	Node* _root = nullptr; // 根节点
};

重点知识点(代码细节)

  1. _parent父指针的意义 : 普通 BST 递归插入依靠参数引用回溯;AVL 插入完成后,需要从新节点自底向上回溯所有祖先更新平衡因子,父指针可以直接向上走,不需要递归保存路径。旋转操作也需要父指针完成上层子树的链接。
  2. _bf初始化 0:新插入节点是叶子,左右子树都是空,平衡因子初始为 0。
  3. 存储pair<K,V>:和 map 一样,key 做排序,value 存储附属业务数据。
  4. 所有旋转函数是私有成员,对外不暴露,封装内部实现。

三、AVL 插入完整流程

插入分为三大阶段:

  1. 普通 BST 规则找到插入位置,创建新节点
  2. 自底向上更新祖先平衡因子
  3. 判断是否失衡,如果 bf 等于 2/-2,执行对应旋转;旋转之后该子树高度恢复插入前状态,插入场景旋转完成直接终止循环,不需要继续向上更新

3.1 平衡因子更新规则

新节点插入parent的右孩子:parent->_bf ++; 新节点插入parent的左孩子:parent->_bf --

三种更新停止条件
  1. 更新后parent->_bf == 0: 更新前 bf 是1→0或者-1→0;新增节点填补矮的一侧,子树整体高度不变,不会继续影响上层祖先,循环 break 结束更新
  2. 更新后parent->_bf == 1 || parent->_bf == -1: 更新前 bf 是0→1或者0→-1;子树高度 + 1,会继续影响上层祖先;cur = parent; parent = parent->_parent;继续向上循环。
  3. 更新后parent->_bf == 2 || parent->_bf == -2: 子树失衡,执行旋转;插入场景旋转之后子树高度恢复插入之前,不会再向上传播,直接 break

⭐重要区分:插入最多触发 1 次旋转(单旋或者双旋);但是删除会出现连锁失衡,可能一路旋转到根,课件没有实现删除,后面补充拓展。

3.2 Insert 完整源码与逐行注释

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
	if (_root == nullptr)
	{
		_root = new Node(kv);
		return true;
	}
	Node* parent = nullptr;
	Node* cur = _root;
	// BST查找插入位置
	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; // key重复,不允许插入
		}
	}
	// 创建新节点,建立父子关系
	cur = new Node(kv);
	if (parent->_kv.first < kv.first)
		parent->_right = cur;
	else
		parent->_left = cur;
	cur->_parent = parent;

	// 自底向上更新平衡因子
	while (parent)
	{
		if (cur == parent->_right)
			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);   // LL:右单旋
			}
			else if (parent->_bf == 2 && cur->_bf == 1)
			{
				RotateL(parent);  // RR:左单旋
			}
			else if (parent->_bf == -2 && cur->_bf == 1)
			{
				RotateLR(parent); // LR:左右双旋
			}
			else if (parent->_bf == 2 && cur->_bf == -1)
			{
				RotateRL(parent); // RL:右左双旋
			}
			else
			{
				assert(false); // 原树本身就非法
			}
			break; // 插入旋转完毕,不再向上
		}
		else
		{
			assert(false);
		}
	}
	return true;
}

3.3 四种失衡场景对照表

失衡类型 失衡节点 bf 孩子节点 bf 旋转方案
LL 左左 -2 -1 右单旋 RotateR
RR 右右 2 1 左单旋 RotateL
LR 左右 -2 1 左右双旋 RotateLR:先左旋子节点,再右旋失衡点
RL 右左 2 -1 右左双旋 RotateRL:先右旋子节点,再左旋失衡点

LL 是左子树的左子树长高;RR 是右子树的右子树长高;LR 左子树的右子树长高;RL 右子树的左子树长高。

四、四大旋转操作深度解析

旋转两个硬性原则:

  1. 保持二叉搜索树有序性质
  2. 修复平衡,并且插入场景旋转之后,子树高度等于插入之前,不再向上传播失衡。

4.1 右单旋 RotateR(处理 LL 场景)

原理图:失衡点 parent,subL 是 parent 的左孩子,subLR 是 subL 的右子树。

  1. subLR脱离 subL,挂载为 parent 的左孩子;注意必须维护subLR->_parent
  2. subL 顶替 parent 的位置,parent 变为 subL 的右孩子;
  3. 处理parentParent:如果 parent 是整棵树的根,更新_root;否则把上层父节点指向 subL;
  4. 更新 parent、subL 的_bf全部置 0。
cpp 复制代码
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;

	if (parentParent == nullptr)
	{
		_root = subL;
		subL->_parent = nullptr;
	}
	else
	{
		if (parentParent->_left == parent)
			parentParent->_left = subL;
		else
			parentParent->_right = subL;
		subL->_parent = parentParent;
	}
	parent->_bf = subL->_bf = 0;
}

高频易错:很多初学者旋转只修改孩子指针,忘记同步修改_parent父指针,会导致树逻辑彻底错乱!

4.2 左单旋 RotateL(处理 RR 场景)

和右单旋镜像:

cpp 复制代码
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 (parentParent->_left == parent)
			parentParent->_left = subR;
		else
			parentParent->_right = subR;
		subR->_parent = parentParent;
	}
	subR->_bf = parent->_bf = 0;
}

4.3 左右双旋 RotateLR(LR 场景)

场景:失衡点 parent bf=-2;它的左孩子 subL bf=1;也就是长高发生在左孩子的右子树,单纯右单旋无法修复,必须双旋。

  1. 先对subL左单旋 RotateL
  2. 再对失衡点parent右单旋 RotateR
  3. ⭐重点:双旋之后平衡因子不一定全部为 0 ,取决于subLR(中间节点)原来的 bf 值,分三种情况:
  • bf == 0:subLR 本身就是新插入节点 → subL、parent、subLR 全部置 0;
  • bf == 1:新节点插入 subLR 的右子树 → subL->_bf=0parent->_bf=1subLR->_bf=0
  • bf == -1:新节点插入 subLR 的左子树 → subL->_bf=-1parent->_bf=0subLR->_bf=0
cpp 复制代码
void RotateLR(Node* parent)
{
	Node* subL = parent->_left;
	Node* subLR = subL->_right;
	int bf = subLR->_bf;

	RotateL(subL);
	RotateR(parent);

	if (bf == 0)
	{
		subL->_bf = 0;
		parent->_bf = 0;
		subLR->_bf = 0;
	}
	else if (bf == -1)
	{
		subL->_bf = 0;
		parent->_bf = 1;
		subLR->_bf = 0;
	}
	else if (bf == 1)
	{
		subL->_bf = -1;
		parent->_bf = 0;
		subLR->_bf = 0;
	}
	else
	{
		assert(false);
	}
}

4.4 右左双旋 RotateRL(RL 场景)

镜像 LR 双旋:失衡点 bf=2,长高发生在右孩子的左子树。

  1. 先对subR右单旋 RotateR
  2. 再对失衡点parent左单旋 RotateL
  3. 根据subRL原始 bf 三种分支更新平衡因子。
cpp 复制代码
void RotateRL(Node* parent)
{
	Node* subR = parent->_right;
	Node* subRL = subR->_left;
	int bf = subRL->_bf;

	RotateR(subR);
	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);
	}
}

双旋最容易踩坑:直接把三个节点 bf 全部赋值 0,忽略三种不同 bf 分支,测试双旋用例直接破坏 AVL 性质。

五、查找、遍历、校验工具函数源码解析

5.1 Find 查找

复用普通 BST 逻辑,平衡不影响查找逻辑:

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

5.2 中序遍历_InOrder

AVL 本质是 BST,中序遍历输出有序序列

cpp 复制代码
void InOrder()
{
	_InOrder(_root);
	cout << endl;
}
private:
void _InOrder(Node* root)
{
	if (root == nullptr)
		return;
	_InOrder(root->_left);
	cout << root->_kv.first << " ";
	_InOrder(root->_right);
}

5.3 校验函数 IsBalanceTree【非常重要测试工具】

校验函数,用来反向验证我们写的 AVL 树是否正确,两个校验条件同时满足:

  1. 当前节点实际计算得到的高度差绝对值必须小于 2;
  2. 当前节点存储的_bf必须等于 右子树高度 − 左子树高度;如果不一致说明平衡因子更新逻辑出错。
cpp 复制代码
bool IsBalanceTree()
{
	return _IsBalanceTree(_root);
}
private:
int _Height(Node* root)
{
	if (root == nullptr)
		return 0;
	int leftHeight = _Height(root->_left);
	int rightHeight = _Height(root->_right);
	return leftHeight > rightHeight ? leftHeight + 1 : rightHeight + 1;
}

bool _IsBalanceTree(Node* root)
{
	if (root == nullptr)
		return true;
	int leftHeight = _Height(root->_left);
	int rightHeight = _Height(root->_right);
	int diff = rightHeight - leftHeight;

	if (abs(diff) >= 2)
	{
		cout << root->_kv.first << "高度差异常" << endl;
		return false;
	}
	if (root->_bf != diff)
	{
		cout << root->_kv.first << "平衡因子异常" << endl;
		return false;
	}
	return _IsBalanceTree(root->_left) && _IsBalanceTree(root->_right);
}

测试代码TestAVLTree1()使用数组{4,2,6,1,3,5,15,7,16,14},里面包含 LL/RR/LR/RL 全部四种旋转场景,IsBalanceTree()返回 true 代表代码正确。
注意:_Height是递归统计高度,仅测试校验使用,业务代码不要频繁调用,复杂度 O (N);生产 AVL 节点一般直接存 height 成员变量 O (1) 读取。

六、拓展知识

6.1 AVL 删除

插入最多一次旋转;删除会造成连锁失衡:删除节点之后,父节点子树高度可能减少,旋转修复当前节点之后,上层祖先又会失衡,最坏需要从删除点一路旋转到根,最坏(O(logN))次旋转。

  • 插入:旋转之后子树高度复原,循环 break;
  • 删除:旋转之后子树高度可能继续降低,不能 break,要继续向上回溯直到 root

这是 AVL 树最大短板,写删除逻辑难度远大于插入。

6.2 AVL vs 红黑树(面试高频)

对比维度 AVL 树 红黑树(set/map 底层)
平衡约束 严格平衡,高度差≤1 弱平衡,最长路径≤2 倍最短路径
查找性能 更好,树更矮 略差,同一个数量级 O (logN)
插入旋转 最多 1‑2 次旋转 最多 2 次旋转
删除旋转 最坏 O (logN) 次连锁旋转 最多 3 次旋转
额外开销 每个节点存平衡因子 int 每个节点仅需要 1bit 颜色位
适用场景 查询远远多于增删 插入删除频繁,工业标准选择

结论:C++ STL 没有采用 AVL,使用红黑树,就是因为删除场景 AVL 旋转开销太大。

6.3 AVL 两种实现流派

  1. 存平衡因子 bf,插入更新 bf;
  2. 存 height 高度:每个节点保存子树高度,旋转之后更新 height,不需要计算 bf; 两种都可以,height 版本做删除逻辑更方便。

6.4 易错坑点汇总

  1. 旋转忘记更新_parent父指针,树内部逻辑断裂,校验直接失败;
  2. 双旋直接把三个节点 bf 全部置 0,忽略subLR三种 bf 分支;
  3. 混淆 bf 定义:有的教材 bf = 左‑右,直接抄课件旋转代码就会全部反向;
  4. 插入旋转之后继续向上循环:插入旋转之后子树高度复原,必须 break;
  5. 把校验用递归_Height用到业务逻辑,造成性能灾难。

七、面试高频问答

AVL 树性质?平衡因子定义?

AVL 是自平衡 BST,任意节点左右子树高度差绝对值不大于 1;本课件 bf = 右子树高度 − 左子树高度,合法取值 - 1,0,1。

AVL 四种失衡类型分别对应什么旋转?

LL 右单旋;RR 左单旋;LR 先左后右双旋;RL 先右后左双旋。

AVL 插入更新平衡因子,循环停止三个条件?

bf=0 停止;bf=±1 继续向上;bf=±2 旋转后停止。

为什么旋转的时候必须维护_parent 指针?

AVL 插入需要自底向上回溯祖先更新 bf;旋转要修改上层父节点的孩子指向,如果父指针错误,树结构完全破坏。

AVL 插入和删除旋转次数差异?

插入最多一次旋转;删除会连锁失衡,最坏 O (logN) 次旋转,这也是 STL 选用红黑树的重要原因。

IsBalanceTree 校验函数做了哪两件校验?

第一校验实际高度差绝对值 < 2;第二校验节点存储 bf 等于真实计算的右减左高度差,排查 bf 更新逻辑 bug。

AVL 树中序遍历结果有什么特点?

和 BST 一样,中序遍历 key 有序。

相关推荐
Logic1011 小时前
C语言/数据结构数组题解:数字列表加一(Plus One)——进位处理与数组扩展
c语言·数据结构·模拟·数组·时间复杂度·算法题·进位
Escalating_xu1 小时前
【C语言常见概念】从第一行代码到字符串结束标志:一次打通编译、main、ASCII、转义字符与注释
c语言·c++
bnmoel1 小时前
C++ 基础入门篇(三):内联函数与空指针
c++·内联函数·nullptr·低层
码匠许师傅1 小时前
【C++三方组件】TinyXML2:两个文件的极简 XML 解析器
xml·c++
free-elcmacom1 小时前
C++学习<1>程序分区
开发语言·c++
xxwxx__4 小时前
C++ STL set 与 map 全套详解:关联式容器、红黑树底层、代码坑点、刷题实战
数据结构·c++
程曦曦5 小时前
MySQL 生产库误删 98 张表后的时间点恢复实战:从 binlog 解析到资金对账
linux·数据结构·其他·算法·ubuntu·运维开发
郝学胜-神的一滴5 小时前
Effective Python 条款 13:善用星号解包,告别下标切片拆分的坑
服务器·开发语言·数据结构·python·程序人生·pycharm
Logic1015 小时前
C语言/数据结构位运算题解:异或XOR找出流水线上的“独特零件编号“——只出现一次的数字
c语言·数据结构·数组·位运算·时间复杂度·算法题·异或性质