1. AVL树到底是什么,为什么搞了这么多年还要学它
先直接说结论:AVL树是带有自平衡机制的二叉搜索树(BST)。它解决的核心问题只有一个——BST在最坏情况下会退化成一条链表,查找复杂度从O(log n)直接变成O(n)。如果你刷过LeetCode或做过题库,应该见过那种"有序插入1、2、3、4、5"之后,树变成一条斜线的场景。那本质上就是BST失去了平衡。
AVL树的名字来自它的发明人Adelson-Velsky和Landis,1962年提出的。它的思路非常纯粹:在每个节点上记录左右子树的高度差,限制这个差值绝对值不超过1。一旦超过,就通过旋转操作把树重新"扳"回来。因为这个机制,AVL树能保证任何查找、插入、删除操作都在O(log n)时间内完成。
说实话,很多初学者一上来就被"各种旋转"吓住了,觉得AVL树特别难。我的经验是,它真正难的地方不在旋转本身——旋转就四种模式,背都能背下来——难的是理解为什么要旋转、什么时候该用哪种旋转,以及代码里更新高度的顺序。这篇文章我就按照自己当初从头实现AVL树踩过的坑来写,尽量把每个"为什么"都讲透。
适合谁看?如果你是正在学数据结构的学生、准备考研或面试的开发者,或者工作中遇到"动态有序数据"场景想选型数据结构的人,这篇文章都很合适。我不会堆一堆公式,但会把复杂度推导、旋转原理、完整代码都过一遍。
2. AVL树的核心概念与平衡原理
2.1 平衡因子:AVL树的"体检指标"
AVL树给每个节点定义一个指标,叫平衡因子(Balance Factor):
平衡因子 = 左子树高度 - 右子树高度
有的教材写成右减左,符号正好反过来。这不重要,重要的是你需要统一约定。我这里统一用"左减右"。
平衡因子的取值只有三种合法情况:-1、0、1。凡是绝对值大于1,就说明这棵子树不平衡了,需要进行调整。注意,这里的"高度"是从当前节点往下到叶子节点的最长路径上的节点数(有的教材定义边数,差一个常数,不影响逻辑)。
为了算平衡因子,每个节点必须额外存储一个"高度"字段。这是AVL树提升空间开销的代价——每个节点多一个int,换来的是查询性能的稳定性。
代码里通常这样定义节点:
class AVLNode { int key; int height; AVLNode left; AVLNode right; AVLNode(int key) { this.key = key; this.height = 1; // 新节点初始高度为1 } }2.2 什么情况下会失衡
往AVL树里插入一个节点,本质上是先按BST规则插入(比当前节点小去左边,大去右边,相等不处理或约定去一边),然后从插入位置一路往上检查每个祖先节点的平衡因子。如果某个节点的平衡因子变成2或-2,就说明以它为根的子树失衡了。
失衡的形态一共有四种,按照"插入位置相对失衡节点"的方向来命名:
| 失衡类型 | 插入位置 | 表现 |
|---|---|---|
| LL | 失衡节点的左孩子的左子树 | 左子树比右子树高2 |
| RR | 失衡节点的右孩子的右子树 | 右子树比左子树高2 |
| LR | 失衡节点的左孩子的右子树 | 左子树比右子树高2 |
| RL | 失衡节点的右孩子的左子树 | 右子树比左子树高2 |
这四种情况,前两种是"一条直线",后两种是"一条折线"。直线用单旋转解决,折线用双旋转解决——理解了这个对应关系,旋转就记住一大半了。
2.3 为什么AVL树能保证O(log n)
我当年学到这里总是有个疑问:就算每次插入后都调整,树的高度到底被限制在什么范围?
平衡因子的绝对值不超过1,意味着高度为h的AVL树至少包含多少节点?设N(h)为高度h的AVL树的最少节点数。为了让树尽量"矮胖",左右子树一个高度为h-1,另一个为h-2(因为平衡因子为1是允许的),所以:
N(h) = N(h-1) + N(h-2) + 1
边界是N(0)=0,N(1)=1。这个递推式长得和斐波那契数列一样。可以推出N(h)大约等于φ^h / √5(φ是黄金分割比1.618)。反过来解,n个节点的AVL树高度h ≈ log_φ(n),以1.618为底的对数。虽然底数不是2,但换底之后也就是一个常数系数问题,复杂度级别仍然是O(log n)。
用大白话说:AVL树通过平衡因子约束,让最坏情况下的树高和完全二叉树高只差一个常数倍。这就是它性能稳定的数学基础。
3. 四种旋转的详细拆解与代码实现
3.1 先理解右旋(对应LL型失衡)
LL型失衡长这样:
失衡节点 Z / Y / XX和Y都在左边这条线上。要恢复平衡,做法是把Y"提"上来当根,Z变成Y的右孩子,Y原来的右子树T2挂到Z的左边。这就是右旋(因为整个子树向右旋转了?不,更准确的记忆方式是:把中间节点Y往上旋转)。
很多教程画图喜欢画"顺时针旋转",但我觉得看代码更容易理解:
private AVLNode rightRotate(AVLNode z) { AVLNode y = z.left; AVLNode t2 = y.right; // 旋转 y.right = z; z.left = t2; // 更新高度(先更新z,再更新y,因为z现在是y的孩子) z.height = 1 + Math.max(height(z.left), height(z.right)); y.height = 1 + Math.max(height(y.left), height(y.right)); return y; // y成为新的子树根 }这里有个关键顺序:更新高度时必须先更新z再更新y。因为z的高度依赖于它的新孩子t2和已经挂上去的子树,而y的高度依赖z。如果先更新y,用的就是z的旧高度,结果会错。这是我第一次写AVL树时踩的第一个坑。
3.2 左旋(对应RR型失衡)
左旋就是右旋的镜像操作。RR型失衡长这样:
Z \ Y \ X代码对称:
private AVLNode leftRotate(AVLNode z) { AVLNode y = z.right; AVLNode t2 = y.left; y.left = z; z.right = t2; z.height = 1 + Math.max(height(z.left), height(z.right)); y.height = 1 + Math.max(height(y.left), height(y.right)); return y; }我个人的记忆技巧是:LL型先对失衡节点右旋,RR型先对失衡节点左旋。"左左"对应"右旋","右右"对应"左旋"。方向相反,别搞混。
3.3 双旋:LR型是什么情况
LR型比LL型麻烦的地方在于,失衡节点Z的左孩子Y的右子树T2被插入了新节点。这时如果直接对Z做右旋,旋转完之后Y的平衡因子可能还是-1(另一侧还是过高),也就是说会"旋转了个寂寞"。
原因在于,T2这颗子树在旋转后会被挂到Z的左边,但T2本身的高度可能很高,导致Z的左边还是过重。所以必须先对这个"折线"做一次调整——先对Y做左旋,让折线变成直线,也就是把LR型变成LL型,然后再对Z做右旋。这就是"双旋"名字的由来。
private AVLNode leftRightRotate(AVLNode z) { z.left = leftRotate(z.left); // 先对左孩子左旋 return rightRotate(z); // 再对失衡节点右旋 }注意顺序:先左旋孩子,再右旋自己。反了就是"先右旋自己再左旋孩子",那处理的是别的问题,千万别搞错。
RL型就是镜像:先对右孩子右旋,再对失衡节点左旋。
private AVLNode rightLeftRotate(AVLNode z) { z.right = rightRotate(z.right); return leftRotate(z); }3.4 旋转代码里的高度更新细节
我发现很多初学者会在旋转函数里漏掉一个点:旋转改变了子树结构,被移动的那颗子树T2的高度其实没变,但Z和Y的高度都变了。所以每次旋转后必须重新计算这两个节点的高度。而且,计算顺序要自下而上,先算"孩子"再算"父"——也就是上面代码里先算z再算y。
另外,空节点的高度约定为0。所以需要一个height辅助函数:
private int height(AVLNode node) { return node == null ? 0 : node.height; }千万别直接访问node.height,万一node是null就空指针了。我见过太多初学者在这里翻车。
4. 插入操作的完整流程与代码
4.1 插入的四个阶段
AVL树的插入可以拆成四步,每一步都不能省:
- 按BST规则递归找到插入位置,创建新节点。
- 递归回溯时,更新当前节点的高度。
- 计算当前节点的平衡因子。
- 如果|平衡因子| > 1,根据四种形态调用对应的旋转。
写成代码:
public AVLNode insert(AVLNode node, int key) { // 1. 普通BST插入 if (node == null) return new AVLNode(key); if (key < node.key) { node.left = insert(node.left, key); } else if (key > node.key) { node.right = insert(node.right, key); } else { return node; // 重复键,不处理 } // 2. 更新高度 node.height = 1 + Math.max(height(node.left), height(node.right)); // 3. 计算平衡因子 int balance = getBalance(node); // 4. 四种失衡情况 // LL型 if (balance > 1 && key < node.left.key) { return rightRotate(node); } // RR型 if (balance < -1 && key > node.right.key) { return leftRotate(node); } // LR型 if (balance > 1 && key > node.left.key) { node.left = leftRotate(node.left); return rightRotate(node); } // RL型 if (balance < -1 && key < node.right.key) { node.right = rightRotate(node.right); return leftRotate(node); } return node; }注意插入判断LR/RL的时机:我们是通过"新插入key在失衡节点的哪一侧"来判断是LL还是LR。如果balance > 1,说明左子树高,此时如果key比node.left.key还小,那就是一路往左插,是LL;如果比node.left.key大,说明先去了左孩子再拐向右,是LR。这个判断逻辑非常清晰,比"看左孩子的平衡因子"更不容易出错。
4.2 递归回溯与高度更新的关系
初学者最容易困惑的问题:为什么插入只有一处更新高度的代码,却能让整条路径上的节点高度都更新?
答案在于递归。insert函数在node.left = insert(node.left, key) 这一行,递归返回后,node.left已经是更新过高度、可能旋转过的新子树根。然后代码继续往下执行,更新node的高度。这样一层一层回溯,每个祖先节点都会在返回时更新自己的高度。旋转操作里也更新了局部高度,所以整棵树的高度数据在插入操作结束时是全局一致的。
4.3 几个容易忽视的边界条件
第一,空树插入返回的是新节点,这也是递归的终止条件。
第二,重复键的处理策略要提前定好。上面代码里选择直接忽略,但是如果你想让重复键插入到右子树,或者用链表存储重复值,那代码要相应调整。面试时最好主动问清楚重复键策略。
第三,平衡因子计算要取绝对值判断。getBalance直接返回左高减右高:
private int getBalance(AVLNode node) { return node == null ? 0 : height(node.left) - height(node.right); }5. 删除操作的难点:和插入根本不一样
5.1 先按BST删除,再回溯调平
很多人以为删除和插入差不多:先删,再更新高度,再旋转。实际上删除比插入复杂一个数量级。原因在于,删除节点后,可能不止一个祖先节点失衡。插入只会让一条路径上的节点高度变化1,最多造成一个节点失衡;但删除会让子树高度可能减少1,影响的范围更广,回溯时需要检查每一个祖先节点并做旋转。
BST删除本身有三种情况:
- 删除叶子节点:直接置空。
- 删除只有一个孩子的节点:让孩子顶上。
- 删除有两个孩子的节点:用中序后继(右子树的最小节点)或中序前驱(左子树的最大节点)替换,然后删除那个后继/前驱节点(它必然只有一个孩子或没有孩子,所以转化为前两种情况)。
5.2 删除后的平衡恢复代码
public AVLNode delete(AVLNode node, int key) { // 标准的BST删除 if (node == null) return null; if (key < node.key) { node.left = delete(node.left, key); } else if (key > node.key) { node.right = delete(node.right, key); } else { // 找到要删除的节点 if (node.left == null || node.right == null) { AVLNode temp = (node.left != null) ? node.left : node.right; if (temp == null) { return null; // 无孩子节点 } else { return temp; // 单孩子节点直接顶替 } } else { // 双孩子:找中序后继 AVLNode successor = minValueNode(node.right); node.key = successor.key; node.right = delete(node.right, successor.key); } } // 如果树只剩一个节点,删除后可能为null if (node == null) return node; // 更新高度 node.height = 1 + Math.max(height(node.left), height(node.right)); // 检查平衡 int balance = getBalance(node); // 这里和插入不同,不能再用key判断形态,而是看孩子的平衡因子 if (balance > 1 && getBalance(node.left) >= 0) { return rightRotate(node); // LL型 } if (balance > 1 && getBalance(node.left) < 0) { node.left = leftRotate(node.left); return rightRotate(node); // LR型 } if (balance < -1 && getBalance(node.right) <= 0) { return leftRotate(node); // RR型 } if (balance < -1 && getBalance(node.right) > 0) { node.right = rightRotate(node.right); return leftRotate(node); // RL型 } return node; }删除和插入在判断失衡形态时的关键区别:插入时,我们知道新节点插在哪个位置,可以直接用key和node的孩子key比较来判断形态。但删除时,被删的节点已经没了,无法用key判断,只能看孩子节点的平衡因子。
比如LL和LR的区分:如果左子树高(balance > 1),此时看node.left的平衡因子,如果>=0说明左孩子的左子树高(或等高),是LL型;如果<0说明左孩子的右子树高,是LR型。这里取>=0是有讲究的,因为删除时可能出现左孩子平衡因子为0的情况,此时无论选LL的单旋还是别的形态,单旋都能恢复平衡。取>=0让代码默认走单旋,更简洁。
5.3 删除操作里的一个隐蔽坑
用中序后继替换后,必须再递归删除右子树中的那个后继节点。这个后继节点在右子树里一定是"最左下的节点",它要么是叶子,要么只有右孩子。所以删除它不会太复杂。但注意这一步也可能引发右子树的失衡,所以delete函数递归返回后还会继续检查平衡。整个过程是自底向上的,任何一次旋转都会改变返回的子树根,必须用返回值重新赋给node.left或node.right。
我在自己实现时犯过一个错误:在双孩子分支里,直接把successor替换上去后没有递归删除,而是直接返回了node,导致那棵子树里残留了一个重复节点,后面查找完全乱掉。
6. 实际工程里AVL树的使用场景与选型思考
6.1 AVL树适合解决什么问题
AVL树的核心优势是查询性能极其稳定。无论插入顺序如何,最坏情况的查找时间都有保证。适合以下场景:
- 需要频繁查找、插入、删除,且数据是动态变化的符号表。
- 对最坏情况响应时间有硬性要求的系统(比如实时系统、数据库索引的一部分设计)。
- 作为学习和面试数据结构的基本功,红黑树建立在它的基础上。
Java的TreeMap和TreeSet底层用的是红黑树而不是AVL树,主要原因是红黑树的插入删除旋转次数更少(最多3次旋转 vs AVL最多O(log n)次旋转),虽然树高略高一点,但整体写入性能更好。AVL树在查询密集、写入较少的场景下其实也很合适。
如果让我给个选型建议:
| 场景 | 推荐 |
|---|---|
| 查询极多,几乎不写 | AVL树(高度更矮,查询更快) |
| 读写混合,写入频繁 | 红黑树(旋转更少) |
| 只需要部分有序遍历 | B树/B+树或跳表 |
6.2 AVL树的变体和工程实现启示
工程上还有很多AVL的变体:比如用平衡因子直接存储(-1/0/1三种状态)而不是存储高度,可以省下一点内存,但代码复杂度上升。还有用非递归实现,避免了递归调用栈的开销,但写起来非常繁琐。我自己做实验时用递归版就足够了,性能瓶颈往往不在递归本身,而在数据分布和比较操作上。
另外,AVL树的思想其实扩展到了很多领域。K-D树的平衡版本、B树的节点分裂也是一种平衡策略。理解了"用局部旋转恢复全局有序"这个思路,再看其他平衡结构会豁然开朗。
7. 常见错误与调试排查经验
7.1 我在实现中遇到的典型错误汇总
旋转后忘记更新高度:这是最高频的错误。旋转改变了父子关系,z和y的高度都必须重新计算。漏掉一个,整棵树的平衡因子计算全都错。
更新高度的顺序反了:先更新y再更新z,导致y用了z的旧高度。写代码时最好养成习惯:谁在旋转后"位置更低"就先更新谁。
判断LR/RL时用错参照:插入时用key和孩子key比较,删除时用孩子平衡因子。两种场景混用就会出现诡异行为。
删除双孩子节点时没递归删除后继:残留重复节点,遍历结果一团糟。
对null解引用:平衡因子和高度函数没有做null判断。user node为null时调用node.height直接崩。
递归函数返回值没接住:insert和delete返回的是新的子树根,调用时必须赋值给父节点的left或right,不能忽略返回值。
7.2 调试AVL树的几个有效手段
老实说,光靠看代码找AVL的bug非常痛苦。我推荐三个方法:
方法一:打印中序遍历验证有序性。AVL树本质仍是BST,所以中序遍历结果必须是有序序列。如果中序遍历乱序,说明某个节点的左右子树接错了,多半是旋转代码里T2挂载位置不对。
方法二:打印每个节点的高度和平衡因子。写一个debug方法,前序遍历输出节点key、height、平衡因子。然后手动构造一个"快要失衡"的测试用例,一步步比对预期结果。这个太有用了。
方法三:用小的随机插入序列反复测试。比如随机插入1到1000,每步插入后验证所有节点的平衡因子绝对值不超过1,并验证中序遍历有序。用脚本自动化跑几千次,比人肉debug高效得多。
private boolean validateAVL(AVLNode node) { if (node == null) return true; int balance = getBalance(node); if (Math.abs(balance) > 1) return false; return validateAVL(node.left) && validateAVL(node.right); }7.3 面试中关于AVL树的几个高频追问
- "AVL树和红黑树的区别?"——高度控制更严格,旋转更频繁,查询稍快,写操作稍慢。
- "为什么平衡因子必须是±1,0不行吗?"——行,你可以设计成更严格的平衡,但收益不大,维护成本更高。
- "AVL树的删除为什么比插入复杂?"——删除后多个祖先可能失衡,且形态判断不能依赖被删key的位置。
8. 实操总结与一点个人体会
AVL树是我认为数据结构里"性价比"最高的一棵树——代码量适中,原理清晰,面试常考,而且写好后那种"每次插入自动维持平衡"的感觉非常有成就感。我在学完AVL树之后再去看红黑树,理解起来快得多,因为很多概念是相通的。
最后分享一个实操小技巧:如果你是为了面试快速上手,重点记住旋转的四种形态就行,代码可以不用完全背,但一定要理解旋转后哪颗子树被挂到哪里。画图是理解旋转的最好方式——拿一张纸,画出LL型、LR型、RR型、RL型的树形图,用箭头标出旋转后的节点位置,十分钟就能把四种情况理清楚。
另外,如果你用的是Java,不妨自己实现一个AVL树,再和TreeMap的行为对比一下插入同样的数据序列后的高度差异。我实测过:随机插入1万条数据,AVL树的高度大约在14到15层,而普通的BST很容易蹿到30层以上。数据的分布对BST影响极大,对AVL树却没什么影响——这就是平衡带来的确定性。