《数据结构与算法分析》详细对比自顶向下与自底向上红黑树——C实现自顶向下插入与删除

本文详细介绍了红黑树的自顶向下插入与删除方法,对比了自底向上的优缺点。通过分析红黑树的性质,解释了自顶向下插入如何避免复杂情况,减少了对父节点指针的依赖,简化了编码。同时,文章探讨了自顶向下删除的策略,包括将删除节点变为红色以保持树的平衡,以及如何处理不同情况下的节点删除。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言:

     这本书学到了最后一章终于出现了红黑树,它不愧为最难的几个数据结构之一,从看书到实现整个红黑树一共用时2天,第一天看书加上实现自顶向下的插入算法大概用了6个小时。

July 的博客里,还有各个知名博主博客里的红黑树基本是使用自底向上的方式来实现删除的,《数据结构与算法分析》这本书上建议使用自顶向下删除,但是对于如何删除,说的特别含糊,基本上不可以参考,于是在网络上寻找是否有详细的讲解,最终,找到一篇英文文献,比较详细的介绍了如何实现《数据结构与算法分析》12.2节中的自顶向下删除的方法。

文献连接:http://www2.ee.ntu.edu.tw/~yen/courses/ds-03/Red-Black-Trees-top-down-deletion.pdf

此文中讲述了各个情况如何旋转红黑树,但是并没有一个流程图,也没有将清楚如何删除,不过自己花了大约10个小时,把整个思路理清楚了,然后完成了自顶向下删除的流程图与伪代码,最终实现了自顶向下的删除过程。

我的github:

我实现的代码全部贴在我的github中,欢迎大家去参观。

https://github.com/YinWenAtBIT

介绍:

红黑树:

一、简介:

红黑树(Red Black Tree)是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。

它是在1972年由Rudolf Bayer发明的,当时被称为平衡二叉B树(symmetric binary B-trees)。后来,在1978年被 Leo J.Guibas 和 Robert Sedgewick 修改为如今的“红黑树”。

红黑树和AVL树类似,都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。

它虽然是复杂的,但它的最坏情况运行时间也是非常良好的,并且在实践中是高效的:它可以在O(log n)时间内做查找,插入和删除,这里的n 是树中元素的数目。

二、红黑树的性质:

性质1. 节点是红色或黑色。

性质2. 根节点是黑色。

性质3 每个叶节点(NIL节点,空节点)是黑色的。

性质4 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)

性质5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

如上是一颗红黑树,在实现红黑树时,我们有一个小技巧,能够巧妙的简化自顶向下的插入与删除操作。

三、平衡树旋转:

平衡树的旋转分成单旋转和双旋转,单旋转分成左旋转和右旋转。

双旋转是由两个单旋转构成的。

旋转的详细内容不是这里要叙述的,如果需要更加详细的介绍可以参考其他博客。

红黑树的旋转同样是使用以上两种旋转来达到其性质要求,主要是保证性质4和5在插入和删除节点时能继续成立。

四、实现小技巧:

在实现红黑树时,我们建立一个NullNode来代表NULL,如果一个节点的孩子指向了NullNode,那么就代表没有孩子。NullNode的颜色设置为黑色,在判断孩子节点颜色的时候,省去了对NULL的特殊处理。NULL的元素值设置为Infinity。

除了NullNode之外,根节点T不是真正的根节点,T中元素设置为NegInfinity,它真正的根在它的右子树上,即T->right才是真正的根,T->left指向NullNode。这样做的好处最开始我一点都不明白,直到实现自顶向下的删除时,才发现这样设计的妙处。

红黑树的操作:

基础操作

一、打印:

打印红黑树就需要从真正的根节点开始了,需要一个驱动程序来驱动真正的打印过程:

inline void PrintTree(RedBlackTree T)
{DoPrint(T->right, 0);}

/*打印红黑树*/
void DoPrint(RedBlackTree T, int depth)
{
	if(T != NullNode)
	{
		DoPrint(T->left, depth +1);
		for(int i =0; i<depth; i++)
			printf("    ");
		printf("%d,%s\n", T->Elememt, T->color == Red? "Red":"Black");

		DoPrint(T->right, depth+1);
	}
}


在这里是打印到终端里,打印出红色和黑色,紧跟着权值。下图是一张例子图,插入的点与《数据结构与算法分析》给出的相同,图的上方代表左孩子,下方代表右孩子。


二、搜索节点:

搜索节点过程中,如果遇到了要找的节点,就返回该节点,如果没有找到,就返回最后停留的节点,而不返回NullNode,这样做的好处是在删除节点时,方便找到子树的最小或者最大节点。

RedBlackTree find(ElementType item, RedBlackTree T)
{
	RedBlackTree Parent;
	while(T != NullNode && T->Elememt != item)
	{
		Parent = T;
		if(item <T->Elememt)
			T =T->left;
		else
			T =T->right;
	}
	if(T == NullNode)
		return Parent;
	else
		return T;
}

插入节点:

插入节点是第一个麻烦的地方,在这里只有把插入的节点设置为红色,才不会影响到树的性质5,但是如果插入节点的父亲节点是红色,那么就麻烦了,成以下几个方式讨论:

情况1:插入的是根结点。

原树是空树,此情况只会违反性质2。

  对策:直接把此结点涂为黑色。

情况2:插入的结点的父结点是黑色。

此不会违反性质2和性质4,红黑树没有被破坏。

  对策:什么也不做。


麻烦的情况:

以下两种情况只考虑插入的父亲节点P是祖父节点G的左子树,另一种镜像的情况可同理推出。

这里设N为插入的节点,即考虑的当前节点,P为N的父亲节点,U为N的兄弟节点,即此时P的右子树。G为P的父亲节点,也就是N的祖父。


情况3:N为红,P为红,U为黑,P为G的左孩子,N为P的左孩子

操作:如图P、G变色,P、G变换即G右旋(镜像情况左旋),结束。

解析:要知道经过P、G变换(旋转),变换后P的位置就是最初G的位置,所以红P变为黑,而黑G变为红都是为了不违反性质5,而维持到达叶节点所包含的黑节点的数目不变!



情况4:N为红,P为红,U为黑,P为G的左孩子,N为P的右孩子(镜像:P为G的右孩子,N为P的左孩子;反正两方向相反)。

操作:需要进行两次变换(旋转),图中只显示了一次变换-----首先P、N变换,颜色不变;然后就变成了情形3的情况,按照情况3操作,即结束。

解析:由于P、N都为红,经变换,不违反性质5;然后就变成3的情形,此时G与G现在的左孩子变色,并变换,结束。



最麻烦的情况:

以上两种麻烦的情况只需要完成变换就可以了,这个第五种情况需要递归的进行,这也是自底向上插入时,向上走的目的。


情况5:N为红,P为红,(祖节点一定存在,且为黑,下边同理)U也为红,这里不论P是G的左孩子,还是右孩子;不论N是P的左孩子,还是右孩子。

操作:如图把P、U改为黑色,G改为红色,未结束。然后将N指向G,P指向G的父亲节点,U指向N的兄弟,G也顺势向上移动,然后重新开始执行插入算法。

解析:N、P都为红,违反性质4;若把P改为黑,符合性质4,显然左边少了一个黑节点,违反性质5;所以我们把G,U都改为相反色,这样一来通过G的路径的黑节点数目没变,即符合4、5。此时G变红了,若G的父节点又是红的就有违反了4。所以如果需要把G当做插入节点,重新判断符合以上5种情况的哪一种,然后再处理。

所以经过上边操作后未结束,把G看做一个插入的红节点继续向上检索----属于哪种情况,按那种情况操作~遇上情况2就结束,或者达到根结点(此时根结点为红色,根据红黑树性质,改成黑色,完成插入)。




自底向上插入的缺点:

以上5种情况就概括了所有的插入时遇到的情况。需要自底向上处理的情况也就是情况5了。要实现情况5,那么就需要在红黑树结构体里加上父亲指针,或者使用栈的方式来解决递归向上。我参看july的博客,发现基本上都是增加一个父亲节点来解决这个问题。

1. 这样就就使得结构体变大了,这样的情况在自底向上实现AVL树时也出现了(我先自底向上实现了AVL树,觉得太繁琐,又自顶向下实现了一遍)。

2. 加上父亲节点之后,编程变得非常的麻烦,特别是旋转操作时,需要非常小心。(在编写自底向上的AVL树时就吃过亏)


自顶向下插入的改进:

使用自顶向下,改进的就是第5种情况,没有了第5种情况,3和4旋转之后,新的父亲节点都是黑色,不会遇上违反性质4的情况,即旋转之后红黑树就完成了插入。

1. 无需添加父亲指针

2. 编码简单


自顶向下的实现方法:

现在既然已经知道,要避免递归往上判断,就需要避免情况5出现,要避免情况5出现,就只需要一个办法:

让兄弟节点U永远是黑色。


插入步骤:

步骤1:. 从根节点往下,记录父亲节点P,祖父节点GP,祖祖父节点GGP,当前节点X。


步骤2.:如果X有两个红色的孩子,那么就使两个孩子变成黑色,X变成红色。这个过程可能使得X与P都是红色。如果不满足这个条件,就跳到步骤4。

 

步骤3.:如果出现了X与P都是红色,那么此时X的兄弟节点U必定是黑色,因为从上往下的过程,我们已经确定了U肯定是黑色的。那么这就回到了上面所描述的插入中的情况3/4。直接使用单旋转或者双旋转就可以解决。解决之后,让X指向旋转之后的根节点,此时X为黑色,两个孩子为红色,原本的X是指向这两个红色孩子中的其中一个的,我们在这里回退,目的是让GP,GGP随着X的下降,回到正常的值(此时不判定两个孩子是否都为红色,刚刚做的事情就是让两个孩子变成红色,根节点变成黑色,)。


步骤4: 完成过程2(3)之后,继续往下前进,重复过程2,4,直到到达key的节点,或者达到NULL,此时X为NULL。


步骤5:如果到达了key,那么key已经存在,不能再插入,直接返回即可。如果到达NULL,那么X指向插入新的节点,并且设X为红色,并且判断此时的P是否是红色,如果是红色,那么兄弟U必然是黑色,那么再进行一次步骤3,就完成了插入。


实现过程中需要保存GGP节点的原因是,G也会参与到旋转中,那么旋转之后,GGP需要指向新的旋转之后的根。

自顶向下插入编码:

循环往下前进,由于T中不是真正的根,且T中保存最小的值,所以X可以自动指向根,并使得P指向合适的值。在这里,GGP, GP, P, X, BRO都是全局变量,所以我感觉红黑树更合适使用C++的类来实现,这样可以把这些元素保存为类的成员,避免了多线程处理的不安全问题。

RedBlackTree insert(ElementType item, RedBlackTree T)
{
	X = P =GP = T;
	NullNode->Elememt = item;
	while(X->Elememt != item)
	{
		GGP = GP; GP = P; P =X;
		if(item<X->Elememt)
			X = X->left;
		else
			X = X->right;
		if(X->left->color == Red && X->right->color == Red)
			HandleReorient(item, T);
	}
	if(X != NullNode)
		return NullNode;

	X = (RedBlackTree)malloc(sizeof(struct RedBlackNode));
	X->Elememt = item;
	X->left = X->right = NullNode;
	
	if(item<P->Elememt)
		P->left = X;
	else
		P->right = X;

	HandleReorient(item, T);
	return T;
}

其中HandleReorient就是变换颜色,将当前X设置为红色,孩子设置为黑色的程序,所以插入节点时,由于新的节点指向NullNode,并且NullNode的颜色时黑色的,所以可以直接调用这个例程。这就是设置NullNode代替NULL的好处。

下面是static void HandleReorient(ElementType item, RedBlackTree T)函数的实现,其中Rotate函数实现情况3,4的旋转。

static void HandleReorient(ElementType item, RedBlackTree T)
{
	X->color = Red;
	X->left->color = Black;
	X->right->color = Black;

	if(P->color == Red)
	{
		GP->color = Red;
		if((item< P->Elememt) != (item < GP->Elememt))
			//P = Rotate(item, GP);
			Rotate(item, GP);

		X = Rotate(item, GGP);
		X->color =Black;

	}
	T->right->color = Black;
}

Rotate函数实现:

/*需要进行旋转的时候进行旋转,旋转之前应先进行判定*/
static Position Rotate(ElementType item, RedBlackTree Parent)
{
	if(item < Parent->Elememt)
		return Parent->left = item<Parent->left->Elememt? 
			rightSingleRotate(Parent->left):
			leftSingleRotate(Parent->left);
	else
		return Parent->right = item <Parent->right->Elememt?
			rightSingleRotate(Parent->right):
			leftSingleRotate(Parent->right);
}
到这里自顶向下的插入过程就完成了。

删除节点:
首先贴出自底向上删除的学习博客:http://blog.youkuaiyun.com/v_JULY_v/article/details/6105630

这篇博客是详细介绍了如何自底向上的插入与删除,插入的过程与我上面介绍的相似,删除过程非常的复杂,我在这里只基本介绍一下,便于进行对照。

自底向上删除:

算法导论上给出的算法先与普通搜索树相同,先找到该节点,再根据是否是叶子节点决定下一步:

  1. 没有儿子,即为叶结点。直接把父结点的对应儿子指针设为NULL,删除儿子结点就OK了。
  2. 只有一个儿子。那么把父结点的相应儿子指针指向儿子的独生子,删除儿子结点也OK了。
  3. 有两个儿子。这是最麻烦的情况,因为你删除节点之后,还要保证满足搜索二叉树的结构。其实也比较容易,我们可以选择左儿子中的最大元素或者右儿子中的最小元素放到待删除节点的位置,就可以保证结构的不变。当然,你要记得调整子树,毕竟又出现了节点删除。习惯上大家选择左儿子中的最大元素,其实选择右儿子的最小元素也一样,没有任何差别,只是人们习惯从左向右。这里咱们也选择左儿子的最大元素,将它放到待删结点的位置。左儿子的最大元素其实很好找,只要顺着左儿子不断的去搜索右子树就可以了,直到找到一个没有右子树的结点。那就是最大的了。
算法伪代码:

  1. TREE-DELETE(T, z)  
  2.  1  
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值