一. AVL树的概念
1.1 为什么需要 AVL 树
二叉搜索树具有良好的查找效率,其平均复杂度为O(log N),但是其有一个致命问题:如果数据是有序插入,BST会退化为线性结构,如下图所示

时间复杂度可能退化为 O(N)。为解决这一问题,人们提出了自平衡二叉搜索树。其中最早的一种实现是 AVL 树,由 Adelson-Velsky 和 Landis在 1962 年发明,其核心思想是
通过控制左右子树高度差,使树始终保持平衡
1.2 AVL 树的定义
AVL 树需要满足以下两个特性:
- 必须是一棵二叉搜索树
- 每个节点的左右子树高度差绝对值不超过 1
因此AVL 树又称高度平衡二叉搜索树。为了保持这种平衡特性,需要引入一个关键概念:平衡因子(Balance Factor)
定义:bf = 右子树高度 - 左子树高度。每个节点的平衡因子必须满足**bf ∈ { -1, 0, 1 }**这一条件
根据上述条件,AVL 树的结构可以这样设计
cpp
template<class K, class V>
struct AVLTreeNode
{
pair<K, V> _kv;
AVLTreeNode* _left;
AVLTreeNode* _right;
AVLTreeNode* _parent;
int _bf;
AVLTreeNode(const pair<K, V>& kv)
:_kv(kv), _left(nullptr), _right(nullptr), _parent(nullptr), _bf(0)
{}
}
为什么要有 parent 指针?
插入后需要从当前节点开始逐层向上更新平衡因子,以避免实现过于复杂
二. 平衡因子的更新规则
在 AVL 树中,平衡因子定义为右子树高度与左子树高度的差值(bf = rightHeight - leftHeight)。当我们插入一个新节点时,逻辑上会先将其作为叶子节点挂载,随后由下而上依次调整祖先节点的平衡因子
更新的基本逻辑如下:
-
若新增节点在 parent 的右子树,则 parent->bf++
-
若新增节点在 parent 的左子树,则 parent->bf--
更新后,我们需要根据 parent->bf 的值分为以下三种情况来决定是否继续向上追溯:
情况1:BF 变为 0(局部填平)
平衡因子由 1 -> 0 或 -1 -> 0,这说明在插入前,parent 子树原本是一边高一边低的,而新增节点恰好插入到了较矮的那一边
插入后 parent 两边达到等高,该子树的整体高度并没有发生变化。既然高度没变,就不会影响更高层祖先的平衡因子。停止更新,插入结束
更新到 3 为根的节点,由于高度不变不会影响上一层,更新结束

情况 2:BF 变为 ±1(局部增高)
平衡因子由 0 -> 1 或 0 -> -1,这说明在插入前,parent 子树的两边是完全等高的。新增节点的插入打破了这种平衡,导致子树变得一边高一边低
虽然此时 parent 子树依然符合 AVL 树的平衡要求,但其整体高度增加了 1 层 。这一高度变化会影响到 parent 的父节点。所以继续向上更新。重复上述逻辑检查 parent -> parent 的平衡因子
一直向上更新最坏到根节点停止

情况 3:BF 变为 ±2(局部断裂)
平衡因子由 1 -> 2 或 -1 -> -2,这说明在插入前,parent 子树已经处于失衡的边缘(BF = ±1),而新节点偏偏又插入到了原本就较高的那一边
此时必须通过旋转操作进行修复。旋转的目的是将 parent 子树重新调整为平衡状态,并使子树高度恢复到插入前的水平。完成旋转后即可停止更新,插入过程结束
更新到 10 节点,平衡因子为 2,子树已经不平衡需要旋转处理

如果更新路径一直追溯到了根节点(root),且根节点的平衡因子变为 ±1。此时由于根节点已无父节点,高度的变化无法再向上递推,更新过程也会自然停止
插入结点及更新平衡因子的代码实现
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(cur->_kv.first > kv.first)
{
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(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;
}
三. 旋转
当平衡因子的绝对值达到 2 时,就必须通过旋转来重新调整树的结构。旋转总共分为四种,左单旋 / 右单旋 / 左右双旋 / 右左双旋
旋转的基本原则
无论执行哪种旋转,都必须严格遵守以下两个底线:
-
保持性质:旋转后,整棵树依然必须满足左小右大的排序规则
-
降低高度:旋转不仅要让子树恢复平衡,更重要的是要将其高度恢复到插入节点之前的水平,从而切断对祖先节点的影响
3.1 右单旋(Right Rotation)
通常用于处理**左左(LL)**场景,即新节点插入在较高左子树的左侧,导致祖先节点左边过重
1. 触发场景分析
我们以节点 10 为根节点为例。假设其左孩子为 5,左孩子 5 的左右子树分别为 a 和 b,而 10 的右子树为 c,假设子树 a, b, c 的高度均为 h。此时 10 的平衡因子为 -1(左边比右边高 1 层)
当我们在子树 a 中插入一个新节点,导致 a 的高度从 h 增加到 h+1,从而使节点 5 的平衡因子变为 -1,而根节点 10 的平衡因子从 -1 变为 -2。此时,以 10 为根的子树违反了平衡规则,且重心明显偏向左侧

2. 旋转步骤
为了重新平衡,我们需要将 10 所在的轴线向右压下去。基于 5 < b < 10 的大小关系,旋转步骤如下:
-
将节点 5 的右子树 b 挂载到节点 10 的左边(因为 b 中的值均大于 5 且小于 10)
-
将节点 10 降级,作为节点 5 的右孩子,将节点 5 提升为这棵局部子树的新根节点。

结果验证
调整后,搜索树仍符合 a < 5 < b < 10 < c 的规则,结构保持完整。旋转操作使得节点 5 和 10 的平衡因子恢复为 0,局部子树的高度恢复至插入前的 h+2 层。这意味着该子树对外部父节点的高度影响与插入前完全一致
右单旋不仅精准地解决了左重问题,还通过降低高度避免了平衡因子向更高层祖先继续追溯
右单旋代码实现
cpp
void RotateR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
// 先记录下父节点
Node* parentParent = parent->_parent;
parent->_left = subLR;
if(subLR) // 防止 subLR 不存在的情况
subLR->_parent = parent;
subL->_right = parent;
parent->_parent = subL;
// parent有可能是整棵树的根,也可能是局部的子树
// 如果是整棵树的根,要修改 _root
// 因为这时候 _root 还在指向 parent
if(parentParent == nullptr)
{
_root = subL;
subL->_parent = nullptr;
}
else // 如果是局部则要跟上一层链接
{
if(parent == parentParent->_left)
subL = parentParent->_left;
else
subL = parentParent->_right;
subL->_parent = parentParent;
}
parent->_bf = subL->_bf = 0;
}
3.2 左单旋**(Left Rotation)**
左单旋是右单旋的镜像操作,专门用于处理**右右(RR)**场景,即新节点插入在较高右子树的右侧,导致祖先节点重心向右严重偏移
1. 触发场景分析
我们以节点 10 为局部根节点进行说明。假设其右孩子为 15,10 的左子树为 a,15 的左右子树分别为 b 和 c。子树 a, b, c 的高度均为 h。此时节点 10 的平衡因子为 1(右边比左边高 1 层)。
当我们在子树 a 中插入一个新节点,导致 a 的高度从 h 增加到 h+1。节点 15 的平衡因子变为 1,而根节点 10 的平衡因子从 1 递增为 2。此时,以 10 为根的子树右侧过重,违反了 AVL 树的平衡准则

2. 旋转步骤
为了恢复平衡,我们需要将 10 所在的轴线向左拉下来。根据搜索树性质 10 < b < 15,旋转逻辑如下:
-
将节点 15 的左子树 b 挂载到节点 10 的右边(因为 b 中的所有值都大于 10 且小于 15)
-
将节点 10 降级,作为节点 15 的左孩子。将节点 15 提升为该局部子树的新根节点

结果验证
经过调整后,整体结构仍严格保持 a < 10 < b < 15 < c 的有序关系。旋转操作完成后,节点10和节点15的平衡因子均归零。并且子树高度已从插入后的 h+3 恢复到插入前的 h+2 水平
将右子树左侧的负载转移给原根节点,同时让原右子节点成为新的根节点。由于旋转后树的高度与插入前保持不变,这种调整不会影响更高层级的祖先节点
左单旋代码实现
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 (parent == parentParent->_left)
{
parentParent->_left = subR;
}
else
{
parentParent->_right = subR;
}
subR->_parent = parentParent;
}
parent->_bf = subR->_bf = 0;
}
3.3 左右双旋 (Left-Right Rotation)
为什么单旋在此时失效?
当节点 10 的左子树 5 过高,且高度增加是由于 5 的右子树(即孙子节点 8 所在的路径)增高引起的,这被称为 LR 型失衡
如果此时强行执行右单旋,节点 5 的右子树(较重的部分)会变成节点 10 的左子树,重心只是从一侧移动到了另一侧,整棵树依然不平衡。因此,我们需要通过两次旋转:先对子树执行左单旋将其拉直,再对整体执行右单旋将其调平

核心逻辑
我们以以下 AVL 树为例。双旋最精妙的地方在于平衡因子的修复。旋转本身是固定的(先 RotateL(5),再 RotateR(10)),但旋转后各个节点的 BF 取值取决于新节点究竟插在了孙子节点 8 的哪一侧

我们设定节点 8 为旋转前 5 的右孩子。根据插入位置的不同,分为以下三个场景:
场景 1:插在孙子左侧 (h >= 1)
新节点插入在 8 的左子树 e,导致 8 的平衡因子变为 -1。旋转后 8 成为新的根节点,两边等高,其 BF = 0。5 的右边被 8 的左子树抵消,其 BF = 0。10 的左边由于只接纳了 8 的右子树(相对较短),其 BF = 1

场景 2:插在孙子右侧 (h >= 1)
新节点插入在 8 的右子树 f,导致 8 的平衡因子变为 1。旋转后 8 成为新的根节点,其 BF = 0。10 的左边被 8 的右子树抵消,其 BF = 0。而5 的左边由于只保留了原来的高度,其 BF = -1

场景 3:孙子本身就是新增节点 (h = 0)
节点 8 本身就是新插入的节点。此时 8 的平衡因子为 0
这是一种最简单的重排。旋转后,8 作为根,5 和 10 分居左右。三个节点的平衡因子均修复为 0

左右双旋的本质是提拔孙子做爷爷。 通过两次旋转,原本位于深层的节点 8 被提升到了局部根节点的位置,而原本的 5 和 10 顺理成章地成为了它的左右孩子
代码实现
cpp
void RotateLR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
int bf = subLR->_bf;
// 复用单旋的代码
RotateL(subL);
RotateR(parent);
// 更新平衡因子
if(bf == -1)
{
subLR->_bf = 0;
subL->_bf = 0;
parent->_bf = 1;
}
else if(bf == 1)
{
subLR->_bf = 0;
subL->_bf = -1;
parent->_bf = 0;
}
else if(bf == 0)
{
subLR->_bf = 0;
subL->_bf = 0;
parent->_bf = 0;
}
else
{
assert(false);
}
}
在实现代码时,一定要在旋转前记录下孙子节点 8 的平衡因子。因为单旋函数内部会修改节点的 BF,如果不提前记录,旋转结束后将无法判断该如何正确修复 5 和 10 的平衡因子
3.4 右左双旋 (Right-Left Rotation)
右左双旋是左右双旋的镜像操作,专门用于处理 **右左(RL)**型失衡。这种失衡发生在根节点的右子树较高,且高度的增加是由右孩子的左子树引起的
与单旋不同,RL 失衡呈现折线状,需要先通过一次右单旋将路径拉直,再通过一次左单旋完成最终的调平
核心逻辑
以以下这棵 AVL 树为例,假设局部根节点为 10,其右孩子为 15。失衡是由于 15 的左孩子 12 所在的子树高度增加导致的

-
第一步:右单旋。以 15 为旋转点执行右单旋,将 12 提升,使 10、12、15 三者在逻辑上连成一条向右倾斜的直线
-
第二步:左单旋。以 10 为旋转点执行左单旋,将 12 提升为新的局部根节点,10 和 15 分列其左右
平衡因子的修复规则
双旋的核心难点在于旋转完成后平衡因子的修正。我们根据新节点插入的具体位置,分为以下三个场景进行讨论:
场景 1:新增节点插入在 12 的左子树 e (h >= 1)
插入后,12 的平衡因子更新为 -1。经过两次旋转,12 成为根节点
节点 10 的右边接纳了 e 的增高部分,趋于平衡,其 BF = 0。节点 15 的左边失去了原本的支撑,相对较矮,其 BF = 1。节点 12 左右均衡,其 BF = 0

场景 2:新增节点插入在 12 的右子树 f (h >= 1)
插入后,12 的平衡因子更新为 1
节点 15 的左边接纳了平衡部分,其 BF = 0。节点 10 的右边相对较低,其 BF = -1。节点 12 成为新根,其 BF = 0

场景 3:节点 12 本身即为新增节点 (h = 0)
12 插入后其平衡因子为 0
这是一次标准的重新排布。旋转完成后,12 作为根,10 和 15 完美对称。三个节点 10、12、15 的平衡因子全部修复为 0

代码实现
cpp
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);
}
}
在实现代码时,双旋函数其实是简单的组合调用,例如 RL 双旋只需 RotateR(parent->right) 紧接着 RotateL(parent)。真正的难点在于 if-else 判断逻辑:必须在旋转之前通过孙子节点的平衡因子确定属于哪种场景,从而在旋转后正确地手动重置各个节点的平衡因子
小结
通过分析这四种旋转操作,我们可以提炼出 AVL 树维持平衡的核心机制:
-
单旋(LL/RR):处理直线型结构,通过一次支点摆动恢复平衡
-
双旋(LR/RL) :处理折线型结构,本质上是先拉直、后调平
四. AVL 树的插入
AVL 插入可以理解为 BST 插入 + 平衡维护
在新增节点挂载到树上后,该节点会导致其父节点乃至更高层祖先节点的高度发生变化。因此,我们需要沿着新增节点 -> 根节点的路径,依次更新祖先节点的平衡因子。在更新过程中,通常会出现以下几种情况:
1. 局部高度未变,提前结束更新
并非所有的更新都会追溯到根节点。如果在更新过程中,某个祖先节点的平衡因子变为 0,这意味着该子树的高度在插入前后保持不变(即新节点填补了原有的低洼处),此时高度变化不再向上传递,更新流程可以提前结束
2. 局部高度增加,继续向上追溯
如果祖先节点的平衡因子更新后变为 ±1,说明该子树的高度增加了一层,但尚在平衡允许范围内。这种高度的变化可能会影响更上层的祖先,因此更新过程必须继续沿着路径向根节点推进,最极端的情况会一直更新至根节点
3. 触发失衡
如果在更新过程中,某个节点的平衡因子达到了 ±2,则说明该子树已经失去平衡。此时,我们需要根据具体类型(LL、RR、LR 或 RL)进行对应的旋转操作
代码实现
补充平衡因子更新机制的实现
cpp
// 迭代更新平衡因子与旋转维护
while (parent) {
// 根据插入位置更新父节点平衡因子(BF)
if (cur == parent->_left) parent->_bf--;
else parent->_bf++;
// 检查平衡因子状态,处理不同情况
if (parent->_bf == 0) {
// 情况 1:填平低洼,子树高度未发生变化,无需继续向上更新
break;
}
else if (parent->_bf == 1 || parent->_bf == -1) {
// 情况 2:子树高度增加,需要继续向上追溯更新祖先节点
cur = parent;
parent = parent->_parent;
}
else if (parent->_bf == 2 || parent->_bf == -2) {
// 情况 3:当前节点触发失衡,根据失衡类型进行旋转处理
if (parent->_bf == 2 && cur->_bf == 1) {
RotateL(parent); // RR型失衡 -> 左单旋
}
else if (parent->_bf == -2 && cur->_bf == -1) {
RotateR(parent); // LL型失衡 -> 右单旋
}
else if (parent->_bf == 2 && cur->_bf == -1) {
RotateRL(parent); // RL型失衡 -> 右左双旋
}
else if (parent->_bf == -2 && cur->_bf == 1) {
RotateLR(parent); // LR型失衡 -> 左右双旋
}
// 旋转后子树高度恢复平衡,平衡因子修复完成,退出循环
break;
}
else {
// 理论上不会执行到此处,若触发说明插入前AVL树已处于失衡状态
assert(false);
}
}
五. AVL 树的查找
查找操作是 AVL 树中最省心的部分。由于 AVL 树在插入阶段已经通过复杂的旋转将平衡性维护到了极致,在查找时,我们完全不需要担心树结构会退化。其查找逻辑与标准的二叉搜索树(BST)完全一致,本质上是一种基于路径过滤的分治思想
从根节点开始,将目标值 x 与当前节点进行比对:若 x 较小则转向左子树,较大则转向右子树,相等则直接返回命中节点。得益于 AVL 树高度 H 始终严格维持在 log N 附近,其查找的最坏时间复杂度被稳定控制在 O(log N)。即便面对海量数据,查找路径也极其短促且性能波动极小
代码实现
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; // 查找失败
}
普通 BST 的查找效率极易受到插入顺序的影响,最坏情况下会退化为 O(N)。而 AVL 树通过在插入时付出微小的旋转成本,换取了极其稳定的查询性能。对于读多写少的业务场景,AVL 树带来的检索稳定性具有极高的工程价值
六. AVL 树的平衡检测 (Verification)
在处理海量随机数据时,仅凭肉眼观察几个简单的用例是远远不够的。我们需要编写一个自动化的平衡检测函数,通过递归遍历整棵树,验证其是否严格遵守 AVL 树的定义
要判定一棵二叉树是否为合法的 AVL 树,必须同时满足以下两个硬性条件:
-
每一个节点的左右子树高度差(平衡因子)的绝对值不超过 1,即 h_right - h_left <= 1
-
节点中存储的平衡因子 _bf 必须与该节点实际计算出的高度差完全吻合。
1. 求子树高度的辅助函数
由于平衡检测依赖于高度值,我们首先需要实现一个递归求高度的函数
cpp
int _Height(Node* root)
{
if(root == nullptr)
return 0;
int leftHeight = _Height(root->_left);
int rightHeight = _Height(root->_right);
// 当前节点高度 = 左右子树中较高者 + 1
return leftHeight > rightHeight ? leftHeight + 1 : rightHeight + 1;
}
2. 平衡检测核心逻辑
检测过程采用前序遍历的思路:先检查当前节点是否平衡,再递归检查左子树和右子树
cpp
bool _IsBalance(Node* root)
{
if(root == nullptr)
return true;
int leftHeight = _Height(root->_left);
int rightHeight = _Height(root->_right);
int diff = rightHeight - rightHeight;
// 检查高度差的绝对值是否超过1
if(abs(diff) >= 2)
{
std::cout << root->_kv.first << " 失衡,平衡因子为:" << diff << std::endl;
return false;
}
// 检查存储的平衡因子与实际高度差是否一致
if(diff != root->_bf)
{
std::cout << root->_kv.first << " 平衡因子异常,实际为:" << diff << std::endl;
return false;
}
// 递归检查左右子树是否平衡
return _IsBalance(root->_left) && _IsBalance(root->_right);
}
时间复杂度为 O(N log N)(由于 _Height 函数会被频繁调用)。虽然在生产环境中性能开销较大,但在开发测试阶段,它作为验证逻辑正确性的黄金标准仍然非常可靠
测试用例
统计节点数量的辅助函数 Size
cpp
size_t _Size(Node* root)
{
if(root == nullptr)
return 0;
// 递归逻辑:左子树节点数 + 右子树节点数 + 根节点(1)
return _Size(root->_left) + _Size(root->_right) + 1;
}
size_t Size()
{
return _Size(_root);
}
具体测试用例
cpp
void TestAVLTree()
{
const int N = 100000;
vector<int> v;
v.reserve(N);
srand((unsigned int)time(0));
// 1. 构造随机数据集
for (size_t i = 0; i < N; i++)
{
// rand() + i 增加了数据的随机区间,减少了 Key 冲突
v.push_back(rand() + i);
}
// 2. 性能测试:插入耗时统计
size_t begin = clock();
AVLTree<int, int> t;
for (auto e : v)
{
t.Insert(make_pair(e, e));
}
size_t end = clock();
// 3. 结果验证
cout << "数据总量 (N): " << N << endl;
cout << "插入耗时 (ms): " << end - begin << endl;
// 验证平衡性:调用我们之前写的 IsBalanceTree
cout << "平衡检测结果 (1-正常/0-异常): " << t.IsBalanceTree() << endl;
// 理论高度约为 1.44 * log2(N)
cout << "树的实际高度: " << t.Height() << endl;
// 验证实际插入的节点数(排除重复项)
cout << "树中节点总数: " << t.Size() << endl;
// 4. 数据一致性校验(可选)
for (auto e : v)
{
if (t.Find(e) == nullptr)
{
assert(false); // 如果插入的值找不到,说明逻辑有误
}
}
cout << "数据查找一致性校验通过!" << endl;
}
通过 10^5 级别的随机压力测试,我们可以看到 AVL 树将高度严格控制在 20 层以内。这意味着即便在十万级的数据量下,每一次查找也仅仅需要不到 20 次的比较。这种极致的平衡,正是 AVL 树在读多写少场景下立足的基石
七. 总结
AVL 树作为计算机科学中最早被发明的自平衡二叉搜索树,其核心特征在于其近乎苛刻的逻辑约束。通过对平衡因子的严格监控以及四种旋转策略的灵活运用,AVL 树确保了任何规模的数据下,查找、插入和删除的时间复杂度都能稳稳锁定在 O(log N)
理解 AVL 树不仅是掌握一种高效搜索结构的过程,更是对递归思维、逻辑严谨性和空间想象力的全面锻炼。在进阶学习中,我们会接触到更通用的平衡方案------红黑树。红黑树通过适当放宽平衡条件实现了更好的综合性能,而要真正理解红黑树,首先需要深刻领会 AVL 树所体现的绝对平衡逻辑
