ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AVL树详解:自平衡原理、四种旋转与完整代码实现

AVL树详解:自平衡原理、四种旋转与完整代码实现 1. AVL树到底是什么为什么搞了这么多年还要学它先直接说结论AVL树是带有自平衡机制的二叉搜索树BST。它解决的核心问题只有一个——BST在最坏情况下会退化成一条链表查找复杂度从O(log n)直接变成O(n)。如果你刷过LeetCode或做过题库应该见过那种有序插入1、2、3、4、5之后树变成一条斜线的场景。那本质上就是BST失去了平衡。AVL树的名字来自它的发明人Adelson-Velsky和Landis1962年提出的。它的思路非常纯粹在每个节点上记录左右子树的高度差限制这个差值绝对值不超过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失衡节点的左孩子的左子树左子树比右子树高2RR失衡节点的右孩子的右子树右子树比左子树高2LR失衡节点的左孩子的右子树左子树比右子树高2RL失衡节点的右孩子的左子树右子树比左子树高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)0N(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树和红黑树的区别——高度控制更严格旋转更频繁查询稍快写操作稍慢。为什么平衡因子必须是±10不行吗——行你可以设计成更严格的平衡但收益不大维护成本更高。AVL树的删除为什么比插入复杂——删除后多个祖先可能失衡且形态判断不能依赖被删key的位置。8. 实操总结与一点个人体会AVL树是我认为数据结构里性价比最高的一棵树——代码量适中原理清晰面试常考而且写好后那种每次插入自动维持平衡的感觉非常有成就感。我在学完AVL树之后再去看红黑树理解起来快得多因为很多概念是相通的。最后分享一个实操小技巧如果你是为了面试快速上手重点记住旋转的四种形态就行代码可以不用完全背但一定要理解旋转后哪颗子树被挂到哪里。画图是理解旋转的最好方式——拿一张纸画出LL型、LR型、RR型、RL型的树形图用箭头标出旋转后的节点位置十分钟就能把四种情况理清楚。另外如果你用的是Java不妨自己实现一个AVL树再和TreeMap的行为对比一下插入同样的数据序列后的高度差异。我实测过随机插入1万条数据AVL树的高度大约在14到15层而普通的BST很容易蹿到30层以上。数据的分布对BST影响极大对AVL树却没什么影响——这就是平衡带来的确定性。
返回列表