从失衡到平衡:手撕 AVL 树的插入旋转操作
目录
0.写在前面
1.AVL树的历史起源
1. 严格的自平衡机制
2. 高效的操作性能
2.AVL树的模拟实现
Step 1:Tree结点的构造
Step 2:构造AVLTree类
Step 3: 完成插入函数
Step 4: 刨析旋转
左单旋:
右单旋:
左右双旋:
编辑
右左双旋:
Step 5:检查AVL树
InOrder:
Height:
IsBalance:
Step 6:测试AVL树
3.AVL树的优缺点
一、AVL 树的优点
1. 严格的平衡性
2. 稳定的性能下限
二、AVL 树的缺点
1. 高维护成本
2. 存储开销
3. 实现复杂度高
0.写在前面
AVL树是数据结构中久负盛名的平衡二叉搜索二叉树,前面已经介绍了BInary Search Tree二叉搜索树的实现,但是由于BST不一定平衡,有可能出现O(N)量级的高度,今天来介绍一下BST的强化版——平衡的BST——AVL,如果没听过这棵树的读者可以了解一下背景:
1.AVL树的历史起源
一、AVL 树的历史背景
-
起源
AVL 树由苏联数学家 G.M. Adelson-Velsky 和 E.M. Landis 于 1962 年 在论文《An algorithm for the organization of information》中首次提出,旨在解决普通二叉搜索树(BST)在动态操作(插入、删除)中退化为链表的问题。 -
命名由来
AVL 树名称取自两位发明者姓氏的首字母,是计算机科学中最早的自平衡二叉搜索树结构之一,其核心思想是通过动态调整树的结构,保持高度平衡,从而保证操作的高效性。
二、AVL 树的强大功能与核心优势
1. 严格的自平衡机制
- 平衡因子:每个节点的平衡因子定义为
左子树高度 - 右子树高度
,AVL 树要求所有节点的平衡因子绝对值不超过1。 - 动态调整:插入或删除节点后,若失衡(平衡因子绝对值≥2),通过旋转操作(单旋或双旋)恢复平衡,确保树的高度始终为O(logn)。
- 四种旋转策略:LL 型(右旋)、RR 型(左旋)、LR 型(先左后右)、RL 型(先右后左)。
2. 高效的操作性能
- 时间复杂度:所有操作(查找、插入、删除)的时间复杂度严格稳定在 O(log n),优于普通 BST 的最坏情况 O (n)。
- 应用场景优势:数据库索引:快速定位数据,支持高并发查询。文件系统:高效管理目录结构,加速文件检索。内存管理:动态分配与回收内存块,避免碎片化。路由表与网络协议:快速匹配 IP 地址,提升网络吞吐量。事件调度系统:精准管理定时任务,如操作系统进程调度。
2.AVL树的模拟实现
由于AVL树的插入和删除都涉及到大量复杂的旋转,由于篇幅有限,本篇重点介绍的是AVL树的插入:并且规定平衡因子的计算是右子树的高度-左子树的高度。
Step 1:Tree结点的构造
思路:
---1--- 先来想想如何存储结点,如果我们使用堆那样的数组是否合适?当然不合适,因为涉及到下标访问的问题,那么还剩下链式结构可以供我们选择了。
---2--- 构造AVLTreeNode当然是要方便类外直接进行访问的,因此用struct来构造,来想想一个结点要存储什么?
---3--- 在结点中存储left地址,right地址,还有parent地址,这是典型的三叉链表的特征,以及当前结点的pair,pair可以是多种类型的,因此需要进行泛型编程,运用模板template,这里也可以只储存Key,我们干脆麻烦一点,储存键值对,pair<Key,Value>。
---4--- 最后要写一个构造函数,因为在创造结点的时候可能会用到,记得将left,right置为nullptr哦~
template<class K, class V>
struct AVLTreeNode
{
AVLTreeNode<K, V>* _left;
AVLTreeNode<K, V>* _right;
AVLTreeNode<K, V>* _parent;
pair<K, V> _kv;
int _bf;
AVLTreeNode(const pair<K, V>& kv)
:_left(nullptr)
, _right(nullptr)
, _parent(nullptr)
, _kv(kv)
, _bf(0)
{
}
};
补充:欸,其实还没有介绍完,对了!这里漏掉了平衡因子,Balance Factor是AVL能保持绝对平衡必不可少的因素,后面的旋转操作需要大量依赖_bf!
Step 2:构造AVLTree类
思路:
---1--- 为了表示AVLTreeNode方便一些,在类中typedef一下以便我们来表示。
---2--- 这里的成员变量只需要_root就足够了,存储根结点
---3--- 下面给出AVLTree类的大致框架:
template<class K, class V>
class AVLTree
{
public:
typedef AVLTreeNode<K, V> Node;
AVLTree()
:_root(nullptr)
{
}
/...
Node* _root;
};
Step 3: 完成插入函数
思路:
---1--- 简单来说,前面的步骤与BST二叉搜索树的插入步骤大相径庭,通过每个节点的值进行比较来确定新的节点要插入的位置。(注意这里存储的是键值对,pair要按照pair.first来作为key进行比较)
---2--- 当cur在往下走之前要记得保存上一结点的位置,方便后面的调整,在找到nullptr时来进行插入,请注意如果根节点为空时,将新插入的结点作为根节点
---3--- 下面进行平衡因子的调整和旋转:!!!重要
首先需要了解这样的几点规则:
- //1.新增在左,prev平衡因子--
- //2.新增在右,prev平衡因子++
- //3.更新后prev平衡因子==0,说明prev所在的子树高度不变,不会再影响祖先,不用再继续沿着到root的路径往上更新
- //4.更新后prev平衡因子==1/-1,说明prev所在的子树的高度变化,会影响祖先,需要继续沿着到root的路径更新
- /5.更新后prev平衡因子==2/-2,说明prev所在的子树高度变化并且不平衡,对parent所在子树进行旋转调整
- //6.如果更新到根节点,插入结束
很明显,这是个循环操作,那么就将循环条件设置为prev不为nullptr,调整_bf是否++/--,之后如果_bf==0,说明已经达到平衡,无需往后调整,如果==1/-1,说明处于半平衡需要将prev给newnode,继续向上更新,如果_bf已经==2/-2,那么就需要进行旋转了:
- 如果prev->_bf == 2 && newnode->_bf == 1,说明插入的节点在右子树的右子树,进行左单旋
- 如果prev->_bf == -2 && newnode->_bf == -1,说明插入的节点在左子树的左子树,进行右单旋
- 如果prev->_bf == 2 && newnode->_bf == -1,说明插入的节点在右子树的左子树,进行右左双旋
- 如果prev->_bf == -2 && newnode->_bf == 1,说明插入的节点在右子树的左子树,进行左右双旋
bool Insert(const pair<K, V>& kv)
{
//建议还是采用非递归的写法,不要使用递归或者指针引用,一方面是防止栈溢出
//stack overflow warning!一方面是锻炼代码能力
Node* cur = _root;
Node* prev = nullptr;
while (cur)
{
if (kv.first < cur->_kv.first)
{
prev = cur;
cur = cur->_left;
}
else if (kv.first > cur->_kv.first)
{
prev = cur;
cur = cur->_right;
}
else
{
return false;
}
}
Node* newnode = new Node(kv);
if (_root == nullptr)
{
_root = newnode;
return true;
}
if (kv.first > prev->_kv.first)
{
prev->_right = newnode;
}
else
{
prev->_left = newnode;
}
newnode->_parent = prev;
//插入完成后要调整父亲的平衡因子
//要满足以下规则:
//1.新增在左,parent平衡因子--
//2.新增在右,parent平衡因子++
//3.更新后parent平衡因子==0,说明parent所在的子树高度不变,不会再影响祖先,不用再继续沿着到root的路径往上更新
//4.更新后parent平衡因子==1/-1,说明parent所在的子树的高度变化,会影响祖先,需要继续沿着到root的路径更新
//5.更新后parent平衡因子==2/-2,说明parent所在的子树高度变化并且不平衡,对parent所在子树进行旋转调整
//6.如果更新到根节点,插入结束
while (prev)
{
if (prev->_left == newnode)
{
prev->_bf--;
}
else
{
prev->_bf++;
}
if (prev->_bf == 0)
{
//更新结束
break;
}
else if (prev->_bf == 1 || prev->_bf == -1)
{
//继续向上更新
newnode = prev;
prev = prev->_parent;
}
else if (prev->_bf == 2 || prev->_bf == -2)
{
//不平衡的AVL,需要旋转
//旋转需要注意的问题:
//1.旋转的同时还要满足是搜索树
//2.变成平衡树且降低这个子树的高度
//3.变成平衡树后就无需向上更新了,旋转后根节点的平衡因子为0
//旋转调整分为四种情况:
//1.左单旋
//2.右单旋
//3.左右双旋
//4.右左双旋
//如果是左单旋,需要判断插入的节点是否位于右子树的右子树
//如果位于右子树的左子树,需要先对右子树进行右单旋,再对根节点进行左单旋,也就是右左双旋
//如果是右单旋,需要判断插入的节点是否位于左子树的左子树
//如果位于左子树的右子树,需要先对左子树进行左单旋,再对根节点进行右单旋,也就是左右双旋
//代码:
if (prev->_bf == 2 && newnode->_bf == 1)
{
//说明插入的节点在右子树的右子树
//进行左单旋
RotateL(prev);
}
else if (prev->_bf == -2 && newnode->_bf == -1)
{
//说明插入的节点在左子树的左子树
//进行右单旋
RotateR(prev);
}
else if (prev->_bf == 2 && newnode->_bf == -1)
{
//说明插入的节点在右子树的左子树
//进行右左双旋
RotateRL(prev);
}
else
{
//最后一种情况,说明插入的节点在左子树的右子树
//进行左右双旋转;
RotateLR(prev);
}
//注意这里旋转完成后,平衡因子满足-1/0/1,不需要再向上更新
break;
}
else
{
assert(false);//用来调试的,如果出现这种情况,说明代码有问题
//插入之前AVL就出现了问题
}
}
return true;
}
Step 4: 刨析旋转
左单旋:
思路:
---1--- 如图,在插入了新节点后,prev->_bf变为2,newnode->_bf变为1,说明新节点插入在了根节点的右子树的右子树,很明显右子树比左子树高2
---2--- 那么此时就需要将左子树向下按1,让两边的子树保持平衡,将root设置为parent,parent->_right设置为cur,cur->_left设置为curleft,这时需要将curleft连接在parent->_right,将parent连接到cur->_left上
---3--- 接下来要对一些细节进行处理,如果parent为根节点,那么旋转后需要更新根节点
---4--- 如果curleft不存在,就不需要更改curleft的父亲了,否则需要更改
---5--- 别忘记了对parent,cur,curleft的父亲进行更改,对parent需要将其parent提前保存在ppnode中,对于cur,别忘记在最后更改其父亲结点,否则在调试时需要花费大量时间!
---6--- 更新平衡因子:在旋转过后,curleft的平衡因子不变,parent和cur的平衡因子都变为0,保持了AVL的平衡
//左单旋
void RotateL(Node* parent)
{
Node* cur = parent->_right;
Node* curleft = cur->_left;
Node* ppnode = parent->_parent;
parent->_right = curleft;
cur->_left = parent;
//这里curleft有可能是不存在的,那么需要特殊判断一下
if (curleft)
{
curleft->_parent = parent;
}
//parent有可能为根结点,根节点需要改变,判断一下
cur->_parent = ppnode;
if (parent == _root)
{
_root = cur;
}
else
{
if (ppnode->_left == parent)
{
ppnode->_left = cur;
}
else
{
ppnode->_right = cur;
}
}
parent->_parent = cur;
cur->_bf=0;
parent->_bf=0;
}
右单旋:
思路:(与左单旋仅仅只有部分不同)
---1--- 如图,在插入了新节点后,prev->_bf变为-2,newnode->_bf变为-1,说明新节点插入在了根节点的左子树的左子树,很明显左子树比右子树高2
---2--- 那么此时就需要将左子树向下按1,让两边的子树保持平衡,将root设置为parent,parent->_left设置为cur,cur->_right设置为curright,这时需要将curright连接在parent->_left,将parent连接到cur->_right上
---3--- 接下来要对一些细节进行处理,如果parent为根节点,那么旋转后需要更新根节点
---4--- 如果curright不存在,就不需要更改curright的父亲了,否则需要更改
---5--- 别忘记了对parent,cur,curleft的父亲进行更改,对parent需要将其parent提前保存在ppnode中,对于cur,别忘记在最后更改其父亲结点,否则在调试时需要花费大量时间!
---6--- 更新平衡因子:在旋转过后,curleft的平衡因子不变,parent和cur的平衡因子都变为0,保持了AVL的平衡
//右单旋
void RotateR(Node* parent)
{
Node* cur = parent->_left;
Node* curright = cur->_right;
Node* ppnode = parent->_parent;
parent->_left = curright;
cur->_right = parent;
//这里curleft有可能是不存在的,那么需要特殊判断一下
if (curright)
{
curright->_parent = parent;
}
//parent有可能为根结点,根节点需要改变,判断一下
cur->_parent = ppnode;
if (parent == _root)
{
_root = cur;
}
else
{
if (ppnode->_left == parent)
{
ppnode->_left = cur;
}
else
{
ppnode->_right = cur;
}
}
parent->_parent = cur;
cur->_bf=0;
parent->_bf=0;
//其实这里cur和parent的_bf一定为0了
}
左右双旋:
思路:
---1--- 对于左右双旋,触发条件是prev->_bf == -2 && newnode->_bf == 1,插入的节点在右子树的左子树 ,那么需要先以parent->_left作为根节点进行左单旋,再以parent为根节点进行右单旋,
---2--- 旋转过程复用单旋就行了,麻烦的是这里的平衡因子更新,我们对curright的_bf平衡因子进行讨论,共需要分为三种情况:
- 如果_bf==0,那么说明插入的结点就是curright,那么最后旋转过后parent,cur,curright的平衡因子都更新为0,达到彻底平衡
- 如果_bf==1,说明插入的结点在curright的右边,那么旋转过后cur->_bf = -1;
parent->_bf = 0,curright->_bf = 0- 如果_bf==-1,说明插入的结点在curright的左边,那么旋转过后cur->_bf = 0;
parent->_bf = 1,curright->_bf = 0
//左右双旋
void RotateLR(Node* parent)
{
//这里要更新平衡因子,分为三种情况
//1.插入在左子树的右子树第一个,此前没有右子树
//2.插入在左子树的右子树的右子树
//3.插入在左子树的右子树的左子树
Node* cur = parent->_left;
Node* curright = cur->_right;
int _bf = curright->_bf;
RotateL(parent->_left);
RotateR(parent);
if (_bf == 0)
{
//最后curright,cur,parent的平衡因子都为0
cur->_bf = 0;
parent->_bf = 0;
curright->_bf = 0;
}
else if (_bf == 1)
{
//说明插入在左子树的右子树的右子树
cur->_bf = -1;
parent->_bf = 0;
curright->_bf = 0;
}
else if (_bf == -1)
{
//说明插入在左子树的右子树的左子树
cur->_bf = 0;
parent->_bf = 1;
curright->_bf = 0;
}
else
{
assert(false);
}
}
右左双旋:
思路:(与左单旋仅仅只有更新平衡因子不同)
---1--- 对于左右双旋,触发条件是prev->_bf == 2 && newnode->_bf == -1,说明插入的节点在右子树的左子树,那么需要先以parent->_right作为根节点进行右单旋,再以parent为根节点进行左单旋,
---2--- 旋转过程复用单旋就行了,麻烦的是这里的平衡因子更新,我们对curleft的_bf平衡因子进行讨论,共需要分为三种情况:
- 如果_bf==0,那么说明插入的结点就是curleft,那么最后旋转过后parent,cur,curright的平衡因子都更新为0,达到彻底平衡
- 如果_bf==1,说明插入的结点在curright的右边,那么旋转过后cur->_bf = 0;
parent->_bf = -1,curleft->_bf = 0- 如果_bf==-1,说明插入的结点在curright的左边,那么旋转过后cur->_bf = 1;
parent->_bf = 0,curleft->_bf = 0
Step 5:检查AVL树
写完了AVL树的插入函数,如果要检查是否正确,该怎么办?请补充接下来三个函数:
InOrder:
我们对AVL树进行中序遍历,先递归左子树,再访问当前结点,最后递归右子树,AVL树的中序遍历正常情况下输出的数据应该有序,这是二叉搜索树的共同特性!
Height:
计算一下二叉树的高度,这在后面检查是否平衡因子会用到,如果递归到空结点,那么返回0,其次判断左子树和右子树的高度,选择较高的一边进行+1
IsBalance:
检查AVL是否平衡,需要计算左子树的高度和右子树的高度,那么当前节点的_bf就是高度之差,检查实际bf与计算出来的是否相等,最后递归判断左子树和右子树是否同样满足
void InOrder()
{
_InOrder(_root);
}
//为了方便测试AVL是否平衡,写一个判断函数
bool IsBalance()
{
//这里可不能直接判断平衡因子,因为平衡因子有可能出错
//选择计算两边的高度差,如果高度差小于2,说明平衡
//但是这只能说明当前节点的平衡,不能说明整棵树的平衡
//所以要递归判断
return _IsBalance(_root);
}
int Height()
{
return _Height(_root);
}
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 _IsBalance(Node* root)
{
if (root == nullptr)
{
return true;
}
int leftHeight = _Height(root->_left);
int rightHeight = _Height(root->_right);
if (root->_bf != rightHeight - leftHeight)
{
return false;
}
return abs(rightHeight - leftHeight) < 2 &&_IsBalance(root->_left) && _IsBalance(root->_right);
}
void _InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_kv.first << " " << root->_kv.second << endl;
_InOrder(root->_right);
}
Step 6:测试AVL树
我将使用下面的test函数来测试AVL树是否平衡:
void test_AVLTree()
{
vector<int> v = { 16,3,7,11,9,26,18,14,15 };
AVLTree<int, int> avl;
for (size_t i = 0; i < v.size(); ++i)
{
avl.Insert(make_pair(v[i], i));
cout << "IsBalance:"<<v[i] <<" " << avl.IsBalance() << endl;
}
avl.InOrder();
cout << endl;
}
一起来看看运行结果:
大功告成!至此AVL树的插入功能就完美实现了~
3.AVL树的优缺点
一、AVL 树的优点
1. 严格的平衡性
- 核心机制:通过平衡因子(右子树高度-左子树高度)约束每个节点的左右子树高度差 ≤1,确保整棵树始终处于近似完全平衡状态。
- 查询效率:所有操作(查找、插入、删除)的时间复杂度严格稳定在 O(log n),尤其适合读密集型场景。
2. 稳定的性能下限
- 避免退化:普通二叉搜索树(BST)在插入有序序列时会退化为链表(最坏时间复杂度 O (n)),而 AVL 树通过动态平衡彻底杜绝了这一问题。
- 适用场景:对实时性要求高的系统,需要严格保证响应时间。
二、AVL 树的缺点
1. 高维护成本
- 频繁旋转:插入或删除节点后,需沿父节点链回溯检查平衡因子,若发现失衡(平衡因子绝对值≥2),立即触发旋转操作。
- 最坏情况:单次插入可能引发 O (log n) 次旋转(如插入导致根节点失衡)
2. 存储开销
- 额外字段:每个节点需存储平衡因子(或子树高度),占用额外内存空间。
- 对比红黑树:红黑树仅需 1 bit 存储颜色标记,而 AVL 树通常需要 32 位整型存储高度或平衡因子。
3. 实现复杂度高
- 代码难度:需处理四种旋转类型(LL/RR/LR/RL)及父节点指针的复杂维护,调试成本高。
“如果 AVL 树如此完美 —— 严格的平衡、稳定的 O (log n) 时间复杂度,为何像 C++ STL 的std::map
、Java 的TreeMap
这些经典库却选择了红黑树?难道红黑树比 AVL 树更‘聪明’吗?”
“当一个数据结构需要每秒处理百万次插入操作时,AVL 树的‘绝对平衡’竟成了它的致命弱点。而红黑树用一种看似‘偷懒’的规则,以牺牲部分平衡性为代价,换取了惊人的性能飞跃 —— 它究竟如何做到的?”
“在插入和删除操作中,AVL 树可能触发连锁旋转,导致性能抖动;而红黑树仅需最多两次旋转即可恢复平衡 —— 这是魔法,还是数学的极致优化?”
下文将揭示:
红黑树如何用 “非严格平衡” 的设计哲学,在工程实践中实现对 AVL 树的性能碾压?它的五大铁律背后隐藏着怎样的数学之美?为何它能在数据库、操作系统、语言标准库中无处不在?
(提示:红黑树的秘密,藏在对 “路径长度” 的巧妙控制中……)