红黑树之我见(一)

研究红黑树有10天多了,从最初了解什么是红黑树,到看伪代码,到网上查找资料,再到自己用C代码实现,感触很多。当然,最多的感触就是,红黑树光从理解和实现角度来说并不复杂,这是带给我很大安慰的地方大笑。然后,强烈推荐一个博客吧http://blog.csdn.net/v_july_v/article/details/6284050,博主是一个很nice的人,热心帮我解答了一个问题,而且,在他的博文里只要任何一处发现了错误,就免费送一些很好的资料的pdf版本。然后,再推荐一本书,相信很多人都知道《算法导论》(Intro to Algorithm)。

好了,切入正题,首先,说一下什么是红黑树。这里假设大家都知道树这样一种数据结构,不明白的朋友快去找本数据结构树看一下吧。红黑树是一种二叉查找树,二叉嘛,就是只分两个叉喽,那就是至多只有两个子树喽。就好比,现在计划生育,只生一个好,那么有两个呢,最好是双胞胎,多棒啊大笑扯远了

红黑树的每一个结点,都有五个域:我把它分为两派,我思想突然转到变形金刚里的博派和狂派了,额。。。这两个派别呢,叫指针域和数据域,指针域包括parent( p )、leftchild( left )和rightchild( right ),数据域包括key和color,key值的类型可以是内置类型或者自己typedef的新类型,color就是咱们红黑树的特色了,红黑红黑,意思就是咱们这个红黑树的结点呢,只有两种颜色,要么是红,要么是黑。哈哈,突然想到,我做人有时候也这样,太过较真,非红即黑,非黑即白,不好不好委屈

《算法导论》第二版原文中说,if a child or the parent of a node does not exist, the corresponding field of the node contains the value NIL。通俗地说,如果某个结点它的一个孩子或者父结点不存在,那么该结点相应的指针域就指向结点NIL(nil[T])。比如,如果结点z的父结点不存在,那么p[z] = nil[T]。算法导论里把nil[T]视为外结点,把红黑树本身带有关键字的结点称为内结点。nil[T]的属性p, left, right, key这四个都可以任意规定,但是color应当是黑色(Black),这是为什么呢,请看下文分解微笑

光知道红黑树的以上这些还是不够,因为这样我就可以随便构造红黑树了,我可以构造一个结点全是黑的或者全是红的,那么还叫什么红黑树啊,所以,最重要的,红黑树必须满足5个条件:

1)每个结点要么是红的,要么是黑色;

2)根结点必须是黑色的;

3)外结点(NIL)是黑色的;

4)如果一个结点是红色的,那么他的两个孩子都是黑的;惊恐,怎么生出来两个都是黑孩儿,难道...)

5)对每个结点,从该结点到子孙结点的所有路径上包含相同的黑结点。这个如果大家不明白,请看下文,我会解释。

这样一个特别的树和普通的二叉树相比他有什么优点呢。普通二叉树的性能与该二叉树的形状有关,如果这个二叉树长得很高,那么用它实现集合操作比如SEARCH,性能不会比顺序表好多少,最坏情况下是O(logn)。但是,咱们的红黑树,根据《算法导论》里的引理13.1: 一棵有n个内结点的红黑树的高度至多为2log(n+1)。所以,动态集合操作SEARCH、MINIMUM、MAXIMUM、SUCCESSOR和PREDECESSOR使用红黑树都可以在O(logn)时间内实现,而接下来要说的RB-TREE-INSERT和RB-TREE-DELETE操作也能在O(logn)时间内实现,总之就是说用红黑树实现动态集合操作在时间复杂性方面很不错,很稳定,鲁棒性好,抗击打能力强,bulabulabula...所以STL里的set就是用红黑树实现的微笑

下面我主要说RB-TREE-INSERTRB-TREE-DELETE,以《算法导论》里的伪代码为例吧

第一个伪代码是旋转,至于这个什么用,请慢慢往下看微笑。好比,想学好一门武功,总要练些基本功,嗯,就当先练扎马步吧

//LEFT-ROTATE(T, x)
  y ← right[x]       ▹ Set y.
  right[x] ← left[y] ▹ Turn y's left subtree into x's right subtree
  if left[y] != nil[T ]
     then p[left[y]] ← x
  p[y] ← p[x]        ▹ Link x's parent to y.
  if p[x] = nil[T]
     then root[T] ← y
     else if x = left[p[x]]
             then left[p[x]] ← y
             else right[p[x]] ← y
  left[y] ← x        ▹ Put x on y's left.
  p[x] ← y

结合下图,我解释下这段伪代码到底干啥的

现在我要把上图经过左旋后变成下图。仔细观察,我们发现实现上只需要改变四个结点的关系即可:结点7,11,18,14。所以,我们需要断开7和11的联系,11和18的联系,18和14的联系,重接7和18的联系,11和14的联系,11和18的联系,这实际上就是上面那段伪代码在说的事儿~

现在我们的结点是11,伪代码第1行,y为11的右孩子18,第3、4行是将14的父指针指向11,这样就断开了14和18的联系,重接11和14的联系,但没有断完,因为18的右孩子仍然指向14,但接下来会说这个。第5行断开7和11的联系,使7和18产生联系,第7~10行断开原有的11和18的联系,并重建二者的联系(父子轮流做,爷孙天天有啊尴尬)。好了,似乎是事情办完了,但是,别忘记了,我们断开的联系只是断了一方面,就是某个结点的父结点换了,但是他原有的父结点的孩子结点仍然指向它,或者它的原有的孩子结点的父结点仍然指向它。那么,11~12两行代码,就是为了彻底断绝父子关系(从此,你就是路人甲,我就是路人乙发火,互不相欠),很简单,大家一看就懂,就不说了。

有了LEFT-ROTATE,那么RIGHT-ROTATE我就不说了,如果有问题,请给我留言!回去睡一觉,明早起来接着写偷笑

休息一晚,精神饱满。咱们继续往下说。上回说到旋转断绝父子关系,曾相识之人变为路人。这个旋转的作用就在于,使得曾经的结点互不相认,为了之后红黑树插入和删除结点修正红黑树的属性服务。

下面先看RB-INSERT伪代码

//RB-INSERT(T, z)
  y ← nil[T]
  x ← root[T]
  while x ≠ nil[T]
      do y ← x 
          if key[z] < key[x]
               then x ← left[x] 
               else x ← right[x]
   p[z] ← y
   if y = nil[T]
      then root[T] ← z 
      else if key[z] < key[y] 
          then left[y] ← z 
          else right[y] ← z 
  left[z] ← nil[T] 
  right[z] ← nil[T]
  color[z] ← RED 
  RB-INSERT-FIXUP(T, z)

RB-INSERT-FIXUP伪代码,既然是FIXUP,想必是在RB-INSERT里出了什么叉子,要来修正一下,使红黑树的5点性质继续得以保持喽!

//RB-INSERT-FIXUP(T, z)
    while color[p[z]] = RED
        do if p[z] = left[p[p[z]]]
                then y ← right[p[p[z]]]
                        if color[y] = RED
                           then color[p[z]] ← BLACK            // Case 1
                                color[y] ← BLACK               // Case 1
                                color[p[p[z]]] ← RED           // Case 1
                                z ← p[p[z]]                    // Case 1
                           else if z = right[p[z]]
                                     then z ← p[z]             // Case 2
                                             LEFT-ROTATE(T, z)  // Case 2
                                color[p[z]] ← BLACK            // Case 3
                                color[p[p[z]]] ← RED           // Case 3
                                RIGHT-ROTATE(T, p[p[z]])        // Case 3
                else (same as then clause with “right” and “left” exchanged)
color[root[T ]]← BLACK

 代码好多,甭急,咱们结合下面几个图,一起慢慢看。 

RB-INSERT第1行,定义一个y结点,并使其指向NIL,这个y的作用往下看才会知道(大侠总是埋得很深微笑),定义结点x指向根结点。先结合下图(这个图是从上面那个博客里扒来的),我现在要在图1中插入结点0,也就是RB-INSERT( T, z )里的z

现在x指向结点9,图中黑色的NULL结点就是NIL。代码3~7行,由于x不是NIL,因此执行循环体,这个很简单,就不细说了,最终,y指向红色结点1(以后结点我就用[1]标示),而x指向left[1]也就是NIL。代码3~7行作用其实就是是给y赋值,而不是x,x也是为了y服务的,我们可以看到接下来的代码里x再没有出现过(狡兔死,走狗烹,飞鸟尽,良弓藏惊恐这个y为什么要定位到[1]呢,因为我们要插入[0],所以,自然,[0]应该插入到[1]的左孩子结点处。于是,我们有了第8行代码,p[0]指向[1],这很显然。然而,这和我们上面说旋转时的代码一样,我们现在只是建立了[0]和[1]的一重关系,就是[0]承认[1]是我爹,但是[1]还没说[0]你就是我儿。那么,9~13行代码,就是为了交代这件事情。14~16行,把[0]染红,并且找了两个NIL当自己的儿子(们)。

还记得我前面说过,插入的结点是红色的吗?这里我给出我自己的看法吧。如果,大家觉得有问题或建议,请给我留言

我认为理由就是,如果你插入的结点是黑色的,那么必然会影响红黑树性质5。那么,有人会问,如果插入结点是红色的,那么会影响性质4和2,这影响的还多一些呢。其实,仔细看一下,性质2,如果插入的结点是根结点,那么很简单,我们直接把这个结点染黑就行了。那么其实,就只剩下性质4和性质5的影响之间的差别了。而,这两个性质影响了,要修正过来都是挺麻烦的。但是,在我看来,如果插入的结点是红色的话,在有些情况下是不需要做任何修正的,就比如我们现在插入[0],直接插入,整个操作就完成了。所以,我的意思是说,插入结点如果是黑色,那么就一定要修正,因为它100%地影响性质5,但是如果结点是红色,并不一定要修正,比如我们在说的这个例子。这就是我对插入的结点为什么要是红色的一些个人意见,如果大家还有什么好的意见,说来听听微笑

插入完[0]后,插入就结束了,红黑树的5条性质得以保持,请看下图。

那么,我们的FIXUP没有用到,因为color[ p[0] ] == Black。所以,我再在上图的基础上接着插入,还是按照那篇博客里提到的顺序插入吧。我现在插入[11],现在大家一眼能看出来了,怎么办。直接插到12的左孩子处即可,so easy!插好后的图如下。

好,我再接着插入,现在插入[7],好了,我们想一下[7]应该放在何处,根据RB-INSERT的伪代码,那么显然是在2的右孩子处,现在问题来了,color[2] == Red,color[7] == Red,违反了性质4.所以,就需要FIXUP了。

是时候分析FXIUP了!

首先,第1行代码,这个必须满足,不满足就不用修正了。我们从第2行开始看,如果插入的结点[z]的父结点是它祖父结点的左孩子,很不幸,我们这里的[2]是[1]的右孩子,那么,我们只能到最后的else里去走一遭了——else (same as then clause with "right" and "left" exchanged)。啊,原来就是把if里的right和left互换即可,这太好办了。咱们就按照这个意思来吧!(把RB-INSERT-FIXUP的2~14行代码的left和right互换)

从第2行看起,现在p[z]也就是[2]是[1]的右孩子,那么y就是z的叔叔,[2]的兄弟,[1]的左孩子——[0],当当当当,闪亮登场。

color[0] == Red,所以,执行5~8,《算法导论》里叫case1,case1是什么呢,通俗地说,就是

case1: 插入的结点的叔叔结点是红色;

处理办法: 把插入结点的叔叔结点和父结点染黑,把其祖父结点染红,并将插入结点上升两层,指向其祖父结点

处理完后,图形如下(手画的,很粗糙,请见谅惊讶

然后再次进入while循环,由于p[z] == [9],而color[9] == Black,所以,循环结束,打印输出结点,如下图所示,红黑树性质得以保持

我们的case2和case3还没有遇到,别急,等我喝口水,再接着说。

好了,现在开始研究case2,我们先在上图中插入[19],这个大家一眼看出来了,如果没看出来,我也不再说了,很简单,见下图

现在,我再插入结点4,想必大家现在也知道了,[4]的位置是left[7],但是却违反了性质4,所以需要修正,开始修正吧!(注意,由于这里p[z] == right [p[ p[z] ] ],属于else ( ... "right" and "left" exchanged))

[7] == right[2],所以,y就是[7]的兄弟,要插入的结点[4]的叔叔(左叔叔)——NIL。color[y] == Black,所以,执行RB-INSERT-FIXUP的9~14行代码,是否符合case2呢,我们来看看case2:z的叔叔结点y是黑色,且z是左孩子,是case2,所以,我们z结点上升一层,指向[7],然后以[7]为支点进行右旋,得到下图中的右图(手画的大笑

之后,由于color[ p[z] ] == Red,再次进入while循环,z的叔叔结点是NIL,不符合case1,z == right[ p[z] ],所以是case3的情况,那么,就把[2]和[4]的颜色互换,然后以[2]为支点进行右旋(如上图中右图的红箭头所示),就得到了下图,红黑树性质又得以保持,我们又可以得瑟了...

好了,上边的三个case我都用图的形式提及了,相信大家仔细看的话应该明白了。唯一不足之处就是,实际上不是三个case,而是6个case,当p[z] == left[ p[ p[z] ] ]时是三个,而p[z] == right[ p[ p[z] ] ]时又是三个,这里我就不细说了,聪明的你,一定会知道。

到这里,红黑树的结点插入操作就基本说完了,那么我们也可以总结下:

1) 结点插入,首先要找到要插入的结点的位置,插入完后,要检查红黑树的5条性质是否都满足,不满足就需要fix up;

2) fix up视两大类,每一大类有三种情况,一共是六种情况,他们分别是:

①如果插入结点的父结点是其祖父结点的左孩子:

case 1: 插入结点的叔叔结点是红色;

case 2: 插入结点的叔叔结点是黑色,且插入结点是右孩子

case 3: 插入结点的叔叔结点是黑色,且插入结点是左孩子

②如果插入结点的父结点是其祖父结点的右孩子:

case 4: 插入结点的叔叔结点是红色;

case 5: 插入结点的叔叔结点是黑色,且插入结点是右孩子

case 6: 插入结点的叔叔结点是黑色,且插入结点是左孩子

这里要说一下,就是,通过上面一个插入[4]的例子我们可以看到,case 5转化为了case 6,而在插入[0]时,也就是case 1的情况,其实也可以找到case 4的情况,他们是互通的,并没有转换到case 2, case 3。实际上,我在上面举的这些是不全面的,case 1可以转化到case 2,而case 2也可以转换到case 3(请参见《算法导论》书中图13-4).同时,case 4,5, 6也有类似的转化,至于 case 1, 2, 3, 4, 5, 6之间的转换,我还没有碰到过,如果有请指出。

至于插入结点操作的时间复杂度的计算,《算法导论》里有严格的证明,而且也是很容易懂的,这里就不再累述。

删除结点的操作和插入想比,稍微复杂一些,正所谓请神容易送神难,但到底有多难,我们不妨看一下,其实,弄懂了,也是so easy!大笑

咱们在第二部分里再见吧,这篇太长了,怕大家看久了视觉疲劳,歇一歇,喝口水,打个小盹,享受一下美好的人生微笑

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

                
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值