尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

红黑树图文详解:C语言200行实现插入删除与验证

红黑树图文详解:C语言200行实现插入删除与验证 红黑树图文详解C语言200行代码实现要说数据结构里哪个东西“面试必问、工程常用、但自己手写就露馅”红黑树绝对排第一。很多人背过它的五条性质也知道STL的map底层就是它但真让用C语言从零写一棵红黑树能当场写对的人真不多。这篇文章我就用图文的方式把红黑树的原理拆开揉碎然后给出一份200行左右的C语言实现插入、删除、查找、验证全都有可以直接抄去用。之所以用C语言而不是C或Java是因为红黑树的核心难点在于指针操作和节点染色用C语言写一遍你对指针的掌控力、对递归和迭代的理解都会有肉眼可见的提升。这东西适合正在学数据结构的在校生、准备面试的求职者以及想夯实C语言功底的在职开发。我会把每个旋转、每种变色为什么这么做讲清楚而不是甩一段代码让你自己背。1. 红黑树原理速览五条性质背后的设计逻辑红黑树本质上还是一棵二叉查找树BST只是给每个节点增加了一个“颜色”属性红色或黑色并通过颜色的约束来维持树的平衡。它的平衡不像AVL树那样严格要求左右子树高度差不超过1而是用一种更松弛的“黑高相等”来保证最坏情况下也有对数级别的时间复杂度。1.1 五条性质到底在约束什么先亮出这五条性质它们不是背诵材料每一条都有明确的工程目的每个节点要么是红色要么是黑色。根节点是黑色。每个叶子节点NIL节点即空节点是黑色。如果一个节点是红色那么它的两个子节点必须是黑色即红节点不能连续出现。从任一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点。这五条里最核心的是第4条和第5条。第4条限制了红色节点不能连续堆叠迫使红色节点必须穿插在黑色节点之间第5条要求所有路径的黑色节点数一致这就保证了最长路径不会超过最短路径的两倍。为什么是两倍因为最长路径是“黑-红-黑-红-黑”交替最短路径是全黑最坏情况下红节点数量等于黑节点数量所以路长最多差一倍。这个“两倍以内”的松弛平衡就是红黑树相比AVL树最大的优势插入删除时需要的旋转次数更少综合性能更好。1.2 红黑树的数据结构定义用C语言实现第一步就是定义节点结构。这里的关键设计是引入一个NIL哨兵节点而不是用NULL来代表空指针。为什么因为性质5要求所有叶子的黑高相等如果用NULL处理空节点时就得在代码里反复判断麻烦且容易出错。统一用NIL节点后所有叶子都指向同一个黑色NIL节点代码逻辑大幅简化。#define RED 0 #define BLACK 1 typedef struct RBNode { int key; int color; struct RBNode *left; struct RBNode *right; struct RBNode *parent; } RBNode; typedef struct { RBNode *root; RBNode *nil; } RBTree;这里我用了“叔节点”“祖父节点”“兄弟节点”这些称谓后文会频繁用到。初始化树的时候让nil节点存在颜色设为黑色左右孩子指向自己root先指向nil。很多新手会漏掉nil的左右孩子指向自己这一步后续插入时一旦访问到nil的孩子就会崩溃这个细节我后面还会再强调。2. 旋转操作红黑树保持平衡的基本功红黑树的左旋和右旋本质上是在不破坏BST中序有序性的前提下调整子树的结构。你可以把它理解为“换根”左旋就是把原来子树的根变成右孩子的左子树右旋是镜像操作。2.1 左旋与右旋的图解逻辑假设要对X节点做左旋前提是X的右孩子Y不能是NIL。左旋之后Y成为子树的新根X变成Y的左孩子Y原来的左子树变成X的右子树。用伪代码描述就是三个“认爹”操作第一步把Y的左子树过继给X当右子树第二步Y替代X成为其父节点的孩子第三步X成为Y的左孩子。右旋完全对称把left和right互换即可。很多教程把旋转写得特别抽象我用一个生活化类比想象三个人排队打饭X在最前面Y排在X身后Z排在Y身后。左旋就是把Y拉到最前面X排到Y身后原本站在X和Y中间的那些人都自动往右挪一位。树的旋转干的就是这个事只不过还要维护parent指针。2.2 旋转代码的注意事项写旋转函数时最容易踩的坑有三个一是忘记处理parent指针二是对nil节点调用旋转三是旋转之后没有更新原父节点的孩子指针。我建议在写代码之前先在纸上画出旋转前后的节点父子关系把每一条指针变化都标出来再去写代码就顺很多。旋转本身不会改变节点的颜色也不会触发平衡调整它只是为后续的变色和再平衡做准备。所以红黑树修复过程中往往是“旋转变色”组合使用我在下一节插入操作里给你看具体怎么配合。3. 插入流程与C语言实现从变色到旋转的三步修复红黑树的插入分两步第一步是普通BST插入新节点一开始染成红色第二步是如果破坏了红黑树性质就通过变色和旋转修复。为什么新节点初始是红色因为插入红节点不影响黑高性质5天然满足只需要处理可能出现的连续红节点问题。3.1 插入修复的三种情况插入修复是一个循环过程从新节点X开始只要X的父节点是红色就说明性质4被破坏需要处理。设X的叔叔节点是U分三种情况情况一叔叔U是红色。解决方案是把父节点和叔叔都染黑把祖父染红然后X向上跳到祖父节点继续循环。这其实就是“把红色向上传递”不涉及旋转。情况二叔叔U是黑色且X是右孩子。先对X的父节点做左旋把情况转化成情况三。情况三叔叔U是黑色且X是左孩子。把父节点染黑祖父染红然后对祖父做右旋。这三个情况的处理顺序很重要情况二本身不会终结修复它只是通过一次旋转变成情况三情况三做完之后整棵树就恢复平衡了。我在刚开始学的时候总是搞不清情况二和情况三的区别后来总结了一句口诀“父红叔红就变色父红叔黑右先左父红叔黑左就右根节点记得涂黑。”口诀只能辅助记忆真正理解还是要盯着图看。3.2 插入修复的C语言代码下面是完整的修复代码配合BST插入一起使用void rbInsertFixup(RBTree *T, RBNode *z) { while (z-parent-color RED) { if (z-parent z-parent-parent-left) { RBNode *y z-parent-parent-right; // 叔叔节点 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-right) { // 情况二当前节点是右孩子先左旋 z z-parent; leftRotate(T, z); } // 情况三当前节点是左孩子右旋变色 z-parent-color BLACK; z-parent-parent-color RED; rightRotate(T, z-parent-parent); } } else { // 镜像对称父节点是祖父的右孩子 // 逻辑与上面完全对称只是把left和right互换 } } T-root-color BLACK; }这里特别提示一点插入修复循环结束之后根节点可能被染成了红色所以最后一定要无条件把根节点染黑。这个操作不破坏任何性质但保证了性质2。4. 删除流程与C语言实现最复杂的双黑节点处理红黑树的删除比插入复杂一个等级因为它可能破坏性质5——删掉一个黑色节点后某些路径的黑高就少了1。修复的核心思路是引入“双黑”概念把被删节点的黑色“下推”给它的孩子让这个孩子变成双黑节点然后通过旋转和变色把双黑节点“抵消”掉。4.1 删除的三种形态和普通BST删除一样根据被删节点z的孩子数量分三种情况z没有孩子直接删除。如果z是红色万事大吉如果z是黑色需要修复。z只有一个孩子用孩子替换z替换后把孩子染黑。因为z是黑色孩子必须继承黑色才能保持黑高。z有两个孩子找到z的后继节点右子树中最小的用后继的key覆盖z然后问题转化为删除后继节点。后继节点最多只有一个右孩子于是又回到前两种情况。删除后的修复是重点。设替换z的孩子是xx可能变成双黑。修复循环里要分四种情况讨论以x是其父节点的左孩子为例情况一x的兄弟w是红色。将w染黑父节点染红对父节点左旋w更新为x的新兄弟。这个操作把问题转化成w是黑色的情况。情况二w是黑色且w的两个孩子都是黑色。把w染红x上移到父节点。这里的效果是x和w各自的黑色都上移给了父节点父节点变成新的双黑节点。情况三w是黑色w的左孩子是红色w的右孩子是黑色。把w染红w的左孩子染黑对w右旋w更新为原来的左孩子。这是为情况四做铺垫。情况四w是黑色w的右孩子是红色。把父节点的颜色赋给w父节点染黑w的右孩子染黑对父节点左旋x指向根节点循环结束。删除修复的每一情况都是在逐步把双黑向上推最终要么被旋转抵消要么到达根节点后被直接消除。这个过程非常绕我强烈建议你做一次完整的“删除演练”在一棵画好的红黑树上手动删除一个黑色节点看它需要经过哪些修复步骤遇到什么情况走什么分支比看十遍代码都管用。4.2 删除修复的C语言代码void rbDeleteFixup(RBTree *T, RBNode *x) { while (x ! T-root x-color BLACK) { if (x x-parent-left) { RBNode *w x-parent-right; if (w-color RED) { // 情况一 w-color BLACK; x-parent-color RED; leftRotate(T, x-parent); w x-parent-right; } if (w-left-color BLACK w-right-color BLACK) { // 情况二 w-color RED; x x-parent; } else { if (w-right-color BLACK) { // 情况三 w-left-color BLACK; w-color RED; rightRotate(T, w); w x-parent-right; } // 情况四 w-color x-parent-color; x-parent-color BLACK; w-right-color BLACK; leftRotate(T, x-parent); x T-root; } } else { // 镜像对称逻辑互换left和right } } x-color BLACK; }注意删除修复结束时最后一行把x染成黑色因为如果循环因x是红色节点而退出说明这个红色节点需要继承被删节点的黑色直接染黑就恢复了平衡。5. 完整C语言实现200行代码搭建可运行的红黑树前面讲解的是核心逻辑这一节给出完整的可运行代码。我把功能分为四大块旋转、插入与修复、删除与修复、验证与遍历。代码风格偏工程化变量命名清晰可以直接编译运行。5.1 全部代码一览#include stdio.h #include stdlib.h #define RED 0 #define BLACK 1 typedef struct RBNode { int key; int color; struct RBNode *left; struct RBNode *right; struct RBNode *parent; } RBNode; typedef struct { RBNode *root; RBNode *nil; } RBTree; RBNode* createNode(RBTree *T, int key) { RBNode *node (RBNode*)malloc(sizeof(RBNode)); node-key key; node-color RED; node-left T-nil; node-right T-nil; node-parent T-nil; return node; } void initTree(RBTree *T) { T-nil (RBNode*)malloc(sizeof(RBNode)); T-nil-color BLACK; T-nil-left T-nil; T-nil-right T-nil; T-nil-parent T-nil; T-root T-nil; } void leftRotate(RBTree *T, RBNode *x) { RBNode *y x-right; x-right y-left; if (y-left ! T-nil) y-left-parent x; y-parent x-parent; if (x-parent T-nil) { T-root y; } else if (x x-parent-left) { x-parent-left y; } else { x-parent-right y; } y-left x; x-parent y; } void rightRotate(RBTree *T, RBNode *x) { RBNode *y x-left; x-left y-right; if (y-right ! T-nil) y-right-parent x; y-parent x-parent; if (x-parent T-nil) { T-root y; } else if (x x-parent-right) { x-parent-right y; } else { x-parent-left y; } y-right x; x-parent y; } void rbInsertFixup(RBTree *T, RBNode *z) { while (z-parent-color RED) { if (z-parent z-parent-parent-left) { RBNode *y z-parent-parent-right; 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-right) { z z-parent; leftRotate(T, z); } z-parent-color BLACK; z-parent-parent-color RED; rightRotate(T, z-parent-parent); } } else { RBNode *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; rightRotate(T, z); } z-parent-color BLACK; z-parent-parent-color RED; leftRotate(T, z-parent-parent); } } } T-root-color BLACK; } void rbInsert(RBTree *T, int key) { RBNode *z createNode(T, key); RBNode *y T-nil; RBNode *x T-root; while (x ! T-nil) { y x; if (z-key x-key) x x-left; else x x-right; } z-parent y; if (y T-nil) { T-root z; } else if (z-key y-key) { y-left z; } else { y-right z; } rbInsertFixup(T, z); } RBNode* rbMinimum(RBTree *T, RBNode *x) { while (x-left ! T-nil) x x-left; return x; } void rbTransplant(RBTree *T, RBNode *u, RBNode *v) { if (u-parent T-nil) { T-root v; } else if (u u-parent-left) { u-parent-left v; } else { u-parent-right v; } v-parent u-parent; } void rbDeleteFixup(RBTree *T, RBNode *x) { while (x ! T-root x-color BLACK) { if (x x-parent-left) { RBNode *w x-parent-right; if (w-color RED) { w-color BLACK; x-parent-color RED; leftRotate(T, x-parent); w x-parent-right; } if (w-left-color BLACK w-right-color BLACK) { w-color RED; x x-parent; } else { if (w-right-color BLACK) { w-left-color BLACK; w-color RED; rightRotate(T, w); w x-parent-right; } w-color x-parent-color; x-parent-color BLACK; w-right-color BLACK; leftRotate(T, x-parent); x T-root; } } else { RBNode *w x-parent-left; if (w-color RED) { w-color BLACK; x-parent-color RED; rightRotate(T, x-parent); w x-parent-left; } if (w-right-color BLACK w-left-color BLACK) { w-color RED; x x-parent; } else { if (w-left-color BLACK) { w-right-color BLACK; w-color RED; leftRotate(T, w); w x-parent-left; } w-color x-parent-color; x-parent-color BLACK; w-left-color BLACK; rightRotate(T, x-parent); x T-root; } } } x-color BLACK; } void rbDelete(RBTree *T, int key) { RBNode *z T-root; while (z ! T-nil) { if (key z-key) z z-left; else if (key z-key) z z-right; else break; } if (z T-nil) return; RBNode *y z; RBNode *x; int y_original_color y-color; if (z-left T-nil) { x z-right; rbTransplant(T, z, z-right); } else if (z-right T-nil) { x z-left; rbTransplant(T, z, z-left); } else { y rbMinimum(T, z-right); y_original_color y-color; x y-right; if (y-parent z) { x-parent y; } else { rbTransplant(T, y, y-right); y-right z-right; y-right-parent y; } rbTransplant(T, z, y); y-left z-left; y-left-parent y; y-color z-color; } free(z); if (y_original_color BLACK) { rbDeleteFixup(T, x); } } void rbInorder(RBTree *T, RBNode *x) { if (x T-nil) return; rbInorder(T, x-left); printf(%d(%s) , x-key, x-color RED ? R : B); rbInorder(T, x-right); } int main() { RBTree T; initTree(T); int nums[] {7, 3, 18, 10, 22, 8, 11, 26, 2, 6, 13}; int n sizeof(nums) / sizeof(nums[0]); printf(插入过程:\n); for (int i 0; i n; i) { rbInsert(T, nums[i]); printf(插入 %2d 后: , nums[i]); rbInorder(T, T.root); printf(\n); } printf(\n删除 18 后: ); rbDelete(T, 18); rbInorder(T, T.root); printf(\n); printf(删除 10 后: ); rbDelete(T, 10); rbInorder(T, T.root); printf(\n); printf(删除 7 后: ); rbDelete(T, 7); rbInorder(T, T.root); printf(\n); return 0; }这份代码严格来说去掉注释和空行核心逻辑也就200行上下。它没有做内存释放的收尾工作比如释放nil和所有节点在实际项目中记得补上避免内存泄漏。我写这个版本主要是强调逻辑清晰所以牺牲了一些极致的精简比如没有用递归删除所有节点但功能是完整的。5.2 代码里的两个关键细节第一个细节是rbTransplant函数它是删除操作的基础。这个函数负责把子树u替换为子树v只处理“替换”这件事不负责更新v的左右孩子——调用方需要额外处理。很多自己实现红黑树的人在这里栽跟头以为transplant把所有事都干完了结果漏了更新v的孩子指针。第二个细节是删除双孩子节点时如果y不是z的直接右孩子需要先把y从原位置摘除再把y的右孩子换成z的右孩子。这个顺序不能反过来否则y的右孩子指针一旦被覆盖y原来的子树就连不上了。这也是红黑树删除代码里最容易写错的地方。6. 测试与调试如何验证你写的红黑树是对的写完红黑树怎么确认它没写错靠肉眼看不靠谱必须写验证代码。红黑树的五条性质中性质1和2可以简单检查性质3因为NIL实现天然满足性质4和5需要遍历验证。我一般写一个rbVerify函数递归计算每条路径的黑高同时检查是否有连续红节点。6.1 验证红黑树性质的测试代码int rbVerify(RBTree *T, RBNode *x, int blackCount, int *targetBlack) { if (x T-nil) { if (*targetBlack -1) *targetBlack blackCount; return blackCount *targetBlack; } if (x-color RED) { if (x-left-color ! BLACK || x-right-color ! BLACK) { printf(性质4违反: 节点 %d 的红色子节点\n, x-key); return 0; } } if (x-color BLACK) blackCount; return rbVerify(T, x-left, blackCount, targetBlack) rbVerify(T, x-right, blackCount, targetBlack); } int rbCheck(RBTree *T) { if (T-root T-nil) return 1; if (T-root-color ! BLACK) { printf(性质2违反: 根节点不是黑色\n); return 0; } int target -1; return rbVerify(T, T-root, 0, target); }把它加到main函数里每次插入或删除后调用rbCheck只要有一处性质被破坏就能立刻发现。我在调试自己的实现时是用随机生成的大量数据做插入删除压力测试每操作一步就调用一次rbCheck确认整棵树始终满足所有性质。这个测试方法比任何单步调试都高效。6.2 常见Bug与排查思路实录根据我自己的踩坑经历和帮别人调代码的经验红黑树实现最常见的Bug基本集中在以下几类旋转时忘记更新parent指针。症状是删除或插入修复后树结构错乱中序遍历出现重复或丢失节点。排查方法是在每次旋转后立刻打印所有节点的parent关系和旋转前的预期逐一对上。NIL节点的parent指针没有被正确维护。很多实现里NIL节点的parent一直保持NULL但代码里又访问了x-parent-left一旦x是NIL这里就段错误。解决办法是在transplant和旋转函数里统一把所有nil的parent都指向正确的父节点或者写一个isNil宏专门判断。插入修复循环里z更新后的parent是否还是红色判断错了。z z-parent-parent之后要重新进入while条件检查有些新手在情况一结束后没有重新赋值z导致死循环。删除双孩子节点时y_original_color取错。记住一定要在y被移动之前保存原颜色如果放在transplant之后取拿到的是y的新颜色修复条件判断就会出错。根节点被误判为NIL。初始化时root指向nil但插入第一个节点后root应该指向新节点。如果initTree时忘了把root置为nil后续所有查找都查不到。这些问题几乎每个人都有机会踩一遍不是笨不笨的问题。我的建议是每写完一个函数先做10次小规模的手工模拟插入删除确认每一步的颜色和形状都符合预期再写下一个函数。数据结构这东西暴力调越多理解越深。7. 红黑树的性能对比与实际应用场景红黑树常被拿来和AVL树、B树、跳表放在一起比较。AVL树更严格平衡查找更快但插入删除的旋转次数更多红黑树牺牲了部分查找性能换来更少的旋转操作。跳表实现简单但内存占用更高且最坏情况不如红黑树稳定。7.1 红黑树 vs AVL树 vs 跳表我用一张表来对比这三者的核心差异对比项红黑树AVL树跳表平衡程度松弛平衡最长路径不超过最短的2倍严格平衡左右子树高度差不超过1随机化平衡不保证严格查询时间复杂度O(log n)O(log n)期望O(log n)插入/删除旋转次数插入最多2次旋转删除最多3次插入/删除都可能O(log n)次旋转无旋转但需要维护多层索引实现复杂度中等偏难中等简单内存开销低低较高多级指针典型应用STL map/set、Java TreeMap、Linux内核数据库索引早期实现Redis ZSET、并发场景红黑树之所以在工程界这么流行核心原因是“综合性能最优”。插入删除虽然也要做平衡调整但旋转次数有常数上限插入最多2次旋转删除最多3次旋转变色操作的代价很低。而AVL树虽然查找稳定一旦插入删除频繁旋转次数多到让人心疼。跳表实现简单但每个节点平均多出1.33个指针内存不划算。7.2 实际工程中的红黑树红黑树在真实项目里到处都是。C STL的std::map和std::set的底层就是红黑树Java的TreeMap和TreeSet也是Linux内核的管理红黑树用于管理内存区间、进程调度等场景Nginx用它来管理定时器。这些都是需要频繁插入删除且需要有序遍历的场景红黑树正好满足。我自己在实际项目中用红黑树最常见的场景是写一个“有超时时间的缓存”需要按key快速查找还需要按时间顺序淘汰超时项。如果用两个数据结构哈希表链表维护同步逻辑很麻烦用红黑树按超时时间排序每次只需要查最左边的节点就是最早超时的项插入删除也能自动维护顺序代码反而更短。这就是红黑树“有序平衡”特性的价值。8. 写在最后动手写一遍才真正理解红黑树红黑树这个数据结构光看教程是永远学不会的只有自己动手实现一遍才能真正理解旋转和变色为什么要那样设计。我这篇文章给到的代码和排查思路都是我自己反复调试磨出来的经验照着抄可以少走很多弯路。最后分享一个小技巧如果你在实现过程中被某个情况绕晕了拿一张纸画一棵小规模的红黑树用硬币或者黑白棋子当节点手动模拟插入删除的每一步看看旋转之后哪些节点变色了、哪些指针变了。我在学习红黑树最艰难的那段时间就是靠这种“手动模拟”一点点把每个情况吃透的。现在再看别人的红黑树代码基本一眼就能看出他写的哪个分支有bug说实在的这种手感完全靠一遍遍调试喂出来的。如果这篇文章对你有帮助建议你把代码拷下来自己加点打印信息一步一步跟踪插入删除的完整流程。等你亲眼看着一棵乱糟糟的树经过几次旋转和变色重新变得“规规矩矩”那种成就感真的很上头。红黑树不难难的是你愿不愿意花两三个小时静下心来写一遍。
返回列表