资讯动态

C++实现AVL树:从零构建平衡二叉树

发布时间:2026/10/13 14:36:43 来源:尧图企业网站定制
很多朋友写二叉搜索树BST的时候都有过这种经历代码照着教科书一行一行写插入、查找、删除全都正确一上强度就拉胯。往里头插10万个随机数还能接受换成有序数据直接肉眼可见地变慢查一个数要从根节点一路走到叶子复杂度从O(log n)躺平成O(n)。问题不在你的代码逻辑而在树自己的结构——它退化成了链表。这就是AVL树存在的唯一理由在BST的基础上强制约束高度把最坏情况也锁在对数级别。这篇文章我会用C从零实现一棵完整可用的AVL树把平衡因子、单旋双旋、插入删除的调整流程全部讲透再分享几个不真跑一遍根本发现不了的坑。适合已经能熟练写出普通BST、想进一步理解平衡树原理的读者也适合面试前系统梳理平衡二叉树的朋友。1. 二叉搜索树的性能陷阱退化是怎么发生的1.1 有序数据是最常见的杀手先看一个最简单的场景。把1、2、3、4、5按顺序插入一棵普通的二叉搜索树每次新节点都比上一个节点大查找路径一路往右走最终得到的结构是根是1右孩子是2右孩子的右孩子是3……整棵树只剩下右子树看起来就是一条向右延伸的链表。这时查找5要比较5次查找100000要比较100000次。换句话说最坏情况下的查找成本从折半查找变成了顺序扫描这是数量级的退化。我见过不少人在做日志系统或者配置管理模块的时候用普通BST存时间戳或者ID。前期数据量小没事等数据量大起来、又恰好存在明显的单调趋势比如批量导入历史数据性能就肉眼可见地崩掉。其实不是他们写错了而是BST本身就有一个隐含假设输入数据足够随机。真实业务数据往往带有局部性、单调性这个假设经常不成立。你控制不了数据的来路只能控制数据结构对数据的容忍度。1.2 平衡的本质把最坏情况拉回对数级BST的平均查找长度和树的高度直接相关。一棵拥有n个节点的完全二叉树高度约为log2(n)但如果退化成了链表高度就是n。我们没办法阻止数据长成什么形态能做的只有一件事在每次插入和删除之后主动检查树的结构发现歪了就立刻扳回来。AVL树做的事情就是这么朴素——它给每个节点定义一个平衡因子左子树高度减右子树高度规定任何节点的平衡因子只能是-1、0、1。一旦某个节点变成2或-2就说明左右子树高度差超出了容忍范围必须通过旋转把这棵子树重新调整平衡。这里有个关键认知AVL的全局平衡是靠每个节点局部平衡堆出来的。只要每个节点都满足|平衡因子|≤1整棵树的高度就一定有上界。数学上的结果是含n个节点的AVL树最大高度约等于1.44×log2(n)。所以哪怕插入的是最恶劣的有序数据查找效率也稳定在O(log n)这就是标题里说的平衡的力量。2. AVL树的平衡机制从定义到失衡判定2.1 平衡因子与高度更新先定好规则再写代码动手写代码之前先约定高度定义空节点高度为0叶子节点高度为1。AVLNode里除了key、左右指针之外还要有一个height字段表示以当前节点为根的子树高度。更新规则非常简单height max(height(left), height(right)) 1。平衡因子则是bf height(left) - height(right)注意正负方向要跟后面的旋转分支保持一致。插入一个节点后新节点会一路向上影响所有祖先节点的高度。最容易漏掉的就是高度更新的时机必须先把左右子树的递归插入全部完成回到当前节点时才能用子树最新的高度来更新自己。如果顺序写反了整棵树的height全是旧值后面所有的平衡判定都是错的。这是我排错时遇到最多的问题没有之一。另一个容易混淆的点是空指针的height是0但单节点子树的height是1这两个基准搞混算出来的平衡因子会整体偏移1导致明明失衡却判不出来。2.2 四种失衡形态速览一张表记住LL/RR/LR/RL当某个节点的平衡因子变成2左高右低或-2右高左低下一步要看它的孙子节点长在哪边据此分成四种失衡形态处理方法也完全不同。形态平衡因子结构特征处理方法LL2失衡节点的左孩子的左子树偏高右旋一次RR-2失衡节点的右孩子的右子树偏高左旋一次LR2失衡节点的左孩子的右子树偏高先左旋左孩子再右旋失衡节点RL-2失衡节点的右孩子的左子树偏高先右旋右孩子再左旋失衡节点记忆口诀LL是左左就整体往右转RR是右右就整体往左转LR是左偏右先解决孩子的倾斜方向再做单旋RL完全镜像。别死记硬背自己动手画一遍旋转前后的树形图比背任何口诀都管用。画图的时候把子树抽象成一个一个的块只看块之间的相对位置变化旋转本质就是把中间的节点提上来当根把原来的根放下去当孩子。3. C核心实现节点、旋转与插入3.1 节点设计与基础工具函数三件套先落地代码从一个最朴素的节点结构开始。我习惯把所有工具函数写成静态或全局函数不塞进类里这样单测更容易写逻辑也更透明。#include iostream #include algorithm struct AVLNode { int key; int height; AVLNode *left; AVLNode *right; explicit AVLNode(int k) : key(k), height(1), left(nullptr), right(nullptr) {} }; 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 std::max(getHeight(node-left), getHeight(node-right)) 1; }这三个函数是全部旋转和调整的地基。getHeight(nullptr)返回0保证空指针上调用安全。getBalance返回的是左高减右高方向别搞反后面所有分支判断都依赖这个符号约定。updateHeight必须在子树结构已经调整完毕之后再调用它的输入是最新的子树高度输出是当前节点的新高度顺序错了整个体系就塌了。3.2 单旋与双旋的实现要点旋转后必须返回新根右旋处理LL失衡代码非常短AVLNode *rotateRight(AVLNode *y) { AVLNode *x y-left; AVLNode *T2 x-right; x-right y; y-left T2; updateHeight(y); updateHeight(x); return x; // 旋转后的新根 }左旋处理RR失衡完全对称AVLNode *rotateLeft(AVLNode *x) { AVLNode *y x-right; AVLNode *T2 y-left; y-left x; x-right T2; updateHeight(x); updateHeight(y); return y; }旋转代码本身不长但有两个细节很致命。第一必须先更新孩子的高度再更新新根的高度。旋转之后y和x的左右子树结构都变了如果先更新xx用的还是旧的孩子高度算出来的height就会错。我习惯把它理解成先更新下面的再更新上面的。第二旋转函数必须返回新根。因为调用方递归的上一层只知道原来的子树根指针旋转后根换了人你不把新根传回去调用方就会继续拿着旧指针操作整棵树直接断掉。这个返回值必须被接收的细节是新手最容易翻车的地方。插入和删除的递归代码里每一层都要记得写node-left insert(node-left, key)这种赋值漏掉任何一个等于把旋转结果丢了。双旋就是两次单旋的组合。LR失衡时先对左孩子做左旋再对失衡节点做右旋RL失衡时先对右孩子做右旋再对失衡节点做左旋。写成函数就是嵌套调用不用另造一套逻辑。有人纠结双旋是不是有独立的高效实现对工程来说追求这点微优化意义不大代码清晰可靠是第一位的。3.3 插入回溯一次递归完成查找、插入、平衡AVLNode *insert(AVLNode *node, int key) { if (node nullptr) { 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; // 重复key按需决定是否更新 } updateHeight(node); int bf getBalance(node); // LL if (bf 1 key node-left-key) { return rotateRight(node); } // RR if (bf -1 key node-right-key) { return rotateLeft(node); } // LR if (bf 1 key node-left-key) { node-left rotateLeft(node-left); return rotateRight(node); } // RL if (bf -1 key node-right-key) { node-right rotateRight(node-right); return rotateLeft(node); } return node; }插入的流程是一路往下找位置回溯往上修平衡。递归的好处在于回溯阶段天然就是从新插入节点到根节点的一条完整路径每个祖先节点都有机会检查自己是否失衡。四个分支的判定用的都是插入的key与当前节点左右孩子的比较这是插入场景特有的判定方式——因为插入路径是确定的失衡原因必然是key落在了某一侧。关于重复key这段代码选择直接返回。真实系统里通常要处理重复键做法也不复杂要么在节点里加一个计数要么约定用继续往右走把重复元素挂到右子树。不同的选择会影响树的结构和查找行为建议在写业务代码之前先明确需求别等到上线了才发现重复数据处理方式跟你预期不一样。还有一个值得注意的点递归深度等于树高。AVL树高约1.44×log2(n)一般规模的数据完全没问题。但如果你的数据量到千万级以上递归栈的压力就需要正视那时候可以把递归改成显式栈的迭代版本。不过绝大多数业务场景和面试场景递归版是最清晰、最不容易出错的选择不必为了理论上的完美而牺牲可维护性。4. 删除操作的平衡维护容易踩坑的重灾区4.1 删除与插入的调整差异判定方式完全不同删除比插入难在两点。第一删除一个节点后如果它左右孩子都在需要找一个替代者顶替它的位置惯例是找右子树的最小节点。第二删除导致的失衡不一定出现在离删除点最近的祖先可能一路向上传导好几层。在递归实现里只要每一层都重新计算高度、检查平衡这个传导是自动完成的。但这里有个关键区别删除之后的失衡判定不能像插入那样用被删除的key来判断分支。原因很直接删除的key可能在很深的子树里回溯到某个祖先节点时你手里已经没有这个key跟当前节点 key 的比较关系了。正确的做法是看当前节点左右孩子各自的平衡因子根据孩子往哪偏来决定旋转方向。这个区别是删除代码和插入代码最大的分水岭也是很多人把插入写法照搬到删除后越修越歪的根源。4.2 删除场景的关键分支与实现AVLNode *getMinNode(AVLNode *node) { AVLNode *cur node; while (cur-left ! nullptr) { cur cur-left; } return cur; } AVLNode *remove(AVLNode *node, int key) { if (node nullptr) { return nullptr; } if (key node-key) { node-left remove(node-left, key); } else if (key node-key) { node-right remove(node-right, key); } else { // 被删节点最多只有一个孩子 if (node-left nullptr || node-right nullptr) { AVLNode *temp node-left ? node-left : node-right; if (temp nullptr) { temp node; node nullptr; } else { *node *temp; // 把孩子内容拷贝上来 } delete temp; } else { // 有两个孩子找右子树最小节点顶替 AVLNode *temp getMinNode(node-right); node-key temp-key; node-right remove(node-right, temp-key); } } if (node nullptr) { return nullptr; } updateHeight(node); int bf getBalance(node); // 左重孩子不偏或偏左 - LL直接右旋 if (bf 1 getBalance(node-left) 0) { return rotateRight(node); } // 左重孩子偏右 - LR先左旋孩子再右旋 if (bf 1 getBalance(node-left) 0) { node-left rotateLeft(node-left); return rotateRight(node); } // 右重孩子不偏或偏右 - RR直接左旋 if (bf -1 getBalance(node-right) 0) { return rotateLeft(node); } // 右重孩子偏左 - RL先右旋孩子再左旋 if (bf -1 getBalance(node-right) 0) { node-right rotateRight(node-right); return rotateLeft(node); } return node; }这段代码里藏着一个很多教材不细讲的细节当被删节点有两个孩子时我把右子树最小节点的key拷贝到当前节点然后去右子树里递归删除这个最小节点。这个删法保证了后续真正物理删除的节点最多只有一个孩子回收逻辑就格外简单。代价是key被拷贝了一次如果你存的是复杂对象拷贝成本要认真评估那时可以改成移动语义或者干脆只交换节点指针。删除的四个调整分支用的是孩子平衡因子的正负而不是key的比较。我见过太多人在这里翻车插入代码里写key node-left-key判断LL删除里也照抄结果就是删除几轮之后树的形态悄悄变歪。记住一句话插入问key往哪走删除问孩子往哪偏。还有一个实现级的小坑*node *temp这行把temp的左右指针也一并拷了过来。在node只有一个孩子的场景里这是正确的因为temp本来就是那棵唯一的子树根。但要注意拷贝完成之后再delete temp时绝不能顺着temp的指针再去访问数据。裸指针管理内存的情况下这个顺序写反了就会读到悬垂指针。如果公司项目里允许直接换成智能指针这些心智负担能省掉大半。5. 实测对比AVL树 vs 普通BST的性能差距5.1 测试方案设计三组场景对照光说不练假把式。我本地写了一个简单的对比程序同一份数据分别插入普通BST和AVL树然后对比树高和查找耗时。测试分三组每一组都跑在同样的数据上场景数据量数据特征关注点随机插入100000随机数生成器产出的整数正常情况下的差距有序插入1000001到100000递增最坏情况下的差距查找压力1000000次在两棵树上分别随机查找实际查询开销树高的统计在插入完成后做一次递归遍历即可。查找耗时用高精度时钟计时同一组查询重复多轮取平均值避免单次运行的抖动。核心就是把同一批整数喂给两棵树跑完全一样的查询序列这样比出来的差异完全来自数据结构本身而不是数据或者操作路径的差异。5.2 结果解读与结论差距在数据特征里放大我这边实测的一组典型数据是这样的随机插入100000个数字时普通BST的树高大概在30左右AVL树在18左右两者差距没有想象中夸张因为随机数据本身就能让BST维持不错的形态。换成有序插入100000个数字之后普通BST的树高直接变成100000AVL树高还是18这是一个数量级的差距。查找压力测试更有意思在随机插入生成的数据上AVL比BST快一些但不算悬殊在有序插入生成的数据上普通BST的100万次查找耗时是AVL的上百倍。原因很直接树高从18涨到100000平均一次查找要多访问好几个数量级的节点时间自然跟着线性膨胀。结论也很明确如果数据源不可控用户输入、日志导入、外部系统同步的数据都有可能是有序或近似有序的普通BST就是一颗定时炸弹。AVL树用每次插入删除时少量额外的旋转开销换来了最坏情况也不会崩的确定性。对于读多写少的查询密集场景这笔账非常划算。当然如果数据规模很小比如几百个元素两者差距完全可以忽略未必值得引入AVL的复杂度这种判断要放在具体业务里做。6. 排查技巧与实战建议6.1 调试平衡因子的三个手段自检优先于自信AVL树写完之后怎么确定自己写对了光靠看起来能跑是不够的错误经常藏在几百次插入之后。我每次写完AVL都会加三个自检函数。第一中序遍历必须严格有序。BST的基本性质决定了中序遍历结果一定是升序如果旋转把搜索树的顺序性质搞乱了中序遍历立刻暴露。第二全树平衡性自检。写一个递归函数对每个节点验证getBalance的绝对值不超过1同时验证height字段等于左右子树height较大者加1。这两个条件一旦有假说明高度更新或旋转逻辑出了问题。第三随机操作对拍。生成一批随机key依次插入再依次删除每步都做中序遍历有序性和平衡性校验。跑完几万次随机操作没触发断言基本就可以放心。bool checkSorted(AVLNode *node, int prev, bool first) { if (node nullptr) return true; if (!checkSorted(node-left, prev, first)) return false; if (!first node-key prev) return false; prev node-key; first false; return checkSorted(node-right, prev, first); } bool checkBalanced(AVLNode *node) { if (node nullptr) return true; if (node-height ! std::max(getHeight(node-left), getHeight(node-right)) 1) { return false; } if (std::abs(getBalance(node)) 1) { return false; } return checkBalanced(node-left) checkBalanced(node-right); }这两个函数写起来不复杂但排查效率极高。我一般在debug模式下用断言把它们挂在每次插入删除之后发现问题能立刻定位到是哪一步操作破坏了不变量不用对着断点一行一行猜。这个不变量自检的思路值得推广到所有自平衡数据结构红黑树、跳表、替罪羊树全都适用。6.2 AVL树与红黑树怎么选读写比决定答案面试里常被追问的一个问题是既然有了AVL树为什么还要红黑树两者都是平衡二叉树差别在平衡的严格程度。AVL要求任何节点的左右子树高度差不超过1红黑树只要求最长路径不超过最短路径的两倍。AVL更高瘦红黑树更矮胖直观上AVL的查找路径更短但维持严格平衡需要更多旋转。落实到选型如果场景是查询密集、插入删除相对少比如内存索引、字典结构AVL的严格平衡能带来更好的最坏查找性能。如果写操作很频繁红黑树的插入删除触发旋转的次数更少整体吞吐更高。前面那组实测也印证了随机数据下两者的查找差距并不大差距主要出现在树形态被数据针对的时候。实际项目中还有一个隐性成本容易被忽略AVL要求的平衡条件严格意味着删除时向上传导的调整更频繁。我用AVL做过一个时间序列索引写入频率一高过期清理和删除阶段明显比插入阶段更耗时后来把过期清理改成批量重建才缓解。这不是说AVL不好而是说要根据真实读写比选型。标准库里默认的红黑树实现很多语言的map/set之所以流行正是因为它在通用场景下读写均衡不需要使用者做额外决策。最后分享两点我的个人习惯。一是AVL节点的height字段用int就够别为了省几个字节去做位压缩或者short牺牲可读性不值得。二是调试阶段强烈建议写一个按层缩进的树形打印函数失衡的时候一眼就能看出是哪个节点拐错了方向比盯着断点猜快得多。平衡树的平衡两个字写出正确代码只是第一关真正吃透它是从你亲手把它调对、测稳、用进真实系统的那一刻开始的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑