Skip to content

平衡二叉搜索树与红黑树 ​

平衡树通过在 BST 基础上增加不变量,防止有序输入等场景将树退化为链表。

结构保证适合的问题常见 C++ 选择
AVL 树高度严格平衡查询非常频繁、更新相对少的有序集合自行实现或专业库
红黑树近似平衡通用有序集合 / 映射,频繁增删std::set、std::map 通常使用

先理解普通二叉搜索树 ​

二叉搜索树(BST)满足:对任意节点 x,左子树所有键小于 x.key,右子树所有键大于 x.key(重复键的放置策略必须统一)。

因此查找、插入、删除只需沿一条根到叶子的路径,复杂度是 O(h),h 为树高:

text
平衡时:h = O(log n)
退化成链表时:h = O(n)

平衡树的目标正是防止有序输入等情况把 BST 退化为链表。基础 BST 的查找、插入、删除、上下界和验证见树。

AVL 树:高度平衡的 BST ​

不变量与旋转 ​

AVL 树在 BST 性质外,要求每个节点的平衡因子:

text
balance_factor = height(left) - height(right)

始终属于 {-1, 0, 1}。插入或删除后,从受影响节点向根更新高度;若平衡因子绝对值达到 2,就通过旋转修复。

失衡形态条件修复
LL节点左重,左孩子不右重对节点右旋
RR节点右重,右孩子不左重对节点左旋
LR节点左重,左孩子右重先对左孩子左旋,再对节点右旋
RL节点右重,右孩子左重先对右孩子右旋,再对节点左旋

以 LL 为例,右旋只改变局部连接,仍保持 BST 的中序顺序:

text
        y                         x
       / \                       / \
      x   C      右旋(y)        A   y
     / \        ─────────►        / \
    A   B                        B   C

操作复杂度 ​

操作时间原因
查找O(log n)AVL 树高为 O(log n)
插入O(log n)找位置和向上更新高度;插入至多修复一个失衡点
删除O(log n)删除后可能一路向上出现失衡,需要多次修复
前驱 / 后继 / 范围查询O(log n + k)定位边界 O(log n),输出 k 个元素
空间O(n)每个节点保存键、左右孩子和高度

"单次旋转是 O(1)"不代表"插入是 O(1)":找插入位置和更新路径仍是 O(log n)。

可运行的 AVL int 集合 ​

下面的实现包括查找、插入和删除。使用 std::unique_ptr 表达节点唯一所有权,避免手写 delete;为了聚焦平衡逻辑,键类型固定为 int。

cpp
#include <algorithm>
#include <memory>

class AvlSet {
    struct Node {
        explicit Node(int value) : key(value) {}

        int key;
        int height = 1;
        std::unique_ptr<Node> left;
        std::unique_ptr<Node> right;
    };

public:
    bool contains(int key) const {
        const Node* current = root_.get();
        while (current) {
            if (key < current->key) current = current->left.get();
            else if (key > current->key) current = current->right.get();
            else return true;
        }
        return false;
    }

    bool insert(int key) {
        bool inserted = false;
        root_ = insert(std::move(root_), key, inserted);
        return inserted;
    }

    bool erase(int key) {
        bool erased = false;
        root_ = erase(std::move(root_), key, erased);
        return erased;
    }

private:
    static int height(const std::unique_ptr<Node>& node) {
        return node ? node->height : 0;
    }

    static int factor(const std::unique_ptr<Node>& node) {
        return node ? height(node->left) - height(node->right) : 0;
    }

    static void refresh_height(Node& node) {
        node.height = 1 + std::max(height(node.left), height(node.right));
    }

    static std::unique_ptr<Node> rotate_right(std::unique_ptr<Node> root) {
        auto pivot = std::move(root->left);
        root->left = std::move(pivot->right);
        refresh_height(*root);
        pivot->right = std::move(root);
        refresh_height(*pivot);
        return pivot;
    }

    static std::unique_ptr<Node> rotate_left(std::unique_ptr<Node> root) {
        auto pivot = std::move(root->right);
        root->right = std::move(pivot->left);
        refresh_height(*root);
        pivot->left = std::move(root);
        refresh_height(*pivot);
        return pivot;
    }

    static std::unique_ptr<Node> rebalance(std::unique_ptr<Node> root) {
        refresh_height(*root);
        if (factor(root) > 1) {
            if (factor(root->left) < 0) {
                root->left = rotate_left(std::move(root->left));
            }
            return rotate_right(std::move(root));
        }
        if (factor(root) < -1) {
            if (factor(root->right) > 0) {
                root->right = rotate_right(std::move(root->right));
            }
            return rotate_left(std::move(root));
        }
        return root;
    }

    static std::unique_ptr<Node> insert(
        std::unique_ptr<Node> root, int key, bool& inserted) {
        if (!root) {
            inserted = true;
            return std::make_unique<Node>(key);
        }
        if (key < root->key) {
            root->left = insert(std::move(root->left), key, inserted);
        } else if (key > root->key) {
            root->right = insert(std::move(root->right), key, inserted);
        } else {
            return root;
        }
        return rebalance(std::move(root));
    }

    static Node* minimum(Node* root) {
        while (root->left) root = root->left.get();
        return root;
    }

    static std::unique_ptr<Node> erase(
        std::unique_ptr<Node> root, int key, bool& erased) {
        if (!root) return nullptr;

        if (key < root->key) {
            root->left = erase(std::move(root->left), key, erased);
        } else if (key > root->key) {
            root->right = erase(std::move(root->right), key, erased);
        } else {
            erased = true;
            if (!root->left) return std::move(root->right);
            if (!root->right) return std::move(root->left);

            const int successor = minimum(root->right.get())->key;
            root->key = successor;
            bool ignored = false;
            root->right = erase(std::move(root->right), successor, ignored);
        }
        return rebalance(std::move(root));
    }

    std::unique_ptr<Node> root_;
};

注意:这份教学实现没有提供迭代器、异构查找、节点分配器、强异常安全保证或线程同步;生产代码通常优先使用经过充分测试的容器。

红黑树:用颜色约束保持近似平衡 ​

红黑树也是二叉搜索树,但不保存每个节点的精确高度,而是用一位颜色和局部旋转约束根到叶子的路径长度。它同时维护 BST 的键顺序和以下五条性质:

  1. 每个实际节点是红色或黑色;
  2. 根节点是黑色;
  3. 所有空孩子都视为黑色叶子;实现常用一个共享哨兵 NIL 表示它们;
  4. 红节点的两个孩子都是黑色,即不能出现连续红节点;
  5. 从任意节点到它的所有后代 NIL,每条路径包含相同数量的黑节点。

颜色不是业务状态,而是维护平衡的不变量。只满足 BST 顺序但破坏任意一条颜色性质,都不能称为合法红黑树。

黑高与高度上界 ​

节点 x 的黑高(black height)是从 x 向下到任意后代 NIL 路径上的黑节点数。这里约定不计 x、计入终点 NIL;性质 5 保证这个值与所选路径无关。不同资料可能采用其他计数约定,推导时不要混用。

高度为什么是 O(log n):

  1. 因为红节点不能相邻,一条根到 NIL 的路径中,红节点数量不可能超过黑节点数量;
  2. 因此最长路径至多是只含黑节点路径长度的两倍;
  3. 黑高为 bh 的子树至少包含 2^bh - 1 个实际节点;
  4. 所以对含 n 个节点的红黑树,有:
text
height <= 2 × log2(n + 1)

这个上界比 AVL 更松,但仍保证查找、插入和删除最坏 O(log n)。红黑树不保证左右子树高度差不超过 1,也不保证每条根叶路径等长。

旋转为什么不破坏 BST 顺序 ​

左旋和右旋只重排局部父子链接,不改变中序顺序。以左旋 x 为例:

text
    x                   y
   / \                 / \
  A   y    左旋(x)     x   C
     / \    ─────►   / \
    B   C           A   B

中序顺序始终是:A, x, B, y, C

旋转本身只调整结构;插入和删除修复还会配合重新着色。工程实现必须同步维护父指针、根指针以及 NIL / 头哨兵链接,否则即使局部形状正确,迭代器和边界节点也可能损坏。

从 2-3-4 树理解颜色 ​

红黑树可以看作 2-3-4 树的一种二叉表示:把一个黑节点和直接连接的红孩子压缩到一起,就对应一个拥有 1~3 个 key 的 2-node、3-node 或 4-node。所有根到 NIL 的黑高相同,对应 2-3-4 树所有叶子处在同一层。

从这个视角看:

  • 红链接表示两个 key 属于同一个多路节点,不应连续出现;
  • 叔叔为红时的颜色翻转,类似把一个临时 4-node 向上分裂;
  • 旋转是在不改变有序关系的情况下,调整同一个多路节点的二叉表示。

左倾红黑树(LLRB)还额外要求红链接向左倾斜,可用更统一的递归代码模拟 2-3 树;它是红黑树的一种实现变体,不能把“红链接必须左倾”误认为普通红黑树的基本性质。

C++ 标准只规定 std::map / std::set 的语义和复杂度,不规定必须使用红黑树;主流实现通常采用红黑树,但业务代码不应依赖这一实现细节。

插入修复:解决“红红冲突” ​

先按普通 BST 找到位置。新节点通常着为红色:如果直接插入黑节点,经过新节点的所有路径会立刻多一个黑节点,破坏黑高;插入红节点不会改变黑高,只可能产生“父节点也是红色”的局部冲突。

若新节点记为 x,父节点为 p,祖父为 g,叔叔为 u:

  1. x 成为根:把它染黑;
  2. p 为黑:所有性质已经满足;
  3. p 为红:g 必然存在且为黑,需要根据叔叔颜色修复。

叔叔是红色:重新着色并向上继续 ​

text
       g(B)                 g(R)
      /    \               /    \
   p(R)    u(R)    →     p(B)    u(B)
   /
 x(R)

把父亲和叔叔染黑、祖父染红,然后令 x = g,继续检查祖父是否与它的父节点形成新的红红冲突。该情况不旋转,但冲突可能沿祖先路径向上传播;最后必须再次把根染黑。

叔叔是黑色:通过旋转结束局部冲突 ​

根据 x-p-g 的方向分为直线和折线:

形态第一步第二步
LL无父染黑、祖父染红,对祖父右旋
RR无父染黑、祖父染红,对祖父左旋
LR先对父节点左旋,转成 LL按 LL 修复
RL先对父节点右旋,转成 RR按 RR 修复

直线情况的一次旋转会让原父节点成为局部根;折线情况先旋转父节点形成直线,再旋转祖父。因此一次插入最多需要 2 次旋转,但重新着色可能向上进行 O(log n) 层。

插入修复可以概括为:

text
叔叔红:颜色上移
叔叔黑:折线先拉直,再旋转祖父并交换父/祖父颜色

删除修复:处理“额外黑色” ​

删除先按普通 BST 处理结构。若目标有两个孩子,实际摘除的通常是它的中序后继,因此要记录真正被摘除节点的原颜色:

  • 摘除红节点:不改变任意路径的黑节点数,无需修复;
  • 摘除黑节点且替代孩子为红:把替代孩子染黑即可;
  • 摘除黑节点且替代孩子也是黑色或 NIL:经过该位置的路径少一个黑节点,可把它抽象成替代节点 x 携带一层“额外黑色”(double black)。

“额外黑色”只是推导修复过程的概念,不是节点颜色枚举中的第三种实际颜色。删除比插入更复杂,因为它可能让黑高不足一路向根传播。

以下假设 x 是父节点的左孩子,兄弟 s 在右侧;左右互换时完全对称。兄弟孩子中,靠近 x 的称为近侄子,远离 x 的称为远侄子。

情况处理目的
兄弟红兄弟染黑、父染红,对父左旋,重新取得新兄弟转换为“兄弟黑”的情况
兄弟黑,两个侄子都黑兄弟染红,把额外黑色上移到父节点当前两侧黑高恢复,可能继续向上
兄弟黑,近侄子红、远侄子黑近侄子染黑、兄弟染红,对兄弟右旋转换为远侄子红的最终情况
兄弟黑,远侄子红兄弟继承父颜色,父和远侄子染黑,对父左旋消除额外黑色,修复结束

若额外黑色上移到一个红色父节点,将父节点染黑即可结束;若最终到达根,直接移除“额外黑色”。一次删除修复最多进行常数次旋转(经典实现最多 3 次),但额外黑色可能沿树高向上传播,所以总时间仍为 O(log n)。

使用共享黑色 NIL 哨兵可以让叶子删除和普通节点使用同一套修复逻辑,并允许修复阶段访问 x 的父亲和兄弟。若用 nullptr 表示空孩子,就必须额外保存父节点,不能直接解引用空指针。

操作复杂度 ​

操作时间说明
find / lower_boundO(log n)按 BST 路径定位
insertO(log n)最多 2 次旋转,重新着色可能向上 O(log n) 层
eraseO(log n)最多 3 次旋转,额外黑色可能向上 O(log n) 层
最小值 / 最大值O(log n)沿最左 / 最右路径定位;容器可额外缓存边界节点
范围查询O(log n + k)定位起点 O(log n),输出 k 个元素
有序遍历O(n)中序遍历按比较器顺序输出
空间O(n)每个节点保存颜色和父子链接

如何验证一棵红黑树 ​

验证不能只检查“根是黑色”和“没有连续红节点”,还必须同时验证 BST 顺序与所有路径黑高一致。下面的函数把 nullptr 当作黑色 NIL,返回子树黑高;返回 -1 表示不合法:

cpp
#include <limits>

enum class Color { Red, Black };

struct RbNode {
    int key;
    Color color;
    RbNode* left = nullptr;
    RbNode* right = nullptr;
};

bool is_red(const RbNode* node) {
    return node && node->color == Color::Red;
}

int validate_subtree(const RbNode* node, long long low, long long high) {
    if (!node) return 1; // NIL 视为黑色
    if (node->key <= low || node->key >= high) return -1;

    if (is_red(node) && (is_red(node->left) || is_red(node->right))) {
        return -1; // 连续红节点
    }

    const int left_black_height =
        validate_subtree(node->left, low, node->key);
    const int right_black_height =
        validate_subtree(node->right, node->key, high);

    if (left_black_height < 0 || right_black_height < 0 ||
        left_black_height != right_black_height) {
        return -1;
    }

    return left_black_height + (node->color == Color::Black ? 1 : 0);
}

bool is_valid_red_black_tree(const RbNode* root) {
    if (!root) return true;
    if (root->color != Color::Black) return false;
    return validate_subtree(root,
                            std::numeric_limits<long long>::lowest(),
                            std::numeric_limits<long long>::max()) > 0;
}

这段验证采用“不允许重复 key”的集合语义。如果实现允许重复键,左右边界规则必须与插入策略保持一致。

std::set / std::map 的具体用法 ​

cpp
#include <set>

std::set<int> scores{30, 10, 50};
scores.insert(40);                  // O(log n)
scores.erase(10);                   // O(log n)

if (scores.contains(30)) {          // C++20,O(log n)
    auto first_not_less = scores.lower_bound(35);
    if (first_not_less != scores.end()) {
        // *first_not_less == 40
    }
}

std::map / std::set 的等价键由比较器定义:若 comp(a, b) 和 comp(b, a) 都为假,两个键视为等价。比较器必须满足严格弱序,不能在元素已经进入容器后改变会影响排序的键。它们是节点式关联容器:插入通常不使已有迭代器失效,删除只使指向被删元素的迭代器失效。

标准要求 begin() 为常数复杂度;常见树实现会在头哨兵中缓存最左节点、最右节点和根节点。手写红黑树若希望支持高效迭代器,也需要维护这些边界链接和父指针,不能只实现颜色修复。

AVL 与红黑树如何选择 ​

维度AVL 树红黑树
平衡约束每个节点左右高度差不超过 1黑高一致、红节点不相邻
树高更严格、更接近最小高度最坏不超过 2 log2(n+1)
查询通常路径稍短仍保证最坏 O(log n)
插入旋转最多 2 次最多 2 次,可能沿途重新着色
删除修复可能沿祖先路径多次旋转最多 3 次旋转,可能传播额外黑色
节点元数据高度或平衡因子一位颜色(实现中常占一个字段)
常见场景查询占绝对多数、更新较少通用有序集合 / 映射、增删较频繁

面试场景选择 ​

需求优先选择原因
有序字典、范围查询、lower_bound红黑树 / std::map全局有序,按键 O(log n)
查找多、更新少且想要更小树高AVL 树平衡更严格
高速精确 key 查找,不要求有序哈希表平均 O(1)

面试中的简洁回答 ​

红黑树是一种自平衡二叉搜索树。它通过根和空叶子为黑、红节点不相邻、任意节点到后代空叶子的黑高相同等约束,使最长路径不超过最短路径的两倍,因此树高最坏 O(log n)。插入先按 BST 插入红节点,再根据叔叔颜色进行重新着色或旋转;删除黑节点可能产生“额外黑色”,再根据兄弟和侄子颜色向上修复。查找、插入、删除最坏都是 O(log n)。相比 AVL,它平衡更宽松,通常适合增删较频繁的通用有序容器;但 std::map 是否用红黑树属于实现细节。

易错点 ​

  1. 写 AVL 时,旋转后必须先更新较低节点的高度,再更新新根;
  2. 删除 AVL 双孩子节点时,替换后仍要从当前节点向上重新平衡;
  3. 红黑树不是“每条路径等长”,而是每条到 NIL 的路径黑节点数相等;
  4. NIL 叶子是黑色的逻辑节点,不要在黑高推导中忽略它;
  5. 新插入节点通常染红,目的是不改变现有路径黑高;
  6. “双黑”只是删除修复的推导状态,不是第三种实际颜色;
  7. 旋转只保证 BST 中序顺序不变,颜色性质还需要重新着色维护;
  8. 插入修复关注叔叔颜色,删除修复关注兄弟及其近、远侄子颜色;
  9. 红黑树和 AVL 的查找、插入、删除都是 O(log n),单次旋转 O(1) 不代表整个操作 O(1);
  10. std::map 常由红黑树实现,但 C++ 标准没有规定具体数据结构。

关联笔记 ​

使用 Markdown 与 VitePress 构建