前言:上一篇文章我们实现的搜索二叉树虽然平均效率还不错,但是在比较极端的情况下会退化成一条单支树,这时候原本看起来很快的查找也会变成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 |
-2或2 |
当前结点已经失衡,需要旋转 |
一棵空树可以看成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>类,其中Node是TreeNode<K, V>的类型别名,_root指向整棵树的根结点。后面旋转代码中的parent、subL和subR等变量保存的都是Node*。
2.2 插入的大致过程
AVL树的插入可以分成两步:
- 先按照普通搜索二叉树的规则找到空位置并插入新结点。
- 从新结点的父结点开始向上更新平衡因子,遇到失衡结点时选择对应的旋转。
第一步并不复杂。新key小于当前结点就向左走,大于当前结点就向右走;如果key已经存在就返回false。找到空位置以后除了连接左右孩子,还要把新结点的_parent指向父结点。
真正容易绕的是第二步。假设当前变高的子树根结点为cur,它的父结点为parent:
cur是parent的左孩子,说明左子树变高,parent->_bf--。cur是parent的右孩子,说明右子树变高,parent->_bf++。
更新以后并不是每次都要继续走到根,接下来只会出现三类结果。

如果parent->_bf变成0,说明插入前这个结点一边高、另一边低,新结点正好补在较低的一边。当前子树的整体高度没有发生变化,更上层结点的平衡因子也不会受影响,所以可以直接停止。
如果parent->_bf变成1或-1,说明当前子树的整体高度增加了1,还要把cur和parent同时向上移动一层,继续更新祖先。
如果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成为这棵局部子树的新根。

图中的subLR是subL原来的右子树。根据二叉搜索树的大小关系,它里面的所有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不为空时,它的新父结点要改成parent;subL成为局部新根以后,它的父结点要接到原来的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左边高,但新增结点落在了左孩子的右侧。这个形状不能只靠一次右旋解决,需要先把弯折的结构拉直:
- 先对
subL做左单旋,让subLR上移。 - 再对
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,是因为RotateL和RotateR会按照单旋场景把相关结点的平衡因子清零。如果旋转以后再读取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;
}
完