26. 平衡二叉树:定义与旋转调整

平衡二叉树,英文叫 AVL 树。说实话,我第一次听到这个名字时,还以为是某个汽车型号。后来才知道,这是两位苏联数学家 Adelson-Velsky 和 Landis 的缩写。

那它到底解决了什么问题?

你想想看,普通的二叉搜索树,如果插入的数据刚好是有序的,比如 1、2、3、4、5……那树就退化成了链表。查找效率直接从 O(log n) 掉到 O(n)。这在项目中是绝对不能忍的。

平衡二叉树就是为了解决这个退化问题而生的。

26.1 平衡二叉树的定义

定义其实不复杂,就两条:

  • 它首先是一棵二叉搜索树(左小右大)
  • 每个节点的左右子树高度差绝对值不超过 1

这个高度差,我们叫它「平衡因子」。平衡因子 = 左子树高度 - 右子树高度。取值只能是 -1、0、1。

核心要点:只要有一个节点的平衡因子绝对值大于 1,这棵树就不平衡了,需要调整。

我在项目中遇到过这样一个场景:一个实时交易系统的订单簿,需要频繁插入和查找价格。如果用普通 BST,遇到连续报价时性能直接崩了。后来换成 AVL 树,查询时间稳定在微秒级。嗯,这就是平衡的力量。

26.2 四种不平衡情况

插入节点后,树可能变得不平衡。我习惯把不平衡情况归纳为四种:

类型 描述 失衡节点
LL 插入在左子树的左子树 失衡节点的左孩子太高
RR 插入在右子树的右子树 失衡节点的右孩子太高
LR 插入在左子树的右子树 失衡节点的左孩子的右孩子太高
RL 插入在右子树的左子树 失衡节点的右孩子的左孩子太高

说白了,LL 和 RR 是「一条直线」的不平衡,LR 和 RL 是「折线」的不平衡。直线好办,一次旋转搞定;折线需要两次旋转。

26.3 四种旋转操作

26.3.1 LL 旋转(右旋)

LL 情况:新节点插入在失衡节点的左孩子的左子树上。这时候需要右旋。

右旋怎么操作?我教你一个口诀:「左孩上位,左孩的右孩给原父当左孩」。

// LL 右旋
struct Node* rotateRight(struct Node* y) {
    struct Node* x = y->left;
    struct Node* 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; // 新根
}

26.3.2 RR 旋转(左旋)

RR 情况是 LL 的镜像。新节点插入在失衡节点的右孩子的右子树上。左旋即可。

// RR 左旋
struct Node* rotateLeft(struct Node* x) {
    struct Node* y = x->right;
    struct Node* 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;
}

26.3.3 LR 旋转(先左旋后右旋)

LR 情况稍微麻烦一点。新节点插入在失衡节点的左孩子的右子树上。这时候是一条折线,不能直接右旋。

正确的做法是:先对左孩子做左旋(变成 LL 情况),再对失衡节点做右旋。

// LR 旋转
struct Node* rotateLR(struct Node* z) {
    z->left = rotateLeft(z->left);  // 先左旋左孩子
    return rotateRight(z);           // 再右旋自己
}

我的经验:写 LR 和 RL 旋转时,最容易搞混顺序。我曾经在代码里把左右旋顺序写反了,调试了整整一个下午。后来我养成了一个习惯——在代码注释里写上「先处理孩子,再处理自己」,再也没错过。

26.3.4 RL 旋转(先右旋后左旋)

RL 是 LR 的镜像。新节点插入在失衡节点的右孩子的左子树上。

// RL 旋转
struct Node* rotateRL(struct Node* z) {
    z->right = rotateRight(z->right); // 先右旋右孩子
    return rotateLeft(z);              // 再左旋自己
}

26.4 完整插入流程

把上面的旋转组合起来,就得到了 AVL 树的插入算法:

struct Node* insert(struct Node* node, int key) {
    // 1. 普通 BST 插入
    if (node == NULL)
        return newNode(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; // 重复键不插入

    // 2. 更新高度
    node->height = 1 + max(height(node->left), height(node->right));

    // 3. 计算平衡因子
    int balance = getBalance(node);

    // 4. 判断并旋转
    // LL
    if (balance > 1 && key < node->left->key)
        return rotateRight(node);
    // RR
    if (balance < -1 && key > node->right->key)
        return rotateLeft(node);
    // LR
    if (balance > 1 && key > node->left->key) {
        node->left = rotateLeft(node->left);
        return rotateRight(node);
    }
    // RL
    if (balance < -1 && key < node->right->key) {
        node->right = rotateRight(node->right);
        return rotateLeft(node);
    }

    return node;
}

避坑指南:我曾经在实现 AVL 树时,忘记在旋转后更新高度。结果树虽然旋转了,但平衡因子计算错误,导致后续插入又出问题。记住:旋转后一定要更新高度,而且要先更新子树的高度,再更新新根的高度。

26.5 知识体系总览

下面这张图总结了平衡二叉树的核心知识结构,方便你整体把握:

平衡二叉树 定义:BST + |平衡因子| ≤ 1 平衡因子 = 左高 - 右高 LL → 右旋 RR → 左旋 LR → 左旋+右旋 RL → 右旋+左旋 四种不平衡类型及对应的旋转策略

26.6 性能分析

AVL 树的性能,说白了就是「用旋转换平衡」:

  • 查找:O(log n)。树始终保持平衡,不会退化。
  • 插入:O(log n)。查找插入位置 O(log n),最多两次旋转 O(1)。
  • 删除:O(log n)。删除后可能需要多次旋转,但总体还是 O(log n)。
  • 空间:每个节点多存一个高度值,额外 O(n) 空间。

实际应用建议:如果你的场景是查询远多于插入删除,AVL 树是很好的选择。但如果插入删除也很频繁,红黑树可能更合适——它牺牲了一点平衡性,换来了更少的旋转次数。

我个人习惯在内存数据库、字典实现、需要频繁范围查询的场景中使用 AVL 树。比如我之前参与的一个网络流量分析工具,用 AVL 树存储 IP 地址段,查询速度比哈希表还稳定——哈希表有冲突,AVL 树没有。

嗯,平衡二叉树的内容就这些。记住四个字:失衡则旋。只要掌握了四种旋转,AVL 树就没什么秘密了。


公众号:蓝海资料掘金营,微信deep3321