红黑树这个东西,凡是做底层开发或者认真啃过数据结构的人,早晚都得正面撞上它。Linux内核的CFS调度器、C++的std::map、Java的TreeMap,背后都是红黑树在撑着。很多教程把红黑树讲得神乎其神,五条性质背得滚瓜烂熟,但真让你手写一个insert,或者调一个delete,立刻露馅。我自己也是踩了无数次坑之后才把它彻底吃透,这文章就把我手写红黑树模拟实现的全过程、关键细节、踩坑记录全部放出来,C语言实现,可以直接照着写,也可以拿来当面试复习提纲。
这篇文章适合正在学数据结构的在校生、准备面试的求职者、以及想深入理解平衡树原理的工程开发者。我需要提前说明一点:红黑树本身不难,难的是把五种插入场景和八种删除场景彻底理顺。如果你能把这里的代码亲手敲一遍、调通、跑过测试,你对红黑树的理解会远超那些只看不练的人。
1. 红黑树到底解决什么问题,为什么值得手动实现一遍
1.1 红黑树的本质是“有损平衡”
先回到最朴素的问题:二叉搜索树(BST)插入有序数据时,会退化成链表,查找复杂度从O(log n)跌到O(n)。AVL树通过维护左右子树高度差不超过1来避免这个问题,但它太“严格”了,每次插入几乎都要旋转,代价高。红黑树的思路不一样,它不追求严格的高度相等,而是通过颜色约束保证任何一条路径的长度不会超过另一条路径的两倍,这是一种“有损平衡”。
代价是什么?代价就是树不像AVL那么矮,查询略慢一点;收益是什么?插入和删除时需要的旋转次数大大减少,尤其在删除场景,AVL最坏情况下需要O(log n)次旋转,红黑树最多3次旋转加O(log n)次变色就能搞定。所以红黑树的真实适用场景是写多读少、频繁插入删除的数据结构,比如内核的定时器、内存管理中的空闲块管理,这些地方插入删除是家常便饭。
1.2 模拟实现和“会用库”完全是两码事
有人会说:C++直接用std::map不就好了?C语言里也有rbtree.h。但会调用接口和真正理解机制是两码事。我当初面试候选人的时候经常会问“红黑树和AVL的区别”,十个有八个能背出来红黑树优势是插入删除快,但再追问一句“为什么插入删除快”就卡住了,根本原因是没动手写过。
手动实现红黑树的价值有几个层面:第一,理解什么叫“不变量维护”,红黑树的五条性质就是一组不变量,每次插入删除后需要恢复不变量,这种思维模式在并发算法、分布式系统里都会用到;第二,理解旋转的本质,左旋右旋不是玄学,它们本质上是改变中序遍历顺序不变的前提下调整树的结构,这个思想在splay树、treap里也通用;第三,写一遍才能真正记住那些修复场景,靠背是背不下来的。
2. 红黑树的五条规则与时间复杂度分析
2.1 五条性质,逐条拆解
红黑树的定义是一棵带有颜色属性的二叉搜索树,满足以下五条性质:
- 每个节点是红色或黑色。
- 根节点必须是黑色。
- 每个叶子节点(NIL节点)是黑色。这里要特别说明,红黑树中的叶子不是我们通常理解的左右子树为空的节点,而是所有空指针统一看作一个黑色NIL节点。这个设计是为了让每条路径的终点一致,方便计数。
- 如果一个节点是红色,那么它的两个子节点必须是黑色。换句话说,不能出现两个连续的红色节点。
- 从任意节点到其每个叶子节点的所有路径上,黑色节点数量相同。这个数量被称为“黑高”(black-height)。
这五条性质里最核心的是4和5。性质4保证了红色节点不会连续出现,性质5保证了黑高一致,两个条件合在一起,就能推导出任意路径长度不超过最短路径长度的两倍。为什么?因为最长路径就是“黑红交替”的路径,最短路径是全黑的路径。如果黑高是h,最短路径长度就是h(全是黑色节点),最长路径最多是2h(黑白交替),所以整体高度控制在O(log n)级别。
2.2 时间复杂度的直觉理解
很多人把红黑树的O(log n)当成理所当然,其实可以简单算一笔账。一棵有n个内部节点的红黑树,它的黑高最多是log2(n+1),这是由性质5决定的,因为黑色节点可以单独构成一棵满的BST。又因为性质4限制红色节点不能连续,所以整体树高最多是2倍的log2(n+1),也就是O(log n)。查找、插入、删除最坏情况都是O(log n)。
这里我需要强调一个容易误解的点:红黑树查找和AVL查找的常数因子不一样。AVL树更矮,查找最坏情况下比红黑树快,但差别是常数级别的。而红黑树的插入删除因为旋转少,实际跑起来往往比AVL更快。对于通用型的数据结构库,标准委员会选择红黑树而不是AVL,不是拍脑袋决定的,是在大量实测基础上选的。
3. 模拟实现前的数据结构设计与基础操作
3.1 C语言里的节点定义
C语言实现红黑树的第一步是定义节点结构体。我用了标准做法,void*指针存数据,这样整棵树不依赖具体数据类型,具备通用性。颜色用枚举,比直接用int或者char可读性好得多。
typedef enum { RB_RED = 0, RB_BLACK = 1 } rb_color_t; typedef struct rb_node { struct rb_node *parent; struct rb_node *left; struct rb_node *right; rb_color_t color; void *data; } rb_node_t;注意我这里没有显式定义NIL节点,而是用NULL指针统一表示叶子。代码里所有判断叶子节点的逻辑都基于NULL,这样比额外分配NIL节点更简洁。但代价是代码里每访问node->left之前都得先判空。Linux内核里用了一个全局的空的node节点代表NIL,各有取舍。教学向的代码建议用NULL,好理解;生产级代码用专门NIL节点可以省掉大量判空,性能更好。
3.2 树的整体结构
树的根节点需要单独包装一层,方便管理根节点颜色变化,也能挂载自定义比较函数和节点数量。
typedef struct rb_tree { rb_node_t *root; int (*compare)(const void *a, const void *b); size_t size; } rb_tree_t;compare函数指针是必须的,否则树不知道如何比较数据。整数可以直接比较,字符串可以用strcmp,自定义结构体就通过这个回调实现。这样做的好处是红黑树本身不用关心数据的含义。
3.3 左旋与右旋:最基础也最关键的几何变换
旋转是红黑树所有修复操作的基础。很多初学者死记代码,问我“为什么要写这几行”,我建议从几何意义理解。左旋操作的定义是:假设节点y是x的右孩子,左旋让y成为子树的根,x变成y的左孩子,y原来的左孩子变成x的右孩子。整个过程中序遍历不变,只是树的形状变了。
static void rb_left_rotate(rb_tree_t *tree, rb_node_t *x) { rb_node_t *y = x->right; x->right = y->left; if (y->left != NULL) { y->left->parent = x; } y->parent = x->parent; if (x->parent == NULL) { tree->root = y; } else if (x == x->parent->left) { x->parent->left = y; } else { x->parent->right = y; } y->left = x; x->parent = y; }右旋就是完全镜像的操作,x的左孩子y变成子树根,x变成y的右孩子,y原来的右孩子变成x的左孩子。我强烈建议你把这两段代码亲手敲一遍,然后画一棵树,走一遍指针变化过程。这一步如果不熟,后面插入删除的修复过程会寸步难行。
注意:旋转操作里最容易犯的错是指针更新顺序颠倒。核心原则是:先处理“孙子节点”的parent指针,再处理“根”的parent指针,最后处理旋转两节点之间的指针。严格按这个顺序来,基本不会出bug。
4. 插入操作:先走BST流程,再修复颜色
4.1 插入修复的总体思路
红黑树插入遵循经典三步:先用普通BST的规则把新节点插入到叶子位置,把新节点涂成红色,然后通过旋转和变色修复可能被破坏的性质。新节点涂红是有讲究的:如果涂黑色,性质5立刻被破坏(这条路径黑高+1),修复起来极其麻烦;涂红色的话,只可能破坏性质4(红色节点的子节点必须是黑色),只需要局部分情况处理,旋转次数少得多。
插入后可能违反性质4的唯一情况是:新节点的父节点也是红色。这时候需要看叔叔节点(父节点的兄弟节点)的颜色来分情况。
4.2 三种修复场景详解
这里我直接整理成表格,方便对照记忆。设当前节点为cur,父节点为p,祖父节点为g,叔叔节点为u。如果p是g的左孩子,那么考虑u的情况:
| 场景 | 条件 | 处理方式 |
|---|---|---|
| 场景1 | u是红色 | 把p和u涂黑,把g涂红,cur=g继续向上检查 |
| 场景2 | u是黑色,且cur是p的右孩子 | 对p左旋,转换为场景3 |
| 场景3 | u是黑色,且cur是p的左孩子 | 对g右旋,交换p和g的颜色 |
场景1不需要旋转,只需要变色,然后向上冒泡。场景2和场景3本质是同一类情况的两种状态,场景2通过一次旋转变成了场景3,场景3再通过一次旋转加变色完成修复。这有点像数学证明里的“辅助线”——旋转本身不直接解决问题,但把问题变成了已经会解的形式。
如果p是g的右孩子,操作完全镜像,左变右、右变左,逻辑不变。
4.3 完整插入代码
void rb_insert(rb_tree_t *tree, rb_node_t *node) { // Step 1: 普通BST插入 rb_node_t *y = NULL; rb_node_t *x = tree->root; while (x != NULL) { y = x; if (tree->compare(node->data, x->data) < 0) { x = x->left; } else { x = x->right; } } node->parent = y; if (y == NULL) { tree->root = node; } else if (tree->compare(node->data, y->data) < 0) { y->left = node; } else { y->right = node; } node->left = NULL; node->right = NULL; node->color = RB_RED; tree->size++; // Step 2: 修复红黑性质 rb_insert_fixup(tree, node); }插入修复函数如下:
static void rb_insert_fixup(rb_tree_t *tree, rb_node_t *z) { while (z->parent != NULL && z->parent->color == RB_RED) { rb_node_t *g = z->parent->parent; if (z->parent == g->left) { rb_node_t *u = g->right; // 场景1:叔叔是红色 if (u != NULL && u->color == RB_RED) { z->parent->color = RB_BLACK; u->color = RB_BLACK; g->color = RB_RED; z = g; } else { // 场景2:叔叔是黑色,且z是右孩子 if (z == z->parent->right) { z = z->parent; rb_left_rotate(tree, z); } // 场景3:叔叔是黑色,且z是左孩子 z->parent->color = RB_BLACK; g->color = RB_RED; rb_right_rotate(tree, g); } } else { // 镜像情况:parent是grandparent的右孩子 // 代码略,逻辑与上面左右对调 } } tree->root->color = RB_BLACK; }我在写场景1时有个容易漏掉的点:当u为NULL时也要视为黑色(NIL节点是黑色),所以u != NULL的判断必须加上。很多bug就出在这里,忘记NULL节点是黑色这一条规则。
4.4 插入修复的“为什么能停”
比较有意思的问题是:为什么修复过程能终止?每次进入场景1,z向上移两层,但树的高度是有穷的,所以最多O(log n)次。场景2和场景3最多各执行一次旋转就结束了。这个就保证了插入整体复杂度O(log n)。我自己写的时候加了一个计数器验证过,随机插入100万个节点,每个节点的修复循环次数都不大,平均不到2次,足以说明插入修复的代价很小。
5. 删除操作:红黑树的分水岭
5.1 为什么删除比插入难这么多
红黑树的删除是公认最难的部分,比插入复杂一个数量级。核心原因在于:删除一个节点可能少掉一个黑色,这直接破坏了性质5,而性质5是全局性的,任何一条路径变了,其他路径都要跟着调整。插入只是引入一个红色节点,违反性质4,局部就可以修复。删除少了一个黑色,相当于把某个路径的黑高减了1,需要通过“双黑”概念来修复。
删除分两步:先用BST的方式找到待删除节点,如果它有左右两个子节点,就找后继节点替换它的值(实际删除的是后继节点),这时真正删除的节点最多只有一个子节点。然后用一个子节点替换被删除节点的位置。如果被删除节点是黑色,就需要修复。
5.2 双黑节点的概念
这是理解删除修复的关键。想象被删除节点是黑色的,那么它的位置被一个子节点x顶替。我们给x加上一个虚拟的“额外黑色”,让它变成“双黑”节点,这样性质5暂时恢复了。修复过程就是把这层额外的黑色在树中上移,直到遇到一个红色节点涂黑它,或者一直上升到根节点。
这个“双黑”不是真实类型,而是一种抽象概念。代码实现中不会真的给节点加一个字段,而是维护一个循环,不断处理x的兄弟节点的各种情况,直到x到达根或者变成红色。
5.3 兄弟节点四种情况的梳理
设x是“双黑”节点,w是x的兄弟节点。这里我直接给出分支逻辑。以x是父节点p的左孩子为例:
| 场景 | w的颜色 | w的子节点情况 | 处理方案 |
|---|---|---|---|
| D1 | w是红色 | 无 | 将w涂黑、p涂红,对p左旋,更新w为p的新右孩子,转为D2/D3/D4 |
| D2 | w是黑色 | w的两个孩子都是黑色 | 将w涂红,把“双黑”上移到p |
| D3 | w是黑色 | w的左孩子是红色,右孩子是黑色 | 将w涂红、w->left涂黑,对w右旋,更新w为p的右孩子 |
| D4 | w是黑色 | w的右孩子是红色 | 将w的颜色设为p的颜色,p涂黑,w->right涂黑,对p左旋,将x设为根结束 |
这四种情况有一个巧妙的记忆方式:D1通过旋转把红色兄弟变成了黑色兄弟,接下来三个场景都以“黑色兄弟”为前提。D2最简单,不需要旋转,只需要把多余的黑色上移一层。D3是D4的预处理,目的是把红色的孙子节点从左边“挪”到右边,让D4成立。D4是终态,通过一次旋转+三次变色,把多余的黑色转移到一个红色节点上,然后彻底解决。
5.4 删除完整流程
删除修复的代码量比插入大不少,我挑关键部分展示:
static void rb_delete_fixup(rb_tree_t *tree, rb_node_t *x) { while (x != tree->root && x->color == RB_BLACK) { if (x == x->parent->left) { rb_node_t *w = x->parent->right; // D1: 兄弟为红色 if (w->color == RB_RED) { w->color = RB_BLACK; x->parent->color = RB_RED; rb_left_rotate(tree, x->parent); w = x->parent->right; } // D2: 兄弟为黑色,且兄弟的两个孩子都是黑色 if (w->left == NULL && w->right == NULL || (w->left && w->left->color == RB_BLACK && w->right && w->right->color == RB_BLACK)) { w->color = RB_RED; x = x->parent; } else { // D3: 兄弟的右孩子是黑色 if (w->right == NULL || w->right->color == RB_BLACK) { if (w->left) w->left->color = RB_BLACK; w->color = RB_RED; rb_right_rotate(tree, w); w = x->parent->right; } // D4: 兄弟的右孩子是红色 w->color = x->parent->color; x->parent->color = RB_BLACK; if (w->right) w->right->color = RB_BLACK; rb_left_rotate(tree, x->parent); x = tree->root; } } else { // 镜像情况,代码略 } } x->color = RB_BLACK; }这段代码我写在项目中约80行。实际调试时,D3和D4是最容易出错的点:D3的旋转完成以后,新的w必须更新,否则指针会悬空;D4处理完以后把x设为root直接跳出循环,这个“跳出”是必须的,因为所有情况已经解决。
6. 验证技巧与调试心得
6.1 用黑高一致式做自动化校验
手写红黑树最痛苦的是出了错不知道错在哪。我的经验是写一个独立的校验函数,每次插入删除后自己检查五条性质。校验函数要递归计算从每个节点到叶子路径的黑高,同时检查红色节点是否有红色孩子,理论上这个函数代价不小(O(n log n)级别),但它只用在测试阶段,生产环境去掉即可。
static int rb_validate_node(rb_node_t *node, int *black_height) { if (node == NULL) { *black_height = 0; return 1; } int left_bh, right_bh; if (!rb_validate_node(node->left, &left_bh)) return 0; if (!rb_validate_node(node->right, &right_bh)) return 0; if (left_bh != right_bh) { printf("黑高不一致,节点 data=%p left=%d right=%d\n", node->data, left_bh, right_bh); return 0; } if (node->color == RB_RED) { if ((node->left && node->left->color == RB_RED) || (node->right && node->right->color == RB_RED)) { printf("连续红色节点\n"); return 0; } } *black_height = left_bh + (node->color == RB_BLACK ? 1 : 0); return 1; }注意:红黑树的根节点如果被写成红色,虽然暂时不违反性质4,但违反了性质2。修复函数的最后一行一定强制把root涂黑,这一步不能省,也不必担心破坏黑高——把根从红变黑等于所有路径黑高+1,依然一致。
6.2 随机插入删除的压力测试
光校验还不够,需要大量随机测试覆盖各种场景。我的做法是生成一组随机数,先反复插入,每次插入后调用校验函数检查五条性质,再把数据打乱顺序删除,每删一个也调用校验。数量从1万到100万都跑一遍,只有这个量级的测试通过了,我才敢说这个实现基本没问题。
随机测试的价值在于:红黑树的修复场景有几十个分支组合,人工构造用例很难全部覆盖。比如先触发场景2的旋转,再触发场景3,再触发场景1的向上冒泡,这些组合只有在随机序列里才会频繁出现。
6.3 常见问题排查:指针悬空、漏判NULL、颜色错乱
我把自己写红黑树遇到最多的问题梳理成一份速查表,如果你调试时卡住了,优先从这三个角度排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 程序崩溃 | 指针悬空 | 检查所有指针赋值顺序,尤其旋转后的parent指针 |
| 黑高校验失败 | NULL被当成了红色 | 所有NIL节点必须视为黑色,判断颜色前先判空 |
| 死循环 | 修复循环没有上移 | 检查while条件是否可能永不满足,z是否为NULL |
最后一点特别值得提:写删除修复的时候要小心NULL指针的访问。比如判断某个孩子是否为红色时,如果孩子是NULL,直接访问color字段就是段错误。我的处理方式是写一个辅助函数:
static int rb_is_red(rb_node_t *node) { return node != NULL && node->color == RB_RED; } static int rb_is_black(rb_node_t *node) { return node == NULL || node->color == RB_BLACK; }这两个函数能省掉大量野指针问题,也是Linux内核里rb_node的常规做法。
7. 从中序遍历角度重新审视红黑树
7.1 旋转为什么不会破坏BST性质
旋转操作做了几十遍以后,我反而对“中序遍历不变”这个性质有了更深体会。左旋和右旋本质上是在调整左右子树的高度差时,保持整棵树的中序遍历序列不变。以左旋为例,x的中序序列是【x的左子树,x,y的左子树,y,y的右子树】,旋转后y成为根,x成为y的左孩子,整个序列还是【x的左子树,x,y的左子树,y,y的右子树】。
这个性质保证了旋转之后树依然是一个合法的BST,不需要重新排序。这个思想跟AVL的旋转是同源的,理解一次,两种树都通了。
7.2 红黑树与AVL树的工程取舍
模拟实现完成后,我拿同一组数据测试了红黑树和AVL树的行为。直观感受是:红黑树的树高大约比AVL高10%~20%,查找次数也多一些,但插入删除的旋转次数明显减少。实际工程里如果要实现的是一种通用的有序容器,红黑树是更好的选择;如果读多写少、对查询性能极其敏感,AVL更合适。没有银弹,看场景。
对我个人来说,手写红黑树最大的收获不是背下了那些修复场景,而是彻底理解了“通过局部操作维护全局不变量”这个思想。面试的时候如果被问到“红黑树的插入修复为什么是O(log n)”,你能从场景1的向上冒泡和场景2/3的常数次旋转解释清楚,就已经超过了绝大多数只背结论的候选人。
这篇文章写完,我已经把完整的C源码项目放到了自己的代码仓库里,包含插入删除、查找、前中后序遍历、黑高校验和随机压力测试。如果你照着写一遍遇到问题,优先跑一下校验函数,它能告诉你哪条性质被破坏了,那基本就是bug所在的方向。红黑树这种东西,看十遍不如写一遍,写一遍不如调通一遍。