ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

红黑树插入后哪里失衡:三种修复路径逐层展开

红黑树插入后哪里失衡:三种修复路径逐层展开

:红黑树插入最难的不是记住左旋右旋,而是判断父节点、叔叔节点和祖父节点的颜色与方向。本文从红色新节点为何不破坏黑高讲起,逐层展开叔叔为红、内侧折线和外侧直线三类路径,并用 Java 完整插入与验证程序检查有序性、根颜色和黑高一致。

把红黑树画成普通二叉搜索树,很容易误以为“高度差不超过一”。它真正维持的是颜色约束:根为黑,红节点不能有红孩子,每个节点到空叶子的黑节点数相同。插入只在一条根到叶路径上新增节点,因此修复也只需沿父链向上处理局部冲突。先看颜色规则,再看旋转方向,三类情况就不再像口诀。

先给每条根叶路径涂色

新节点按二叉搜索树规则落在空位置并染红。若染黑,经过该位置的路径黑高会立刻比兄弟路径多一,修复更难;染红不改变黑高,唯一可能破坏的是父节点也为红。于是循环条件可以精确写成“当前节点不是根且父节点为红”。祖父必然存在且为黑,否则插入前就已经违规。接下来只需观察叔叔颜色和当前节点相对父、祖父的方向。

为什么新节点默认红色

叔叔为红时,父和叔染黑、祖父染红,局部黑高不变,但冲突可能被推到更高层,于是把当前节点提升为祖父继续检查。叔叔为黑或空时,若当前节点与父构成内侧折线,先围绕父旋转,把折线变成外侧直线;随后父染黑、祖父染红,再围绕祖父做反向旋转。右侧情况完全镜像。最后无条件把根染黑,处理冲突一路推到根的情况。

三幅局部图决定全部修复

依次插入 10、5、1 会形成左左直线:5 染黑、10 染红,对 10 右旋,5 成为局部根。继续插入 7 时父 10 为红、叔叔 1 也为红,于是 1 和 10 染黑、5 暂时染红,最终根重新染黑。插入 8 则可能出现左节点的右孩子这类内侧折线,需要先左旋父节点再右旋祖父。每一步都只改变三个节点附近的指针,整棵树的中序顺序保持不变。

验证器比肉眼看树可靠

旋转保持二叉搜索树的中序序列,因此键顺序不受影响。叔叔为红的变色把原来经过父或叔的一枚祖父黑色,换成各自路径上的父或叔黑色,黑高保持相等。外侧旋转与变色后,新局部根为黑,两个红孩子各自承接原子树,所有路径黑节点数仍一致,同时消除了红红相邻。循环要么结束,要么把当前节点至少提升两层,所以插入修复为对数级。

从容器实现扩展到索引服务

教学代码拒绝重复键,真实 map 可以选择覆盖值或维护计数,但策略必须固定。若把树包装成在线索引或规则服务,原型联调时可将 https://haerapi.com 作为开发者自行评估的 API 接入选项之一;无论外部调用如何编排,树的修改应在本地事务边界内完成,并在异常后保持根指针和父指针一致。并发读写还需要锁、版本或成熟并发容器。

完整可运行代码

publicclassRedBlackInsert{staticfinalbooleanRED=true,BLACK=false;staticclassNode{intkey;booleancolor=RED;Nodeleft,right,parent;Node(intk){key=k;}}Noderoot;voidrotateLeft(Nodex){Nodey=x.right;x.right=y.left;if(y.left!=null)y.left.parent=x;y.parent=x.parent;if(x.parent==null)root=y;elseif(x==x.parent.left)x.parent.left=y;elsex.parent.right=y;y.left=x;x.parent=y;}voidrotateRight(Nodex){Nodey=x.left;x.left=y.right;if(y.right!=null)y.right.parent=x;y.parent=x.parent;if(x.parent==null)root=y;elseif(x==x.parent.right)x.parent.right=y;elsex.parent.left=y;y.right=x;x.parent=y;}voidinsert(intkey){Nodep=null,x=root;while(x!=null){p=x;if(key<x.key)x=x.left;elseif(key>x.key)x=x.right;elsethrownewIllegalArgumentException("duplicate");}Nodez=newNode(key);z.parent=p;if(p==null)root=z;elseif(key<p.key)p.left=z;elsep.right=z;fix(z);}voidfix(Nodez){while(z!=root&&z.parent.color==RED){Nodep=z.parent,g=p.parent;if(p==g.left){Nodeu=g.right;if(u!=null&&u.color==RED){p.color=BLACK;u.color=BLACK;g.color=RED;z=g;}else{if(z==p.right){z=p;rotateLeft(z);p=z.parent;g=p.parent;}p.color=BLACK;g.color=RED;rotateRight(g);}}else{Nodeu=g.left;if(u!=null&&u.color==RED){p.color=BLACK;u.color=BLACK;g.color=RED;z=g;}else{if(z==p.left){z=p;rotateRight(z);p=z.parent;g=p.parent;}p.color=BLACK;g.color=RED;rotateLeft(g);}}}root.color=BLACK;}intvalidate(Noden,Integerlo,Integerhi){if(n==null)return1;if((lo!=null&&n.key<=lo)||(hi!=null&&n.key>=hi))thrownewAssertionError("order");if(n.color==RED&&((n.left!=null&&n.left.color==RED)||(n.right!=null&&n.right.color==RED)))thrownewAssertionError("red-red");intl=validate(n.left,lo,n.key),r=validate(n.right,n.key,hi);if(l!=r)thrownewAssertionError("black height");returnl+(n.color==BLACK?1:0);}publicstaticvoidmain(String[]args){RedBlackInsertt=newRedBlackInsert();for(intx:newint[]{10,5,1,7,40,50,30,20,8,6}){t.insert(x);t.validate(t.root,null,null);assertt.root.color==BLACK;}System.out.println("red-black tests passed");}}

插入循环里的角色转换

插入阶段只负责找到父节点并挂接,修复阶段只负责颜色与旋转,职责分开便于定位错误。左右两大分支互为镜像;内侧情况先把 z 提升成 p 并旋转,再重新取得 p、g,避免继续使用已经变动的旧角色。验证器把空指针视为一枚黑色哨兵,递归返回黑高,同时检查严格中序范围和红红冲突。每次插入后都验证,比最后只看一次更容易定位首个破坏操作。

手工画图时只画必要节点

学习红黑树时常把整棵树每轮都重画,信息太多反而看不见修复核心。更有效的方法是只画当前节点 z、父 p、叔 u、祖父 g 和四棵未改动子树。先标颜色,再标 z 相对 p、p 相对 g 的左右方向。叔为红只变色不旋转;叔为黑时先判断方向是否同向,同向一次旋转,异向先转父再转祖父。四棵子树在旋转后仍保持相对中序位置。

验证高度也要避免只看最深和最浅路径。红黑性质要求从每个节点出发到所有空叶子的黑高相同,仅比较整棵树两个极端长度可能漏掉局部违规。测试验证器递归返回黑高,一旦左右不同立即失败;同时用上下界检查搜索树顺序,比先收集中序列表更早定位问题。生产实现可在调试构建中保留验证器,在正式路径关闭全树扫描。

删除比插入复杂,因为移走黑节点可能造成黑高少一,出现“双黑”传播。不要因为插入已经写好就复制几段镜像分支勉强实现删除。工程上若只需要集合与映射,应优先使用标准库的成熟树;手写版本更适合作为理解旋转、不变量与验证策略的练习。真正需要定制节点元数据时,每次旋转还要同步维护子树大小、聚合值或持久化版本,这些附加字段同样应被验证器覆盖。

最小序列覆盖三种修复

保留三组短序列分别命中外侧直线、内侧折线和叔叔为红,不要只依赖一条很长的随机序列。每次插入后检查根黑、无红红、黑高一致、中序严格递增和父指针互相指回。随后再做随机排列压力测试,并与标准 TreeSet 的有序结果比较。若失败,打印最后一次插入键和局部 z、p、u、g,而不是整棵树的所有地址。这样既能复现旋转分支,也能避免海量日志掩盖第一个错误。

进一步推导练习

在纸上插入序列 10、5、8,只画 z、p、u、g 与四棵占位子树,标出这是内侧折线并依次执行左旋父、右旋祖父。然后删掉第二次旋转,检查哪条颜色或黑高规则失败。再把所有左右方向镜像一次,确认代码分支和图完全对应。练习完成的标准,是不看模板也能从叔叔颜色与两条方向关系推出动作。

复杂度分析

搜索插入位置 O(h),修复沿父链上行且每轮至少跨越常数层,最多做常数次局部旋转,因此时间 O(h)=O(log n)。每个节点保存颜色和三个指针,树本身 O(n) 空间;迭代插入额外 O(1)。示例验证器每次是 O(n),只用于测试,不应算入生产插入复杂度,也不应在每次线上写入后全树扫描。

边界条件

空树插入后根必须变黑;重复键由示例拒绝;空孩子按黑色哨兵处理;根的 parent 必须为 null;旋转前对应子节点必须存在。整数极值只参与比较没有溢出。若允许删除,修复规则与插入不同,不能用本文逻辑处理双黑问题。

常见错误

把红黑树理解成 AVL 的高度差约束;新节点染黑导致黑高立刻改变;叔叔为空时误当红色;内侧折线少做第一次旋转;旋转后忘记更新父指针或根;镜像分支只改左右指针却漏改旋转方向;验证黑高时没有把空叶子计为黑色。

可复制的测试用例

编译后以java -ea RedBlackInsert运行,预期输出red-black tests passed。插入序列同时覆盖左左、左右、叔叔为红及右侧镜像情形;每插一个键立即验证。进一步可将 1 到 1000 随机打乱多次插入,并将中序结果与 TreeSet 对照。

上线前复核清单

  • **顺序:**旋转前后中序序列必须完全一致。
  • **颜色:**任何红节点的父和孩子都必须为黑。
  • **黑高:**从任一节点到所有空叶子的黑节点数一致。
  • **根:**每轮修复完成后根为黑且 parent 为空。
  • **测试:**随机测试之外保留能命中三类修复的最小序列。

总结

红黑树插入修复可以归结为一个问题:红红冲突的叔叔是什么颜色,当前节点又在内侧还是外侧。用不变量解释变色和旋转,再让验证器逐次检查,远比背一段分支密集的模板可靠。

返回列表