写技术文章最怕的一点,就是把“数据结构”讲成了“数据结构书”。BST(二叉搜索树)和 AVL 树是面试和实际工程里被问烂了的两棵树,网上的资料也确实多,但大部分要么只给一颗节点定义加几个函数,要么把旋转过程画得天花乱坠却没说清楚“为什么要这样转”。我写这篇东西,想用一种偏实战的视角,把这两种树的来龙去脉、代码实现、踩坑点一次性说清楚。
这篇文章适合三类人看:一是刚学完指针和递归、准备系统刷树的初学者,读完之后能把 BST 和 AVL 的主干逻辑串成一个整体;二是已经能写出基本 BST、但一写平衡树就犯迷糊的开发者,重点看我第二部分和第三部分的旋转时序与删除回溯;三是要在真实项目里做有序数据检索选型的人,最后一部分的对比和避坑清单可以直接当参考。我尽量做到既讲“怎么写”,也讲“为什么这一步必须这么做”。
1. 先从业务需求聊起:为什么要平衡
1.1 一次线上查询超时,让我重新审视 BST
我之前接手过一个内部系统的工单检索模块,数据量也就一万多条,按工单号查记录。当时团队图省事,直接用了 MySQL 里的一张普通索引表,逻辑上没什么问题,但随着并发上来,某几天开始频繁出现查询返回值飘红。看慢日志,问题不在 SQL,而在索引层之外的一个内存缓存模块——有人为了“加快查询”,把工单记录全部塞进了一棵二叉搜索树里,理由是 BST 查找平均 O(log n),比数组扫描快。
听起来没问题吧?问题在于插入的数据几乎是有序的。工单号按时间顺序生成,树插入的时候一路往右走,最后变成了一条只有右孩子的“链表”。一万个节点的“链表”,二分优势彻底没了,查找退化成 O(n),加上海量并发,超时自然就来了。
这件事给我留下的印象很深:BST 这个数据结构本身没有错,错的是用的人没有约束它的形态。BST 的性能高度依赖树的“形状”,同样的数据,插入顺序不同,树的组成完全不同。数据恰巧有序、近似有序,树的形态就会恶化。所以 AVL 树作为强制平衡的版本才被设计出来——它通过限制任意节点的左右子树高度差,保证树始终维持在接近完美的形态。
1.2 BST 的基本约束与 AVL 的“加码”
BST 的规则一句话就能说清:对于任意一个节点,左子树所有节点的值都小于当前节点,右子树所有节点的值都大于当前节点。对,是“所有”,不只是相邻的左右孩子。比如根节点是 10,那么整棵右子树里可以出现 8 吗?不行。任何小于 10 的节点都不能出现在右子树。这是 BST 的核心不变量,查找、插入、删除全都依赖它。
因为每个节点天然把数据划分成“小于自己”和“大于自己”两块,查找时每一步都能扔掉一半的方向,这就是二分思想的树形实现。但坏也坏在这:BST 只规定了“相对大小关系”,没有规定“树的形状”。你把 1, 2, 3, 4, 5 依次插入,树不一定是平衡的,而是养成了一条链。AVL 就是在这个基础之上加了一条硬约束:任意节点左右子树的高度之差不能超过 1。
我习惯把这条约束理解成“跷跷板”:坐上去的人在左右两端各压一个人,两边的高度差超过 1,板就翻了,得有人站起来换位置。AVL 的每个节点都尽量让左右子树的“身高”接近,这样整棵树的高度就能严格控制在 log n 量级,查找、插入、删除的复杂度才能稳定在 O(log n),而不是“平均 O(log n)、最差 O(n)”。
1.3 平衡因子:AVL 的核心度量
AVL 树里每个节点除了存值和左右孩子指针,还要多存一个高度字段。注意,这里的高度不是“以这个节点为根的子树里有多少层节点数”,而是从叶子向上统计的路径长度。叶子节点的高度通常约定为 1,空节点高度为 0。
然后定义平衡因子(balance factor):
balance = 左子树高度 - 右子树高度平衡因子的合法范围是 -1、0、1。一旦某个节点的平衡因子变成 2 或者 -2,说明左右子树高度差超过了限制,就要触发旋转来重新平衡。
这里有一个非常容易疏忽的点:很多人写 AVL 时只记得在插入后用平衡因子判断该不该旋转,却忘了一个前提——子树的高度必须是对的。也就是说,每个节点的 height 必须在其所有子孙节点的高度都更新完之后才能更新。树是递归结构,所以这天然就要求“先递归处理孩子,再回来更新当前节点”。如果你更新高度的时机不对,后面所有判断都会失真,表现为“明明调了旋转,树还是歪的”。
所以要我说,AVL 的核心不是那四种旋转动作,而是“对高度的维护纪律”。旋转只是工具,确保高度正确才是根基。
2. 核心操作拆解:插入、删除、查找的细节
2.1 BST 查找:比二分查找更自由的路径
BST 的查找流程很直接:拿着目标值从根出发,比当前节点小就往左走,比它大就往右走,相等就返回。这个思路和二分查找几乎一模一样,但 BST 的优势在于它不需要数据提前存放在连续内存里,插入和删除的成本也更可控,尤其是面对动态变化的数据集时,不用频繁搬动整片内存。
查找可以用递归,也可以用迭代。在实际工程里我倾向于用迭代,因为它没有函数调用的额外开销,也不存在递归过深爆栈的风险。代码大概是这样的:
template <typename T> bool searchNode(TreeNode<T>* root, const T& target) { TreeNode<T>* cur = root; while (cur) { if (target < cur->val) { cur = cur->left; } else if (target > cur->val) { cur = cur->right; } else { return true; } } return false; }这里有个隐蔽的坑:如果树里允许重复值,比较要用 <= 和 > 来区分“走左还是走右”,但这样又会影响删除逻辑和树的形态。很多教材默认 BST 不含重复值,实际工程里如果有重复键需求,建议在节点里加一个 count 计数器,而不是真的插入多个相同节点。用计数器方案的话,查找、插入、删除的核心逻辑都不用改,只是计数加减,代码会干净很多。
BST 还有一个非常重要的特性:中序遍历结果是有序的。你可以用这个特性来验证一棵树是不是合格的 BST,也可以在需要“按范围输出有序结果”的场景,直接中序遍历整棵树,相当于做了一次隐式排序。
2.2 BST 删除:三种形态,两个边界
删除是 BST 最麻烦的操作,麻烦就麻烦在“删掉一个节点之后,还要让剩余节点继续保持左小右大的性质”。具体可以分为三种情况:
第一种,被删除的节点是叶子节点。这种最简单,直接把它从父节点上摘掉,置为 nullptr,释放内存就行。
第二种,被删除的节点只有一个孩子。这时候把它的孩子直接提上来,接到父节点的对应位置。比如要删的节点只有右孩子,就让右孩子顶替它的位置,左孩子为空的缺点不用管。
第三种,被删除的节点有两个孩子。这种情况不能简单地用某个孩子顶替,因为两个孩子都非空,提任何一个上来都会破坏 BST 的全局大小关系。标准做法是找到“右子树中最小的节点”(即中序后继),用它覆盖当前节点的值,然后再递归删除那个后继节点。反过来,找“左子树中最大的节点”来做替换也是可行的,只要全树统一用同一种策略就行。
我第一次写这段逻辑时犯过一个不算罕见但很烦人的错误:在“有两个孩子”的情况下,我只用后继的值覆盖了当前节点,却没有递归删除后继节点,结果树里出现了两个相同值的节点。后来查了资料才明白,覆盖值只是完成了“把后继挪上来”的前半步,后半步必须回到右子树里把那个节点真正删掉,而且因为后继节点必然没有左孩子,第二次删除一定退化成前两种情况,不会无限递归。
2.3 AVL 旋转:四种旋转怎么记更牢
AVL 平衡的关键是旋转操作,本质上就是“在不破坏中序遍历顺序的前提下,把子树重新拉平”。很多初学者记四种旋转容易死记硬背,其实只要抓住一个规律,就能完全不用背:旋转方向只取决于失衡节点的哪一侧更深。
第一种叫 LL(左-左):新节点插入在失衡节点的左孩子的左子树上,导致左子树过高。处理方式是“右旋”。你可以想象成抓住失衡节点,把它往右下方压,左孩子升上来成为新的根。
第二种叫 RR(右-右),镜像对称:插入位置在右孩子的右子树,导致右子树过高,处理方式就是“左旋”:把失衡节点往左下方压,右孩子升上来。
第三种叫 LR(左-右):插入位置在左孩子的右子树。如果直接右旋,会发现问题还在。这时需要先对左孩子做一次左旋,把它变成“LL 形态”,然后再对失衡节点做右旋。
第四种叫 RL(右-左):镜像版本,先对右孩子做右旋,再对失衡节点做左旋。
记忆的关键就一句话:“哪边深,压哪边,转的方向跟深的一侧相反”。更具体地:
| 失衡类型 | 插入位置 | 第一步操作 | 第二步操作 |
|---|---|---|---|
| LL | 左孩子左子树 | 无 | 对失衡节点右旋 |
| RR | 右孩子右子树 | 无 | 对失衡节点左旋 |
| LR | 左孩子右子树 | 先对左孩子左旋 | 再对失衡节点右旋 |
| RL | 右孩子左子树 | 先对右孩子右旋 | 再对失衡节点左旋 |
在实际写代码时,判断是哪种类型不只是看“danger > 1”或者“danger < -1”,还需要检查失衡节点的孩子一侧的平衡因子。这也是新手最容易忽略的地方:只知道失衡了,不知道该转哪一步。没关系,我当时是用一个辅助函数根据 balance 的值做分支判断,比手写四个条件要稳得多,后面实现部分我会给出这种写法。
3. 实操复现:从伪代码到可运行代码
3.1 BST 的 C++ 实现与复杂度验证
我先把 BST 的完整实现贴出来,它也是后面 AVL 版本的基础。这里的实现用了递归插入和递归删除,逻辑上最直观,实际工程里若担心递归深度,可以改成栈辅助的迭代版本,但初学时先写递归,理解成本最低。
#include <iostream> #include <algorithm> using namespace std; template <typename T> struct BSTNode { T val; BSTNode* left; BSTNode* right; explicit BSTNode(const T& v) : val(v), left(nullptr), right(nullptr) {} }; template <typename T> class BST { private: BSTNode<T>* root = nullptr; BSTNode<T>* insertNode(BSTNode<T>* node, const T& val) { if (!node) return new BSTNode<T>(val); if (val < node->val) node->left = insertNode(node->left, val); else if (val > node->val) node->right = insertNode(node->right, val); return node; } BSTNode<T>* findMin(BSTNode<T>* node) { while (node && node->left) node = node->left; return node; } BSTNode<T>* removeNode(BSTNode<T>* node, const T& val) { if (!node) return nullptr; if (val < node->val) { node->left = removeNode(node->left, val); } else if (val > node->val) { node->right = removeNode(node->right, val); } else { // 情况1:叶子节点 if (!node->left && !node->right) { delete node; return nullptr; } // 情况2:只有一个孩子 if (!node->left) { BSTNode<T>* tmp = node->right; delete node; return tmp; } if (!node->right) { BSTNode<T>* tmp = node->left; delete node; return tmp; } // 情况3:两个孩子,用中序后继替换 BSTNode<T>* successor = findMin(node->right); node->val = successor->val; node->right = removeNode(node->right, successor->val); } return node; } void inorder(BSTNode<T>* node) { if (!node) return; inorder(node->left); cout << node->val << " "; inorder(node->right); } public: void insert(const T& val) { root = insertNode(root, val); } void remove(const T& val) { root = removeNode(root, val); } void printInorder() { inorder(root); cout << endl; } };这个版本的删除方法有一个值得一提的细节:在两个孩子的分支里,我没有真的“递归删除后继节点”,而是递归调用了 removeNode(node->right, successor->val)。因为后继节点一定在右子树中并且一定没有左孩子,所以这次删除要么是叶子情况,要么是只有一个右孩子的情况,不会触发“两个孩子”递归下去,递归深度可控。
从复杂度上说:含 n 个节点的满二叉树高度约为 log2(n),查找、插入、删除都沿着一条从根到叶子的路径进行,所以复杂度 O(log n)。但 BST 不保证“满”,以上复杂度只在树形健康的情况下成立。按 1,2,...n 顺序插入,树就是一条链,操作的复杂度就变成 O(n)。
所以验证 BST 时要特别检查“坏序列”。我做过一个简单实验:连续插入 1 到 100000 的正序数据,再查询最大值的耗时,明显比插入随机数据后再查同样值慢好几个数量级。这组对比可以直接当作 BST 需要自平衡机制的证据。
3.2 AVL 插入与删除的完整实现
接下来是 AVL 的完整实现。我在节点结构里增加了 height 字段,并提供了更新高度和计算平衡因子的辅助函数。注意插入和删除之后的处理是同一套模式:递归到底完成插入/删除,然后回溯更新当前节点高度,再检查平衡因子并做旋转。
template <typename T> struct AVLNode { T val; int height; AVLNode* left; AVLNode* right; explicit AVLNode(const T& v) : val(v), height(1), left(nullptr), right(nullptr) {} }; template <typename T> int getHeight(AVLNode<T>* node) { return node ? node->height : 0; } template <typename T> int getBalance(AVLNode<T>* node) { return node ? getHeight(node->left) - getHeight(node->right) : 0; } template <typename T> void updateHeight(AVLNode<T>* node) { node->height = 1 + max(getHeight(node->left), getHeight(node->right)); } template <typename T> AVLNode<T>* rotateRight(AVLNode<T>* y) { AVLNode<T>* x = y->left; y->left = x->right; x->right = y; updateHeight(y); updateHeight(x); return x; } template <typename T> AVLNode<T>* rotateLeft(AVLNode<T>* x) { AVLNode<T>* y = x->right; x->right = y->left; y->left = x; updateHeight(x); updateHeight(y); return y; }插入部分:
template <typename T> AVLNode<T>* insertAVL(AVLNode<T>* node, const T& val) { if (!node) return new AVLNode<T>(val); if (val < node->val) node->left = insertAVL(node->left, val); else if (val > node->val) node->right = insertAVL(node->right, val); else return node; updateHeight(node); int balance = getBalance(node); // LL if (balance > 1 && val < node->left->val) return rotateRight(node); // RR if (balance < -1 && val > node->right->val) return rotateLeft(node); // LR if (balance > 1 && val > node->left->val) { node->left = rotateLeft(node->left); return rotateRight(node); } // RL if (balance < -1 && val < node->right->val) { node->right = rotateRight(node->right); return rotateLeft(node); } return node; }删除部分比插入复杂在第 3.1 节提过的三种情况后,还需要从被删除节点的父路径一路回溯、更新高度、执行旋转。由于删除时我们不能像插入那样用“新插入值的位置”来决定旋转方向,只能借助当前节点的平衡因子和左右孩子的平衡因子来判断旋转类型,所以分支看起来略有不同,但核心思想一致。
template <typename T> AVLNode<T>* removeAVL(AVLNode<T>* node, const T& val) { if (!node) return nullptr; if (val < node->val) node->left = removeAVL(node->left, val); else if (val > node->val) node->right = removeAVL(node->right, val); else { if (!node->left || !node->right) { AVLNode<T>* tmp = node->left ? node->left : node->right; if (tmp) { node->val = tmp->val; node->height = tmp->height; node->left = tmp->left; node->right = tmp->right; delete tmp; } else { delete node; return nullptr; } } else { AVLNode<T>* successor = node->right; while (successor->left) successor = successor->left; node->val = successor->val; node->right = removeAVL(node->right, successor->val); } } updateHeight(node); int balance = getBalance(node); // LL 或 LR if (balance > 1 && getBalance(node->left) >= 0) return rotateRight(node); if (balance > 1 && getBalance(node->left) < 0) { node->left = rotateLeft(node->left); return rotateRight(node); } // RR 或 RL if (balance < -1 && getBalance(node->right) <= 0) return rotateLeft(node); if (balance < -1 && getBalance(node->right) > 0) { node->right = rotateRight(node->right); return rotateLeft(node); } return node; }删除时的“单孩子”分支我采用了先拷贝再删除的方式:把子节点的值、高度、左右指针整体拷贝上来,然后释放原孩子。这样做可以避免在递归返回的过程中访问到已经被回收的内存,代码也整齐不少。
有几个细节需要单独强调,因为它们是 AVL 实现里最容易搞错的部分:
一是高度更新时机。递归回到当前节点后,必须先 updateHeight(node),再 getBalance(node)。如果顺序反了,balance 用的是旧高度,可能判断出错误的旋转类型。我在调试时用很短的数据集对比过,几乎所有“旋转后仍然失衡”的诡异现象都是这个顺序问题。
二是插入的重值处理。插入遇到相同值时,我的代码直接 return 当前节点,相当于忽略重复插入。实际业务里如果需要支持重复,建议像前面说的加 count 字段,不要用向左右子树随机旋转的方式去处理重复键,否则平衡判断会变得非常混乱。
三是删除时能否直接用 val 判断旋转方向。在插入里可以,因为失衡是由新插入值引起的;在删除里不行,因为删除后节点新失衡的位置取决于被删值在哪个子树,递归返回路径上你可能根本不知道“新值应该插哪边”。所以要依赖 balance 和左右子树的 balance 做分支判断。
3.3 测试数据怎么设计才算靠谱
测试一棵平衡树,不能只跑一两条“通过案例”就结束。我建议至少覆盖四类数据集:
第一类是有序序列,比如从 1 到 20000 递增插入。BST 会退化,AVL 必须依然稳得住。
第二类是逆序序列,比如 20000 到 1 递减插入。这专测 RR 型和 RL 型旋转。
第三类是随机打乱序列,模拟真实业务输入。插入和删除各做几万次,每次结束后抽查整棵树是否满足 BST 和平衡条件。
第四类是重复值序列,把相同的值插很多遍,验证重值不会导致失衡或内存泄露。
除了功能正确性,还要验证树高。AVL 树高应该是 log2(n) 级别的常数倍,实际可以在满足平衡因子的条件下做估算:高度 h 的 AVL 至少包含 Fh+2 - 1 个节点,其中 F 是斐波那契数列。利用这个关系可以反过来判断树高是否符合预期。
我常写的调试断言有两个。第一个是校验 BST 性质:
template <typename T> bool isBST(AVLNode<T>* node, T minVal, T maxVal) { if (!node) return true; if (node->val <= minVal || node->val >= maxVal) return false; return isBST(node->left, minVal, node->val) && isBST(node->right, node->val, maxVal); }第二个是递归校验整棵树是否平衡:
template <typename T> bool isBalanced(AVLNode<T>* node) { if (!node) return true; int bf = getBalance(node); if (bf < -1 || bf > 1) return false; return isBalanced(node->left) && isBalanced(node->right); }这两个断言函数结合起来,可以对随机操作序列里的每棵树做全量检测。配合前序遍历打印树结构,基本可以定位绝大多数旋转错误。
4. 常见问题、排查技巧与选型建议
4.1 调试平衡树最有效的三个土办法
第一个办法是打印树结构。我写过一个小工具函数,把树逆时针旋转 90 度打印到日志里,节点之间的父子关系一目了然。每行输出值、高度、平衡因子三个信息,配合中序遍历结果,能很快看出哪一层失衡、哪一步旋转没生效。这个办法比单步调试高效得多,因为树高度小的时候你还能盯着看,一旦插入上千个节点,单步调试就完全失控了。
第二个办法是小数据手动推演。找一组能触发四种旋转的最小数据集:比如插入 10、5、15 得到平衡树,再插 6 触发 LR,插 14 触发 RL。把每一步旋转前后的树画出来,和代码跑出来的打印结果对比,基本上一次能排查出绝大多数问题。我一直觉得,对旋转这种图形化操作,纸笔推演比盯着抽象代码更快。
第三个办法是随机化压测配合断言。随机生成操作序列,每执行几十次就调用 isBST 和 isBalanced 两个断言函数做全量校验,一旦失败马上记录导致失败的操作序列,然后缩小到最小复现集。这个思路本质上就是“用人肉二分法定位 bug”,核心是要把操作序列和断言的输出完整保留下来,否则复现时没有线索。
4.2 为什么标准库选了红黑树而不是 AVL
这可能是被问得最多的一道题:既然 AVL 平衡性更严格,查找性能更好,为什么常见语言的标准库 map、set 大多用红黑树,而不是 AVL 树?
答案在于“查找和插入删除的频率结构”。AVL 的平衡更严格,树高更低,理论上查找更快。但为了维持这种低树高,AVL 在每次插入/删除后几乎都要做一些旋转,而且删除后的调整可能一直回溯到根节点,最坏情况下的旋转次数比红黑树要多一些。红黑树的平衡约束相对宽松,允许左右子树高度差最多达到两倍,因此插入和删除时的调整频率更低,写操作成本更小。对大多数应用来说,写操作的比例并没有低到可以让查找的微小优势占据主导,所以工程库选择红黑树是整体吞吐最优的结果。
但这不代表 AVL 没有用武之地。如果业务是“一次构建、频繁查询”,而且查询性能极其敏感,AVL 就是更好的选择,因为它树高更低。你也可以在内存里用 AVL 做数据库索引的近似模拟,理解更严格平衡带来的树高收益。真要选型时,建议先量一下读写比例,再按数据量做基准测试,不要只凭“红黑树更流行”来定。
简单总结就是:读多写少、极度强调最坏延迟,优先 AVL;读多写多、需要平衡插入删除的开销,优先红黑树;而如果数据量很小,比如几百条,连 BST 的退化影响都不明显,就直接用有序数组加二分查找,省事且 cache 友好。
4.3 避坑清单:给马上要写树的你
我按自己踩过的坑和不定期帮别人 review 代码时看到的错误,整理了一份清单,按优先级排:
- 高度更新顺序:先递归,后 updateHeight,再 getBalance。顺序反了,旋转判断全错。
- 删除后的回溯:删除节点不是结束,要沿递归路径把祖先节点的高度和平衡因子全部重新计算。漏了这一步,删几次树就坏。
- 单孩子删除不要直接删除节点:先拷贝子树的 key 和指针再释放孩子,防止递归返回时踩到悬空指针。
- 插入重值直接忽略或计数:不要往左右子树随机插重复值,后面的平衡完全没法判断。
- 更新孩子指针之后要重新计算原根高度再返回新根:旋转函数里很容易漏掉对现代节点的高度更新,导致上层拿到更新后的结构时高度还是旧值。
- 平衡因子的定义要统一:有人用左减右,有人用右减左,这本身没有对错,但旋转方向判断必须和你的定义保持一致。
- 释放内存和递归返回的顺序:删除叶子节点之后直接 return nullptr 是没问题的,但如果在递归深处 delete 了节点,却继续使用 parent 指针就不行了。
这些问题在教科书上往往只有一行注释,在实际代码里却会蔓延成很隐蔽的 bug。我的建议是:写完后别急着提交或者复用,先跑一轮随机压测加断言,消耗几万次操作,确认无异常再说“完成了”。
再分享一个小技巧:如果你用的是支持调试器内存检查的环境,可以在每次插入删除后调用一个计算整棵树节点数的函数,对比实际申请数量和释放数量是否一致。这个对排查内存泄漏非常有效,不用专门引入检测工具就能定位“哪个分支没 delete”。
我个人在实际项目里的体会是:能动手把 BST 和 AVL 的代码完整写一遍,尤其是删除和旋转这两种最绕的部分,比背十遍教材上的定义有价值得多。看着打印出的树从歪歪扭扭变成每一层都匀称整齐,那种对“不变量”的理解才会真正长在脑子里。之后再去碰红黑树或者 B 树,你会发现自己不会再怕了,因为底层那套“约束-失衡-调整”的思维方式,在 BST 和 AVL 这一关就已经建立起来了。