ARTICLE DETAIL

资讯详情

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

C++实现AVL树:平衡因子、旋转操作与完整代码解析

C++实现AVL树:平衡因子、旋转操作与完整代码解析 如果面试官让你在白板上手写一颗AVL树而且要求一次跑通你的第一反应是什么我当年第一次遇到这题时脑子里只有“平衡因子”“左旋右旋”这几个词真到写代码的时候旋转之后根节点丢了、height忘更新、递归越界……全踩了一遍。后来自己摸索出一套“先画图、再写节点、再写旋转、最后插入”的顺序才把AVL树彻底吃透。这篇我就用C把AVL树的完整实现、验证方法、常见坑一次讲清楚适合正在学数据结构的学生、准备大厂面试的开发者以及任何想把搜索性能做扎实的C程序员——你不需要数学功底只需要耐心看几遍图动手敲一遍代码。1. 二叉搜索树的退化困境与AVL的解法1.1 最坏情况插入有序数据后树退化成链表二叉树搜索的核心优势在于每次比较能砍掉一半的搜索范围。但这有个隐含前提树长得足够“匀称”。当你往一棵普通的二叉搜索树里依次插入 1、2、3、4、5……这些值的时候树会一路向右生长最后变成一条只有右孩子的“链表”。插入第 n 个节点时查找复杂度从理想的 O(log n) 退化到 O(n)十万条数据就是十万次比较和顺序查找没有区别。这种退化在真实场景里太常见了时间戳、自增ID、按序到达的日志全是“有序插入”的典型来源。提示千万别认为这是理论上的极端情况。只要是按单调递增或递减顺序插入二叉搜索树一定会退化成链表这是结构决定的和编译优化、硬件速度都无关。解决思路就两条要么在插入过程中不断整理树的结构让树始终保持“矮胖”要么放弃二叉树结构改用哈希、跳表。AVL树选择的是前者它是一棵带有平衡条件的二叉搜索树。1.2 平衡二叉树的定义与高度上界推导AVL树由Adelson-Velsky和Landis在1962年提出核心约束只有一句话任意节点的左右子树高度差绝对值不超过1。这里的高度定义为从当前节点到最远叶子的边的数量没有子节点的叶子高度为0。这个约束看起来宽松却能带来非常强的高度保证。可以这样粗略推导设高度为 h 的AVL树至少包含 N(h) 个节点为了让高度变大左子树或右子树至少要有一边的高度为 h-1另一边因为平衡条件限制至少为 h-2所以 N(h) N(h-1) N(h-2) 1。这个递推和斐波那契数列类似反推回去可以得到n 个节点的AVL树高度不超过约 1.44 * log2(n1)。举个例子100万个节点的AVL树最坏高度只有约29也就是说最多比较29次就能找到目标。这就是AVL树的价值它把“搜索性能不稳定”这件事从根上解决了。2. AVL树节点设计先定好数据结构再谈算法2.1 节点定义与高度字段的取舍我见过不少初学AVL树的人直接拿普通二叉搜索树节点改只加了平衡因子结果旋转之后各种混乱。这里我推荐一个明确的节点结构“capacity”不需要但 height 必须有struct AVLNode { int key; int height; AVLNode* left; AVLNode* right; explicit AVLNode(int k) : key(k), height(1), left(nullptr), right(nullptr) {} };关键变量就是height而不是 balance。很多教材把平衡因子直接存下来新人一学就会照做但实则后患无穷旋转之后你得同时维护子树 height 和 balance 两个值漏一个就全盘崩。我的习惯是只存 height平衡因子现场计算等于每次只需要维护一个变量。height 为什么从1开始而不是0因为叶子节点的高度是1表示从叶节点到自身只有一层这样父节点更新高度时直接取左右孩子最大值加1逻辑上更顺。实操心得如果你的节点里还需要存 value、count、parent 指针结构会更复杂。除非确实需要反向遍历否则不要提前加 parent 指针AVL旋转时维护 parent 指针特别容易漏后面我会专门讲这个坑。2.2 平衡因子与高度计算规则平衡因子的定义是左子树高度减去右子树高度。这个符号约定直接决定了后面旋转分支怎么判断。我习惯用 leftHeight - rightHeight平衡因子范围就是 -1、0、1。下面这三个工具函数是整个AVL树的“基础设施”任何操作都要复用int getHeight(AVLNode* node) { return node ? node-height : 0; } int getBalance(AVLNode* node) { return node ? getHeight(node-left) - getHeight(node-right) : 0; } void updateHeight(AVLNode* node) { node-height 1 std::max(getHeight(node-left), getHeight(node-right)); }注意getHeight里对空指针返回0这样很多地方就不用做判空简洁且不容易漏。updateHeight永远在旋转之后、递归回溯时调用顺序不能乱。你在实现AVL树的过程里可以反复问自己一个问题“我刚动了哪几个节点它们的 height 变化了吗”这句自查能救回大量bug。3. 四个旋转场景拆解LL、RR、LR、RL3.1 LL失衡右旋一次解决失衡形态可以用“插入位置相对发现失衡节点的方向”来命名。假设某个节点 A 的左子树太高而失衡的子节点又在其左子树的左侧这叫 LL 型。图形上看起来就是一条“向左延伸的长链”。解决办法是右旋也叫右单旋把中间节点提上来当根。文字画一下A 是失衡节点B A.leftB.right T2。右旋之后 B 变成子树根A 变成 B.rightT2 变成 A.left。为什么这样转完之后平衡因子一定恢复因为 LL 失衡本质上左子树高度已经超出右旋让原左子树的根上位相当于“削平左边、垫高右边”两边高度差会收敛到 0 或 ±1。AVLNode* rightRotate(AVLNode* y) { AVLNode* x y-left; AVLNode* T2 x-right; x-right y; y-left T2; updateHeight(y); updateHeight(x); return x; }右旋之后必须先更新 y 再更新 x。因为 x 是新的根它的 height 依赖 y 的 height如果先更新 x 再更新 yx 的高度就错了。这个细节我在第一次写的时候踩过坑后来把它当成铁律。3.2 RR失衡左旋是右旋的镜像RR 型和 LL 完全对称A 的右子树太高且插入位置在右子树的右侧。此时执行左旋把中间节点 B A.right 提上来当根A 变成 B.leftB.left 变成 A.right。代码就是右旋的“镜像手术”。AVLNode* leftRotate(AVLNode* x) { AVLNode* y x-right; AVLNode* T2 y-left; y-left x; x-right T2; updateHeight(x); updateHeight(y); return y; }两个旋转我都建议想尽办法在纸上手推一遍比背代码可靠。你只需要记住一点旋转后返回的是子树的新根调用者必须把这个新根接回原父节点。谁接管新根谁就负责重新接线。3.3 LR失衡先左旋成LL再右旋LR 型是第一个容易混淆的形态。失衡节点 A 的左子树B不算太高但 B 的右子树C却高出一截插入点在“左子树的右侧”。这种形态直接对 A 右旋没用因为右旋会把过高的 C 甩到错误的位置。标准解法是先对 A.left 做左旋把 LR 型转成 LL 型再对 A 做右旋。具体步骤A 失衡B A.leftC B.right。第一步对 B 执行leftRotate(B)此时 C 上位A.left 变成了 C第二步对 A 执行rightRotate(A)C 上位成为整棵子树的新根。合并起来代码有两个调用root-left leftRotate(root-left); return rightRotate(root);第一次旋转的返回值必须赋值给root-left否则上层节点指向的还是旧子树根第二次旋转时就会把旧节点当参数结果完全不对。反过来说如果你忘了这个赋值还能编译通过但运行结果会莫名乱掉调试起来很难定位这是 LR 型最容易出现的隐藏bug。3.4 RL失衡先右旋成RR再左旋RL 型又是一个镜像失衡节点 A 的右子树B偏高但插入发生在右子树的左侧C。正确的处理是先对 A.right 做右旋把 RL 型转成 RR 型再对 A 做左旋。root-right rightRotate(root-right); return leftRotate(root);这里给一张“气质总结表”我在面试时也经常用这张表帮自己回忆失衡类型判定特征第一次操作第二次操作LL左左偏高无右旋 rootRR右右偏高无左旋 rootLR左子树的右侧偏高左旋 root-left右旋 rootRL右子树的左侧偏高右旋 root-right左旋 root有些人会混淆 LR 和 LL 的判定本质上是没搞清“偏高发生在哪一侧”就动手旋转。旋转不是背的是靠图理解出来的。4. 插入完整流程与代码实现4.1 插入主流程递归回溯的三步走AVL树的插入其实就是普通BST插入加一步“恢复平衡”。递归实现最优雅因为递归天然支持回溯——先走到空位置新建节点然后一层层返回每层都更新高度、检查平衡因子、做对应的旋转。整个过程可以拆成三步第一步按二叉搜索树规则递归找位置key比根小走左比根大走右相等就返回这里我按不允许重复key处理否则平衡条件会有更多分支。第二步回溯过程中更新当前节点 height。注意是“回溯过程”不是“插入之后抽空更新一次”因为路径上每个节点的高度都可能变化。第三步计算平衡因子根据四种类型旋转把新根返回给上层。递归实现的妙处在于旋转后新根被return回上一层上一层自动完成接线不用像迭代法那样自己维护 parent 指针。这也是我为什么建议初学者优先用递归写AVL树。4.2 插入代码四行旋转条件怎么落直接放完整的插入代码用环境g 13 / C17实测可过AVLNode* insert(AVLNode* root, int key) { if (!root) { return new AVLNode(key); } if (key root-key) { root-left insert(root-left, key); } else if (key root-key) { root-right insert(root-right, key); } else { return root; // 已存在则不插入 } updateHeight(root); int balance getBalance(root); if (balance 1 key root-left-key) { return rightRotate(root); // LL } if (balance -1 key root-right-key) { return leftRotate(root); // RR } if (balance 1 key root-left-key) { root-left leftRotate(root-left); return rightRotate(root); // LR } if (balance -1 key root-right-key) { root-right rightRotate(root-right); return leftRotate(root); // RL } return root; }重点解释一下四行 if 的判断逻辑。假设当前节点平衡因子balance 1说明左子树比右子树高但还需要知道是 LL 还是 LR。怎么区分看插入的 key 相对于root-left-key的位置如果 key 还小说明插入路径一路向左对应 LL如果 key 比左孩子的 key 大说明插入在左子树的右半部分对应 LR。这比你硬记balance 1 key root-left-key要有效得多。4.3 完整环境与主函数验证在主函数里做最基础的验证#include iostream #include algorithm #include vector void inorder(AVLNode* root, std::vectorint result) { if (!root) return; inorder(root-left, result); result.push_back(root-key); inorder(root-right, result); } int main() { AVLNode* root nullptr; std::vectorint keys {9, 5, 10, 0, 6, 11, -1, 1, 2}; for (int key : keys) { root insert(root, key); } std::vectorint sorted; inorder(root, sorted); for (int key : sorted) { std::cout key ; } std::cout std::endl; return 0; }如果你输出的是从小到大的有序序列至少说明插入和旋转没把自己的有序性弄丢。但有序性只是基本盘离“这棵树真是AVL树”还差得远完整验证我在第6节会展开。5. 删除操作AVL树里最麻烦的角落5.1 删节点后如何恢复平衡很多人能写对插入但删除一写就崩因为删除后平衡调整的方向不是固定一种。删除的完整流程是这样的先按BST规则找到并删除目标节点如果目标节点有两个孩子通常用中序后继右子树最左节点替换它的key然后递归删除中序后继节点从删除点开始沿父路径回溯逐层更新高度、计算平衡因子、做旋转。难点在于删除可能让某一侧变矮导致父节点失衡旋转之后又可能让祖父节点失衡所以必须一直回溯到根节点。只给理论不给代码等于没说我把核心骨架写出来AVLNode* remove(AVLNode* root, int key) { if (!root) return nullptr; if (key root-key) { root-left remove(root-left, key); } else if (key root-key) { root-right remove(root-right, key); } else { if (!root-left || !root-right) { AVLNode* child root-left ? root-left : root-right; delete root; return child; } else { AVLNode* successor root-right; while (successor-left) { successor successor-left; } root-key successor-key; root-right remove(root-right, successor-key); } } if (!root) return nullptr; updateHeight(root); int balance getBalance(root); if (balance 1 getBalance(root-left) 0) { return rightRotate(root); } if (balance 1 getBalance(root-left) 0) { root-left leftRotate(root-left); return rightRotate(root); } if (balance -1 getBalance(root-right) 0) { return leftRotate(root); } if (balance -1 getBalance(root-right) 0) { root-right rightRotate(root-right); return leftRotate(root); } return root; }注意删除场景的旋转判定和插入略有不同插入时我们知道“新key插在哪”所以参照 key 和子树根的相对大小删除时无法依赖 key只能用子树的平衡因子符号来判断。比如balance 1说明左子树高再看getBalance(root-left)如果非负说明左子树的左面高或者等高用右旋如果为负说明左子树的右面高要先左旋再右旋。这种差异非常容易踩坑我从第一次在删除里套用插入的旋转判断结果压测直接崩掉后来才彻底悟明白。5.2 懒删除工程上常用的省心方案如果业务里删除不是高频操作或者只是作为辅助结构我强烈推荐懒删除思路给节点加一个bool deleted标记删除时只标记不真正的移除。查找时跳过标记节点插入时如果发现同 key 节点已标记就重新激活它。懒删除的好处是把“删除”这一步退回成普通BST的水平完全不用处理删除后的旋转。对面试题“手写AVL树”来说懒删除是个加分项展示你能从工程复杂度角度做取舍而不是只会照搬教科书。代价是树里可能积攒大量带标记的节点内存会涨。所以适用场景很明确删除少、读多、k 可以接受“假节点仍占空间”。6. 自测与压测证明你的AVL树没写错6.1 三大基础校验有序性、高度同步、平衡因子AVL树代码写完后最怕的是“看着对但其实不对”。我的实践是写三个递归校验函数全部通过才叫达标。第一中序遍历必须严格升序。这验证了BST性质没有因旋转被破坏。第二每个节点的height必须等于1 max(left.height, right.height)。这个校验能抓住绝大多数“忘更新height”的问题代码很简单bool validateHeightConsistency(AVLNode* node) { if (!node) return true; int actual 1 std::max(getHeight(node-left), getHeight(node-right)); if (node-height ! actual) return false; return validateHeightConsistency(node-left) validateHeightConsistency(node-right); }第三平衡因子绝对值必须不超过1而且必须基于真实高度计算不能直接用节点里存的那个 height。校验函数如下bool validateBalance(AVLNode* node) { if (!node) return true; int leftH getHeight(node-left); int rightH getHeight(node-right); if (std::abs(leftH - rightH) 1) return false; return validateBalance(node-left) validateBalance(node-right); }如果这三关都过AVL树的核心性质就没有被破坏。6.2 随机插入压测的观测方法写完基础校验还得用数据量压一压。我的做法是写一个循环#include random int main() { std::mt19937 rng(std::random_device{}()); std::uniform_int_distributionint dist(-100000, 100000); AVLNode* root nullptr; for (int i 0; i 100000; i) { root insert(root, dist(rng)); } std::cout tree height: getHeight(root) std::endl; std::cout validateHeightConsistency: validateHeightConsistency(root) std::endl; std::cout validateBalance: validateBalance(root) std::endl; // 清理内存环节这里省略开发阶段可以先不管 }插入10万个随机数后AVL树的高度应该在20到30之间。如果出现几十上百说明某些时候树偷偷退化了。还有一个更狠的测试按1到10万顺序插入这对AVL树来说是最容易暴露问题的用例。普通BST会变成100000层的链表而AVL树高度仍然应该在20到30之间。我每次写完旋转代码都会先跑这个有序插入用例比什么调试器都直观。7. 写AVL树时最容易踩的四个坑7.1 更新height的时机比你想的更严格旋转函数里先更新谁、后更新谁很多人不放在心上结果高度差1平衡因子判断就错。右旋时y在旋转后变成了x的右孩子所以必须先更新y的高度再更新x的高度。如果顺序搞反x-height用的是旧的y高度整棵树的高度信息立刻失真。同样在insert的回溯过程里旋转前要updateHeight(root)旋转后旋转函数内部还会再更新一次这两个更新缺一不可。7.2 旋转返回新根后上层连接必须重新接线递归版本里这个问题不明显因为每次旋转都把新根 return 回去了上层自动root-left insert(...)重新接线。但一旦你尝试写迭代版或者写 LR、RL 的复合旋转时忘了把第一次旋转的结果赋给左/右孩子新根就会被丢弃上层还指着旧根结构立刻断掉。我的建议是所有修改孩子指针的地方统一用root-left xxx这种显式赋值不要用临时变量绕过。7.3 height初始值0还是1建议统一从1开始我见过很多实现把空树高度当 -1叶子高度当0也有人把叶子高度设为1。两种约定都能写对就怕混用。从我给的人门代码可以看出我统一约定空指针高度0叶子高度1叶子的父节点高度2。这样getHeight(nullptr)返回0非常自然updateHeight也不必特殊处理空孩子。一旦你中途改约定所有判断全部崩盘而且很可能只崩在某些边界 case 上。7.4 递归深度与内存泄漏AVL树高度是对数级的正常十万个节点递归深度大概二十几层完全不会爆栈。但如果你插入有序数据时旋转逻辑有bug树高退化成几千层递归深度就会增加甚至爆栈。所以“爆栈”本身就是旋转没写对的一个信号。内存方面动态new出来的节点必须成对delete写个递归destroyTree负责释放void destroyTree(AVLNode* node) { if (!node) return; destroyTree(node-left); destroyTree(node-right); delete node; }在 main 结束前调用一下配合 AddressSanitizer 或 valgrind 检测泄漏。我实测过一个100万节点的树如果只 insert 不释放内存会涨得吓人这在线上是事故级别的隐患。8. 面试和工程里的实际取舍AVL树在面试里是硬通货但工程里其实很多场景会用红黑树或跳表替代。红黑树也是自平衡BST只保证最长路径不超过最短路径的两倍牺牲了一点严格平衡换来了更少的旋转次数所以C STL的std::map、std::set底层都选红黑树。AVL树更适合查询远多于插入删除的场景比如内存数据库的索引、需要频繁查找且对稳定性要求高的地方。我个人在实际操作中的体会是AVL树的关键不是记住那四个旋转的名字而是真正理解“哪些节点会因为新节点的插入而失衡旋转又为什么能恢复平衡”。面试官问AVL树时更愿意听你讲清楚“为什么插入后要回溯更新height”“旋转返回新根后上层怎么接线”这类工程问题而不是听你背代码。如果你能把第6节的三个校验函数那套思路讲出来那绝对是加分项。最后再分享一个小技巧写AVL树时准备一张白纸每种失衡类型画一棵只有三个节点的最小子树标注好 A、B、C 和 T1、T2、T3旋转操作本质上就是把这三块重新排列。等你把这张图刻在脑子里代码一眼就能写出来再也不会慌。
返回列表