你有没有遇到过这种情况:在网上订票时,系统要在一亿条记录里查出你的那一单;游戏排行榜要即时插入新分数,又要立刻返回前几名。这些场景背后都藏着一个共同需求——查找要快,插入也要快。而能同时兼顾两者的数据结构,最经典的答案之一,就是今天要讲的 AVL 树。

AVL 树的全名叫"Height-balanced Binary Search Tree"(高度平衡的二叉搜索树),它的名字来自两位前苏联科学家 G. M. Adelson-Velsky 和 E. M. Landis。1962 年,他们在一篇名为《An algorithm for the organization of information》(信息组织的一种算法)的论文中提出了它。有意思的是,AVL 树是最先被发明出来的自平衡二叉搜索树——在它之前,大家写二叉搜索树全靠运气:数据有序就快,数据糟糕就一团糟。AVL 树第一次让"树自己会纠正自己"变成了现实。

这篇文章我会带着你,从最朴素的二叉搜索树开始,一步步看清它为什么会"退化",再亲手把 AVL 树从头实现一遍:结点的定义、插入、平衡因子的更新,以及让它恢复平衡的四种旋转。阅读前你最好已经接触过二叉搜索树,不过没接触也没关系,我会在最开始把这些前置知识铺开讲透。准备好了吗?

从二叉搜索树退化说起

先回到最朴素的二叉搜索树(Binary Search Tree,简称 BST)。它的规则只有一条:对于任意一个结点,它左子树里的所有结点都比它小,右子树里的所有结点都比它大。就这么一条规则,让"查找"变得非常高效——你想找某个值,从根出发,比根小往左走,比根大往右走,每一步都砍掉一半的可能性。

你可以把这种查找想象成玩"猜数字"游戏:我在 1 到 100 里想一个数,你每次猜一个数,我告诉你"大了"还是"小了"。最笨的办法是从 1 一个一个往上猜,最聪明的办法是每次猜中间——每一次都能排除一半。二叉搜索树的查找就是"猜中间"的升级版:每走一层,把搜索范围缩小一半。

这里就引出了第一个需要就地讲清楚的前置概念——树的高度(Height)。先说结论,这本书采用"层数"惯例:空树高度是 0,只有一个结点的树高度是 1。请注意,高度和后面要用到的平衡因子计算,全都建立在同一个统一的"层数"口径上。不同教材对高度的定义并不完全一致:有的教材把高度定义成"根到最远叶子经过的边数",那样单结点树的高度就是 0;有的定义成"根到最远叶子的层数",这样单结点树的高度就是 1。两种定义都合法,真正要紧的是——整本书、整段代码必须从头到尾用同一种口径,千万不要混用。你如果去网上看不同人的 AVL 博客,发现写的高度值偶尔对不上,往往不是有人算错,而是各自的"层数"惯例不同。只要理解这点,你就不会被这种差异困扰。在理想情况下,一棵二叉搜索树如果长得又矮又胖,n 个结点的高度大约是 log₂(n),那么查找一次最多就只走 log₂(n) 步,时间复杂度记作 O(log n)——这已经非常快了,一亿个结点也只需要 27 步左右。

为什么高度和查找步数是绑定的?因为二叉搜索树的查找是一次"单路径向下走":每一步只访问一个结点,要么进左子树、要么进右子树,绝不可能同时走进两边。所以"查找一步 = 下一层",走的层数恰好不会超过树的高度。树国度和最高层跨度多深,一次查找最坏就要下潜多深。因此"控制树高"就是"控制查找代价",这可是一条贯穿全篇的主线。

但问题来了:二叉搜索树"长什么样"完全取决于插入的顺序。让我们用代码写一棵最朴素的二叉树结点,然后亲手演示这个坑。

#include <iostream>
#include <utility>   // 提供 std::pair,后文 AVL 树要用到
#include <cstdlib>   // 提供 abs、rand、srand,验证时会用
#include <ctime>     // 提供 time,生成随机种子时会用
#include <cassert>   // 提供 assert,防御性检查时会用
using namespace std;
 
// 一棵最朴素的二叉搜索树结点
template<class K>
struct BSTNode
{
    K _key;                // 关键字,用于比较大小
    BSTNode<K>* _left;     // 左孩子指针
    BSTNode<K>* _right;    // 右孩子指针
 
    // 构造函数:负责把三个成员初始化干净
    BSTNode(const K& key)
        : _key(key)
        , _left(nullptr)
        , _right(nullptr)
    {}
};

我们在空树里依次插入 1, 2, 3, 4, 5 这五个数。规则是"小的往左,大的往右",结果你会发现:每个新数都比已插入的都大,全都往右跑,最后形成一条向右下方延伸的"单链"——1 的右孩子是 2,2 的右孩子是 3,3 的右孩子是 4,4 的右孩子是 5。这棵树的高度不再是 log₂(5)≈2.3,而是 5,退化成了差不多一条线性表。

退化之后会发生什么?查找 5 的时候,你得从 1 一路走到 5,走满 5 步。如果插入的是 1 到 1 万个有序数,查找最后一个数就得走 1 万步——复杂度从 O(log n) 直接退化成了 O(n)。换句话说,当数据基本有序插入时,二叉搜索树退化成链表,所有美好假设都崩塌了。这是个非常经典的坑,也是 AVL 树要解决的问题的起源。

这里有必要把"哪些插入序列会害死 BST"讲透一点,因为它直接决定了我们为什么需要 AVL:

  • 严格升序:1, 2, 3, ... , n,每次插在已有序列的最右边,长成一条右链。
  • 严格降序:n, n-1, ..., 1,每次插在最左边,长成一条左链。
  • 交替有序的怪序列:比如 1, n, 2, n-1, 3, n-2 ...,也容易长歪。
  • 随机序列:绝大多数随机序列其实是"比较平衡"的(因为随机插入时,大致半个会落左、半个落右),所以随机插入不是退化主因。

真正危险的,是那些"局部特征明显"的数据:成绩排序、按时间戳到达的日志、按 id 递增的订单——这类真实数据恰恰就是有序插入的主力军。也就是说,BST 的退化和我们日常碰到的数据形态高度重合,这也让 AVL 的"自平衡"价值变得更加真实可感。

别光看文字,我们把这一幕亲自跑一遍。下面是一份可独立编译的完整程序,它用朴素 BST 插入 1 到 10000 的升序数据,然后把树的高度打给你看:

#include <iostream>
#include <cmath>     // 提供 log2
using namespace std;
 
// 一棵最朴素的二叉搜索树(不带任何平衡机制)
template<class K>
struct BSTNode
{
    K _key;
    BSTNode<K>* _left;
    BSTNode<K>* _right;
 
    BSTNode(const K& key)
        : _key(key), _left(nullptr), _right(nullptr)
    {}
};
 
template<class K>
class NaiveBST
{
    typedef BSTNode<K> Node;
public:
    // 只做最普通的 BST 插入
    bool Insert(const K& key)
    {
        if (_root == nullptr)
        {
            _root = new Node(key);
            return true;
        }
        Node* cur = _root;
        Node* parent = nullptr;
        while (cur)
        {
            parent = cur;
            if (key < cur->_key)
                cur = cur->_left;
            else if (key > cur->_key)
                cur = cur->_right;
            else
                return false;         // 键重复,拒绝
        }
        if (key < parent->_key)
            parent->_left = new Node(key);
        else
            parent->_right = new Node(key);
        return true;
    }
 
    int Height() { return _Height(_root); }
 
private:
    int _Height(Node* root)
    {
        if (root == nullptr) return 0;              // 空树高 0
        int left = _Height(root->_left);
        int right = _Height(root->_right);
        return left > right ? left + 1 : right + 1; // 高的那边 + 根这一层
    }
 
    Node* _root = nullptr;
};
 
int main()
{
    NaiveBST<int> t;
    const int N = 10000;
 
    // 严格升序插入:数据越是有序,普通 BST 越倒霉
    for (int i = 1; i <= N; ++i)
        t.Insert(i);
 
    cout << "升序插入 " << N << " 个结点后,普通 BST 高度 = " << t.Height() << endl;
    cout << "而理论上限(理想平衡)约 log2(" << N << ")+1 = "
         << (int)(log2(N)) + 1 << endl;
    return 0;
}

在你自己的电脑上编译运行,你会看到高度几乎等于 N(两边的差值取决于"单结点高度算 0 还是算 1"以及根这层怎么数),而理想高度只是个位数。n 个结点却长成了一条 n 层的链——这就是 BST 退化最直白的证据。

顺带思考一个效率问题:这种"树上查找"退化成"链上顺序查找"(O(n))之后,和直接遍历一个数组有什么区别?几乎没有,甚至因为指针跳转还会更慢一些。所以退化的 BST 不仅时间上不再划算,空间上的指针开销也显得多余——两头都不讨好,这才逼出了自平衡树的必要性。

AVL 树的定义与平衡因子

既然二叉搜索树会因为"一边长得太高"而退化,那思路就很自然了:在每次插入后,检查一下树平不平衡,如果太歪了,就把它拗回正轨。 这就是"自平衡"的含义——树不需要你手动干预,它自己在插入过程中就把自己弄平衡了。

那什么叫"平衡"?AVL 树给出了精确的定义:一棵 AVL 树要么是空树,要么满足下面两个条件:

  1. 它的左右子树都是 AVL 树;
  2. 左右子树的高度差的绝对值不超过 1。

注意这个定义是递归的——判断一棵树是不是 AVL 树,先要判断它的左右子树是不是 AVL 树,一路递归到叶子。你可以理解为:平衡是一层一层传下来的,任何一层歪了,整棵树就不算 AVL 树。

为了量化和控制"高度差",AVL 树引入了一个关键概念——平衡因子(Balance Factor,英文常缩写为 BF,代码里就叫 _bf)。每个结点都有自己的平衡因子,它的计算公式是:

平衡因子 = 右子树的高度 - 左子树的高度

为什么是"右减左"而不是反过来?纯属人为约定,两边都可以,只要全局保持一致就行。在"右减左"这个约定下,因为高度差的绝对值不超过 1,所以任何结点的平衡因子只可能是 -1、0、1 三个值中的某一个——左边比右边高 1,平衡因子就是 -1;两边一样高,就是 0;右边比左边高 1,就是 1。

这里有个值得多聊两句的问题:为什么 AVL 树要求高度差"绝对值不超过 1",而不是干脆要求"高度差为 0"?0 听起来不是更平衡吗?画几个图你就明白了:不是不想,而是有相当多的情况根本做不到高度差为 0。比如整棵树只有 2 个结点——一个根带一个左孩子,那它只能是"左边一层、右边零层",高度差注定是 1;又比如 4 个结点的完全二叉树形态,高度差最好也就是 1。强行要求高度差为 0,等于逼某些规模的树"长不出来"。所以 AVL 树选择了务实的高度平衡——误差放开到 1,既保证了复杂度,又覆盖了所有规模的树。

让我把"高度差必须为 0 做不到"这个论点再往深里推一点。一个很自然的疑问是:"我能做出 n 个结点、左右两边一般高的树啊,比如 3 个结点的满二叉树(根 + 左右各一个孩子),它的高度差不就是 0 吗?" 对,有些规模的树确实能做成高度差 0。但问题在于,AVL 的规则必须能覆盖从 1 个结点到任意个结点的所有规模。既然存在"只有 2 个结点"这种无论如何高度差都是 1 的规模,那么"要求高度差为 0"这个规则在数学上就不可能被所有规模满足。所以严谨的说法是:我们要找的是一个对所有 n 都成立的平衡规则,而"高度差 ≤ 1"恰好是能做到这点且足够紧的规则。任何规模都能装下,这就是它胜过"0"的根本原因。

有了平衡因子这个"风向标",我们观察树的平衡状态就容易多了:哪个结点的平衡因子跑出了 [-1, 0, 1],哪个结点所在的子树就不平衡了,该出手时就出手。课件里有一句很形象的比喻——平衡因子就像风向标,有了它,我们不用重新去算整棵子树的高度,只看这个小小的数字就能判断该不该旋转,方便观测也方便控制。

平衡因子的方向约定,会影响你的代码吗?

"右减左"和"左减右"既然都是合法约定,那切换方向会对代码的哪些部分造成影响?这个问题非常值得掰开,因为很多人写 AVL 时在不同博客见到的 _bf++/_bf-- 方向不同,会晕。我们把影响面摊开:

  • 判断"哪边高"的符号变了。右减左时 _bf > 0 表示右边高;左减右时正好相反,_bf < 0 表示右边高。于是插入时"新结点在右子树 _bf++"会变成"新结点在右子树 _bf--"。
  • 旋转触发判断的符号变了。右减左时,_bf == 2 意味着右重,要向左旋(RotateL);而左减右时 _bf == 2 意味着左重,要向右旋。模型判断里的 parent->_bf == 2 && cur->_bf == 1 这类组合也要整体镜像。
  • 验证函数 _IsBalanceTree 里的真实高度差计算式变了。你存的是哪种约定,验证时就必须用同一种约定去算 diff,否则永远对不上,一验证就报"平衡因子异常"。

所以结论是:方向本身无关对错,关键是要"全链路一致"——结点更新、旋转恢复、验证检测三处的符号约定必须一模一样。只要有一个地方符号写反,树就会"全面性歪掉又发现不了"。

收益量化:高度差 ≤ 1 到底换来几层?斐波那契式的最紧界

光说"复杂度是 O(log n)"还显得抽象,我们把它定量化。任意一棵高度为 h 的 AVL 树,至少要有多少个结点? 想清楚这个,就能得到最紧的高度上界。

设 $N(h)$ 是高度为 $h$ 的 AVL 树最少的结点数。为了又高又省结点,根的两棵子树要尽量"矮",可根的两侧高度差又最多差 1,所以它应该是一棵高度为 $h-1$、一棵高度为 $h-2$ 的 AVL 子树,加上根自己:

$$ N(h) = N(h-1) + N(h-2) + 1, \qquad N(1)=1,\ N(2)=2 $$

这个递推长得像斐波那契数列,事实上它的增长就由斐波那契数列决定。$N(h)$ 呈指数增长,反过来解 h 就能得到:给定 n 个结点,AVL 的高度最多约为 $1.44 \times \log_2(n)$。这已经非常接近完全二叉树理想高度的上限了,系数 1.44 就是"高度差放宽到 1"所付出的代价上限。这是一个值得记住的数字:AVL 的高度虽然略高于完全二叉树,但依然严格落在对数量级。

我们立刻用一段可独立编译的程序,把"高度 h → 至少需要的结点数"打印出来,你会直观看到那个指数增长的数值:

#include <iostream>
using namespace std;
 
// 计算高度为 h 的 AVL 树最少能有多少个结点
// 递推:N(h) = N(h-1) + N(h-2) + 1,  N(1)=1, N(2)=2
long long MinNodes(int h)
{
    if (h == 0) return 0;   // 空树
    if (h == 1) return 1;   // 单个结点
    if (h == 2) return 2;   // 根 + 一个孩子
    return MinNodes(h - 1) + MinNodes(h - 2) + 1;
}
 
int main()
{
    cout << "AVL 树:要达到某个高度,至少需要多少个结点?\n\n";
    for (int h = 1; h <= 20; ++h)
        cout << "高度 h = " << h
             << "  最少结点数 = " << MinNodes(h) << '\n';
    return 0;
}

跑一下你就会发现:高度才 20 的 AVL 树,已经能塞下十几万甚至更多结点——树高随结点数增长是超慢的(对数级),这正是所有"查询快"的根源。

抛开数学,用一个生活类比也许更好消化:高度差 ≤ 1 的树,就像一座被规定"每层楼墩距最大相差一层"的楼。它虽然不一定像完全二叉树那样是规整的立方体,但再怎么歪,也歪不出"这里高耸入云、那里塌成地下室"的极端样貌。于是无论从顶层下楼梯到任意房间,走的步数都被锁在一个对数的小区间里。AVL 树的"高度平衡",本质就是给这栋楼装了一道"上下层数差不能超过一层"的建筑规范。

那我们就能得到这样一棵"高度平衡"的树能带来什么收益:AVL 树整体的结点数量和分布与完全二叉树类似,高度被严格控制在 O(log n) 这个量级,因此增、删、查、改的效率也都能稳定在 O(log n)。相比普通二叉搜索树那个"好的时候 O(log n)、坏的时候 O(n)"的看运气版本,这可以说是本质的提升了。

AVL 结点的定义

现在动手写结点。和朴素 BST 结点唯一的差别是:AVL 结点要往外多管两个事——一个父亲指针,一个平衡因子。完整定义如下:

// AVL 树的结点:KV 结构,key 用于比较,value 存数据
template<class K, class V>
struct AVLTreeNode
{
    pair<K, V> _kv;                // 键值对,first 是键,second 是值
    AVLTreeNode<K, V>* _left;      // 左孩子
    AVLTreeNode<K, V>* _right;     // 右孩子
    AVLTreeNode<K, V>* _parent;    // 父结点指针,更新平衡因子时要用
    int _bf;                       // balance factor,平衡因子
 
    // 构造一个全新的结点:所有指针置空,平衡因子初始为 0(自己一个点,左右都空,高度差为 0)
    AVLTreeNode(const pair<K, V>& kv)
        : _kv(kv)
        , _left(nullptr)
        , _right(nullptr)
        , _parent(nullptr)
        , _bf(0)
    {}
};

逐个字段解释。_kv 存的是 pair<K, V>,也就是"键值对"——用一个键(key,比如学号)去映射一个值(value,比如姓名)。比较大小用的是 _kv.first(键)。_left 和 _right 跟普通 BST 一样,是左右孩子。

真正和普通人拉开差距的是最后这两个——_parent 和 _bf。

先说 _parent。普通 BST 不需要父亲指针,因为插入、查找都是从上往下走的。但 AVL 树不同:插入新结点之后,我们要从下往上,逐个更新父结点们的平衡因子,而这个"从下往上"的路径,没有父亲指针就得借助递归回溯,有了 _parent 指针就能直接 parent = parent->_parent 一口气跳上去。你可以理解为:_parent 就是给树装上了"回头路",方便我们逆流而上。这是 AVL 和普通 BST 一个很实际的区别。

再说 _bf,就是我们前面反复提到的平衡因子,专门用来记录"右高减左高",初始一定是 0——新结点刚诞生,左右都为空,两边一样"高"。

让我把 _parent 背后的必要性再讲深一层:它本质上是把"递归回溯"改成了"显式迭代"。 在纯 BST 里,你想从一个结点往上走,没有任何指针支持,只能靠递归调用栈一层层把"中途状态"捎带回去。而 AVL 因为插入后要沿祖先链自底向上改平衡因子,走得又频繁,于是干脆在结点里塞一个 _parent 指针,用 while(parent) 直接改——它把"函数调用栈"这笔隐式开销,化成了一根随时可用的显式指针。这是一种典型的空间换时间、隐式换显式的工程折中。有得必有失:_parent 让每次改孩子指针时,都要同步修正一条父亲指针,这就是后文旋转里那个"最容易漏"的坑的来源。

还有一个值得提醒的编程细节:_kv 用的是模板 pair<K,V>,比较键用 _kv.first。如果你的 V 是一个大对象(比如整个员工实体),那么构造 new Node(kv) 时 _kv(kv) 会拷贝整个对象;要是对象很大很重,拷贝开销不可忽视。更讲究的写法会用移动语义或右值引用,这里先不深入,但你要知道 pair<K,V> 的语义是"键值一起搬",这也是后面 Insert({e, e}) 这种初始化写法能生效的原因。

最后澄清一个命名习惯:很多地方的 AVL 键值定义是 K(只存键)而非 KV(键值对)。课件和本文采用 KV 结构,是因为实际使用中绝大多数是"键映射值",比如学号映射姓名。只存键也能实现同样的平衡逻辑,你理解原理后随手就能改。

结点定义好了,整个树的骨架也就清楚了——AVL 树本质上就是"一根 _root 指针 + 一堆结点+一堆让它们保持平衡的方法"。

// AVL 树本体:只持有一个根指针,所有操作都围着一棵树转
template<class K, class V>
class AVLTree
{
    typedef AVLTreeNode<K, V> Node;   // 给结点类型起个短别名,后面写起来省事
 
    // 成员函数:插入、旋转、查找、验证……下面逐个实现
 
private:
    Node* _root = nullptr;            // 根结点,空树就是空指针
};

这里 _root = nullptr 用了 C++11 的类内成员默认初始化,这样无论走哪个构造函数,_root 开场都是空指针,而类里没写自定义构造函数也没关系——编译器会帮你默认构造。这是 C++11 之后写类成员的标准做法,比在构造函数体里 _root = nullptr 更省心也更不容易漏。

顺手提醒一个真实工程里的坑:我们目前在类的析构上什么都没做,结点都是 new 出来的,没有任何地方 delete。对一篇讲原理的文章这没问题,也不会泄漏到影响你要点,但真实项目里要么给 AVLTree 写析构函数做一遍后序遍历式释构,要么让 shared_ptr 托管结点。千万别忽略析构,否则长期跑会内存泄漏——这是个经典的生产级隐患,等你把本课原理吃透,很值得自己补一个析构函数练手。

插入:找位置与更新平衡因子

AVL 的插入可以拆成三步走,理解了这三步,其余都是细节:

第一步,按二叉搜索树的规则把新结点插进去。 这一步和普通 BST 一模一样——从根出发,键比当前结点小就往左,大就往右,一路走到空位置插入。唯一多做的动作:把新结点的 _parent 指向上面的父结点。

第二步,从新结点出发,沿父亲链向上,更新平衡因子。 这一点要想明白:插入一个叶子,并不会影响所有结点的高度,只会影响它所有祖先结点的高度——那些跟它没有任何"父子祖先"关系的结点,高度纹丝不动。所以我们要更新的,是从新结点到根结点这条路径上所有祖先的平衡因子。最坏情况下这条路径会一路顶到根,但也经常更新到半路就停,什么情况停下来?这正是第三步要讲的规则。

第三步,一边更新一边检查。 更新过程中只要发现某个祖先的平衡因子变成了 2 或 -2,说明它脚下的子树不平衡了,立刻对这颗子树做旋转,把它扭回平衡。

在进平衡因子的更新细节之前,先解释一个关键推论:为什么新增叶子只影响祖先、不影响别的结点? 因为"子树的高度"是这样定义的——子树中最深叶子所在的那一层,而新增结点只可能出现在它自己这条祖先链上。任何不在该链上的结点,它的左右子树完全没被动过,高度自然不变,平衡因子自然也不该变。只有祖先才可能因为"孩子多了这一层"而长高。这听起来像废话,但它恰恰是"为什么从新结点往上更新"这条原则的底气——如果哪个结点高度变了而我们没去更新它,验证时就会露馅。

平衡因子的更新原则与停止条件

先把更新原则摆出来,这些是全篇的地基,必须记牢:

  • 只有子树的"高度"发生变化,才会影响到当前结点的平衡因子。 如果插入后一个结点左右子树的高度比例没变,它的平衡因子就不该变。
  • 插入新结点,会增加高度。所以——新结点插在 parent 的右子树,parent->_bf++;插在左子树,parent->_bf--。 很简单:右边多探出一个结点,右高减左高自然 +1,反之 -1。
  • parent 所在子树的高度变没变,决定了要不要继续往上更新。 这是整段逻辑的枢轴。

为什么是"++/--"这么直白的自增自减,而不是重新算两棵子树的高度?因为我们是沿链条一级级往上走的,每次只知道"最新插入在我们孩子的哪一侧"。把平衡因子的定义代进去:新结点进了右子树,右子树高度 +1,那么"右高−左高"就比之前多 1,也就是 _bf++。自增自减正是对"右子树高度变化 ±1"的忠实翻印,它省去了每次重新求高度的 O(子树规模) 开销,让每次更新固定在 O(1)。

那具体怎么判断停不停?记住三个分支:

  1. 更新后 parent 的平衡因子等于 0。也就是从 -1 变成 0(之前左边高),或从 1 变成 0(之前右边高)。这说明插入的这个结点恰好补在了矮的那一侧,把两边拉平了。拉平意味着整棵子树的高度没变——原来的高度本来就是"高一侧"决定的,现在矮侧补了一个,总高度不变。既然子树高度没变,它父亲的平衡因子自然不受影响,于是更新到此结束。

  2. 更新后 parent 的平衡因子等于 1 或 -1。也就是从 0 变成 1,或从 0 变成 -1。这说明插入前两边一样高,插入后变成了一边高一边低。此时这个子树虽然本身还健全(高度差 1,没违约),但它整体高度增加了 1。高度一加,就会影响它父亲的平衡因子,所以要继续往上更新。

  3. 更新后 parent 的平衡因子等于 2 或 -2。也就是从 1 变成 2,或从 -1 变成 -2。这说明插入前已经一边高,结果新结点还插在了高的那一边,高上加高,平衡被彻底打破了。此时必须旋转处理,旋转有两个目标:一是把 parent 的子树旋转平衡;二是降低 parent 子树的高度,让它恢复到插入以前的高度。既然高度恢复了,上一层也就不再受影响,旋转完插入直接结束,不用再往上查。

顺带补最后一个边界:如果一路更新到了根,发现根的平衡因子是 1 或 -1,也正常结束——根已经没有父亲了,没得往上更新,树仍然平衡。所以你发现没有,插入路径上,每个结点的平衡因子最多只会被更新一次,而这一路的每个分支都处理得干干净净。

我把三个分支的执行路径用一句话串起来给你当记忆锚点:归 0 停,归 ±1 继续走,归 ±2 旋转。整棵 AVL 的插入 while 循环,其实就是在反复问三个问题:我平了吗?(0→停)我偏了吗但还能忍?(±1→往上去)我歪得不能忍了?(±2→转完停)。逻辑高度对称,别记混。

Insert 代码实现

把上面的逻辑落成代码,就是下面这个函数。为了让旋转分支清晰,我在这里把四种旋转提前挂上去(函数名先认识一下,后面逐个实现):

// 插入一个键值对。键重复时返回 false,插入成功返回 true
bool Insert(const pair<K, V>& kv)
{
    // 空树:直接把新结点当根
    if (_root == nullptr)
    {
        _root = new Node(kv);
        return true;
    }
 
    // 第一步:按二叉搜索树规则找插入位置,全程记下走过的父结点
    Node* parent = nullptr;
    Node* cur = _root;
    while (cur)
    {
        if (cur->_kv.first < kv.first)
        {
            parent = cur;          // 记住当前结点,作为下一步的父
            cur = cur->_right;     // 键更大,往右走
        }
        else if (cur->_kv.first > kv.first)
        {
            parent = cur;
            cur = cur->_left;      // 键更小,往左走
        }
        else
        {
            return false;          // 键已存在,不允许重复插入
        }
    }
 
    // 走到空位置,新建结点并挂到 parent 下面
    cur = new Node(kv);
    if (parent->_kv.first < kv.first)
        parent->_right = cur;      // cur 是右孩子
    else
        parent->_left = cur;       // cur 是左孩子
    cur->_parent = parent;         // 关键:补上父亲指针,向上更新要用
 
    // 第二步:沿父亲链向上更新平衡因子
    while (parent)
    {
        // 更新当前父结点的平衡因子:新结点在左减一,在右加一
        if (cur == parent->_left)
            parent->_bf--;
        else
            parent->_bf++;
 
        // 第三分支接口:按更新的结果决定下一步
        if (parent->_bf == 0)
        {
            // 变成 0:子树高度没变,上一层不受影响,更新结束
            break;
        }
        else if (parent->_bf == 1 || parent->_bf == -1)
        {
            // 变成 ±1:子树高度加了 1,继续向上更新
            cur = parent;
            parent = parent->_parent;
        }
        else if (parent->_bf == 2 || parent->_bf == -2)
        {
            // 变成 ±2:不平衡了,根据形态选择旋转,然后插入结束
            if (parent->_bf == 2 && cur->_bf == 1)
                RotateL(parent);                // 右右型:左单旋
            else if (parent->_bf == -2 && cur->_bf == -1)
                RotateR(parent);                // 左左型:右单旋
            else if (parent->_bf == 2 && cur->_bf == -1)
                RotateRL(parent);               // 右左型:先右旋再左旋
            else // parent->_bf == -2 && cur->_bf == 1
                RotateLR(parent);               // 左右型:先左旋再右旋
            break;                              // 旋转后该子树高度恢复,直接结束
        }
        else
        {
            assert(false);                      // 理论上走不到这里,到这里一定是出了 bug
        }
    }
 
    return true;
}

注意最后那个旋转选择是怎么来的。前面推导过:parent 的平衡因子是 2 说明它右边高,-2 说明左边高;而走到这一步时,cur 还是那个让 parent 失衡的直接孩子,所以 cur->_bf 能告诉我们"高的那一侧里,又偏向哪一边":

  • parent 是 2(右边高)、cur 是 1(cur 的右边又高)→ 右边趵高,往右偏——这叫右右型,用左单旋;
  • parent 是 -2、cur 是 -1 → 左边趵高,往左偏——左左型,用右单旋;
  • parent 是 2、cur 是 -1 → 右边高,但右边孩子自己的左边高——右左型,双旋;
  • parent 是 -2、cur 是 1 → 左边高,但左边孩子自己的右边高——左右型,双旋。

这里有个你可能没意识到的精巧之处:走到 ±2 分支时,cur 一定是 parent 在"高的那一侧"的孩子。为什么?因为我们是"沿上升链"上来的——能走到 parent 平衡因子变 ±2,说明 parent 的平衡因子是从 ±1 继续升上来的,之前必然经历过 cur = parent(把现在的 child 当成了前一步的 parent)然后 parent = parent->_parent(再往上爬一格)。也就是说,cur 就是我们刚才更新的那个"孩子们中间的那个",它当然是"让 parent 变高"的那个孩子。这个事实保证了 cur->_bf 一定携带了"高侧内部偏向"的信息,旋转判断才有依据。

再补一个边界细节:在 ±2 分支里,cur->_bf 不可能是 0。 设想一下,如果 cur->_bf 是 0,那说明 cur 这棵子树刚被"拉平"了——而这种情况下,前面 while 循环会在 cur->_bf == 0 那个分支直接 break,根本到不了 parent 这里来继续往上爬。所以我们必然走到 ±2 分支时,cur 的平衡因子只能是 ±1。这个推论很重要,它保证"两个旋转判断 + 两个双旋判断"这四选一覆盖了全部可能,不可能落空。

还有一个小细节值得点一下:assert(false) 那条是防御性断言。因为前面对平衡因子取值范围的分析(每层只可能变成 0、±1、±2)数学上是完备的,若真走到 else 分支,说明我们的代码在某处写错了。assert 在 debug 模式下会立刻崩给你看,帮你第一时间发现 bug;release 模式下会被移除(不产生运行时开销)。把头文件 <cassert> 带上,它就能用。

旋转:保持有序、找回平衡

在逐个讲四种旋转之前,先把总的原则定下来,它适用于后面所有旋转代码:

第一,绝对不能破坏二叉搜索树的有序性(左小右大)。 这是旋转的"生命线"——怎么拧都可以,但拧完后中序遍历必须仍然有序。一旦破坏,后续的查找、验证全部失灵。

第二,旋转要同时做到两件事:让子树恢复平衡,并降低该子树的高度。 旋转的目的不只是"把形状弄正",而是借"降低子树高度"顺便切断向上更新的链条——子树高度恢复原状,上一层的平衡因子就不用再动了,插入到此收工。

第三,旋转时,孩子指针和 _parent 指针必须一一对应地改。 一个结点有两个方向的关系:它指向孩子(孩子指针),以及孩子指回它(父亲指针)。两步是配套动作,漏改任何一边就会在后续向上更新时拿到错误指针甚至野指针。

这里先给你一份"旋转类型 — 触发形态 — 用到哪个函数"的总对照表,后面再逐个展开:

失衡形态触发判断(parent, cur)旋转函数旋转口诀
LL(左左型)parent=-2、cur=-1RotateR右单旋
RR(右右型)parent=2、cur=1RotateL左单旋
LR(左右型)parent=-2、cur=1RotateLR先左旋再右旋
RL(右左型)parent=2、cur=-1RotateRL先右旋再左旋

记起来有个小技巧:形态名字的两个字母,就是从破景点往下连续偏了两次的方向。"LL"就是连续两次偏左,"RR"连续两次偏右,"LR"先左后右,"RL"先右后左。 而旋转方向的两个字母,恰好是形态字母的逆序——LL 用 RR 动作?不对,这里有个容易绕晕的点,单独拎出来说清楚:

双旋的函数名我设为 RotateLR,它对应"先 RotateL 再 RotateR"。但**"LR"形态用的是"先左旋再右旋"**,两个"LR"一字不差,很容易让人误以为形态和动作正好同名。真相是:形态"LR"(左重但左孩子右偏)要先对"left"执行一次左单旋(把 LR 的"R"先捋直),再对 parent 做一次右单旋(解决整体左重)。所以动作序列是「L 然后 R」,和形态字面"LR"碰巧同序。而形态"RL"则是「先 R 单旋再 L 单旋」,同样跟形态字面同序。换句话说:左右双旋(形态 LR)的动作就是"先左后右";右左双旋(形态 RL)的动作就是"先右后左"。 之所以先动的那个恰好是"第二字母",是因为我们得先处理内层孩子那段的偏斜,再处理外层整体——这个"先内后外"的顺序后面会再解释一遍。

右单旋:解决"左边太高"

四种旋转里,单旋是最朴素、也是双旋的基础。先看右单旋(RotateR),它解决的是"某棵子树左边太高"的问题。

看这样一个场景:以 10 为根的一棵子树,它的左子树是 5,5 的左右分别还有左右两棵抽象子树。为了讲清楚,我们把 5 和 10 下面的"空位"用 a、b、c 三棵高为 h 的子树来表示(h 可以等于 0,即该位置是空树),并且 a、b、c 各自都已经是符合 AVL 标准的正常子树。注意这句"a/b/c 是我们抽象出来的",非常重要——它把所有右单旋的具体形态都概括进来了,不管底下实际长什么样,抽象之后逻辑都一样。

现在,我们在 a 子树(也就是 10 的左边偏左,5 的左边)插入一个新结点,让 a 的高度从 h 变成 h+1。这个新结点的插入,会让平衡因子的更新压力一路向上传导,最终导致 10 的平衡因子从 -1 变成 -2——10 这个根的左右高度差超过 1,违反平衡规则,必须处理。此时 10 的根"左边太高的"。

怎么旋?先记住右单旋的三个关键动作,它们每一步都在讲"怎么在保持搜索树规则的前提下,把左边的高个子扶正":

  1. 因为 5 这一整棵子树的值,都小于 10 而大于等于 b 子树的值(5 < b 子树的值 < 10),所以把 b 从 5 的右孩子,过继成 10 的左孩子;
  2. 把 10 自己,变成 5 的右孩子;
  3. 让 5 成为这棵子树新的根。

为什么要这么做?抓住一个准则——旋转绝不能破坏二叉搜索树的有序性。b 子树里的每个值都满足"5 < b < 10",所以无论是把它放到 10 的左子树(要求比 10 小),还是从 5 的右孩子位置上拿下来(要求比 5 大),都完全合法。旋转把 5 扶上根的位置后,这棵子树的高度从 h+3 恢复到插入前的 h+2,同时也变平衡了——既保持有序,又把高度降了一档,两个目标同时达成。

这里把"为什么 b 必须过继给 10、而不能让它跟着 5 一起上来"掰开讲:想象旋转后 5 当了新根,那 5 的右子树位置需要一个"比 5 大、又比 10 小"的子树——b 正好符合(因为 b 原本就在 5 和 10 之间)。而 10 当了 5 的右孩子后,10 的左子树位置需要一个"比 10 小"的子树——b 也符合。同一个 b,在两个位置上都不违反有序性,于是把它平移过去,两边都成立。这就好比把中间那块"夹心"重新归位:既能当 5 的右邻居,也能当 10 的左邻居。旋转的本质,就是看清这层"夹心可归位",把它平移到平衡需要的位置。

如果 10 本来只是整棵大树里的一个局部子树,那么旋转后它的高度恢复原样,上一层不再受影响,插入到这里就圆满结束。

// 右单旋:parent 的左边太高时,以 parent 为支点向右转,把左孩子 subL 提起来
void RotateR(Node* parent)
{
    Node* subL = parent->_left;      // subL 是 parent 的左孩子,旋转后它要当新的根
    Node* subLR = subL->_right;      // subLR 是 subL 的右孩子,旋转后要"过继"给 parent
 
    // 动作 1:把 subLR 挂到 parent 的左孩子位置
    parent->_left = subLR;
    if (subLR)                       // 如果 subLR 存在(h 可能等于 0,此时它为空)
        subLR->_parent = parent;     //   别忘记修正 subLR 的父亲指针
 
    // 动作 2:先把 parent 的老父亲记下来,待会要把新根接上去用
    Node* parentParent = parent->_parent;
 
    // 动作 3:让 parent 成为 subL 的右孩子,subL 顶上当根
    subL->_right = parent;
    parent->_parent = subL;
 
    // 动作 4:把 subL 与上层正确链接
    if (parentParent == nullptr)
    {
        // parent 原本就是整棵树的根:新的根就是 subL
        _root = subL;
        subL->_parent = nullptr;
    }
    else
    {
        // parent 原本只是某棵子树的根:把 subL 接到 parent 原来的位置
        if (parent == parentParent->_left)
            parentParent->_left = subL;
        else
            parentParent->_right = subL;
        subL->_parent = parentParent;
    }
 
    // 动作 5:旋转后 parent 与 subL 两棵子树高度相等,平衡因子都归零
    parent->_bf = subL->_bf = 0;
}

这段代码里藏着一个特别容易翻车的点,你务必记住:旋转时除了要改孩子指针,还必须同步修正 _parent 指针。 因为我们的结点带了父亲指针,四根链子(孩子的父亲、父亲的孩子)每一根都要对上,漏改任何一根,后面向上更新平衡因子时就会拿到错误甚至野指针。很多初学 AVL 的人插入正确、查找正确,可一到验证平衡就崩溃,十有八九是某个 _parent 没更新导致的。

我把右单旋里全部需要维护的六条链一条条列出来,你对照代码抽查,看看是不是每条都处理到了:

  1. parent->_left → subLR(孩子指针,动作 1)
  2. subLR->_parent → parent(前提 subLR 非空,父亲指针,动作 1)
  3. subL->_right → parent(孩子指针,动作 3)
  4. parent->_parent → subL(父亲指针,动作 3)
  5. parentParent 的对应孩子指针指向 subL(动作 4 上层的孩子指针,需要区分左右)
  6. subL->_parent → parentParent(父亲指针,动作 4)

注意一个经常被问到的细节:动作 2 里 parentParent = parent->_parent 为什么必须提前保存?因为动作 3 那行 parent->_parent = subL 已经把 parent 的父亲指针覆盖掉了。如果在动作 3 之后再读 parent->_parent,拿到的就不再是"上层原来的父亲"而是 subL 了,上层断链。所以先把老父亲快照下来,再动手改,这是所有写过旋转的人都知道的"防覆盖"顺序。左右单旋里都藏着这个套路。

左单旋:解决"右边太高"

左单旋(RotateL)是右单旋的镜像,逻辑完全对称,只是方向反过来。它解决的是"右边太高"的问题。

沿用同样的抽象。以 10 为根的一棵子树,右子树是 15,15 自身也带着左右两棵抽象子树,a/b/c 三棵子树高都为 h,且各自平衡。此时往 a 子树(10 的右孩子方向、偏右的一侧)插入新结点,a 的高度从 h 涨到 h+1,压力一路传导,10 的平衡因子从 1 变成 2——右边太高,得往左转。

三个关键动作变成:因为 10 < b 子树的值 < 15,所以把 b 从 15 的左孩子位置上拿下来,过继成 10 的右孩子;把 10 变成 15 的左孩子;让 15 成为新的根。这样既保持"左小右大"的有序性,又把子树高度降了一档,两边恢复平衡。

注意,左右单旋虽然写法一字不差地对称,但方向千万别写反:右单旋动的是 parent->_left(拿左孩子上来当新根),左单旋动的是 parent->_right(拿右孩子上来当新根)。两者镜像对应,特别容易复制粘贴后人手一改就错位。每次写之前先问自己一句:"我现在是解决哪边太高?"——左边高就动左孩子(向右旋),右边高就动右孩子(向左旋)。方向记住了,代码就顺了。

// 左单旋:parent 的右边太高时,以 parent 为支点向左转,把右孩子 subR 提起来
void RotateL(Node* parent)
{
    Node* subR = parent->_right;     // subR 是 parent 的右孩子,旋转后要当新的根
    Node* subRL = subR->_left;       // subRL 是 subR 的左孩子,旋转后要"过继"给 parent
 
    // 动作 1:把 subRL 挂到 parent 的右孩子位置
    parent->_right = subRL;
    if (subRL)                       // 若 subRL 存在,修正它的父亲指针
        subRL->_parent = parent;
 
    // 动作 2:记录 parent 的老父亲
    Node* parentParent = parent->_parent;
 
    // 动作 3:让 parent 成为 subR 的左孩子,subR 顶上当根
    subR->_left = parent;
    parent->_parent = subR;
 
    // 动作 4:把 subR 与上层正确链接
    if (parentParent == nullptr)
    {
        _root = subR;                // parent 原本是整棵树的根
        subR->_parent = nullptr;
    }
    else
    {
        if (parent == parentParent->_left)
            parentParent->_left = subR;
        else
            parentParent->_right = subR;
        subR->_parent = parentParent;
    }
 
    // 动作 5:旋转后 parent 与 subR 子树高度相等,平衡因子归零
    parent->_bf = subR->_bf = 0;
}

左单旋的六条链和右单旋一一对应,只是把 left↔right 和 subLR↔subRL 互换。多的不赘述,你拿右单旋的清单对着理一遍即可。

左右双旋:先左旋再右旋

搞定了两种单旋,双旋就好懂了——双旋 = 两次单旋。这句话请先刻在心里。它不只是"两个函数连起来调用"那么简单,更是理解"为什么这么转"的钥匙:双旋本质上就是先化解内部偏斜,再解决外部失衡的两次单旋接力。

什么时候需要双旋?关键在于:右单旋只能解决"纯粹的左边高",左单旋只能解决"纯粹的右边高"。 如果左边虽然高,但高出来的新结点不是插在"纯左侧",而是插在了某个中间地带,单旋就旋不动了,旋完还是歪的。

还是用 10、5 那棵树举例。这次新结点不插在 a 子树(5 的纯左边),而是插在 b 子树(也就是 10 的左孩子 5 的右子树)里。b 的高度从 h 变成 h+1。这时候对 10 来说,怎么看都是"左边变高了",于是我们天真地先试右单旋——结果发现旋完之后树还是不平衡。为什么?

因为形态变了:对于 10 来说它确实是左边高,但对于 5 来说,它是右边高! 一个"根觉得左高、左孩子却觉得右高"的矛盾形态,靠一次"单纯地往右扭"是拧不过来的。换句话说,这棵子树不是"纯粹的左高",而是"左里带右"的复合扭曲,需要用两次旋转才能分离这份扭曲:

  1. 先以 5 为(旋转点其实是被旋的支点,下面写清楚)执行一次左单旋,把"5 右边高"这个局部问题先理顺;
  2. 再以 10 为右单旋的支点执行一次右单旋,把"10 左边高"的整体问题解决。

直观上你可以理解为:先做一次小范围的"反向单旋",把复合扭曲里偏着的那截先拉正,让整棵树退化成"只剩纯粹左高"的简单形态,再用一次标准的右单旋一锤定音。

为什么"先内后外"的顺序是必须的?用一句话收拢:只有先把内层的偏斜捋直,外层才会退化成单旋能解决的"纯偏"形态。 如果我们反过来,先对外层做右单旋,会把什么后果?原本内层(5 与 8)的偏斜会被放大成更复杂的形态,右单旋的平移逻辑基于"左孩子是纯左偏"的假设,一旦这个假设不成立,旋转后有序性可能崩溃。所以顺序是死的:解决 LR 先动内层 5 的左单旋,再动外层 10 的右单旋。

下面把 b 子树的细节展开看清楚。因为我们要对 5 做左单旋,而左单旋要动 5 的右孩子,所以把 b 子树进一步展开成:一个结点 8,它带两棵高为 h-1 的子树 e 和 f(当 h 很大时,e 和 f 都不为空)。新结点插在 b 子树的不同位置,会导致旋转后平衡因子的更新细节不同,所以按 8 的平衡因子分三种场景:

  • 场景 1(h ≥ 1,新结点插在 e 子树):e 从高 h-1 变高 h,压力沿 8 → 5 → 10 传导,触发旋转。此时 8 的平衡因子是 -1。旋转后:8 和 5 的平衡因子变成 0,10 的平衡因子变成 1。
  • 场景 2(h ≥ 1,新结点插在 f 子树):f 从高 h-1 变高 h,同样触发旋转。此时 8 的平衡因子是 1。旋转后:8 和 10 的平衡因子变成 0,5 的平衡因子变成 -1。
  • 场景 3(h == 0):此时 a、b、c 全是空树,b 自己就是一个新增结点(也就是 8 就是那个新点)。压力沿 5 → 10 传导,触发旋转。此时 8 的平衡因子是 0。旋转后:8、10、5 的平衡因子全都是 0。

这三个场景为什么要分这么细?因为旋转把三个关键结点的位置重新洗了牌,它们各自新的平衡因子取决于"新增结点到底落在哪个角落",而"落在哪个角落"正好编码在枢纽(8)原有的平衡因子里。所以我们必须根据 bf 的取值,分别给出三种截然不同的恢复结果。这正是双旋比单旋"麻烦"的地方——单旋只要无脑把两个端点置 0,双旋却要按三叉重建。

代码实现的关键在于——在旋转之前,先把 subLR(也就是图中的 8)原始的平衡因子存下来,旋转完再根据它恢复正确的平衡因子。为什么要先存?因为两次单旋内部都会把相关结点的平衡因子重新算一遍(单旋会把两个端点置 0),旋转结束后原来的 bf 就丢了,我们必须靠旋转前记下的那个值,才能重建出三种场景各自的正确结果。

// 左右双旋:解决"左中带右"的复合扭曲,先对 left 左旋,再对 parent 右旋
void RotateLR(Node* parent)
{
    Node* subL = parent->_left;     // 左孩子(图中的 5)
    Node* subLR = subL->_right;     // 左孩子的右孩子(图中的 8),这是分叉枢纽
    int bf = subLR->_bf;            // 先记录枢纽原有的平衡因子,旋转结束后要靠它恢复
 
    RotateL(parent->_left);         // 第一步:以 5 为支点做左单旋,先把局部理顺
    RotateR(parent);                // 第二步:以 10 为支点做右单旋,把整体旋平
 
    // 第三步:按枢纽原来的平衡因子,恢复三个关键结点的平衡因子
    if (bf == 0)                    // 场景 3:h==0,新点就是枢纽自己
    {
        subL->_bf = 0;
        subLR->_bf = 0;
        parent->_bf = 0;
    }
    else if (bf == -1)              // 场景 1:新点插在枢纽的左子树(e)
    {
        subL->_bf = 0;
        subLR->_bf = 0;
        parent->_bf = 1;
    }
    else if (bf == 1)               // 场景 2:新点插在枢纽的右子树(f)
    {
        subL->_bf = -1;
        subLR->_bf = 0;
        parent->_bf = 0;
    }
    else
    {
        assert(false);              // bf 只能是 -1/0/1,走到这必是 bug
    }
}

有人会问:RotateL(parent->_left) 这个参数传的是"左子树根"而不是 parent 本身,那第一步旋完,parent->_left 还是指向正确的新根吗?答案是肯定的。回忆左单旋:当它以 subL(5)为根执行时,因为 5 右边高、它的右孩子 8 被提上来当新根,所以旋完后 5 变成 8 的左孩子——parent->_left 这把"指向左子树根的指针"始终指向这棵子树的新根(原来是 5,旋完是 8)。这正是"RotateX 只依赖传入根的人,上层指针由物理链接保证跟随"的奥妙:我们传的是子树的"入口指针的当前位置",旋转函数内部会负责把它的最顶层指针更新到位。这一步想通了,你对"为什么不用改 parent->_left"的理解就到位了。

三个阶段、四个旋转的"先存、后转、再恢复"套路是统一的。你只要抓住:双旋 = 先保存枢纽 bf → 两次单旋 → 按 bf 三分支重建平衡因子,就抓住了双旋的半壁江山。

右左双旋:先右旋再左旋

右左双旋(RotateRL)和左右双旋完全对称,只是把"左"和"右"对调:它解决的是"右里带左"的扭曲——根觉得自己右边高,但右孩子却觉得自己左边高。此时右单旋救不了,得先对右侧做一次右单旋,再对根做一次左单旋。

道理和上一节一模一样,我直接说结论。设 parent 是 10,右孩子是 15,15 又带左右两棵子树,我们把它展开成结点 12,12 下面挂着高为 h-1 的 e(左)、f(右)两棵子树。新结点插的位置不同,按 12 的平衡因子分三场景:

  • 场景 1(h ≥ 1,新点插在 e 子树):12 的平衡因子是 -1,旋转后 10 和 12 的平衡因子为 0,15 的平衡因子为 1。
  • 场景 2(h ≥ 1,新点插在 f 子树):12 的平衡因子是 1,旋转后 15 和 12 的平衡因子为 0,10 的平衡因子为 -1。
  • 场景 3(h == 0):12 的平衡因子是 0,旋转后 10、12、15 的平衡因子全为 0。

对照代码你会发现,右左双旋的平衡因子恢复逻辑,正好和左右双旋"镜像"——左右双旋里 parent 看的是 _bf == -1 时 parent 置 1、_bf == 1 时 subL 置 -1;右左双旋里则是 _bf == -1 时 subR 置 1、_bf == 1 时 parent 置 -1。方向正好反着。写反是这里的经典错误,对照着记不容易乱。

// 右左双旋:解决"右中带左"的复合扭曲,先对 right 右旋,再对 parent 左旋
void RotateRL(Node* parent)
{
    Node* subR = parent->_right;    // 右孩子(图中的 15)
    Node* subRL = subR->_left;      // 右孩子的左孩子(图中的 12),分叉枢纽
    int bf = subRL->_bf;            // 同样要先记录枢纽原始的平衡因子
 
    RotateR(parent->_right);        // 第一步:以 15 为支点做右单旋
    RotateL(parent);                // 第二步:以 10 为支点做左单旋
 
    // 第三步:按枢纽原来的平衡因子恢复
    if (bf == 0)                    // 场景 3:h==0
    {
        subR->_bf = 0;
        subRL->_bf = 0;
        parent->_bf = 0;
    }
    else if (bf == 1)               // 场景 2:新点插在枢纽的右子树
    {
        subR->_bf = 0;
        subRL->_bf = 0;
        parent->_bf = -1;
    }
    else if (bf == -1)              // 场景 1:新点插在枢纽的左子树
    {
        subR->_bf = 1;
        subRL->_bf = 0;
        parent->_bf = 0;
    }
    else
    {
        assert(false);
    }
}

双旋场景的实操验证思路

理论知识说得再漂亮,不如亲手构造一次。双旋最容易出错的地方是三个场景的 bf 恢复值写反。给你一个自查方法:用一段固定序列让程序走到双旋,然后在 _IsBalanceTree 里输出每个结点的真实高度差,和 _bf 比对,一旦对不上就能精确定位是哪个场景恢复错了。

其实很多人在本教程最后那段随机压力测试跑过、IsBalanceTree() 返回 true 后,就不再关心双旋是否正确被覆盖了。这里要提醒你:随机大数据压测,不一定每条分支都触发到。 它翻车率极低,但概率不为零。严谨的测法是构造出能精确命中 LL/RR/LR/RL 各一次的最小用例(比如后面 TestAVLTree1 那组 {4,2,6,1,3,5,15,7,16,14} 就是特意兜住双旋场景的),挨个验证,才算覆盖完整。理解"测试要覆盖到每一个分支"这条,比记某一个旋转的答案更要紧。

旋转之后:平衡因子怎样更新

上面四段代码里,其实已经把"旋转后平衡因子怎么更新"写得明明白白了,但这是个非常容易记混的点,值得单独拎出来系统地捋一遍,讲透。

先说两条总规律:

第一,单旋之后,只有两个结点的平衡因子需要改,而且直接置 0。 右单旋里是 parent->_bf = subL->_bf = 0,左单旋里是 parent->_bf = subR->_bf = 0。为什么?想想旋转后的形态:subL(或 subR)当了新根,它原来的孩子和 parent 现在都成了它的左右两边,而这两边经过旋转恰好高度一样,所以新根和 parent 的平衡因子都归零。其它结点要么位置完全没变、高度没变所以 bf 不用动,要么它的 bf 在这次旋转前本来就不该有变化。所以单旋只需动两个点。

第二,双旋之后,要按"枢纽原来的平衡因子"分三支恢复,动三个结点。 这里的枢纽指的就是那个分叉点——左右双旋里的 subLR、右左双旋里的 subRL。因为两次单旋加起来会把三个关键结点(subL/subLR/parent 或 subR/subRL/parent)的平衡因子全部搅乱,我们没法直接算,只能提前把它原始的 bf 存下来,再按三种摆放情况手动重建。三种情况正好对应 h≥1 插小侧、h≥1 插大侧、h==0 三种真实形态。

这张表把四个旋转后的平衡因子更新收在一起,对照机型记忆:

旋转类型触发形态更新后的平衡因子
右单旋 RotateR左边趄高(左左型)parent=0,subL=0
左单旋 RotateL右边趄高(右右型)parent=0,subR=0
左右双旋 RotateLR左高但左孩子右高(左右型)枢纽 bf=-1:parent=1;bf=1:subL=-1;bf=0:全 0
右左双旋 RotateRL右高但右孩子左高(右左型)枢纽 bf=-1:subR=1;bf=1:parent=-1;bf=0:全 0

这里有个总结性规律你可以在心里默记三遍:单旋,端点归零;双旋,枢纽决定三分支。 双旋里非枢纽的失衡端点,永远看枢纽的符号——枢纽偏哪边,就由那个方向的端点补上反号。

至于"为什么单旋时其它结点的 bf 不需要动",值得把话说到底。旋转只改变了 subL/subR 与 parent 这两三个结点的子孙关系,其它结点的子树内容一个字都没变——a、b、c 里每一个内部结点,它的孩子是谁、位置在哪、大小关系,全都保持原样。子树没变,高度没变,平衡因子自然不该动。所以旋转代码里只显式该 parent 和 subL(或 subR)这两个点,剩下的不动,不是"懒得管",而是"根本无需动"。这一点想通了,你写旋转代码时会特别安心,知道自己没有漏改什么。

另一种实现观:递归返回高度来更新平衡因子

到这里你可能会想:每次插入都要维护一个 _parent 指针,还要精确地改这改那,好麻烦。确实有另一派实现——不存 _parent,也不存 _bf,纯靠"递归返回子树高度"来当场推衍平衡状态。它更贴合"旋转"这个词的直观,代码也更难写错方向,因为每一步都是"拿到子树高度,算出我该不该转,再把新高度返回给上层"。大致骨架长这样(示意):

// 伪代码式的递归思路,帮助你对拍两种实现的分工
int InsertRec(Node*& root, const pair<K, V>& kv)
{
    // 走到空位:插入,返回 1 表示"这层长高了一层"
    if (root == nullptr) { root = new Node(kv); return 1; }
 
    int delta = 0;
    if (kv.first < root->_kv.first)
        delta = InsertRec(root->_left, kv);       // 深左,拿回下层长高量
    else if (kv.first > root->_kv.first)
        delta = InsertRec(root->_right, kv);      // 深右
    else
        return 0;                                  // 键重复,没变化
 
    // 用返回的高度增量更新当前层平衡,必要时旋转
    // 关键:每次递归回来,我们其实"重新测了高度差",
    // 而不是依赖 -1/0/1 的自增推断,所以不存在 _bf 残留问题。
    // ...(旋转逻辑在此展开,原理与上文完全相同)
    return /* 本层是否长高:平衡/旋转后是否把子树高度压回原值 */;
}

等等,这看起来好像更简洁,为什么主流的、课件用的反而是带 _parent 和 _bf 的迭代版?因为两者是空间与表达力之争:

  • 递归+返回高度版:不存 _parent、不存 _bf,天然不需要维护那么多指针,也不会有"旋转后 bf 残留"的坑(每次都是真算)。代价是每次要递归下层并实际返回高度,代码结构和"函数栈"绑定,数据一多可能栈深风险;而且没有 _bf 字段,就无法像本课那样"一个 int 定量精确地描述失衡角度",只能靠当场重测。
  • _parent + _bf 迭代版:在指针里预存了"回头路",向上更新时 O(1) 直达,旋转内聚成四个独立函数,语义清晰、栈深度风险低。代价是 _parent 四/六条链的同步维护容易出错,且 _bf 需要在旋转后显式恢复。

两种实现殊途同归,旋转的几何原理完全一样。我给初学者的建议是:先用课件这套 _parent + _bf 迭代版把原理吃透,因为它把"平衡因子从哪儿来、更新到哪儿停"暴露得最显式,最利于理解;之后如果感兴趣,再回头用递归版默写一遍,你会对"为什么节点要带 bf"有更深的体感。两种写法不是对错关系,而是工程取舍。

AVL 的查找:和二叉搜索树一模一样

好消息是,查找这一块 AVL 树完全不需要"自平衡"——它复刻的就是普通二叉搜索树的逻辑,从根一路往下走就行。之所以不用改,是因为 AVL 树永远保持着二叉搜索树的有序性质,旋转只是挪了挪子树的位置、改了改父子关系,从来没破坏过"左小右大"的顺序,所以查找算法可以原封不动地搬过来,复杂度也因为树高被压住而稳定在 O(log n)。

// 查找:给定一个键,返回对应结点的指针;找不到返回空指针
Node* Find(const K& key)
{
    Node* cur = _root;
    while (cur)
    {
        if (cur->_kv.first < key)
            cur = cur->_right;       // 当前键比目标小,往右找
        else if (cur->_kv.first > key)
            cur = cur->_left;        // 当前键比目标大,往左找
        else
            return cur;              // 找到了
    }
    return nullptr;                  // 走到空还没找到
}

这里想给你提炼一句关于"查找为何能复用 BST 代码"的深层逻辑:查找针对的是"有序性",而旋转动作的目标之一恰恰是"不得破坏有序性"。 换句话说,AVL 的自平衡机制从设计上就被要求"旋转不改变中序遍历结果",因此查找器可以永远只依赖"有序"这一条不变量,而完全无视树长什么形状。这是一条非常漂亮的解耦——查找器关心"值在不在该走的路径上",旋转器关心"形状还在不在容忍范围内",两者通过"有序性不变量"解耦,各自独立正确。 理解这个,你会更清楚为什么 AVL 的查找能放心复用 BST 代码。

再补一个工程细节:因为 Find 不改动 _root,这类函数天然可以声明为 const 版本(Node* Find(const K&) const),从而允许 const 对象调用它。这属于 C++ 用 const 表达"只读操作"的良好习惯,你写真实类时值得补上。

验证一棵 AVL:是 BST 吗?平衡吗?

实现完一颗"自认为 AVL"的树,怎么确认它真的合格?尤其是我这样手搓的版本,光靠眼睛看是不靠谱的。最稳妥的办法是用程序反向验证:

  1. 首先,它必须是一棵正确的二叉搜索树——中序遍历的结果必须是有序的(这能同时证明"查找路径"没被旋转破坏)。
  2. 其次,它必须是平衡的——对每个结点,递归算出它左右子树的高度差,这个差值的绝对值必须不超过 1,而且必须等于结点上记录的平衡因子 _bf。如果算出来的真实高度差和存着的 _bf 对不上,说明我们的平衡因子更新逻辑写错了,这棵树只是"表面上平衡",某些角落早就不对劲了。

第二条的反向校验可以说是整个实现正确性的"试金石"。来看两个辅助函数,递归在这段代码里出镜率极高:

// 计算某棵子树的高度:空树为 0,否则取左右子树较高的那个再加 1
int _Height(Node* root)
{
    if (root == nullptr)
        return 0;
    int leftHeight = _Height(root->_left);    // 递归求左子树高度
    int rightHeight = _Height(root->_right);  // 递归求右子树高度
    // 整棵树的高度 = 较高的一侧高度 + 根这一层
    return leftHeight > rightHeight ? leftHeight + 1 : rightHeight + 1;
}
 
// 判断一棵子树是否平衡:空树确实是 AVL 树
bool _IsBalanceTree(Node* root)
{
    if (nullptr == root)
        return true;
 
    // 计算根结点左右子树的高度差
    int leftHeight = _Height(root->_left);
    int rightHeight = _Height(root->_right);
    int diff = rightHeight - leftHeight;      // 与定义保持一致:右高减左高
 
    // 检查 1:高度差绝对值超过 1,一定不是 AVL 树
    if (abs(diff) >= 2)
    {
        cout << root->_kv.first << " 高度差异常" << endl;
        return false;
    }
 
    // 检查 2:结点存着的平衡因子与真实高度差对不上,说明更新有 bug
    if (root->_bf != diff)
    {
        cout << root->_kv.first << " 平衡因子异常" << endl;
        return false;
    }
 
    // 左右子树都必须平衡,整棵树才算平衡(递归判断)
    return _IsBalanceTree(root->_left) && _IsBalanceTree(root->_right);
}

这里深挖一个很容易被忽略的点:判断平衡,必须同时递归检查左右子树,而不是只看根结点的高度差。 一个结点自身的高度差合格,不代表它脚下的子树就平衡——完全可能"上面挺正,底下歪着"。而平衡因子又是沿路径逐层更新出来的,只要有一次更新算错,某个深处的结点平衡因子就会偏离真实值。所以 _IsBalanceTree 必须递归到每一个结点,从树叶一路验证回来,整棵树全绿才算过。

_Height 里那句话值得单独顿一顿:子树的高度只看更高的一侧,不看矮的一侧。 这是高度定义的天经地义——"最高层在哪里,树就有多高",矮侧再矮也不拖低总高度。这套定义和平衡因子"右减左"一起构成了整个验证体系的基石:diff = rightHeight - leftHeight 算出来的就是当前根的真实平衡因子,"它该等于结点存着的 _bf"这条等式,就是验证的刻度尺。只要这把尺子对不上了,就是更新逻辑出错的直接信号。 也正因如此,_IsBalanceTree 才敢于把"bf 号对不上"当成"必是 bug"来报警。

顺带说明,_Height 与 _IsBalanceTree 这两个"私有+公有"的配对,是我刻意为之的封装习惯——对外暴露无参的 Height()、IsBalanceTree(),对内用带 Node* 的私有函数做递归,把递归细节藏在类内部,调用方只管调接口就好了。下面把这两个公开的入口,连同 Size()(统计结点总数,课件测试里用到了它)、中序遍历,以及一片完整可运行的测试代码一并补上。

在给完整程序之前,先说清楚 Size() 的递归逻辑:它跟 _Height 的逻辑一正一反——_Height 取"左右更高 +1",_Size 则是"左右相加 +1"(因为每个结点都要计入,一个都不能少)。一个是"往高处递归",一个是"全量累加",别混。

// 对外公开接口:无参版本,内部去调私有递归版
int Height() { return _Height(_root); }
size_t Size() { return _Size(_root); }
bool IsBalanceTree() { return _IsBalanceTree(_root); }
 
// 中序遍历:递归版,左-根-右,正好输出有序序列
void InOrder() { _InOrder(_root); cout << endl; }
void _InOrder(Node* root)
{
    if (root == nullptr) return;
    _InOrder(root->_left);                 // 先左
    cout << root->_kv.first << " ";        // 再根
    _InOrder(root->_right);                // 后右
}
 
// 计算结点总数
size_t _Size(Node* root)
{
    if (root == nullptr) return 0;
    return 1 + _Size(root->_left) + _Size(root->_right);
}
 
// 测试 1:一个自带多种旋转场景的固定用例
void TestAVLTree1()
{
    AVLTree<int, int> t;
    // 这组数据刻意兜住左右双旋、右左双旋等复杂场景
    int a[] = { 4, 2, 6, 1, 3, 5, 15, 7, 16, 14 };
    for (auto e : a)
        t.Insert({ e, e });
 
    t.InOrder();                           // 应输出有序的 1 2 3 4 5 6 7 14 15 16
    cout << (t.IsBalanceTree() ? "是平衡树" : "不是平衡树") << endl;
    cout << "高度: " << t.Height() << endl;
}
 
// 测试 2:插入大量随机数,压力测试平衡性并观察效率
void TestAVLTree2()
{
    const int N = 100000;                  // 插十万个随机数
    srand((unsigned)time(nullptr));
    AVLTree<int, int> t;
    for (int i = 0; i < N; ++i)
        t.Insert({ rand() + i, rand() + i });
 
    // 随机数的键可能会有重复,导致实际插入不足 N 个,这很正常
    cout << "十万级随机插入后是否平衡: "
         << (t.IsBalanceTree() ? "平衡" : "不平衡") << endl;
    cout << "高度: " << t.Height() << endl;
    cout << "结点总数: " << t.Size() << endl;
}

下面给你一份完整、可独立编译的整棵 AVL 程序——它把我们前面所有散布的函数(Insert、四个旋转、Find、InOrder、Height、Size、IsBalanceTree 及两个测试)收进一个文件里,你可以直接复制到编译器里跑起来。这份代码就是上面所有片段"拼起来"的成品:

#include <iostream>
#include <utility>   // 提供 std::pair
#include <cstdlib>   // 提供 abs、rand、srand
#include <ctime>     // 提供 time
#include <cassert>   // 提供 assert
using namespace std;
 
template<class K, class V>
struct AVLTreeNode
{
    pair<K, V> _kv;
    AVLTreeNode<K, V>* _left;
    AVLTreeNode<K, V>* _right;
    AVLTreeNode<K, V>* _parent;
    int _bf;
 
    AVLTreeNode(const pair<K, V>& kv)
        : _kv(kv), _left(nullptr), _right(nullptr), _parent(nullptr), _bf(0)
    {}
};
 
template<class K, class V>
class AVLTree
{
    typedef AVLTreeNode<K, V> Node;
public:
    bool Insert(const pair<K, V>& kv)
    {
        if (_root == nullptr) { _root = new Node(kv); return true; }
 
        Node* parent = nullptr;
        Node* cur = _root;
        while (cur)
        {
            if (cur->_kv.first < kv.first) { parent = cur; cur = cur->_right; }
            else if (cur->_kv.first > kv.first) { parent = cur; cur = cur->_left; }
            else return false;
        }
        cur = new Node(kv);
        if (parent->_kv.first < kv.first) parent->_right = cur;
        else parent->_left = cur;
        cur->_parent = parent;
 
        while (parent)
        {
            if (cur == parent->_left) parent->_bf--;
            else parent->_bf++;
 
            if (parent->_bf == 0)
                break;                              // 拉平,子树高度没变,结束
            else if (parent->_bf == 1 || parent->_bf == -1)
            { cur = parent; parent = parent->_parent; }   // 继续向上
            else if (parent->_bf == 2 || parent->_bf == -2)  // 失衡,旋转
            {
                if (parent->_bf == 2 && cur->_bf == 1)      RotateL(parent);
                else if (parent->_bf == -2 && cur->_bf == -1) RotateR(parent);
                else if (parent->_bf == 2 && cur->_bf == -1) RotateRL(parent);
                else                                        RotateLR(parent);
                break;
            }
            else
                assert(false);
        }
        return true;
    }
 
    Node* Find(const K& key)
    {
        Node* cur = _root;
        while (cur)
        {
            if (cur->_kv.first < key) cur = cur->_right;
            else if (cur->_kv.first > key) cur = cur->_left;
            else return cur;
        }
        return nullptr;
    }
 
    int Height() { return _Height(_root); }
    size_t Size() { return _Size(_root); }
    bool IsBalanceTree() { return _IsBalanceTree(_root); }
    void InOrder() { _InOrder(_root); cout << endl; }
 
private:
    void RotateR(Node* parent)
    {
        Node* subL = parent->_left;
        Node* subLR = subL->_right;
 
        parent->_left = subLR;
        if (subLR) subLR->_parent = parent;
 
        Node* parentParent = parent->_parent;
        subL->_right = parent;
        parent->_parent = subL;
 
        if (parentParent == nullptr) { _root = subL; subL->_parent = nullptr; }
        else
        {
            if (parent == parentParent->_left) parentParent->_left = subL;
            else parentParent->_right = subL;
            subL->_parent = parentParent;
        }
        parent->_bf = subL->_bf = 0;
    }
 
    void RotateL(Node* parent)
    {
        Node* subR = parent->_right;
        Node* subRL = subR->_left;
 
        parent->_right = subRL;
        if (subRL) subRL->_parent = parent;
 
        Node* parentParent = parent->_parent;
        subR->_left = parent;
        parent->_parent = subR;
 
        if (parentParent == nullptr) { _root = subR; subR->_parent = nullptr; }
        else
        {
            if (parent == parentParent->_left) parentParent->_left = subR;
            else parentParent->_right = subR;
            subR->_parent = parentParent;
        }
        parent->_bf = subR->_bf = 0;
    }
 
    void RotateLR(Node* parent)
    {
        Node* subL = parent->_left;
        Node* subLR = subL->_right;
        int bf = subLR->_bf;
 
        RotateL(parent->_left);
        RotateR(parent);
 
        if (bf == 0)            { subL->_bf = 0; subLR->_bf = 0; parent->_bf = 0; }
        else if (bf == -1)      { subL->_bf = 0; subLR->_bf = 0; parent->_bf = 1; }
        else if (bf == 1)       { subL->_bf = -1; subLR->_bf = 0; parent->_bf = 0; }
        else                    assert(false);
    }
 
    void RotateRL(Node* parent)
    {
        Node* subR = parent->_right;
        Node* subRL = subR->_left;
        int bf = subRL->_bf;
 
        RotateR(parent->_right);
        RotateL(parent);
 
        if (bf == 0)            { subR->_bf = 0; subRL->_bf = 0; parent->_bf = 0; }
        else if (bf == 1)       { subR->_bf = 0; subRL->_bf = 0; parent->_bf = -1; }
        else if (bf == -1)      { subR->_bf = 1; subRL->_bf = 0; parent->_bf = 0; }
        else                    assert(false);
    }
 
    int _Height(Node* root)
    {
        if (root == nullptr) return 0;
        int l = _Height(root->_left);
        int r = _Height(root->_right);
        return l > r ? l + 1 : r + 1;
    }
 
    size_t _Size(Node* root)
    {
        if (root == nullptr) return 0;
        return 1 + _Size(root->_left) + _Size(root->_right);
    }
 
    bool _IsBalanceTree(Node* root)
    {
        if (nullptr == root) return true;
        int l = _Height(root->_left);
        int r = _Height(root->_right);
        int diff = r - l;
        if (abs(diff) >= 2) { cout << root->_kv.first << " 高度差异常" << endl; return false; }
        if (root->_bf != diff) { cout << root->_kv.first << " 平衡因子异常" << endl; return false; }
        return _IsBalanceTree(root->_left) && _IsBalanceTree(root->_right);
    }
 
    void _InOrder(Node* root)
    {
        if (root == nullptr) return;
        _InOrder(root->_left);
        cout << root->_kv.first << " ";
        _InOrder(root->_right);
    }
 
    Node* _root = nullptr;
};
 
// 测试 1:固定用例,故意覆盖四种旋转场景
void TestAVLTree1()
{
    AVLTree<int, int> t;
    int a[] = { 4, 2, 6, 1, 3, 5, 15, 7, 16, 14 };
    for (auto e : a)
        t.Insert({ e, e });
 
    cout << "中序遍历(应有序): ";
    t.InOrder();
    cout << (t.IsBalanceTree() ? "是平衡树" : "不是平衡树") << endl;
    cout << "高度: " << t.Height() << "   结点数: " << t.Size() << endl;
}
 
// 测试 2:十万级随机插入压力测试
void TestAVLTree2()
{
    const int N = 100000;
    srand((unsigned)time(nullptr));
    AVLTree<int, int> t;
    for (int i = 0; i < N; ++i)
        t.Insert({ rand() + i, rand() + i });
 
    cout << "十万级随机插入后是否平衡: "
         << (t.IsBalanceTree() ? "平衡" : "不平衡") << endl;
    cout << "高度: " << t.Height() << "   结点数: " << t.Size() << endl;
}
 
int main()
{
    TestAVLTree1();
    TestAVLTree2();
    return 0;
}

你可以把整篇文章从"AVL 结点定义"到这里的代码按顺序拼接起来,就是一份完整可编译的 C++ 程序。跑起来你会看到:固定用例的中序遍历严格有序,平衡检测返回"是平衡树",十万元素也稳稳平衡,高度轻轻松松压在对数级别。你的手写 AVL 树,通过了反证测试。

这个测试设计值得你再品一品:它同时验证了 AVL 的三个不变量——① 中序遍历有序(证明旋转没破坏搜索树性质),② 每个结点真实高度差 ≤ 1(证明树真平衡),③ 每个结点存着的 _bf 等于真实高度差(证明我们手写的平衡因子更新逻辑没算错)。三条全绿,这棵树才"配得上"叫 AVL。想挑战自己,可以把第一步的某个 _parent 更新注释掉再跑——验证函数会立刻把平衡因子异常揪出来,这正是"反向验证"存在的意义。

AVL 的复杂度与不足

到此,AVL 树的完整链路——定义、结点、插入、四种旋转、平衡因子更新、查找、验证——就全部串起来了。它把二叉搜索树的复杂度从"看运气的 O(log n) 或 O(n)",稳定焊死在 O(log n)。插入时每遇到失衡,只需一两次旋转(一次单旋或一次双旋),每次旋转都是常数时间操作,加上沿路径的平衡因子更新,整体依旧是 O(log n)。这就是它相比普通 BST 最本质的提升。

把复杂度挂在心上,用一张表把它彻底钉死:

操作普通 BST(最好/最坏)AVL 树
查找 FindO(log n) / O(n)稳定 O(log n)
插入 InsertO(log n) / O(n)O(log n) + 至多一次旋转(O(1))
删除 EraseO(log n) / O(n)O(log n),但可能连锁多次旋转
树高最好 ≈ log₂n,最坏 = n至多 ≈ 1.44·log₂n

"至多次旋转"这种说法要读准确:一次插入引发失衡,触发一次单旋或一次双旋就结束了,因为旋转会恢复该子树原来的高度,从而切断向上的连锁反应。但请注意删除不一样——删除后高度可能会"变矮"而不是"变高",连锁反应更长,这是后面要说的第一点不足。

不过 AVL 树也不是完美的,最后盘点一下它的不足:

第一,删除比插入麻烦多了。 删除一个结点后,它所有祖先的高度都可能变化,平衡因子更新的路径和旋转的种类会比插入更复杂,还可能出现"删除一个点、连锁旋转好几次"的情况。因此很多教科书(比如殷人昆的《数据结构:用面向对象方法与 C++ 语言描述》)会专门用一整章讲 AVL 删除,这一课我们先把插入拿下,删除感兴趣的话可以自己去翻那本书。

让我把"删除为什么比插入难"讲得更具体些,免得你觉得只是"难度略高"。插入时,一个叶子顶多让祖先"长高一层",旋转一次就能把高度压回原值,连锁立即中断。删除却是反方向的攻击——删掉一个结点,某些子树可能变矮,"变矮"同样会向上传导平衡因子的变化,而且可能不止一处需要补旋转;更麻烦的是,删除后可能要从被删结点一路向上做多棵子树的多次旋转,每一轮都未必终结。这就把"删除后如何重新平衡"变成了一个需要仔细分析路径与旋转组合的问题,教科书单开一章绝非小题大做。

第二,AVL 是一种很严苛的平衡——它要求高度差绝对值不超过 1,这保证了极高的查询效率,但代价是插入和删除时触发旋转的频率较高,维护平衡的常数开销不小。如果一棵树"写得多、读得少",AVL 的重平衡成本会显得略贵。

原因的根源在于"阈值太紧":AVL 把高度差压到 1 以内,稍有风吹草动就触发旋转。数据量大、写操作频繁时,旋转次数自然偏多。这是"高查询效率"与"低维护成本"之间的一对天然矛盾——你不可能两头都占满。

也正是因为这个原因,AVL 在生产实践中的"低成本自平衡"接班人出现了:红黑树。红黑树放宽了平衡的定义(用颜色约定代替严格高度差,保证最长路径不超过最短路径的两倍),牺牲一点点查询严格性,换来插入删除时更少的旋转次数,成为 C++ std::map / std::set 底层默认的选择。不过别急着跑——AVL 树是理解"树自平衡"这一整套思想的最佳入门:旋转的原理、平衡因子的更新、双旋拆单旋的思路,全都清晰可辨。把 AVL 吃透,再去看红黑树,你会觉得像是把已经学过的舞步,换成了一种新的舞曲。

最后聊聊 AVL 在真实世界到底用在哪,让这套理论落回地面:凡是"查询频率极高、数据规模大、需要稳定对数级查找"的地方,都是 AVL 的主场。比如:

  • 数据库的索引思想:B 树/B+ 树可看作对"平衡树"在磁盘场景下的扩展,而 AVL 是理解这类"树为了保证查找效率而自我调整"思路的最佳起点。
  • 内存字典/映射的平衡实现:需要在内存里维护"键到值"的映射、且要求查找稳定快的场景,手写 AVL 是完全可行的选择(STL 用红黑树是因为它牺牲了些许查找换来了更低的写成本)。
  • 手写数据结构与刷题/竞赛:在不允许依赖 std::map、又想要稳定 O(log n) 的自定义情景下,AVL 常被用来快速实现一个可自定义比较规则的自平衡键值容器。
  • 学习价值大于即用价值:AVL 是所有平衡树(红黑树、B 树、splay、treap)思想的浓缩样本。理解它,等于拿到了理解整个"自平衡树家族"的通用底牌。

本质上看,AVL 之所以值得学,不是因为它一定是你生产代码里的标配(很多时候你会顺手用红黑树底层的 std::map),而是因为它把"查询效率 = 有序 + 可锁高度"这条原理,用最小的一整套机器掰开了给你看。看懂这台机器,再复杂的平衡树都是它的变奏曲。

最后给你留几个值得动笔推演的问题:试试自己分别构造出会触发左单旋、右单旋、左右双旋、右左双旋的插入序列;然后故意把某个旋转里的 _parent 更新注释掉,看看验证函数能不能帮你抓住这个 bug;最后,用二叉搜索树和 AVL 树分别插入同一批有序数据,比较一下树高——你会直观看到那个从 O(n) 到 O(log n) 的差距,有多么惊人。

如果你贪心一点,还可以做这样几道"进阶题"来检验自己是不是真的吃透了:① 把 _IsBalanceTree 改成同时输出每一个"平衡因子异常或高度差异常"的结点键值,用来定位到底是哪次插入写错了;② 给整棵树补一个正确的析构函数(后序遍历释放所有结点),消灭内存泄漏;③ 在学习红黑树之前,先尝试用 AVL 的视角回答"红黑树为什么要放宽到最长路径不超过最短路径两倍"。能答上来第③问,你对"平衡的代价"就有了真正的体感——不过那已经是下一课的故事了。

参考答案与详解

动笔推演题 1:分别构造会触发四种旋转的插入序列

用最少结点命中每种失衡(这些只是最小触发序列之一,能造出同类失衡形态的任何序列都算对):

失衡形态依次插入触发时的形态调用
LL(左左){10, 5, 1}插入 1 后平衡因子沿 5→10 更新,10 由 -1 变 -2,且 cur(5)._bf = -1RotateR(右单旋)
RR(右右){1, 5, 10}插入 10 后 1 由 1 变 2,且 cur(5)._bf = 1RotateL(左单旋)
LR(左右){8, 4, 6}插入 6(在 4 的右边)后 8 由 -1 变 -2,cur(4)._bf = 1RotateLR(先左旋再右旋)
RL(右左){4, 8, 6}插入 6(在 8 的左边)后 4 由 1 变 2,cur(8)._bf = -1RotateRL(先右旋再左旋)

验证时要留神:触发后中间三层的高度正好相差 1 是正常的——因为前面插入时根的 _bf 已经停在 ±1 并持久保存,这一次再往"高侧"补一个,才从 ±1 变 ±2 触发旋转。这正是迭代版"增量更新"与"每次重算高度"两种实现最本质的区别。

动笔推演题 2:故意注释掉旋转里的 _parent 更新,验证函数能不能抓住 bug?

要分清 _IsBalanceTree 的工作方式:它是沿孩子指针递归算出真实高度差,再与结点存的 _bf 比对——它根本不读 _parent。所以若只删掉某条 _parent 更新而此时 _bf 恰好还算对,单看这一次旋转,验证可能不报异常。但在完整的插入序列里,断掉的父指针会让随后某次"向上更新平衡因子"循着错误甚至野指针走:轻则改错某个祖先的 _bf(于是最后被 _IsBalanceTree 以"平衡因子异常"抓到),重则直接解引用野指针导致段错误崩溃。结论:验证函数是"事后哨兵",能抓住"因 _parent 断裂而最终污染了 _bf"的 bug,但它不能保证第一时间定位到那一根断链——真正的防线仍是每次旋转后逐条核对那六条链(4 根孩子指针 + 2 根父指针)。

动笔推演题 3:用 BST 和 AVL 分别插入同一批有序数据,树高差多少?

对严格升序 1, 2, ..., N 依次插入:朴素 BST 每个新结点都落到最右侧,退化成一条右链,高度 = N(查找最后一个就是 N 步);AVL 每失衡一次就旋转扳平,高度 ≈ 1.44·log₂N。数据越多差距越悬殊——比如 N = 10000 时,BST 高度 10000,AVL 高度只有 15 左右;N = 100 万时,一个是 100 万步、一个只要约 30 步。这就是"退化 vs 自平衡"的天堑。

进阶题 ①:让验证函数输出每个异常结点

把 _IsBalanceTree 里命中 abs(diff) >= 2 或 _bf != diff 的两处 cout 保留,并在打印后 return false 即可(每个异常结点恰好打印一次)。跑固定的 TestAVLTree1 时哪个键值先报出来,就说明从它往下的某次插入/旋转把这一支的平衡给改坏了,配合在 Insert 里临时打点,能快速缩小到具体某次插入。

进阶题 ②:补一个正确的析构函数

AVL 结点全是用 new 分配的,必须后序释放(先孩子后根,否则根一删,孩子的指针就悬空了):

~AVLTree() { _Destroy(_root); }
 
private:
void _Destroy(Node* root)
{
    if (root == nullptr) return;
    _Destroy(root->_left);
    _Destroy(root->_right);
    delete root;
}

别忘了它是三/五法则的践行者:一旦自定义了析构,通常还应考虑拷贝构造与拷贝赋值(如需拷贝语义要做深拷贝,用递归逐一重建结点,逻辑类似 BST 篇的 _Copy)。

进阶题 ③:红黑树为什么把"高度差 ≤ 1"放宽成"最长路径 ≤ 最短路径的两倍"?

AVL 的"高度差绝对值 ≤ 1"是极严苛的平衡:查询确实最快,但为了守住这个阈值,插入删除时旋转触发得非常频繁、修正成本高。而红黑树放弃"严格等高",改用两条颜色规则把最长路径限制在最短路径的 2 倍以内——它依然是 O(log n)(只是系数略大于 AVL 的 1.44),但每次插入/删除只需常数次旋转 + 变色即可恢复平衡,写操作的代价远低于 AVL。一句话权衡:读多写少、追求极致查询用 AVL;写多读少、追求总体吞吐选红黑树——这也是 std::map / std::set 底层选红黑树而不选 AVL 的根本原因。