【算法导论】红黑树详解之一(插入)

        本文地址:http://blog.csdn.net/cyp331203/article/details/42677833

        作者:苦_咖啡

        欢迎转载,但转载请注明出处,否则将追究相应责任,谢谢!。



        红黑树是建立在二叉查找树的基础之上的,关于二叉查找树可以参看【算法导论】二叉搜索树的插入和删除和【算法导论】二叉树的前中后序非递归遍历实现。对于高度为h的二叉查找树而言,它的SEARCH、INSERT、DELETE、MINIMUM、MAXIMUM等操作的时间复杂度均为O(h)。所以在二叉查找树的高度较高时,上述操作会比较费时,而红黑树就可以解决这种问题。红黑树是许多平衡搜索树中的一种,可以保证在最坏的情况下基本动态集合操作的时间复杂度为O(logn),n为节点个数。


        一、什么是红黑树


        红黑树是一颗二叉查找树,所不同的是,它的每个节点上增加了一个存储位来表示节点的颜色,可以是RED或BLACK。红黑树的每个节点包含5个属性:color、key、left、right、和p(父节点)。如果一个节点没有子节点或父节点,在二叉查找树中,相应的指针就会指向NULL(空),而这里就是红黑树与二叉查找树的第二个不同之处:


        红黑树中并没有任何一个节点的左子节点、右子节点或者父节点会指向NULL(空),取而代之的是使用一个nil的哨兵节点来放在原来为NULL的位置,类似于【算法导论】10.2不带哨兵节点和带哨兵节点的双向链表文中带哨兵节点的双向链表。在红黑树中,哨兵节点nil是所有一个与树种普通节点有相同属性的对象。它的color属性为BLACK,而它的其他属性可以任意设置,一般时没有什么意义。nil用来替换二叉查找树中原本为NULL的指针的指向,同时root节点的父指针指向nil。我们将除nil节点的其他节点视为内部节点,在红黑树中,我们主要关注内部节点。


        红黑树结构图:

【算法导论】红黑树详解之一(插入)_第1张图片



        红黑树的五条性质:


        红黑树,通过对任何一条从根到叶子的简单路径上各个节点的颜色进行约束,红黑树可以保证没有一条路径会比其他路径长出2倍以上,所以称之为平衡。为了保持平衡,红黑树要满足以下五条性质:


        1、每个节点不是红色就是黑色的。


        2、根结点是黑色的。


        3、每个叶节点(nil)是黑色的,实际上nil只有一个。


        4、如果一个节点是红色的,则它的两个子节点都是黑色的。


        5、对每个节点,从该节点到其所有后代叶结点的简单路径上,均包含相同数目的黑色节点。


        对于上面的第5点,又有一个新的定义,黑高:从某个节点x出发(不含该节点)到达一个叶结点的任意一条简单路径上的黑色节点个数称之为该节点的黑高注意黑高是将nil计算在内的


        将上图的黑高标识出来:

【算法导论】红黑树详解之一(插入)_第2张图片

       

        红黑树如何保证其性能O(lgn)?


        证明:红黑树的高度为O(lgn)


        实际上,一颗有n个内部节点的红黑树的高度至多为2lg(n+1)。我们设一个节点x的黑高为hb(x)。


                先来证明以任一节点x为根的子树中至少包含2^(hb(x)-1) -1个内部节点,使用归纳法:


                ①如果x的高度为0,那么x是叶结点(nil),且以x为根结点的子树至少包含2^(1-1) -1=0个内部节点,符合结论。


                ②假设x的高度为k时(k>=1),以x为根节点的子树至少包含2^(hb(x)-1)-1个内部节点。


                ③对于x的高度为k+1的红黑树,我们考虑它的两个子树,它的两个子树的高度为k,那么满足②号条件,两个子树至少包含了2^(hb(x)-1)-1个内部节点,所以以x为根的树至少包含了(2^(hb(x)-1)-1)+(2^(hb(x)-1)-1)+1=2^(hb(x))-1个内部节点,因此得证。


        对于高度为h的红黑树,根据性质4我们不难发现在每一条路径上,至少有一半以上的黑色节点,否则必定有两个红色节点会相邻。所以树的黑高至少为h/2。所以根据上面已证明的结论,有n>=2(h/2 - 1)-1,所以有:h<=2lg(n+1)。得证。


        根据这个证明,我们由二叉搜索树的字典操作的时间复杂度为O(h),而红黑树的高度为O(lgn)可知,红黑树的字典操作的时间复杂度应为O(lgn)。所以红黑树有比二叉搜索树更优的性能。

        

        红黑树的单分支情况


        这里需要注意的是,由于红黑树的性质的限制,对于某些情况的单分支是不可能出现的。


        1、可能出现的单分支:

        【算法导论】红黑树详解之一(插入)_第3张图片

        因为只有在上面的两种情况下才有可能在单分支的情况下,保持上面的第五条性质(注意nil节点)。而下面的几种情况,都是不能保证第五条性质的单分支情况。


        2、不可能出现的单分支情况:


        【算法导论】红黑树详解之一(插入)_第4张图片

因为上述四中但分支情况下,不能保证性质5。例如前两种,必定会让红色节点->叶子节点(nil)线路的黑色节点数比红色节点->黑色节点线路的黑色节点数少一。所以,我们容易发现,红黑树种只可能有双分支或黑上红下的单分支情况



二、左旋和右旋操作


        旋转操作是后序很多红黑树操作必不可少的部分,旋转操作可以保持二叉搜索树性质的搜索树局部操作。


        左旋和右旋(都是针对上面的那个节点):

        【算法导论】红黑树详解之一(插入)_第5张图片


        通过上图,我们很容易发现,旋转之后,还是可以保持二叉搜索树的性质。但是却不能保证红黑树的性质,例如右图,如果是21节点是单分支的情况(a节点=nil),就一定不会满足第五条性质。


我们可以比较容易的给出左旋和右旋的代码:


左旋:

/*
	 * 以x为支点进行左旋
	 */
	void left_rotate(node* x) {
		if (x == nil_node) {
			return;
		}

		node* y = x->r_child;

		x->r_child = y->l_child; //让y的左节点接到x的右节点位置,这样能满足二叉搜索树的性质

		if (y->l_child != nil_node) { //这一句判断不能少
			y->l_child->parent = x; //让左节点的父指针指向x
		}

		y->parent = x->parent;

		if (x->parent == nil_node) { //x原本为根结点的情况
			this->root = y;
		} else if (x == x->parent->r_child) {
			y->parent->r_child = y;
		} else {
			y->parent->l_child = y;
		}
		//处理左节点
		y->l_child = x;
		x->parent = y;
	}


右旋:

	/*
	 * 以x为支点进行右旋
	 */
	void right_rotate(node* x) {
		if (x == nil_node) {
			return;
		}

		node* y = x->l_child;

		x->l_child = y->r_child;
		x->l_child->parent = x;

		y->parent = x->parent;

		if (x->parent == nil_node) { //x原本为根结点的情况
			this->root = y;
		} else if (x == x->parent->l_child) {
			y->parent->l_child = y;
		} else {
			y->parent->r_child = y;
		}

		y->r_child = x;
		x->parent = y;
	}


我们容易发现,旋转操作,不论是左旋还是右旋,时间复杂度都是O(1)。


三、红黑树的插入操作

        

        之前有提到过,二叉搜索树的字典操作的时间复杂度为O(h),而红黑树的操作却可以在O(lgn)_内完成。为了做到这一点,我们肯定需要在插入之后进行一些节点的调整,让其满足红黑树的性质。所以在完成插入之后,还需要对树进行调整,对节点重新着色,并旋转。

        红黑树的节点插入可以分为插入过程和调整过程;其中插入过程与二叉搜索树差不太多。只是注意搜索树中为NULL的地方,现在被替换成nil哨兵节点,其次插入的节点我们总是将其着色为红色。

        

        插入的代码:

	void insert(int k) {
		node* n = new node(k);
		node* y = nil_node;
		node* x = root;
		while (x != nil_node) { //寻找合适的插入位置,这里主要是满足二叉搜索树的性质
			y = x; //y来记录x的位置
			if (x->key > k) {
				x = x->l_child;
			} else {
				x = x->r_child;
			}
		}

		n->parent = y; //找到了合适的插入位置,y为这个位置的父节点

		if (y == nil_node) { //为空树的情况
			root = n;
		} else if (y->key > n->key) { //左节点
			y->l_child = n;
		} else {
			y->r_child = n; //右节点
		}

		//处理nil
		n->l_child = nil_node;
		n->r_child = nil_node;

		//处理插入节点的颜色
		n->color = RED;

		//调整操作
		rb_insert_fixup(n);
	}
        


        下面是重头戏,我们来看一看,rb_insert_fixup这个调整函数如何实现。

        要调整,我们首先得明白要调整什么,即我们之前的插入操作,会破坏红黑树的哪些性质?我们再来看看这5条性质:


        1、每个节点不是红色就是黑色的。


        2、根结点是黑色的。


        3、每个叶节点(nil)是黑色的,实际上nil只有一个。


        4、如果一个节点是红色的,则它的两个子节点都是黑色的。


        5、对每个节点,从该节点到其所有后代叶结点的简单路径上,均包含相同数目的黑色节点。


        其中1、3性质肯定是满足的,而对于5性质,由于我们插入的节点都染红色了,所以不存在多出黑色节点的情况,所以5性质也可以满足。可能出现问题的就是2、4性质,对于2号性质,如果插入的是一颗空树(只有nil节点),那么根结点就是红的,就不满足2性质。如果插入的节点的父节点是红色,而我们插入的也是一个红色的节点,那么就不满足4性质了。

       

       处理性质2:


        对于2号性质,其实很好解决,我们只需要在调整函数最后加上一句root->color=BLACK即可,将根结点染黑。


       处理性质4:       


        而对于4号性质,出现问题的是父节点为红色的情况,这时待调整节点和父节点都为红,不满足4性质,于是我们需要调整的判断条件就是父节点如果为红,就需要继续调整。这里实际上就要分三种情况,首先都已经默认z的父节点z->p的颜色为红色,循环条件为while(z->color==RED):


        情况一、z的叔节点y是红色,其中z为要调整的节点(下同):


       由于z的父节点z->p和叔节点都是红色,所以z的祖父节点z->p->p的颜色一定是黑色,这是因为插入之前是一颗“完好”的红黑树。

       对于这种情况,处理办法是:将z的父节点和叔节点都染黑,而将z的祖父节点染红,这样做了之后,我们仔细思考一下,发现其实这样对当前树的走父节点或走叔节点的路径的黑节点数没有影响。然后这时我们再将z点指向z的祖父节点位置,然后继续进入循环


       【算法导论】红黑树详解之一(插入)_第6张图片



        看到这里,你可能不理解,为什么要这样做,其实可以这样来看,我们解决问题的方法无外乎就是改变染色和旋转,这里z,z的父节点,z的叔节点都是红色,如果我们单纯利用旋转,其实是没办法完全解决这个问题的,因为父节点和叔节点的关系是“对称”的,这里他们的颜色也一样,都为红色,不论左旋还是右旋都无法完全解决红色节点相邻的问题,比如:我们以祖父节点21为支点进行不论左旋还是右旋,会发现11和13还是在一起,且为红色。所以这里我们考虑使用染色,先将21染红,然后将13和22染黑,这样可以解决11和13都为红色的情况,但是却不能解决21的父节点仍然是红色的情况,实际上我们不能保证21的父节点9是什么颜色,所以我们也不可以使用旋转来解决,只能把z指向21,将问题扔给循环去继续处理。




       情况二、z的叔节点y是黑色的且z是一个右孩子

       这里的做法就是将二情况转换成三情况,当然就要用到旋转,但是我们又不希望z的位置发现变化,所以这里先让z=z->p,然后再以z为支点进行左旋,因为左旋会让z下降一级,所以实际上z还是指向的原来那一层的节点,z->p->p的位置还是没有变。


       【算法导论】红黑树详解之一(插入)_第7张图片

        这里只是简单的转换成情况三,重点关注情况三。


       情况三、z的叔节点y是黑色的且z是一个左孩子

       这时实际上是z和z->p都是红色,z->p->p和z.->p->right都是黑色,我们想做的就是让z和z->p之间多个黑节点,这样就能满足性质4,但是同时我们又希望保持性质5。于是我们这样处理:

       z->p染黑,z->p->p染红,然后以z->p->p为支点右旋,这样就搞定了,如图:


       【算法导论】红黑树详解之一(插入)_第8张图片



        上图中的情况,首先要明确的一点是节点9的左右子节点并不是nil,否则肯定是不平衡的,所以情况三的出现一定是在循环运行中,而不会是刚刚插入之后。那么现在可以看看,为什么这里使用一次染色和一次右旋就可以解决问题:


        ①染色:通过将21染黑和26染红,我们可以解决性质4,但是却让原本26-39这条路径上少了一个黑色节点,一定会破坏性质5;于是我们接下来借助旋转

       

        ②左旋:为么右旋在这里能够解决26-39路线上少一个黑色节点的问题,而且能够保证红黑颜色性质。


        先来看节点,我们通过右旋(以26红为支点),让21(黑)上升到祖父节点,让26(红)下降到右子树中,这样从21-9的路径上黑色节点与以前一样,而且21-39的右路径上,补上了一个黑色节点21,这样性质5被保证了。


        其次看红黑性质,也就是性质4。旋转之后的21,9,26,39四个节点来看,红黑性质是可以保证的,主要关注右旋过程中移动了的原21节点的右节点a,右旋之后移动到了26的左子树上,那么就要求a一定要是一个黑色的节点,那么a是么?可以肯定a一定是,因为在转换之前21-a这条线路要满足性质4,而只有9和21都是红色会破坏性质4,也就是说,除却21和9,红黑树的其他地方都是满足红黑树的性质的,所以21这个右节点a也不例外,a为黑色,所以bingo,problem solved!



       经过上面分别对性质2和性质4三种情况处理的分析,不难给出实现代码:


	/*
	 * 修复颜色,因为插入的点,我们总是将其先染成红色,所以如果父节点为黑色则不需要修复,如果父节点为红色,则需要修复
	 * 修复颜色分三种情况:
	 * ①当前插入点的父亲为红色,且祖父节点的另一个节点为也为红色,且父节点为祖父节点的左子节点
	 * ②当前插入点的父节点为红色,且祖父节点的另一个节点为黑色,且本节点为父结点的右子节点
	 * ③当前插入点的父节点为红色,且祖父节点的另一个节点为黑色,且本节点为父结点的左子节点
	 */
	void rb_insert_fixup(node* z) {
		while (z->parent->color == RED) {
			if (z->parent == z->parent->parent->l_child) { //如果z的父节点是祖父节点的左子节点
				node* y = z->parent->parent->r_child;
				if (y->color == RED) {									//情况1		处理性质4
					z->parent->color = BLACK;							//情况1
					z->parent->parent->r_child->color = BLACK;			//情况1
					z->parent->parent->color = RED;						//情况1
					z = z->parent->parent;								//情况1
				} else if (z == z->parent->r_child) {					//情况2
					z = z->parent;										//情况2
					left_rotate(z);										//情况2
				} else {												//情况3
					z->parent->color = BLACK;							//情况3
					z->parent->parent->color = RED;						//情况3
					right_rotate(z->parent->parent);					//情况3
				}
			} else { //当父节点是祖父节点的右孩子的情况,与上面差别在于将所有的left操作和节点改成对应的right的
				node* y = z->parent->parent->l_child;
				if (y->color == RED) {
					z->parent->color = BLACK;
					z->parent->parent->l_child->color = BLACK;
					z->parent->parent->color = RED;
					z = z->parent->parent;
				} else if (z->parent == z->parent->parent->l_child) {
					z = z->parent;
					right_rotate(z);
				} else {
					z->parent->color = BLACK;
					z->parent->parent->color = RED;
					left_rotate(z->parent->parent);
				}
			}
		}
		root->color = BLACK;													//处理性质2
	}


四、插入操作的算法分析


       通过观察,不难发现,由于有n个节点的红黑树的高度为O(lgn),因此插入过程的时间复杂度为O(lgn);而对于调整过程rb_insert_fixup而言,只有情况1让z上升两层,有可能让while一直发生,但是由于树高的限制,时间复杂度也只可能在O(lgn),而对于情况2和情况3,程序所做的旋转不会超过两次。因为只要执行到情况2或者3,while循环就将要结束了。所以整个节点插入的时间复杂度为O(lgn)。


       下一篇将是红黑树的删除节点,敬请关注。




你可能感兴趣的:(C/C++,算法,算法导论)