天天看点

算法导论学习---红黑树详解之插入(C语言实现)

前面我们学习二叉搜索树的时候发现在一些情况下其高度不是很均匀,甚至有时候会退化成一条长链,所以我们引用一些”平衡”的二叉搜索树。红黑树就是一种”平衡”的二叉搜索树,它通过在每个结点附加颜色位和路径上的一些约束条件可以保证在最坏的情况下基本动态集合操作的时间复杂度为O(nlgn).下面会总结红黑树的性质,然后分析红黑树的插入操作,并给出一份完整代码。

先给出红黑树的结点定义:

#define RED 1
#define BLACK 0

///红黑树结点定义,与普通的二叉树搜索树相比多了一个颜色域
typedef struct node
{
      int key,color; ///定义1为红,0为黑
      node *p,*left,*right;
      node(){
         color=BLACK; ///默认结点颜色为黑
      }
}*RBTree;
           

一.从红黑树的性质讲起

红黑树是一棵二叉搜索树,它在每个结点上增加了一个存储位来表示结点的颜色,可以是RED或BLACK。通过堆任何一条从根到叶子结点的简单路径上各个结点颜色进行约束,红黑树确保没有一条路径会比其它路径要长出两倍,因而是近似于平衡的。下面给出红黑树的五条”红黑性质”,这几条性质是后面插入和删除的基础。

1).每个结点或是红色或是黑色。

2).根结点是黑色的。

3).每个叶结点是黑色的

4).如果一个结点是红色的,那么它的两个子节点都是黑色的。

5).对于每个结点,从该结点出发到其所有后代叶结点的简单路径上均包含相同数目的黑结点。

我们设从某个结点x出发(不含该结点)到达一个叶结点的一条简单路径上的黑结点个数为该结点的黑高,记为bh(x)。显然根据性质5,每个结点的黑高都是唯一的。下面我们来证明:一棵含有n个内部结点的红黑树的高度至多为2lg(n+1).

先证明以任一结点x为根的子树中至少包含2^bh(x)-1个内部结点。我们采用数学归纳法,对x的黑高进行归纳。如果x的黑高高度为0即x为叶子结点,则显然正确。x的黑高为bh(x),则其两棵子树的黑高为bh(x)或bh(x)-1(取决与它们本身的颜色),则可归纳出对应的内部结点至少为2^(bh(x)-1)-1,即x包含的内部结点至少为2^(bh(x)-1)-1+2^(bh(x)-1)-1+1=2^bh(x)-1,因此得证。然后现在我们设含n个结点的树高为h,然后根据性质4,可以知道从根到叶结点(不包括根结点)的任一条简单路径上都至少有一半的结点为黑色。因此树的黑高至少为h/2;然后有上面的结论得n>=2^(h/2)-1.解得h<=2lg(n+1)。于是我们就证明了动态集合操作Search,Minimum,Successor等都可以在O(lgn)时间复杂度内完成了。

下面给一张红黑树的图片。

算法导论学习---红黑树详解之插入(C语言实现)

二.旋转操作

后面的插入和删除过程中都会有破坏红黑树性质的情况发送,为了维护这些性质,必须要改变树中某些结点的颜色和指针结构。指针结构的修改是通过旋转来完成的,旋转被分为左旋和右旋;在插入和删除操作中这两个操作会多次出现,我们先来分析一下这两个操作。

这两个操作对应的图像如下:

算法导论学习---红黑树详解之插入(C语言实现)

从图像上可以看出,左旋和右旋操作并不是很复杂。下面我们以左旋为例进行解析:如图我们假设结点x的右儿子为y。左旋的前提是:结点必须要有右儿子。左旋的结果是:y代替x,x成为y的左儿子,x成为y的左儿子,y的左儿子成为x的右儿子。下面是具体的代码实现:

///左旋:对具有任意具有右孩子的结点可以进行
///传入要选择的结点指针的引用
///旋转以后x的右孩子y取代了x,而x成为y的左孩子,y的左孩子成为x的右孩子
///下面的代码就是完成这三个部分
void LeftRotate(RBTree x)
{
      RBTree y=x->right; ///y表示x的右孩子
      x->right=y->left; ///第一步:将x的右孩子设为y的左孩子
      if(y->left!=Nul)
            y->left->p=x;

     y->p=x->p;  ///更改y的父结点为x的父结点
      if(x->p==Nul)    ///第二步:y替代x,需要分情况讨论
            rt=y; ///x原来是根结点,则设y为根结点
      else if(x==x->p->left)
           x->p->left=y;   ///更改y为x父结点的左孩子
      else
           x->p->right=y; ///更改y为x父结点的右孩子

      y->left=x; ///第三步:将x设为y的左孩子
      x->p=y;
}
           

右旋与左旋是类似的,代码如下:

///右旋:对任何具有左孩子的结点可以进行
///传入要右旋的结点指针的引用
///旋转以后结点x被左孩子y替换,x成为y的右儿子,y的右孩子成为x的左孩子
void RightRotate(RBTree x)
{
        RBTree y=x->left; ///y表示x的左孩子
        x->left=y->right; ///第一步:x的左孩子更改为y的右孩子
        if(y->right!=Nul)
            y->right->p=x;

        y->p=x->p;  ///更改y的父结点为x的父结点
        if(x->p==Nul) ///第二步:y替代x,需要分情况讨论
            rt=y;  ///x原来为根结点,指定y为新根结点
        else if(x==x->p->left)
            x->p->left=y;  ///更改x父结点的左孩子为y
        else
            x->p->right=y; ///更改x父结点的右孩子为y


       y->right=x; ///第三步:更改y的右结点为x
       x->p=y;
}
           

三. 插入详解

理解红黑树插入和删除操作的难点在于:在我们改变一个结点以后,产生的情况太多。相应的我们也就得到了基本的讨论方法:将所有的情况进行分类讨论,然后就可以理解代码为什么要这样写了。实际上只要把《算法导论》上的那些Case理解清楚,红黑树的操作也就不难懂了。下面应用分类讨论的思想来理解红黑树的插入操作。

首先下面我们先给出插入部分的代码:

///红黑树的插入
///RB插入函数与普通的BST的插入函数只是稍微有点不同
///我们将原来的null换成了Nul结点,然后对新加入的结点,染成红色
///然后调用RBInsertFixUp函数进行调整,使得红黑树的性质不被破坏
void RBInsert(int key)
{
       RBTree z=new node;
       z->color=RED;
       z->key=key;
       z->p=z->left=z->right=Nul;
       RBTree y=Nul;
       RBTree x=rt;
       while(x!=Nul) ///按照二叉搜索树的性质寻找z的插入点
       {
            y=x;
            if(z->key<x->key)
                x=x->left;
            else
                x=x->right;
       }
       z->p=y;
       if(y==Nul)///插入的是根节点
            rt=z;
       else if(z->key<y->key)
            y->left=z;
      else
           y->right=z;
      RBInsertFixUp(z); ///插入红色结点可能违反了红黑树的某些性质,调用调整函数进行调整
}
           

从代码上看,这里的插入和二叉搜索树的插入并没有什么太大的不同。只是在最后加了一个插入调整函数:RBInsertFixUp()。之所以要加入调整函数是因为我们插入一个结点并将其染成红色导致红黑树的某条性质被违反了;所以我们需要着重的讨论到底会违反那条性质,然后我们这么样在调整函数中就这种情况进行性质的恢复。

首先我们设插入的结点为z,插入以后z会被染成红色。如果z的父结点是黑色的,则不会违反任何性质,不需要调整。所以我们下面只需要讨论z的父结点为红色的情况。然后在这种情况下,只有性质4是肯定会被违反的,然后我们再按z的父结点是其祖父结点的左儿子还是右儿子分类(这两种情况没有本质性的区别,只是在编码上稍微有点不同而已),下面我们只讨论z的父结点是其祖父结点的左儿子的情况。然后我们设z的叔结点(即z父结点的兄弟结点)结点为y。在这些前提下我们再进行分类讨论:

1)情况1:y的颜色为红色

这时其祖父结点一定是黑色的(因为在插入这个结点之前红黑树没有性质被违反)。这种情况下,我们可以将z的父结点染成黑色,z的叔结点染成黑色,z的祖父结点染成红色;这样染色以后,z的祖父结点以下的子树肯定是合法的红黑树,但是z的祖父结点可能违反性质4了,相当于将z在树中上升了两层(z=z->p->p)。这种情况下如果新z的父结点为黑就会退出循环了(如果新z为根节点就一定会退出,这时候根节点有可能会被染成红色,所以在退出循环后需要再将根节点染成黑色)。

这种情况的示意图如下:

算法导论学习---红黑树详解之插入(C语言实现)

2):y的颜色为黑

在y的颜色为黑的情况下我们在按z是其父结点的左孩子还是右孩子进行分类

(1).z是其父结点的左孩子

这时我们可以通过将z的父结点染成黑色,z的祖父结点染成红色,然后对z的祖父结点进行一次右旋恢复性质4,并且不违反其它的性质(对于这一点我们只要自己画一下图就可以很清楚的看到了)。

(2).z是其父结点的右孩子

这种情况显然是可以通过将z指向z的父结点,然后对z进行一次左旋就可以变成情况(1)处理了。

这两种情况的示意图如下:其中图片上的情况二对应我们这里的(2),情况三即为我们这里的(1)

算法导论学习---红黑树详解之插入(C语言实现)

总的来讲只要y为黑,我们就可以退出循环了;上面的两种情况的划分不过是内部结构的一些小转变而已。

综上,其实红黑树的插入操作主要是违反了性质4,调整函数的过程就是分了两类情况对性质4进行调整的过程,并不是很复杂。上面给出的是z的父结点为其祖父结点左儿子的情况,其实对于右儿子的情况基本上是一样的(具体的见代码)。

还有一个令人困惑的地方在于红黑树的代码中大量的使用了z->p->p这种形式,但是这真的不会导致空指针异常吗?对此算法导论上进行了比较详细的论证。在这里我简单的说一下我的看法:首先,在插入根节点的时候,是不会进入循环中的,所以就不会引用z->p->p。然后在插入根节点以后,除根结点外每个结点的祖父结点都是一定存在的,所以第一次引用不会出问题。问题只有可能出现在z在树中上移的情况下(对应情况1),可能会上移到某个位置,而这个位置的父结点不存在父结点,这时就会导致空指针的危险了;但是我们要注意到,这个位置只有可能是根结点,而如果z移动到了根节点,那么就会因为根节点的父结点是黑色而退出循环了!根本就不会再出现z->p->p这种形式的引用。所以不需要担心z->p->p的引用会出现空指针异常。

下面给出红黑的的插入调整函数RBInsertFixUp()函数的代码,其对应我们上面讨论的三种情况:

///红黑树插入调整函数
///我们将插入结点染成红色,可能违反了性质4,所以要进行调整
///调整的过程其实就是根据不同的情况进行分类讨论,不断转换的过程
///最后转成可以通过染色和旋转恢复性质的情况
void RBInsertFixUp(RBTree z)
{
    ///在下面的代码中z结点总是违反性质4的那个结点
     while(z->p->color==RED) ///x是红色,它的父结点也是红色就说明性质4被违反,要持续调整
     {
          ///下面的过程按x->p是其祖父结点的左孩子还是右儿子进行分类讨论
           if(z->p==z->p->p->left) ///父结点是其祖父结点的左孩子
           {
                 RBTree y=z->p->p->right;  ///表示z的叔结点

                 ///下面按y的颜色进行分类讨论
                 if(y->color==RED)
                 {///如果y是红色并z的祖父结点一定是黑色的,这时我们通过下面的染色过程
                   ///在保证黑高不变的情况下(性质5),将z在树中上移两层,z=z->p->p
                        z->p->color=BLACK;
                        y->color=BLACK;
                        z->p->p->color=RED;
                        z=z->p->p;///如果上移到根节点或某个父结点不为红的结点就可以结束循环了
                 }
                 else   ///叔结点为黑色
                 { ///此时要根据z是其父结点的左孩子还是右孩子进行分类讨论
                   ///如果z是左孩子则可以直接可以通过染色和右旋来恢复性质
                   ///如果z是右孩子则可以先左旋来转成右孩子的情况

                      if(z==z->p->right)
                      {
                            z=z->p;
                            LeftRotate(z); ///直接左旋
                      }
                      ///重新染色,再右旋就可以恢复性质
                      z->p->color=BLACK;
                      z->p->p->color=RED;
                      RightRotate(z->p->p);
                 }
           }
           else///父结点是祖父结点的右孩子
           {
                  RBTree y=z->p->p->left;  ///叔结点
                  if(y->color==RED)
                  {
                         z->p->color=BLACK;
                         y->color=BLACK;
                         z->p->p->color=RED;
                         z=z->p->p;
                  }
                  else
                  {///右儿子的时候可以直接左旋,重新调色恢复性质
                   ///左儿子可以先右旋成右儿子再处理
                         if(z==z->p->left)
                         {
                              z=z->p;
                              RightRotate(z);
                         }
                         z->p->color=BLACK;
                         z->p->p->color=RED;
                         LeftRotate(z->p->p);
                  }
           }
     }
     ///将根节点染成黑色,是必要的步骤;处理两种情况
     ///1.第一次插入根结点被染成红色的情况
     ///2.和在上面的循环中根节点可能被染成红色的情况
     rt->color=BLACK;
}
           

下一篇:算法导论学习–红黑树详解之删除

继续阅读