AVL树这个东西,很多人在学校都学过,面试也常考,但真正能从头到尾写出一份能跑的完整实现,并且把旋转讲明白的,不算多。我见过不少人背了四种旋转的代码,碰到插入还能应付,一到删除就懵了。原因很简单:旋转不是靠背的,是靠理解的。你只要把旋转的几何关系看明白,什么LL、RR、LR、RL都不是事,代码自然就写出来了。
这篇文章我从头讲起,不假设你已经会AVL,但也不啰嗦到从指针讲起。核心就干三件事:讲清楚平衡是怎么定义的,讲清楚旋转为什么能恢复平衡,然后给出一份可以直接抄作业的完整实现,最后把我在实际调试中踩过的坑和排查技巧一并拿出来。代码用Python写,但不影响你换成C++或者Java,逻辑是通用的。
1. 平衡的度量与设计思路
1.1 为什么普通二叉搜索树会退化
二叉搜索树(BST)的好处不用多说,左小右大,查找、插入、删除平均都是O(log n)。但这只是平均情况,而且是建立在输入序列足够随机的前提下。一旦输入有序,比如依次插入1、2、3、4、5,普通的BST就会变成一条链表,查找一个节点要从根一路走到叶子,复杂度直接退化到O(n)。
我之前在项目里就吃过这个亏。当时用普通BST存一批按时间递增的数据,前期数据量小没感觉,等数据到了十几万条,查询延迟肉眼可见地涨。后来一查树的高度,好家伙,和节点数一个量级。这就是典型的退化问题,根因就是树没有自平衡能力,形态完全由输入顺序决定。
AVL树解决的就是这个问题。它给每个节点增加了一个“平衡因子”的概念,并且在插入、删除之后主动调整树的形态,保证任何节点的左右子树高度差不超过1。这样一来,树的高度始终被限制在O(log n),查询、插入、删除的复杂度也就稳定在对数量级。
1.2 平衡因子与最小不平衡子树
AVL树的平衡因子定义很简单:
平衡因子 = 左子树高度 - 右子树高度
取值只可能是-1、0、1。只要某个节点的平衡因子绝对值大于1,就说明以它为根的子树失衡了,需要通过旋转来修复。
这里有一个关键概念:最小不平衡子树。在一棵AVL树里,插入或删除一个节点后,可能多个祖先节点的平衡因子都超出了范围,但我们只需要从插入点向上找到第一个失衡的节点,旋转它以它为根的子树,整棵树就恢复了。不需要处理更高的祖先,因为旋转之后这棵子树的高度会回到插入前的高度,对更上层来说等于什么都没发生。
这个“高度恢复”的性质是AVL树高效的关键。它保证了一次插入最多只需要一次旋转操作(单旋或者双旋),删除则可能需要沿路径多次旋转,但整体复杂度依然是O(log n)。你先记住这个结论,后面讲删除的时候会用到。
2. 旋转的本质:四种情况其实是一件事
2.1 右旋与左旋:把“拎起来”和“挂下去”看明白
旋转是AVL树最核心的操作,也是很多人卡住的地方。我见过最有效的讲解方式,不是贴代码,而是画图。先在纸上画一棵右旋前的树:
y / \ x T3 / \ T1 T2其中x是y的左孩子。现在x的左子树T1偏高,导致y的平衡因子变成了2(左高右低)。右旋的目标是把x提上来当根,y降级为x的右孩子,具体操作就三步:
- x的右孩子T2过继给y,变成y的左孩子。
- y变成x的右孩子。
- x顶替原来y的位置,成为这棵子树的新根。
旋转之后:
x / \ T1 y / \ T2 T3注意看T2的位置变化,它原本是x的右子树,里面所有节点的值都大于x、小于y,所以过继给y当左孩子完全符合二叉搜索树的性质。这就是旋转为什么不会破坏有序性的根本原因。左旋就是右旋的镜像,把上面的图左右翻一下,操作步骤完全对称。
我在工程里习惯用一句话记忆:右旋就是把左孩子“拎上来”,左孩子原来的右子树要“过继”给当前节点当左子树。左旋则完全镜像。
2.2 四种失衡形态的判断方法
实际插入时,失衡形态有四种,分别对应LL、RR、LR、RL。很多资料喜欢用“左左型”“右右型”这种叫法,初看容易晕,其实判断标准很简单:
- LL型:失衡节点的左孩子偏高,且左孩子的左子树(不是右子树)偏高。
- LR型:失衡节点的左孩子偏高,但左孩子的右子树偏高。
- RR型:失衡节点的右孩子偏高,且右孩子的右子树偏高。
- RL型:失衡节点的右孩子偏高,但右孩子的左子树偏高。
处理方式更简单:LL型右旋一次,RR型左旋一次,LR型先对左孩子左旋再对失衡节点右旋,RL型先对右孩子右旋再对失衡节点左旋。
我在代码里实现时,不用查什么“型”,直接看平衡因子就能决定:
if balance > 1 and self._height(node.left) >= 0: return self._right_rotate(node) if balance > 1 and self._height(node.left) < 0: node.left = self._left_rotate(node.left) return self._right_rotate(node) if balance < -1 and self._height(node.right) <= 0: return self._left_rotate(node) if balance < -1 and self._height(node.right) > 0: node.right = self._right_rotate(node.right) return self._left_rotate(node)这里用左子树高度判断是LL还是LR,是因为平衡因子为2时,左子树一定偏高,但不确定偏高的是左子树的左孩子还是右孩子。只要左孩子的高度大于等于0(即左孩子本身平衡或左高),就是LL;小于0就是LR。RR和RL同理。
2.3 旋转与坐标变换的类比
顺便说个题外话。很多人学AVL的时候,会联想到图形学里的旋转,比如坐标系绕原点旋转、欧拉角、矩阵乘法表示旋转之类的。这两者确实有相通之处,都是在保持某种“结构”不变的前提下,改变元素的相对位置。
坐标系旋转用的是三角函数和矩阵乘法,AVL旋转用的是指针重连,数学形式完全不同,但思想一样:旋转是局部的、可逆的,而且不改变元素之间的顺序关系。在坐标系里是坐标值的大小顺序,在AVL树里是元素值的排序关系。
所以我一直觉得,理解旋转最好的方式是把它看成一个几何动作,而不是一段代码。心里有那棵树的形状,写代码就是翻译而已。
3. 插入实现与回溯更新
3.1 递归插入的骨架
AVL树的插入和普通BST一样,先按大小找到插入位置,创建新节点。区别在于,插入完成后要沿着递归路径回溯,逐层更新高度、计算平衡因子,并检查是否需要旋转。
递归天然适合这种自底向上的调整,因为递归调用返回时,我们已经处理完了子树,可以从底层往上一层一层修正。用Python写出来的插入代码非常简洁:
def insert(self, key): self.root = self._insert(self.root, key) def _insert(self, node, key): if node is None: return TreeNode(key) if key < node.key: node.left = self._insert(node.left, key) elif key > node.key: node.right = self._insert(node.right, key) else: return node self._update_height(node) return self._rebalance(node)_update_height很简单:
def _update_height(self, node): node.height = 1 + max(self._height(node.left), self._height(node.right))_rebalance就是前面提到的四种情况的判断和处理。
这里有个细节容易被忽略:插入第一步用的还是普通BST的递归,如果在递归过程中遇到相同key,直接返回,什么都不做。很多人在这一步想当然地认为AVL不允许重复key,其实可以允许,只是实现上要么给节点加count字段,要么把相同key放到右子树,养成一个统一约定就行。我习惯直接禁止重复key,调用方在插入前自行查重,逻辑简单,不容易出错。
3.2 自底向上的高度更新与再平衡
高度更新和再平衡必须放在递归返回之后,也就是先处理完子树再更新当前节点。这个顺序是AVL插入正确性的关键,不能搞反。
如果你在递归进入前更新高度,那拿到的是旧高度,平衡因子算出来全是错的,旋转判断自然也会错。我见过不止一个初学AVL的人在调试时发现树越旋转越乱,最后的根因都是高度更新时机不对,子树还没递归完就开始算平衡因子。
正确的调用链是:递归深入 → 创建节点 → 返回上一层 → 更新该层高度 → 算平衡因子 → 需要就旋转 → 再返回上一层。这样就保证了每一层看到的都是子树的最新高度。
3.3 完整插入流程模拟:一个具体序列
写代码之前,先手动模拟一个序列,能直观看到旋转在做什么。假设依次插入10、20、30、40、50、25,传统BST的执行过程我就不写了,直接看AVL:
- 插入10,树只有一个节点,平衡。
- 插入20,10的右子树高度1,左子树高度0,平衡因子-1,平衡。
- 插入30,10的平衡因子变成-2,右孩子20的右子树高度1,属于RR型,对10左旋。旋转后20成为根,10是20的左孩子,30是20的右孩子。
- 插入40,20的平衡因子变成-1,平衡。
- 插入50,20的平衡因子变成-2,右孩子40的右子树高度1,RR型,对20左旋。旋转后40成为根,20是40的左孩子,50是40的右孩子。
- 插入25,此时40的左子树是20,右子树是50。插入25后,20的右子树高度变成1,20的平衡因子变成-1,但40的平衡因子变成1(左子树高度2,右子树高度1),尚在允许范围内。再往上,根40的平衡因子1,整棵树平衡,不需要旋转。
整个过程里,每次插入最多只触发一次旋转,这就是AVL插入的复杂度保证。你可以把这段过程画在纸上,对照代码走一遍,比看十篇文章都管用。
4. 删除实现与再平衡
4.1 删除后为什么更麻烦
AVL树的删除比插入麻烦,主要体现在两点:
- 删除节点后,可能导致多个祖先节点失衡,而不只是最近的祖先。
- 修复一个祖先节点的失衡,可能让更高层的节点重新失衡,所以需要沿着路径一直向上检查到根。
原因是删除减少了树的高度,旋转虽然修复了局部平衡,但可能让这棵子树的高度再降低一层,从而影响更上层的平衡因子。这就和插入不同,插入时旋转后子树高度恢复到插入前,上层不用再管;删除时旋转后子树高度可能比原高度少1,上层必须继续检查。
所以删除的代码结构一般是:先做普通BST删除,然后在递归回溯过程中对每个节点做一次和插入完全相同的再平衡判断。要知道是否还需要继续向上,只需要看当前节点的高度是否变化了,如果没变化,上层就不受影响。但这种优化实现起来比较绕,通常直接沿路径走到根也没问题,复杂度依然在O(log n)范围内。
4.2 删除的代码实现
def delete(self, key): self.root = self._delete(self.root, key) def _delete(self, node, key): if node is None: return None if key < node.key: node.left = self._delete(node.left, key) elif key > node.key: node.right = self._delete(node.right, key) else: if node.left is None: return node.right if node.right is None: return node.left min_node = self._get_min(node.right) node.key = min_node.key node.right = self._delete(node.right, min_node.key) self._update_height(node) return self._rebalance(node)删除的核心逻辑在else分支:如果当前节点有两个孩子,就用右子树里的最小节点值覆盖当前节点,然后去右子树里删除那个最小节点。这叫“后继替换”,保证删除后依然满足二叉搜索树性质。用一个孩子的情况,直接返回那个孩子即可,被删节点会被垃圾回收。
很多人在这一步卡住,是因为没有意识到这里其实是在递归地删除右子树的最小节点,而这个递归返回后也会触发再平衡,所以不需要也不应该手动去旋转。
4.3 删除需要双旋甚至多次旋转的边界
刚才说了,删除后可能涉及多次旋转,那什么情况下会出现“双旋”?举个例子,一棵树根节点是5,左子树高度2、右子树高度0,平衡因子2,但左子树的右子树更高。这种情况就是LR型,需要先对左子树左旋,再对根节点右旋。在删除场景里,这种形态的出现比插入更隐蔽,因为失衡节点的某个孩子可能是刚被删成了空树,高度计算容易出错。
我在实测中遇到过这样一个边界:删除节点后,某个祖先的平衡因子刚好等于2,其左孩子的平衡因子等于0。这时候按前面插入的判断逻辑,会走LL型分支做一次右旋。结果也是对的,但旋转后那棵子树的高度发生了改变,上层又出现了新的失衡,于是需要沿着路径再来一轮。它就是删除和插入在“旋转后是否继续向上”上的最大区别。
所以删除操作的调试重点,不是盯着第一次旋转,而是要确认从删除点一直到根节点,所有节点的平衡因子最终都在合法范围内。这通常需要反复遍历所有节点检查,不能只看局部。
5. 测试、调试与常见问题
5.1 如何验证一棵树真的是AVL树
写好实现后,第一件事不是跑业务逻辑,而是写一个验证函数。我每次写完树结构都会先做三步检查:
- 中序遍历结果必须是有序的,这验证了二叉搜索树性质没有被旋转破坏。
- 每个节点的平衡因子绝对值不超过1,这验证了平衡条件。
- 每个节点的height字段和实际子树高度一致,这验证了高度更新没有出错。
第三步特别容易被忽略,但恰恰是最容易出错的。很多旋转bug之所以隐蔽,就是因为树还是有序的,但height已经不对了,导致后续插入的旋转判断全部跑偏。检查函数可以这样写:
def is_avl(node): if node is None: return True, 0 left_ok, left_h = is_avl(node.left) right_ok, right_h = is_avl(node.right) if not left_ok or not right_ok: return False, 0 if node.height != 1 + max(left_h, right_h): return False, 0 if abs(left_h - right_h) > 1: return False, 0 return True, node.height每次插入或删除后用随机序列大规模验证,比如随机生成10000个整数插入,再随机删除其中一部分,每操作一步都检查is_avl是否成立。实测下来,只要is_avl能连续跑几万次不报错,实现基本就稳了。
5.2 常见问题速查表
我把这几年带人写AVL时遇到的典型问题整理成一张表,对应着排查,效率高很多:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 插入后树仍有序,但某个子树高度不对 | 更新高度放在递归前,或旋转后没更新高度 | 把高度更新放在递归返回后,旋转后立刻重算左右孩子高度 |
| 旋转后整棵树乱序 | T2过继位置接错,或左旋右旋镜像搞反 | 对照画图,确认旋转后序列仍是左小右大 |
| 删除后树不平衡,但插入时没问题 | 没有沿路径向上继续再平衡 | 删除用统一回溯,对每个祖先节点都执行rebalance |
| LR/RL型用了单旋 | 判断逻辑里只看了失衡节点的平衡因子,没看孩子 | 必须在旋转前检查左/右孩子的平衡因子,确定是LL/RR还是LR/RL |
| 删除只有一个孩子时返回错节点 | 直接返回了node.left或node.right之前没有判断哪个为空 | 用if node.left is None: return node.right,反之亦然 |
| 重复key插入导致树高度异常 | BST插入时对相同key没处理 | 统一约定:重复key直接返回,不进递归 |
5.3 实操心得:先画图再写码
最后分享一点我自己的习惯。我每次要写AVL的时候,哪怕代码已经背得很熟了,也会先在纸上画一棵简单的小树,比如四五个节点,然后手动模拟一次插入和删除,把旋转前后的树形态画出来,再对着图写代码。
这不是浪费时间。旋转的代码本质上就是在改三个指针,但指针改错一个,树的形状就全变了。而纸上的树不会骗你,你把旋转前的图中要改的边用不同颜色标出来,写代码时对照着标号来,基本不会错。
如果是要用AVL树做在线评测系统的题目,还有个额外建议:手写细节多,直接用库更稳。Python里可以用内置的sortedcontainers,C++里直接用std::map或者std::set,底层就是红黑树。AVL和红黑树性能差别不大,但红黑树实现更复杂。如果项目允许,我不建议从零造轮子;但如果是为了学习或者面试准备,手写一遍AVL是非常值得的,它能帮你把递归、回溯、树的旋转这些基本功彻底打通。