16、红黑树的插入:插入操作的3种情况、变色与旋转、C语言实现要点

红黑树的插入,说实话,是很多人学树结构时的一道坎。我当年第一次看《算法导论》里那堆 case 分析,差点把书扔了。但后来在实际项目中写内存分配器时,才发现这东西真香——它保证了最坏情况下的查找效率,不像普通 BST 那样容易退化成链表。

今天咱们就把红黑树的插入拆开揉碎了讲。你只要记住三个核心操作:变色、左旋、右旋。所有复杂的 case 分析,最终都落到这三板斧上。

16.1 红黑树的五大性质(先过一遍)

在讲插入之前,咱们得先确认一个共识:红黑树本质上是一棵自平衡的二叉搜索树。它通过给每个节点涂上红色或黑色,并遵守以下五条规则,来保证树的高度不超过 2log(n+1):

  1. 每个节点要么是红色,要么是黑色。
  2. 根节点是黑色。
  3. 叶子节点(NIL)是黑色。
  4. 红色节点的两个子节点必须是黑色(不能有连续的红节点)。
  5. 从任意节点到其每个叶子节点的所有路径上,黑色节点数量相同。

嗯,这里要注意:第 3 条说的叶子节点不是我们通常理解的左右孩子为 NULL,而是指 NIL 哨兵节点。我在项目中习惯用一个全局的 NIL 节点来统一处理,省得每次判空写一堆 if。

16.2 插入操作的核心思路

红黑树的插入分两步走:

  1. 先按普通 BST 的方式插入,新节点默认涂成红色。
  2. 再通过变色和旋转修复,让树重新满足红黑树性质。

为什么新节点默认是红色?说白了,如果涂成黑色,那从根到该节点的路径上就多了一个黑节点,会直接破坏性质 5,修复起来更麻烦。涂成红色只可能破坏性质 4(不能有连续红节点),修复范围更小。

个人习惯:我在实现时,会把新节点的左右孩子都指向 NIL 哨兵,而不是 NULL。这样在后续的旋转和变色操作中,不需要额外判断空指针,代码会干净很多。

16.3 插入后的三种情况(核心)

插入新节点后,如果它的父节点是黑色,那万事大吉,直接结束。如果父节点是红色,就出现了连续红节点,需要根据叔叔节点的颜色分三种情况处理。

为了方便描述,我们约定几个符号:

  • N:新插入的节点
  • P:N 的父节点
  • G:N 的祖父节点
  • U:N 的叔叔节点(P 的兄弟)

情况 1:叔叔节点 U 是红色

这是最简单的情况。直接把 P 和 U 变黑,G 变红。然后把 G 当作新插入的节点,继续向上检查。

为什么?因为 P 和 U 都是红色,它们变黑后,G 到子树的路径上黑节点数不变。但 G 变红了,如果 G 的父节点也是红色,就需要继续处理。

避坑指南:我曾经在第一次实现时,忘了把 G 当作新节点继续向上递归,结果只修复了局部,上层还有连续红节点没处理。记住:情况 1 的修复是向上传递的,不是一次就完事。

情况 2:叔叔节点 U 是黑色,且 N 是 P 的内侧子节点

这里「内侧」指的是:如果 P 是 G 的左孩子,N 是 P 的右孩子;或者 P 是 G 的右孩子,N 是 P 的左孩子。说白了就是 N 和 P 的方向相反。

处理方式:对 P 做一次旋转,让 N 和 P 的角色互换,然后转化为情况 3。

举个例子:如果 P 是 G 的左孩子,N 是 P 的右孩子,那就对 P 做一次左旋。旋转后,N 到了原来 P 的位置,P 成了 N 的左孩子。这时候 N 和 P 的方向就一致了。

情况 3:叔叔节点 U 是黑色,且 N 是 P 的外侧子节点

「外侧」就是 N 和 P 方向相同。比如 P 是 G 的左孩子,N 也是 P 的左孩子。

处理方式:对 G 做一次旋转(方向与 P 相反),然后交换 P 和 G 的颜色。

具体来说:如果 P 是 G 的左孩子,N 是 P 的左孩子,那就对 G 做右旋。旋转后,P 成为新的子树根,G 成为 P 的右孩子。然后把 P 涂黑,G 涂红。

这样处理后,子树根是黑色,它的两个孩子都是红色,性质 4 和性质 5 都满足了。

16.4 三种情况的总结表格

情况 叔叔颜色 N 与 P 的位置关系 操作
1 红色 任意 P、U 变黑,G 变红,将 G 作为新 N 继续向上
2 黑色 内侧(方向相反) 对 P 旋转,转化为情况 3
3 黑色 外侧(方向相同) 对 G 旋转,交换 P 和 G 的颜色

16.5 左旋与右旋的 C 语言实现

旋转是红黑树最基础的操作。说白了就是调整节点之间的父子关系,同时保持 BST 的性质不变。

左旋:把某个节点往下压,让它的右孩子升上来。右旋反过来。

// 左旋:以 x 为轴心,将 x 的右孩子 y 旋转上来
void left_rotate(RBTree *tree, Node *x) {
    Node *y = x->right;   // y 是 x 的右孩子
    x->right = y->left;   // 把 y 的左子树过继给 x 的右子树

    if (y->left != NIL) {
        y->left->parent = x;
    }

    y->parent = x->parent;  // y 的父指针指向 x 的父节点

    if (x->parent == NIL) {
        tree->root = y;     // x 是根,旋转后 y 成为新根
    } else if (x == x->parent->left) {
        x->parent->left = y;
    } else {
        x->parent->right = y;
    }

    y->left = x;            // x 成为 y 的左孩子
    x->parent = y;
}

// 右旋:对称实现,把 left 和 right 互换即可
void right_rotate(RBTree *tree, Node *y) {
    Node *x = y->left;
    y->left = x->right;

    if (x->right != NIL) {
        x->right->parent = y;
    }

    x->parent = y->parent;

    if (y->parent == NIL) {
        tree->root = x;
    } else if (y == y->parent->left) {
        y->parent->left = x;
    } else {
        y->parent->right = x;
    }

    x->right = y;
    y->parent = x;
}
注意:旋转操作中,一定要先更新子节点的 parent 指针,再更新父节点的 child 指针。顺序搞反了会导致指针丢失。我曾经在这个坑里 debug 了整整一个下午。

16.6 插入修复的 C 语言实现

插入修复函数是核心中的核心。它把上面三种情况串起来,用循环或递归处理。

void rb_insert_fixup(RBTree *tree, Node *z) {
    while (z->parent->color == RED) {
        // 判断父节点是祖父节点的左孩子还是右孩子
        if (z->parent == z->parent->parent->left) {
            Node *y = z->parent->parent->right;  // 叔叔节点

            if (y->color == RED) {
                // 情况 1:叔叔是红色
                z->parent->color = BLACK;
                y->color = BLACK;
                z->parent->parent->color = RED;
                z = z->parent->parent;  // 向上传递
            } else {
                // 叔叔是黑色
                if (z == z->parent->right) {
                    // 情况 2:内侧,先左旋
                    z = z->parent;
                    left_rotate(tree, z);
                }
                // 情况 3:外侧,右旋 + 变色
                z->parent->color = BLACK;
                z->parent->parent->color = RED;
                right_rotate(tree, z->parent->parent);
            }
        } else {
            // 对称情况:父节点是祖父节点的右孩子
            // 把上面的 left 和 right 互换,逻辑完全对称
            Node *y = z->parent->parent->left;

            if (y->color == RED) {
                z->parent->color = BLACK;
                y->color = BLACK;
                z->parent->parent->color = RED;
                z = z->parent->parent;
            } else {
                if (z == z->parent->left) {
                    z = z->parent;
                    right_rotate(tree, z);
                }
                z->parent->color = BLACK;
                z->parent->parent->color = RED;
                left_rotate(tree, z->parent->parent);
            }
        }
    }
    tree->root->color = BLACK;  // 确保根节点是黑色
}

你想想看,这个函数其实就两个分支(父节点是左孩子还是右孩子),每个分支里再根据叔叔颜色和 N 的位置做判断。代码看着长,但逻辑很清晰。

16.7 红黑树插入的知识体系图

下面这张图把插入操作的完整流程画出来了,从插入新节点到修复结束,一目了然。

插入新节点(红色) 父节点是黑色? 插入完成 叔叔节点是红色? 情况1:P、U变黑,G变红 G作为新N继续向上 情况2:内侧 → 对P旋转 情况3:外侧 → 对G旋转+变色

16.8 C 语言实现要点总结

最后,我把自己在实际编码中踩过的坑和总结的经验列出来,希望能帮你少走弯路:

  • 哨兵节点 NIL 一定要全局唯一:不要在每个节点里都存一个 NIL,浪费内存。用一个全局的静态变量,所有空指针都指向它。
  • 旋转时先更新 parent 指针:我习惯先处理子节点的 parent,再处理父节点的 child,这样不容易丢指针。
  • 插入修复用 while 循环:不要用递归,因为最坏情况下要一直向上回溯到根,递归容易栈溢出。
  • 最后一定要把根涂黑:不管中间怎么变,最后一行 tree->root->color = BLACK 不能少。这是性质 2 的要求。
  • 对称代码不要复制粘贴:父节点是左孩子和右孩子的代码逻辑完全对称,但很多人直接复制后忘了改 left 和 right。我建议写一个宏或者辅助函数来生成对称代码。
我的经验:红黑树的插入看起来复杂,但只要你把三种情况背下来,再手写几遍旋转代码,就会发现它其实很有规律。我当年在项目中第一次写红黑树时,花了三天才调通。现在让我写,半小时就能搞定。多练,没别的捷径。

好了,红黑树的插入就讲到这里。记住那三种情况,记住旋转的代码结构,剩下的就是多写多练。下一节咱们会讲红黑树的删除,那才是真正的硬骨头——不过有了插入的基础,你已经有底气去啃它了。


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