拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

从零实现AVL树:C++代码详解旋转、插入删除与平衡修复

从零实现AVL树:C++代码详解旋转、插入删除与平衡修复

如果你写过二叉搜索树,一定碰到过那种极端尴尬的情况:明明数据是排着队进来的,树却硬生生长成了一根链表,查一个数得从头摸到尾。AVL树就是来收拾这个烂摊子的。作为计算机历史上第一个被提出的自平衡二叉搜索树,它会在每次插入和删除之后,通过旋转操作让整棵树的高度始终维持在 O(log n) 级别,查找效率不会因为数据顺序而崩掉。

这篇文章我会用 C++ 从零实现一颗完整的 AVL 树,覆盖节点设计、四种旋转、插入、删除、查找以及正确性验证,代码可以直接拷到 VSCode 配好的 C/C++ 环境里跑起来验证。无论你是考研复习数据结构、准备面试手撕算法,还是工作中想自己搭一个有序索引结构,这份实现和踩坑记录应该都能帮到你。

1. 为什么需要AVL树:从BST的退化说起

1.1 普通BST的致命弱点:数据一有序就变链表

二叉搜索树的查找效率完全依赖于树的高度。如果插入的数据顺序均匀,树的形态接近满二叉树,查找一个元素的时间是 O(log n)。但问题在于:普通 BST 对输入顺序没有任何约束,你插入 1、2、3、4、5 这样递增的数据,它就会乖乖地把每个新节点挂到右子树上,最终长成一根只有右孩子的链表,树高等于节点数 n,查找退化到 O(n)。

这个场景绝对不是纸上谈兵。数据库按自增 ID 逐条插入索引记录,日志系统按时间戳顺序写入缓存,都天然产生有序数据。我见过不少新手在工程里直接拿普通 BST 当索引表,插到几万条数据之后查询突然变得奇慢无比,就是被这个退化问题坑的。插入 n 个节点时,每一次插入都要从根一路走到链表尾,总代价是 O(n²),数据量一上来基本就废了。

1.2 AVL树的定义:平衡因子与严格平衡

AVL 树的核心思想非常朴素:给每个节点定义一个平衡因子(Balance Factor),等于左子树高度减去右子树高度。节点允许的平衡因子只有三个值:-1、0、1。一旦某个节点的平衡因子超出这个区间,触发旋转,把它重新压回平衡范围。

空节点高度约定为 0,叶子节点高度为 1,这个约定可以让所有代码保持自洽。数学上可以证明,高度为 h 的 AVL 树最少包含 N(h) = N(h-1) + N(h-2) + 1 个节点,这是一个斐波那契式的递推关系。推导一下就能得到树高 h 不超过 1.44 × log₂(n + 2) - 1.33。也就是说,即便构造出最极端的 AVL 树,100 万节点的树高也只有 20 层左右,查找一次顶多比较 20 次,这就是平衡带来的底气。

1.3 AVL树的应用场景与选型定位

AVL 树适合三类典型需求:第一,内存型的有序键值索引,比如交易系统里的价格订单索引、内存缓存的热点键排序;第二,需要频繁做范围查询或者中序有序遍历的集合,这是哈希表给不了的;第三,编译器符号表等经典的“插一次查很多次”的场景。

当然 AVL 树也不是万能药。它相比普通 BST 多了高度维护和旋转开销,相比哈希表丢了 O(1) 的等值查找速度。选型逻辑通常是:读多写少、要顺序遍历,就选 AVL;插入删除特别频繁、对查找速度要求没那么苛刻,红黑树更合适;只做等值查询不关心顺序,选哈希表。后面我会专门用一节讲这个对比。

2. 核心设计:节点结构、高度与旋转

2.1 节点设计与高度管理:为什么不直接存平衡因子

AVL 树的节点和普通 BST 相比,多了 height 这个字段。我用的定义是:空指针高度为 0,叶子节点高度为 1,任意节点的高度等于左右子树高度较大者加 1。

template <typename T> struct AVLNode { T key; int height; AVLNode* left; AVLNode* right; explicit AVLNode(T k) : key(k), height(1), left(nullptr), right(nullptr) {} };

这里有一个容易纠结的设计问题:为什么不直接存平衡因子,而是存高度?我的经验是存高度更好。平衡因子可以通过左右子树的高度差实时算出来,存它属于冗余信息;而高度在旋转之后是必须更新的,存高度可以顺便从 child 的高度推出来。直接存平衡因子反而要维护两套数据,旋转之后容易忘更新,埋坑。

2.2 旋转操作的本质:换个姿势,中序序列不变

旋转是整个 AVL 树最容易写崩的地方。理解旋转的关键在于想清楚一件事:旋转到底在做什么。

以右旋为例,失衡节点 A 的左子树太高,需要把 A 的左孩子 B 提上来当新根,A 退到 B 的右子树位置,B 原来的右子树 T2 则移给 A 当左子树。这个操作完成后,中序遍历的序列完全不变,变的只是节点之间的父子挂接关系。左旋完全对称,把失衡节点的右孩子提上来当新根。

template <typename T> AVLNode<T>* rightRotate(AVLNode<T>* y) { AVLNode<T>* x = y->left; AVLNode<T>* T2 = x->right; x->right = y; y->left = T2; y->height = max(height(y->left), height(y->right)) + 1; x->height = max(height(x->left), height(x->right)) + 1; return x; } template <typename T> AVLNode<T>* leftRotate(AVLNode<T>* x) { AVLNode<T>* y = x->right; AVLNode<T>* T2 = y->left; y->left = x; x->right = T2; x->height = max(height(x->left), height(x->right)) + 1; y->height = max(height(y->left), height(y->right)) + 1; return y; }

写旋转代码时最关键的顺序是:先把中间子树 T2 保存下来,再动指针挂接,最后按照“先孩子后父亲”的顺序更新高度。口诀就是“先存再挂,先下后上”。

2.3 四种失衡情况与判定技巧

AVL 树的失衡可以归纳成四种模式:LL、RR、LR、RL。LL 表示失衡节点的左孩子的左子树太重,RR 对称;LR 表示左孩子的右子树太重,RL 对称。处理方式如下:

失衡类型描述处理方式
LL左孩子的左子树过高对失衡节点做一次右旋
RR右孩子的右子树过高对失衡节点做一次左旋
LR左孩子的右子树过高先对左孩子左旋,再对根右旋
RL右孩子的左子树过高先对右孩子右旋,再对根左旋

插入场景下,因为知道新插的 key 具体走的是哪条路径,可以直接用 key 判断方向。删除场景则不一样,删除之后难以及时知道“哪个孩子方向失衡”,更稳的判断方式是看子树的平衡因子方向。这个区别非常容易搞混,后面我实现删除的时候会重点拎出来讲。

判断旋转方向还有一个实用口诀:“LL 右旋、RR 左旋、LR 先左后右、RL 先右后左,左重右转,右重左转”。

3. 完整C++实现:从插入到删除的实战

3.1 插入:递归插入加旋转修复

插入的实现思路是分三步:按普通 BST 规则把节点插到正确位置,沿递归回溯路径更新每个祖先节点的高度,计算平衡因子并执行对应旋转。插入带来的失衡特点是:在从插入点到根的路径上,最多只有一个节点会失衡,旋转一次就能恢复整棵树的平衡。这也是 AVL 插入容易写的原因之一。

template <typename T> AVLNode<T>* insert(AVLNode<T>* node, T key) { if (node == nullptr) { return new AVLNode<T>(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; } node->height = max(height(node->left), height(node->right)) + 1; int balance = getBalance(node); // getBalance(node) = height(node->left) - height(node->right),空节点返回0 if (balance > 1 && key < node->left->key) { return rightRotate(node); } if (balance < -1 && key > node->right->key) { return leftRotate(node); } if (balance > 1 && key > node->left->key) { node->left = leftRotate(node->left); return rightRotate(node); } if (balance < -1 && key < node->right->key) { node->right = rightRotate(node->right); return leftRotate(node); } return node; }

有几个细节值得说。第一,getBalance 函数内部必须先判空,直接对空指针取 height 会崩。第二,更新高度要在计算平衡因子之前完成,顺序写反了,平衡因子用的是旧高度,后续旋转判断全错。第三,遇到重复 key 时直接返回原节点,不做插入也不更新高度,这个设计保证树不会因为重复键产生多余节点。

3.2 删除:最容易写崩的环节

删除是 AVL 树实现里真正的分水岭。普通 BST 删除一个节点要分三种情况:叶子节点直接删,单孩子节点让孩子顶替,双孩子节点用右子树最小节点(后继)替换。AVL 树额外要处理的,是删除之后沿着回溯路径逐层检查平衡,而且删除可能导致不止一次旋转。

我在第一次实现删除时踩过一个很大的坑:删除后只在当前节点做了一次旋转就返回,结果随机测试里树频繁失衡。原因在于插入的失衡只会出现在一个节点上,旋转一次立刻恢复;但删除会让路径上多个节点都失衡,必须从递归返回的每一层都检查高度和平衡。

template <typename T> AVLNode<T>* minValueNode(AVLNode<T>* node) { AVLNode<T>* cur = node; while (cur->left != nullptr) { cur = cur->left; } return cur; } template <typename T> AVLNode<T>* remove(AVLNode<T>* node, T 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) { AVLNode<T>* temp = node->right; delete node; return temp; } if (node->right == nullptr) { AVLNode<T>* temp = node->left; delete node; return temp; } AVLNode<T>* successor = minValueNode(node->right); node->key = successor->key; node->right = remove(node->right, successor->key); } node->height = max(height(node->left), height(node->right)) + 1; int balance = getBalance(node); if (balance > 1 && getBalance(node->left) >= 0) { return rightRotate(node); } if (balance < -1 && getBalance(node->right) <= 0) { return leftRotate(node); } if (balance > 1 && getBalance(node->left) < 0) { node->left = leftRotate(node->left); return rightRotate(node); } if (balance < -1 && getBalance(node->right) > 0) { node->right = rightRotate(node->right); return leftRotate(node); } return node; }

双孩子节点用后继替换这个技巧非常值得展开讲。被删除节点的右子树最小节点,处于那个子树的最左端,它最多只有一个右孩子。把它替换上来后,问题就简化为“删除右子树中的最小节点”,而这个最小节点删除时不需要处理双孩子的情况,逻辑瞬间清爽很多。

删除后旋转方向的判定要与插入严格区分。插入知道新 key 走左还是走右,可以用 key 和 node->left->key 的对比判断是 LL 还是 LR;删除时如果用 node->key 去判断方向,极可能出错,因为当前节点的 key 可能已经被后继替换过了。正确做法是直接看孩子节点的平衡因子方向:左孩子 BF >= 0 说明是 LL,左孩子 BF < 0 说明是 LR。

3.3 查找、遍历和正确性验证

基础操作写完,还有一个很多人忽略的关键环节:验证。AVL 树的正确性不仅仅是“平衡因子都在 -1 到 1 之间”,还必须同时满足“中序遍历有序”。有些实现旋转挂接指针时会无意破坏 BST 性质,只查平衡因子发现不了这种 bug,必须两套检查一起做。

template <typename T> bool isBST(AVLNode<T>* node, AVLNode<T>* minNode, AVLNode<T>* maxNode) { if (node == nullptr) { return true; } if (minNode && node->key <= minNode->key) { return false; } if (maxNode && node->key >= maxNode->key) { return false; } return isBST(node->left, minNode, node) && isBST(node->right, node, maxNode); } template <typename T> bool isBalanced(AVLNode<T>* node) { if (node == nullptr) { return true; } int balance = getBalance(node); if (balance > 1 || balance < -1) { return false; } return isBalanced(node->left) && isBalanced(node->right); } template <typename T> bool validate(AVLNode<T>* node) { return isBST(node, nullptr, nullptr) && isBalanced(node); }

这里用指针作为上下界,比用 INT_MIN/INT_MAX 更严谨,因为一旦 key 类型换成 long long 或者自定义结构体,整数边界就不适用了。用空节点表示“没有边界限制”,代码语义非常清楚。

配套一个简单的测试程序,覆盖有序插入、随机插入删除:

int main() { AVLTree<int> tree; for (int i = 1; i <= 1000; ++i) { tree.insert(i); } std::cout << "有序插入1000个数,树高: " << tree.getHeight() << std::endl; std::cout << "中序遍历前10个: "; tree.printPrefix(10); std::cout << "验证通过: " << (tree.validate() ? "yes" : "no") << std::endl; AVLTree<int> t2; srand(2024); for (int i = 0; i < 10000; ++i) { t2.insert(rand() % 100000); } for (int i = 0; i < 5000; ++i) { t2.remove(rand() % 100000); } std::cout << "随机插删后验证: " << (t2.validate() ? "yes" : "no") << std::endl; return 0; }

如果插入 1000 个有序数但树高只有十几,而且随机删除 5000 次后 validate 依然全部通过,说明插入、删除、旋转这几个环节基本没有较大问题。这个测试模板可以一直留在工程里当回归用例用。

4. 复杂度分析、选型对照与避坑经验

4.1 时间与空间开销到底是多少

AVL 树的三类核心操作时间复杂度都是 O(log n),这一点从树高上界可以直接推出。但“都是 O(log n)”背后隐藏的常数差异非常大:查找只做比较,代价低;插入需要从插入点回溯更新高度,执行一次旋转;删除最麻烦,可能要沿路径执行多次旋转,旋转本身做指针挂接和高度更新,代价比变色高不少。

空间开销方面,每个节点比普通 BST 多了一个 int 型的 height 字段。在 64 位系统上,一个 AVL 节点包含 key、左右指针和 height,算上对齐,一个节点通常占 32 字节左右,height 字段的额外开销约 4 到 8 字节。数据量大时这个额外内存不能忽略。

还有一个经常被问的问题:递归写 AVL,栈会不会爆?AVL 树高被严格限制在 1.44 × log₂(n) 左右,10 亿节点的树高也不到 50 层,递归深度非常安全。真正的风险来自普通 BST 退化后的递归深度,而不是 AVL 本身。

4.2 AVL树、红黑树和哈希表的选型对照

既然 AVL 树这么能打,为什么 C++ 标准库的 map 和 set 底层不用它,而选了红黑树?这是初学者最容易问的问题。标准库选择红黑树是工程综合考量:红黑树的平衡条件更宽松,允许节点路径上的黑色节点数相同即可,因此树高上限是约 2 × log₂(n),树普遍比 AVL 略高一点,查找常数稍差,但插入删除恢复平衡需要的旋转次数明显更少,尤其删除场景,红黑树的调整成本相比 AVL 低不少。

维度AVL树红黑树哈希表
查找效率最坏 O(log n),常数小O(log n),常数略大平均 O(1),最坏 O(n)
插入删除代价O(log n),删除可能多次旋转O(log n),旋转次数通常更少均摊 O(1),可能触发 rehash
有序范围查询支持支持不支持
额外内存每节点一个 int 高度每节点一个颜色标记表空间加哈希函数
典型场景读多写少的有序索引STL map/set、内核调度KV 缓存、等值快速查找

我的实际体会是,如果一个场景特点是“构建一次,查询上万次”,AVL 树因为树高更矮,整棵树的比较次数更少,实测往往会比红黑树快几个百分点。反过来如果业务里删除插入非常频繁,比如每秒几十万次订单变更,AVL 树每删一次就要回溯旋转的成本会明显拖后腿,这种情况红黑树更稳。哈希表则完全不适合范围查询和顺序遍历,别硬拿哈希表当有序容器用。

4.3 手写AVL最容易犯的五个错

症状可能原因解决思路
树高异常增加某个节点高度没更新,或者在旋转里只更新了新根没更新孩子给 height() 打个断点,检查每个旋转函数里两个节点的高度更新
旋转后节点丢了指针挂接顺序错了,中间子树 T2 没有先保存对照旋转示意图,先保存中间子树,再改指针
删除后仍有节点失衡只在删除点做一次旋转,没有沿回溯路径检查删除函数里,递归返回后的每一层都要更新高度、计算平衡因子
中序遍历不是升序BST 性质被破坏,通常是旋转中指针挂错,或者 key 比较方向写反用 validate() 同时查 isBST 和 isBalanced
删除双孩子节点崩溃直接 delete 了被删节点,但该节点还有两个孩子用后继节点 key 覆盖当前节点 key,再递归删后继

这五个问题我几乎全踩过。其中删除不沿路径回溯这个问题尤其隐蔽,因为它只在某些特定删除序列下暴露,随机测试规模小一点根本发现不了。教训就是:AVL 的删除和插入在平衡修复策略上完全不同,不能拿插入的经验直接套。

4.4 验证技巧与性能实测心得

验证 AVL 树正确性最有效的手段是持续随机插删加 validate。我平时的测试套路是这样的:先做 1 万次随机插入,再做 5000 次随机删除,每次都调用 validate,中间的 0.01 秒耗时完全可以接受。一旦 validate 返回 false,立刻把当前 key 和树的中序序列打出来定位。实测下来,大部分 bug 都能在这种压力测试下 5 分钟内暴露。

性能上我做过一个直观实验:普通 BST 依次插入 10 万个递增数字,树高变成 10 万,单次查找最坏要比较 10 万次;同一批数据插入 AVL 树,树高只有 18 层左右,查找耗时差出三个数量级。反过来在随机均匀数据下,AVL 插入因为旋转开销,比普通 BST 插入慢大约 10% 到 15%,这是它维持平衡的正常代价。

从工程角度说,我写过一段时间 AVL 之后的体会是:当数据规模小(几百以内),或者读写比例接近 1:1,AVL 和红黑树的差异在性能指标上几乎测不出来,这时候可读性和实现正确性远比炫技重要。一旦数据量过大且删除频繁,AVL 的多次旋转会带来可感知的抖动,选型时一定要结合自己的读写比例来判断。

最后分享一个我自己的小经验:最早我是在一个内存订单簿项目里用 AVL 树做价格索引的,价格档位的数量级在十万上下,查询和遍历的频率远高于增删,AVL 树高度低的优势被发挥得比较充分,整体表现相当稳定。后来有一次业务加了大量撤单操作,删除次数飙上来,我才切实体会到删除旋转的开销。如果你也想找个练手场景,可以试着用这篇文章的代码实现一个带顺序统计功能的 AVL 树,在节点里维护子树大小,就能支持查询第 K 大元素,这是 AVL 树扩展中实用性很高、也很好玩的一个方向。

返回列表