我在做项目的时候经常会遇到一些情况,是需要用到队列的。比如,我要维护消息队列,或者动画队列。STL中有两种容器胜任此项工作,它们分别是list和deque。list是基于链表实现的,而deque是基于动态数组实现的。
1.vector 底层数据结构为数组 ,支持快速随机访问
2.list 底层数据结构为双向链表,支持快速增删
(
双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。一般我们都构造双向循环链表
list是一个线性双向链表结构,它的数据由若干个节点构成,每一个节点都包括一个信息块(即实际存储的数据)、一个前驱指针和一个后驱指针。它无需分配指定的内存大小且可以任意伸缩,这是因为它存储在非连续的内存空间中,并且由指针将有序的元素链接起来。由于其结构的原因,list 随机检索的性能非常的不好,因为它不像vector 那样直接找到元素的地址,而是要从头一个一个的顺序查找,这样目标元素越靠后,它的检索时间就越长。检索时间与目标元素的位置成正比。虽然随机检索的速度不够快,但是它可以迅速地在任何节点进行插入和删除操作。因为list 的每个节点保存着它在链表中的位置,插入或删除一个元素仅对最多三个元素有所影响,不像vector 会对操作点之后的所有元素的存储地址都有所影响,这一点是vector 不可比拟的。
list 的特点:
(1) 不使用连续的内存空间这样可以随意地进行动态操作;
(2) 可以在内部任何位置快速地插入或删除,当然也可以在两端进行push 和pop 。
(3) 不能进行内部的随机访问,即不支持[ ] 操作符和vector.at() ;
(4) 相对于verctor 占用更多的内存。
初学list:需要掌握的知识:
(1)定义一个list
(2)向list中加入元素
(3)如何知道list是否为空
(4)如何使用for循环来遍历一个list
(5)如何使用STL的通用算法for_each来遍历list
(6)list成员函数begin() 和 end() 以及它们的意义
(7)iterator范围的概念和一个范围的最后一个位置实际上并不被处理这一事实
第一:定义,插入,遍历打印。
代码实现如下:
#include<stdio.h>
#include<iostream>
#include<list>
#include<string>
#include<algorithm>
using namespace std;
void PrintIt(string& StringToPoint)
{
cout << StringToPoint << endl;
}
int main()
{
list<string> test;
list<string>::iterator testiterator;
test.push_back("no");
test.push_back("march");
test.push_front("ok");
test.push_front("loleina");
test.push_front("begin");
test.push_back("end");
for (testiterator = test.begin(); testiterator != test.end(); ++testiterator)
{
cout << *testiterator << endl;
}
cout << "-------------" << endl;
for_each(test.begin(), test.end(), PrintIt);
cout << "-------------" << endl;
system("PAUSE");
return 0;
}
定义了一个字符串类型的list。需要包含提供STL的 list类的头文件#include <list>即可;list的成员函数push_back()把一个对象放到一个list的后面,而 push_front()把对象放到前面。
我们想要遍历一个list,比如打印一个list中的所有对象来看看list上不同操作的结果。要一个元素一个元素的遍历一个list, 可以这样做:
A. 这个程序定义了一个iterator(类似指针),testiterator。它指向了这个list的第一个元素。 这可以调用testiterator.begin()来做到,它会返回一个指向list开头的iterator。然后把它和testiterator.end()的 返回值来做比较,到了那儿的时候就停下来。 容器的end()函数会返回一个指向容器的最后一个位置的iterator。 在上面的例子中,每一次执行for循环,我们就重复引用iterator来得到我们打印的字符串。
注意:不能用testiterator.begin()+2来指向list中的第三个对象,因为STL的list是以双链的list来实现的,所有的数据存放不一定是连续存放的。 它不支持随机存取。
B.使用STL的通用算法for_each()来遍历一个iterator的范围,然后调用PrintIt()来处理每个对象。 不需要初始化、比较和给iterator增量。for_each()完成了这些工作。执行于对象上的操作被很好的 打包在这个函数以外了,不用再做那样的循环了,代码更加清晰了。
第二:count()和count_if() 的基本使用
STL的通用算法count()和count_it()用来给容器中的对象记数。就象for_each()一样,count()和count_if() 算法也是在iterator范围内来做的。
#include<stdio.h>
#include<iostream>
#include<list>
#include<string>
#include<algorithm>
using namespace std;
class IsLoleina
{
public:
bool operator()(string& name)
{
return name == "loleina";
}
};
int main()
{
list<string> test;
list<int> score;
list<string>::iterator testiterator;
test.push_back("no");
test.push_back("march");
test.push_front("ok");
test.push_front("loleina");
test.push_front("begin");
test.push_back("end");
score.push_back(100);
score.push_back(90);
score.push_back(80);
score.push_back(70);
score.push_back(100);
score.push_back(20);
int countNum(0);
countNum= count(score.begin(), score.end(), 100);
cout << "there are " << countNum << " scores of 100" << endl;
cout << "-------------" << endl;
int countLoleina(0);
countLoleina=count_if(test.begin(), test.end(), IsLoleina());
cout << "there are " << countLoleina << " loleina" << endl;
system("PAUSE");
return 0;
count()算法统计等于某个值的对象的个数。count_if() 带一个函数对象的参数。函数对象是一个至少带有一个operator()方法的类。有些STL算法作为参数接收函数对象并调用这个函数对象的operator()方法。函数对象被约定为STL算法调用operator时返回true或false。它们根据这个来判定这个函数。举个例子会 说的更清楚些。count_if()通过传递一个函数对象来作出比count()更加复杂的评估以确定一个对象是否应该被记数。
)
支持首尾(中间不能)快速增删,也支持随机访问
{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
list就是数据结构中的双向链表(根据sgi stl源代码),因此它的内存空间可以是不连续的,通过指针来进行数据的访问,这个特点使得它的随即存取变的非常没有效率,因此它没有提供[]操作符的重载。但由于链表的特点,它可以以很好的效率支持任意地方的删除和插入。
deque是一个double-ended queue,它的具体实现不太清楚,但知道它具有以下两个特点:
它支持[]操作符,也就是支持随即存取,并且和vector的效率相差无几,它支持在两端的操作:push_back,push_front,pop_back,pop_front等,并且在两端操作上与list的效率也差不多。
}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}「
(list是一个线性双向链表结构)
std::deque
( double-ended queue ,双端队列)是有下标顺序容器,它允许在其首尾两段快速插入及删除。另外,在 deque 任一端插入或删除不会非法化指向其余元素的指针或引用。
与 std::vector 相反, deque 的元素不是相接存储的:典型实现用单独分配的固定大小数组的序列,外加额外的登记,这表示下标访问必须进行二次指针解引用,与之相比 vector 的下标访问只进行一次。
deque 的存储按需自动扩展及收缩。扩张 deque 比扩展 std::vector 便宜,因为它不涉及到复制既存元素到新内存位置。另一方面, deque 典型地拥有较大的最小内存开销;只保有一个元素的 deque 必须分配其整个内部数组(例如 64 位 libstdc++ 上为对象大小 8 倍; 64 位 libc++ 上为对象大小 16 倍或 4096 字节的较大者)。
deque 上常见操作的复杂度(效率)如下:
- 随机访问——常数 O(1)
- 在结尾或起始插入或移除元素——常数 O(1)
- 插入或移除元素——线性 O(n)
std::deque
满足容器 (Container
) 、具分配器容器 (AllocatorAwareContainer
) 、顺序容器 (SequenceContainer
) 和可逆容器 (ReversibleContainer
) 的要求。
底层数据结构为一个中央控制器和多个缓冲区,详细见STL源码剖析P146,支持首尾(中间不能)快速增删,也支持随机访问
deque是一个双端队列(double-ended queue),也是在堆中保存内容的.它的保存形式如下:[堆1] --> [堆2] --> [堆3] --> ...每个堆保存好几个元素,然后堆和堆之间有指针指向,看起来像是list和vector的结合品.
大小有限制,扩容耗时
··················································································》》》》》》》》
======================================================》》》》》》》》》》》》》》》》》》》。。。
stack底层实现
define _STACK_
#include<iostream>
#include"../List/MyList.h"
using namespace std;
class StackIsEmpty{};
template<typename T>
class MyStack{
public:
MyStack():mylist()
{}
~MyStack()
{}
MyStack(const MyStack<T>& mystack)
{
mylist=mystack.mylist;
}
MyStack<T>& operator=(const MyStack<T>& mystack)
{
mylist=mystack.mylist;
}
void push(const T& t)
{
mylist.push_back(t);
}
void pop()throw (StackIsEmpty)
{
try
{
mylist.pop_back();
}
catch(ListIsEmpty&)
{
throw StackIsEmpty();
}
}
T top()
{
return mylist.end()->data;
}
bool empty()
{
return mylist.empty();
}
size_t size()
{
return mylist.size();
}
private:
List<T> mylist;
};
#endif
5.queue 底层一般用list或deque实现,封闭头部即可,不用vector的原因应该是容量大小有限制,
扩容耗时(stack和queue其实是适配器,而不叫容器,因为是对容器的再封装)6.priority_queue 的底层数据结构一般为vector为底层容器,堆heap为处理规则来管理底层容器实现
7.set 底层数据结构为红黑树,有序,不重复
8.multiset 底层数据结构为红黑树,有序,可重复
9.map 底层数据结构为红黑树,有序,不重复
10.multimap 底层数据结构为红黑树,有序,可重复
11.hash_set 底层数据结构为hash表,无序,不重复
12.hash_multiset 底层数据结构为hash表,无序,可重复
13.hash_map 底层数据结构为hash表,无序,不重复
14.hash_multimap 底层数据结构为hash表,无序,可重复
{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{
Hash表
Hash表也称散列表,也有直接译作哈希表,Hash表是一种特殊的数据结构,它同数组、链表以及二叉排序树等相比较有很明显的区别,它能够快速定位到想要查找的记录,而不是与表中存在的记录的关键字进行比较来进行查找。这个源于Hash表设计的特殊性,它采用了函数映射的思想将记录的存储位置与记录的关键字关联起来,从而能够很快速地进行查找。
1.Hash表的设计思想
对于一般的线性表,比如链表,如果要存储联系人信息:
张三 13980593357 李四 15828662334 王五 13409821234 张帅 13890583472
那么可能会设计一个结构体包含姓名,手机号码这些信息,然后把4个联系人的信息存到一张链表中。当要查找”李四 15828662334“这条记录是否在这张链表中或者想要得到李四的手机号码时,可能会从链表的头结点开始遍历,依次将每个结点中的姓名同”李四“进行比较,直到查找成功或者失败为止,这种做法的时间复杂度为O(n)。即使采用二叉排序树进行存储,也最多为O(logn)。假设能够通过”李四“这个信息直接获取到该记录在表中的存储位置,就能省掉中间关键字比较的这个环节,复杂度直接降到O(1)。Hash表就能够达到这样的效果。
Hash表采用一个映射函数 f : key —> address 将关键字映射到该记录在表中的存储位置,从而在想要查找该记录时,可以直接根据关键字和映射关系计算出该记录在表中的存储位置,通常情况下,这种映射关系称作为Hash函数,而通过Hash函数和关键字计算出来的存储位置(注意这里的存储位置只是表中的存储位置,并不是实际的物理地址)称作为Hash地址。比如上述例子中,假如联系人信息采用Hash表存储,则当想要找到“李四”的信息时,直接根据“李四”和Hash函数计算出Hash地址即可。下面讨论一下Hash表设计中的几个关键问题。
1. Hash函数的设计
Hash函数设计的好坏直接影响到对Hash表的操作效率。下面举例说明:
假如对上述的联系人信息进行存储时,采用的Hash函数为:姓名的每个字的拼音开头大写字母的ASCII码之和。
因此address(张三)=ASCII(Z)+ASCII(S)=90+83=173;
address(李四)=ASCII(L)+ASCII(S)=76+83=159;
address(王五)=ASCII(W)+ASCII(W)=87+87=174;
address(张帅)=ASCII(Z)+ASCII(S)=90+83=173;
假如只有这4个联系人信息需要进行存储,这个Hash函数设计的很糟糕。首先,它浪费了大量的存储空间,假如采用char型数组存储联系人信息的话,则至少需要开辟174*12字节的空间,空间利用率只有4/174,不到5%;另外,根据Hash函数计算结果之后,address(张三)和address(李四)具有相同的地址,这种现象称作冲突,对于174个存储空间中只需要存储4条记录就发生了冲突,这样的Hash函数设计是很不合理的。所以在构造Hash函数时应尽量考虑关键字的分布特点来设计函数使得Hash地址随机均匀地分布在整个地址空间当中。通常有以下几种构造Hash函数的方法:
1)直接定址法
取关键字或者关键字的某个线性函数为Hash地址,即address(key)=a*key+b;如知道学生的学号从2000开始,最大为4000,则可以将address(key)=key-2000作为Hash地址。
2)平方取中法
对关键字进行平方运算,然后取结果的中间几位作为Hash地址。假如有以下关键字序列{421,423,436},平方之后的结果为{177241,178929,190096},那么可以取{72,89,00}作为Hash地址。
3)折叠法
将关键字拆分成几部分,然后将这几部分组合在一起,以特定的方式进行转化形成Hash地址。假如知道图书的ISBN号为8903-241-23,可以将address(key)=89+03+24+12+3作为Hash地址。
4)除留取余法
如果知道Hash表的最大长度为m,可以取不大于m的最大质数p,然后对关键字进行取余运算,address(key)=key%p。
在这里p的选取非常关键,p选择的好的话,能够最大程度地减少冲突,p一般取不大于m的最大质数。
2.Hash表大小的确定
Hash表大小的确定也非常关键,如果Hash表的空间远远大于最后实际存储的记录个数,则造成了很大的空间浪费,如果选取小了的话,则容易造成冲突。在实际情况中,一般需要根据最终记录存储个数和关键字的分布特点来确定Hash表的大小。还有一种情况时可能事先不知道最终需要存储的记录个数,则需要动态维护Hash表的容量,此时可能需要重新计算Hash地址。
3.冲突的解决
在上述例子中,发生了冲突现象,因此需要办法来解决,否则记录无法进行正确的存储。通常情况下有2种解决办法:
1)开放定址法
即当一个关键字和另一个关键字发生冲突时,使用某种探测技术在Hash表中形成一个探测序列,然后沿着这个探测序列依次查找下去,当碰到一个空的单元时,则插入其中。比较常用的探测方法有线性探测法,比如有一组关键字{12,13,25,23,38,34,6,84,91},Hash表长为14,Hash函数为address(key)=key%11,当插入12,13,25时可以直接插入,而当插入23时,地址1被占用了,因此沿着地址1依次往下探测(探测步长可以根据情况而定),直到探测到地址4,发现为空,则将23插入其中。
2)链地址法
采用数组和链表相结合的办法,将Hash地址相同的记录存储在一张线性表中,而每张表的表头的序号即为计算得到的Hash地址。如上述例子中,采用链地址法形成的Hash表存储表示为:
虽然能够采用一些办法去减少冲突,但是冲突是无法完全避免的。因此需要根据实际情况选取解决冲突的办法。
4.Hash表的平均查找长度
Hash表的平均查找长度包括查找成功时的平均查找长度和查找失败时的平均查找长度。
查找成功时的平均查找长度=表中每个元素查找成功时的比较次数之和/表中元素个数;
查找不成功时的平均查找长度相当于在表中查找元素不成功时的平均比较次数,可以理解为向表中插入某个元素,该元素在每个位置都有可能,然后计算出在每个位置能够插入时需要比较的次数,再除以表长即为查找不成功时的平均查找长度。
下面举个例子:
有一组关键字{23,12,14,2,3,5},表长为14,Hash函数为key%11,则关键字在表中的存储如下:
地址 0 1 2 3 4 5 6 7 8 9 10 11 12 13
关键字 23 12 14 2 3 5
比较次数 1 2 1 3 3 2
因此查找成功时的平均查找长度为(1+2+1+3+3+2)/6=11/6;
查找失败时的平均查找长度为(1+7+6+5+4+3+2+1+1+1+1+1+1+1)/14=38/14;
这里有一个概念装填因子=表中的记录数/哈希表的长度,如果装填因子越小,表明表中还有很多的空单元,则发生冲突的可能性越小;而装填因子越大,则发生冲突的可能性就越大,在查找时所耗费的时间就越多。因此,Hash表的平均查找长度和装填因子有关。有相关文献证明当装填因子在0.5左右的时候,Hash的性能能够达到最优。因此,一般情况下,装填因子取经验值0.5。
5.Hash表的优缺点
Hash表存在的优点显而易见,能够在常数级的时间复杂度上进行查找,并且插入数据和删除数据比较容易。但是它也有某些缺点,比如不支持排序,一般比用线性表存储需要更多的空间,并且记录的关键字不能重复。
代码实现:
/*Hash表,采用数组实现,2012.9.28*/ #include<stdio.h> #define DataType int #define M 30 typedef struct HashNode { DataType data; //存储值 int isNull; //标志该位置是否已被填充 }HashTable; HashTable hashTable[M]; void initHashTable() //对hash表进行初始化 { int i; for(i = 0; i<M; i++) { hashTable[i].isNull = 1; //初始状态为空 } } int getHashAddress(DataType key) //Hash函数 { return key % 29; //Hash函数为 key%29 } int insert(DataType key) //向hash表中插入元素 { int address = getHashAddress(key); if(hashTable[address].isNull == 1) //没有发生冲突 { hashTable[address].data = key; hashTable[address].isNull = 0; } else //当发生冲突的时候 { while(hashTable[address].isNull == 0 && address<M) { address++; //采用线性探测法,步长为1 } if(address == M) //Hash表发生溢出 return -1; hashTable[address].data = key; hashTable[address].isNull = 0; } return 0; } int find(DataType key) //进行查找 { int address = getHashAddress(key); while( !(hashTable[address].isNull == 0 && hashTable[address].data == key && address<M)) { address++; } if( address == M) address = -1; return address; } int main(int argc, char *argv[]) { int key[]={123,456,7000,8,1,13,11,555,425,393,212,546,2,99,196}; int i; initHashTable(); for(i = 0; i<15; i++) { insert(key[i]); } for(i = 0; i<15; i++) { int address; address = find(key[i]); printf("%d %d\n", key[i],address); } return 0; }
}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
===================================》
======================================================》
====================================》
1、vector 容器
vector 的数据安排以及操作方式,与 array 非常相似。两者的唯一区别在于空间的运用的灵活性。array 是静态空间,一旦配置了就不能改变,vector 是动态数组。在堆上分配空间。vector 是动态空间,随着元素的加入,它的内部机制会自行扩充空间以容纳新元素(有保留内存,如果减少大小后内存也不会释放。如果新值>当前大小时才会再分配内存,这大大影响了 vector 的效率,)。因此,vector 的运用对于内存的合理利用与运用的灵活性有很大的帮助,我们再也不必因为害怕空间不足而一开始要求一个大块的 array。vector 动态增加大小,并不是在原空间之后持续新空间(因为无法保证原空间之后尚有可供配置的空间),而是以原大小的两倍另外配置一块较大的空间,然后将原内容拷贝过来,然后才开始在原内容之后构造新元素,并释放原空间。因此,对 vector 的任何操作,一旦引起空间重新配置,同时指向原vector 的所有迭代器就都失效了。
对最后元素操作最快(在后面添加删除最快),此时一般不需要移动内存。对中间和开始处进行添加删除元素操作需要移动内存。如果你的元素是结构或是类,那么移动的同时还会进行构造和析构操作,所以性能不高(最好将结构或类的指针放入 vector 中,而不是结构或类本身,这样可以避免移动时的构造与析构)。访问方面,对任何元素的访问都是 O(1),也就是常数时间的。
总结:
vector 常用来保存需要经常进行随机访问的内容,并且不需要经常对中间元素进行添加删除操作。
2、list 容器
相对于 vector 的连续空间,list 就显得复杂许多,它的好处是每次插入或删除一个元素,就配置或释放一个元素空间,元素也是在堆中。因此,list 对于空间的运用有绝对的精准,一点也不浪费。而且,对于任何位置的元素插入或元素移除,永远是常数时间。STL 中的list 底层是一个双向链表,而且是一个环状双向链表。这个特点使得它的随即存取变的非常没有效率,因此它没有提供 [] 操作符的重载。
总结:
如果你喜欢经常添加删除大对象的话,那么请使用 list;
要保存的对象不大,构造与析构操作不复杂,那么可以使用 vector 代替。
list<指针> 完全是性能最低的做法,这种情况下还是使用 vector<指针> 好,因为指针没有构造与析构,也不占用很大内存
3、deque 容器
deque 是一种双向开口的连续线性空间,元素也是在堆中。所谓双向开口,意思是可以在队尾两端分别做元素的插入和删除操作。deque 和 vector 的最大差异,一在于 deque 允许于常数时间内对起头端进行元素的插入或移除操作,二在于deque没有所谓容量观念,因为它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并链接在一起。换句话说,像 vector 那样“因旧空间不足而重新配置一块更大空间,然后复制元素,再释放旧空间”这样的事情在 deque 是不会发生的。它的保存形式如下:[堆1] --> [堆2] -->[堆3] --> ...deque 是由一段一段的定量连续空间构成。一旦有必要在 deque 的前端或尾端增加新空间,便配置一段定量连续空间,串接在整个 deque 的头端或尾端。deque 的最大任务,便是在这些分段的定量连续空间上,维护其整体连续的假象,并提供随机存取的接口。避开了“重新配置,复制,释放”的轮回,代价则是复杂的迭代器架构。因为有分段连续线性空间,就必须有中央控制器,而为了维持整体连续的假象,数据结构的设计及迭代器前进后退等操作都颇为繁琐。
deque 采用一块所谓的 map 作为主控。这里的 map 是一小块连续空间,其中每个元素都是指针,指向另一段连续线性空间,称为缓冲区。缓冲区才是 deque 的存储空间主体。( 底层数据结构为一个中央控制器和多个缓冲区)SGI STL 允许我们指定缓冲区大小,默认值 0 表示将使用 512 bytes 缓冲区。
支持[]操作符,也就是支持随即存取,可以在前面快速地添加删除元素,或是在后面快速地添加删除元素,然后还可以有比较高的随机访问速度和vector 的效率相差无几。deque 支持在两端的操作:push_back,push_front,pop_back,pop_front等,并且在两端操作上与 list 的效率也差不多。
在标准库中 vector 和 deque 提供几乎相同的接口,在结构上区别主要在于在组织内存上不一样,deque 是按页或块来分配存储器的,每页包含固定数目的元素;相反 vector 分配一段连续的内存,vector 只是在序列的尾段插入元素时才有效率,而 deque 的分页组织方式即使在容器的前端也可以提供常数时间的 insert 和 erase 操作,而且在体积增长方面也比 vector 更具有效率。
总结:
vector 是可以快速地在最后添加删除元素,并可以快速地访问任意元素;
list 是可以快速地在所有地方添加删除元素,但是只能快速地访问最开始与最后的元素;deque 在开始和最后添加元素都一样快,并提供了随机访问方法,像vector一样使用 [] 访问任意元素,但是随机访问速度比不上vector快,因为它要内部处理堆跳转。deque 也有保留空间。另外,由于 deque 不要求连续空间,所以可以保存的元素比 vector 更大,这点也要注意一下。还有就是在前面和后面添加元素时都不需要移动其它块的元素,所以性能也很高。
因此在实际使用时,如何选择这三个容器中哪一个,应根据你的需要而定,一般应遵循下面的原则:
1、如果你需要高效的随即存取,而不在乎插入和删除的效率,使用 vector;
2、如果你需要大量的插入和删除,而不关心随即存取,则应使用 list;3、如果你需要随即存取,而且关心两端数据的插入和删除,则应使用deque。
4、stack
stack 是一种先进后出(First In Last Out , FILO)的数据结构。它只有一个出口,stack 允许新增元素,移除元素,取得最顶端元素。但除了最顶端外,没有任何其它方法可以存取stack的其它元素,stack不允许遍历行为。以某种容器( 一般用 list 或 deque 实现,封闭头部即可,不用 vector 的原因应该是容量大小有限制,扩容耗时)作为底部结构,将其接口改变,使之符合“先进后出”的特性,形成一个 stack,是很容易做到的。deque 是双向开口的数据结构,若以 deque 为底部结构并封闭其头端开口,便轻而易举地形成了一个stack。因此,SGI STL 便以 deque 作为缺省情况下的 stack 底部结构,由于 stack 系以底部容器完成其所有工作,而具有这种“修改某物接口,形成另一种风貌”之性质者,称为 adapter(配接器),因此,STL stack 往往不被归类为 container(容器),而被归类为 container adapter。
5、 queue
queue 是一种先进先出(First In First Out,FIFO)的数据结构。它有两个出口,queue 允许新增元素,移除元素,从最底端加入元素,取得最顶端元素。但除了最底端可以加入,最顶端可以取出外,没有任何其它方法可以存取 queue 的其它元素。6、heap
heap 并不归属于 STL 容器组件,它是个幕后英雄,扮演 priority queue(优先队列)的助手。priority queue 允许用户以任何次序将任何元素推入容器中,但取出时一定按从优先权最高的元素开始取。按照元素的排列方式,heap 可分为 max-heap 和 min-heap 两种,前者每个节点的键值(key)都大于或等于其子节点键值,后者的每个节点键值(key)都小于或等于其子节点键值。因此, max-heap 的最大值在根节点,并总是位于底层array或vector的起头处;min-heap 的最小值在根节点,亦总是位于底层array或vector起头处。STL 供应的是 max-heap,用 C++ 实现。堆排序 C++ 语言实现: 点击这里。
7、priority_queue
priority_queue 是一个拥有权值观念的 queue,它允许加入新元素,移除旧元素,审视元素值等功能。由于这是一个 queue,所以只允许在底端加入元素,并从顶端取出元素,除此之外别无其它存取元素的途径。priority_queue 带有权值观念,其内的元素并非依照被推入的次序排列,而是自动依照元素的权值排列(通常权值以实值表示)。权值最高者,排在最前面。缺省情况下 priority_queue 系利用一个 max-heap 完成,后者是一个以vector 表现的 complete binary tree.max-heap 可以满足 priority_queue 所需要的“依权值高低自动递减排序”的特性。priority_queue 完全以底部容器(一般为vector为底层容器)作为根据,再加上 heap 处理规则,所以其实现非常简单。缺省情况下是以 vector 为底部容器。queue 以底部容器完成其所有工作。具有这种“修改某物接口,形成另一种风貌“”之性质者,称为 adapter(配接器),因此,STL priority_queue 往往不被归类为 container(容器),而被归类为 container adapter。
8、set 和 multiset 容器
set 的特性是,所有元素都会根据元素的键值自动被排序。set 的元素不像 map 那样可以同时拥有实值(value)和键值(key),set 元素的键值就是实值,实值就是键值,set不允许两个元素有相同的值。set 底层是通过红黑树(RB-tree)来实现的,由于红黑树是一种平衡二叉搜索树,自动排序的效果很不错,所以标准的 STL 的 set 即以 RB-Tree 为底层机制。又由于 set 所开放的各种操作接口,RB-tree 也都提供了,所以几乎所有的 set 操作行为,都只有转调用 RB-tree 的操作行为而已。multiset的特性以及用法和 set 完全相同,唯一的差别在于它允许键值重复,因此它的插入操作采用的是底层机制是 RB-tree 的 insert_equal() 而非 insert_unique()。
9、map 和 multimap 容器
map的特性是,所有元素都会根据元素的键值自动被排序。map 的所有元素都是 pair,同时拥有实值(value)和键值(key)。pair 的第一元素被视为键值,第二元素被视为实值。map不允许两个元素拥有相同的键值。由于 RB-tree 是一种平衡二叉搜索树,自动排序的效果很不错,所以标准的STL map 即以 RB-tree 为底层机制。又由于 map 所开放的各种操作接口,RB-tree 也都提供了,所以几乎所有的 map 操作行为,都只是转调 RB-tree 的操作行为。
multimap 的特性以及用法与 map 完全相同,唯一的差别在于它允许键值重复,因此它的插入操作采用的是底层机制 RB-tree 的 insert_equal() 而非 insert_unique。
10、hash_set 和 hash_multiset 容器
hash_set 底层数据结构为 hash 表,无序,不重复。
hash_multiset 底层数据结构为 hash 表,无序,不重复。
11、hash_map 和 hash_multimap 容器
hash_map 底层数据结构为 hash 表,无序,不重复。
hash_multimap 底层数据结构为 hash 表,无序,不重复。
参考 这个地方和 这个地方还有 这个地方
目录
1 红黑树的介绍
2 红黑树的应用
3 红黑树的时间复杂度和相关证明
4 红黑树的基本操作(一) 左旋和右旋
5 红黑树的基本操作(二) 添加
6 红黑树的基本操作(三) 删除
作者:Sky Wang 于 2013-08-08
概述:R-B Tree,又称为“红黑树”。本文参考了《算法导论》中红黑树相关知识,加之自己的理解,然后以图文的形式对红黑树进行说明。本文的主要内容包括:红黑树的特性,红黑树的时间复杂度和它的证明,红黑树的左旋、右旋、插入、删除等操作。
请尊重版权,转载注明出处:http://www.cnblogs.com/skywang12345/p/3245399.html
更多内容: 数据结构与算法系列 目录
(01) 红黑树(一)之 原理和算法详细介绍
(02) 红黑树(二)之 C语言的实现
(03) 红黑树(三)之 Linux内核中红黑树的经典实现
(04) 红黑树(四)之 C++的实现
(05) 红黑树(五)之 Java的实现
(06) 红黑树(六)之 参考资料
R-B Tree简介
R-B Tree,全称是Red-Black Tree,又称为“红黑树”,它一种特殊的二叉查找树。红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。
红黑树的特性:
(1)每个节点或者是黑色,或者是红色。
(2)根节点是黑色。
(3)每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
(4)如果一个节点是红色的,则它的子节点必须是黑色的。
(5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
注意:
(01) 特性(3)中的叶子节点,是只为空(NIL或null)的节点。
(02) 特性(5),确保没有一条路径会比其他路径长出俩倍。因而,红黑树是相对是接近平衡的二叉树。
红黑树示意图如下:
红黑树的应用
红黑树的应用比较广泛,主要是用它来存储有序的数据,它的时间复杂度是O(lgn),效率非常之高。
例如,Java集合中的TreeSet和TreeMap,C++ STL中的set、map,以及Linux虚拟内存的管理,都是通过红黑树去实现的。
红黑树的时间复杂度和相关证明
红黑树的时间复杂度为: O(lgn)
下面通过“数学归纳法”对红黑树的时间复杂度进行证明。
定理:一棵含有n个节点的红黑树的高度至多为2log(n+1).
证明:
"一棵含有n个节点的红黑树的高度至多为2log(n+1)" 的逆否命题是 "高度为h的红黑树,它的包含的内节点个数至少为 2h/2-1个"。
我们只需要证明逆否命题,即可证明原命题为真;即只需证明 "高度为h的红黑树,它的包含的内节点个数至少为 2h/2-1个"。
从某个节点x出发(不包括该节点)到达一个叶节点的任意一条路径上,黑色节点的个数称为该节点的黑高度(x's black height),记为bh(x)。关于bh(x)有两点需要说明:
第1点:根据红黑树的"特性(5) ,即从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点"可知,从节点x出发到达的所有的叶节点具有相同数目的黑节点。这也就意味着,bh(x)的值是唯一的!
第2点:根据红黑色的"特性(4),即如果一个节点是红色的,则它的子节点必须是黑色的"可知,从节点x出发达到叶节点"所经历的黑节点数目">= "所经历的红节点的数目"。假设x是根节点,则可以得出结论"bh(x) >= h/2"。进而,我们只需证明 "高度为h的红黑树,它的包含的黑节点个数至少为 2bh(x)-1个"即可。
到这里,我们将需要证明的定理已经由
"一棵含有n个节点的红黑树的高度至多为2log(n+1)"
转变成只需要证明
"高度为h的红黑树,它的包含的内节点个数至少为 2bh(x)-1个"。
下面通过"数学归纳法"开始论证高度为h的红黑树,它的包含的内节点个数至少为 2bh(x)-1个"。
(01) 当树的高度h=0时,
内节点个数是0,bh(x) 为0,2bh(x)-1 也为 0。显然,原命题成立。
(02) 当h>0,且树的高度为 h-1 时,它包含的节点个数至少为 2bh(x)-1-1。这个是根据(01)推断出来的!
下面,由树的高度为 h-1 的已知条件推出“树的高度为 h 时,它所包含的节点树为 2bh(x)-1”。
当树的高度为 h 时,
对于节点x(x为根节点),其黑高度为bh(x)。
对于节点x的左右子树,它们黑高度为 bh(x) 或者 bh(x)-1。
根据(02)的已知条件,我们已知 "x的左右子树,即高度为 h-1 的节点,它包含的节点至少为 2bh(x)-1-1 个";
所以,节点x所包含的节点至少为 ( 2bh(x)-1-1 ) + ( 2bh(x)-1-1 ) + 1 = 2^bh(x)-1。即节点x所包含的节点至少为 2bh(x)-1。
因此,原命题成立。
由(01)、(02)得出,"高度为h的红黑树,它的包含的内节点个数至少为 2^bh(x)-1个"。
因此,“一棵含有n个节点的红黑树的高度至多为2log(n+1)”。
红黑树的基本操作(一) 左旋和右旋
红黑树的基本操作是添加、删除。在对红黑树进行添加或删除之后,都会用到旋转方法。为什么呢?道理很简单,添加或删除红黑树中的节点之后,红黑树就发生了变化,可能不满足红黑树的5条性质,也就不再是一颗红黑树了,而是一颗普通的树。而通过旋转,可以使这颗树重新成为红黑树。简单点说,旋转的目的是让树保持红黑树的特性。
旋转包括两种:左旋 和 右旋。下面分别对它们进行介绍。
对x进行左旋,意味着"将x变成一个左节点"。
左旋的伪代码《算法导论》:参考上面的示意图和下面的伪代码,理解“红黑树T的节点x进行左旋”是如何进行的。
LEFT-ROTATE(T, x) 01 y ← right[x] // 前提:这里假设x的右孩子为y。下面开始正式操作 02 right[x] ← left[y] // 将 “y的左孩子” 设为 “x的右孩子”,即 将β设为x的右孩子 03 p[left[y]] ← x // 将 “x” 设为 “y的左孩子的父亲”,即 将β的父亲设为x 04 p[y] ← p[x] // 将 “x的父亲” 设为 “y的父亲” 05 if p[x] = nil[T] 06 then root[T] ← y // 情况1:如果 “x的父亲” 是空节点,则将y设为根节点 07 else if x = left[p[x]] 08 then left[p[x]] ← y // 情况2:如果 x是它父节点的左孩子,则将y设为“x的父节点的左孩子” 09 else right[p[x]] ← y // 情况3:(x是它父节点的右孩子) 将y设为“x的父节点的右孩子” 10 left[y] ← x // 将 “x” 设为 “y的左孩子” 11 p[x] ← y // 将 “x的父节点” 设为 “y”
理解左旋之后,看看下面一个更鲜明的例子。你可以先不看右边的结果,自己尝试一下。
对x进行左旋,意味着"将x变成一个左节点"。
右旋的伪代码《算法导论》:参考上面的示意图和下面的伪代码,理解“红黑树T的节点y进行右旋”是如何进行的。
RIGHT-ROTATE(T, y) 01 x ← left[y] // 前提:这里假设y的左孩子为x。下面开始正式操作 02 left[y] ← right[x] // 将 “x的右孩子” 设为 “y的左孩子”,即 将β设为y的左孩子 03 p[right[x]] ← y // 将 “y” 设为 “x的右孩子的父亲”,即 将β的父亲设为y 04 p[x] ← p[y] // 将 “y的父亲” 设为 “x的父亲” 05 if p[y] = nil[T] 06 then root[T] ← x // 情况1:如果 “y的父亲” 是空节点,则将x设为根节点 07 else if y = right[p[y]] 08 then right[p[y]] ← x // 情况2:如果 y是它父节点的右孩子,则将x设为“y的父节点的左孩子” 09 else left[p[y]] ← x // 情况3:(y是它父节点的左孩子) 将x设为“y的父节点的左孩子” 10 right[x] ← y // 将 “y” 设为 “x的右孩子” 11 p[y] ← x // 将 “y的父节点” 设为 “x”
理解右旋之后,看看下面一个更鲜明的例子。你可以先不看右边的结果,自己尝试一下。
旋转总结:
(01) 左旋 和 右旋 是相对的两个概念,原理类似。理解一个也就理解了另一个。
(02) 下面谈谈如何区分 左旋 和 右旋。
在实际应用中,若没有彻底理解 左旋 和 右旋,可能会将它们混淆。下面谈谈我对如何区分 左旋 和 右旋 的理解。
仔细观察上面"左旋"和"右旋"的示意图。我们能清晰的发现,它们是对称的。无论是左旋还是右旋,被旋转的树,在旋转前是二叉查找树,并且旋转之后仍然是一颗二叉查找树。
左旋示例图(以x为节点进行左旋):
z x / / \ --(左旋)--> x y z / y
对x进行左旋,意味着,将“x的右孩子”设为“x的父亲节点”;即,将 x变成了一个左节点(x成了为z的左孩子)!。 因此,左旋中的“左”,意味着“被旋转的节点将变成一个左节点”。
右旋示例图(以x为节点进行右旋):
y x \ / \ --(右旋)--> x y z \ z
对x进行右旋,意味着,将“x的左孩子”设为“x的父亲节点”;即,将 x变成了一个右节点(x成了为y的右孩子)! 因此,右旋中的“右”,意味着“被旋转的节点将变成一个右节点”。
红黑树的基本操作(二) 添加
将一个节点插入到红黑树中,需要执行哪些步骤呢?首先,将红黑树当作一颗二叉查找树,将节点插入;然后,将节点着色为红色;最后,通过旋转和重新着色等方法来修正该树,使之重新成为一颗红黑树。详细描述如下:
第一步: 将红黑树当作一颗二叉查找树,将节点插入。
红黑树本身就是一颗二叉查找树,将节点插入后,该树仍然是一颗二叉查找树。也就意味着,树的键值仍然是有序的。此外,无论是左旋还是右旋,若旋转之前这棵树是二叉查找树,旋转之后它一定还是二叉查找树。这也就意味着,任何的旋转和重新着色操作,都不会改变它仍然是一颗二叉查找树的事实。
好吧?那接下来,我们就来想方设法的旋转以及重新着色,使这颗树重新成为红黑树!
第二步:将插入的节点着色为"红色"。
为什么着色成红色,而不是黑色呢?为什么呢?在回答之前,我们需要重新温习一下红黑树的特性:
(1) 每个节点或者是黑色,或者是红色。
(2) 根节点是黑色。
(3) 每个叶子节点是黑色。 [注意:这里叶子节点,是指为空的叶子节点!]
(4) 如果一个节点是红色的,则它的子节点必须是黑色的。
(5) 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
将插入的节点着色为红色,不会违背"特性(5)"!少违背一条特性,就意味着我们需要处理的情况越少。接下来,就要努力的让这棵树满足其它性质即可;满足了的话,它就又是一颗红黑树了。o(∩∩)o...哈哈
第三步: 通过一系列的旋转或着色等操作,使之重新成为一颗红黑树。
第二步中,将插入节点着色为"红色"之后,不会违背"特性(5)"。那它到底会违背哪些特性呢?
对于"特性(1)",显然不会违背了。因为我们已经将它涂成红色了。
对于"特性(2)",显然也不会违背。在第一步中,我们是将红黑树当作二叉查找树,然后执行的插入操作。而根据二叉查找数的特点,插入操作不会改变根节点。所以,根节点仍然是黑色。
对于"特性(3)",显然不会违背了。这里的叶子节点是指的空叶子节点,插入非空节点并不会对它们造成影响。
对于"特性(4)",是有可能违背的!
那接下来,想办法使之"满足特性(4)",就可以将树重新构造成红黑树了。
下面看看代码到底是怎样实现这三步的。
添加操作的伪代码《算法导论》
RB-INSERT(T, z) 01 y ← nil[T] // 新建节点“y”,将y设为空节点。 02 x ← root[T] // 设“红黑树T”的根节点为“x” 03 while x ≠ nil[T] // 找出要插入的节点“z”在二叉树T中的位置“y” 04 do y ← x 05 if key[z] < key[x] 06 then x ← left[x] 07 else x ← right[x] 08 p[z] ← y // 设置 “z的父亲” 为 “y” 09 if y = nil[T] 10 then root[T] ← z // 情况1:若y是空节点,则将z设为根 11 else if key[z] < key[y] 12 then left[y] ← z // 情况2:若“z所包含的值” < “y所包含的值”,则将z设为“y的左孩子” 13 else right[y] ← z // 情况3:(“z所包含的值” >= “y所包含的值”)将z设为“y的右孩子” 14 left[z] ← nil[T] // z的左孩子设为空 15 right[z] ← nil[T] // z的右孩子设为空。至此,已经完成将“节点z插入到二叉树”中了。 16 color[z] ← RED // 将z着色为“红色” 17 RB-INSERT-FIXUP(T, z) // 通过RB-INSERT-FIXUP对红黑树的节点进行颜色修改以及旋转,让树T仍然是一颗红黑树
结合伪代码以及为代码上面的说明,先理解RB-INSERT。理解了RB-INSERT之后,我们接着对 RB-INSERT-FIXUP的伪代码进行说明。
添加修正操作的伪代码《算法导论》
RB-INSERT-FIXUP(T, z) 01 while color[p[z]] = RED // 若“当前节点(z)的父节点是红色”,则进行以下处理。 02 do if p[z] = left[p[p[z]]] // 若“z的父节点”是“z的祖父节点的左孩子”,则进行以下处理。 03 then y ← right[p[p[z]]] // 将y设置为“z的叔叔节点(z的祖父节点的右孩子)” 04 if color[y] = RED // Case 1条件:叔叔是红色 05 then color[p[z]] ← BLACK ▹ Case 1 // (01) 将“父节点”设为黑色。 06 color[y] ← BLACK ▹ Case 1 // (02) 将“叔叔节点”设为黑色。 07 color[p[p[z]]] ← RED ▹ Case 1 // (03) 将“祖父节点”设为“红色”。 08 z ← p[p[z]] ▹ Case 1 // (04) 将“祖父节点”设为“当前节点”(红色节点) 09 else if z = right[p[z]] // Case 2条件:叔叔是黑色,且当前节点是右孩子 10 then z ← p[z] ▹ Case 2 // (01) 将“父节点”作为“新的当前节点”。 11 LEFT-ROTATE(T, z) ▹ Case 2 // (02) 以“新的当前节点”为支点进行左旋。 12 color[p[z]] ← BLACK ▹ Case 3 // Case 3条件:叔叔是黑色,且当前节点是左孩子。(01) 将“父节点”设为“黑色”。 13 color[p[p[z]]] ← RED ▹ Case 3 // (02) 将“祖父节点”设为“红色”。 14 RIGHT-ROTATE(T, p[p[z]]) ▹ Case 3 // (03) 以“祖父节点”为支点进行右旋。 15 else (same as then clause with "right" and "left" exchanged) // 若“z的父节点”是“z的祖父节点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。 16 color[root[T]] ← BLACK
根据被插入节点的父节点的情况,可以将"当节点z被着色为红色节点,并插入二叉树"划分为三种情况来处理。
① 情况说明:被插入的节点是根节点。
处理方法:直接把此节点涂为黑色。
② 情况说明:被插入的节点的父节点是黑色。
处理方法:什么也不需要做。节点被插入后,仍然是红黑树。
③ 情况说明:被插入的节点的父节点是红色。
处理方法:那么,该情况与红黑树的“特性(5)”相冲突。这种情况下,被插入节点是一定存在非空祖父节点的;进一步的讲,被插入节点也一定存在叔叔节点(即使叔叔节点为空,我们也视之为存在,空节点本身就是黑色节点)。理解这点之后,我们依据"叔叔节点的情况",将这种情况进一步划分为3种情况(Case)。
现象说明 | 处理策略 | |
Case 1 | 当前节点的父节点是红色,且当前节点的祖父节点的另一个子节点(叔叔节点)也是红色。 | (01) 将“父节点”设为黑色。 |
Case 2 | 当前节点的父节点是红色,叔叔节点是黑色,且当前节点是其父节点的右孩子 | (01) 将“父节点”作为“新的当前节点”。 |
Case 3 | 当前节点的父节点是红色,叔叔节点是黑色,且当前节点是其父节点的左孩子 | (01) 将“父节点”设为“黑色”。 |
上面三种情况(Case)处理问题的核心思路都是:将红色的节点移到根节点;然后,将根节点设为黑色。下面对它们详细进行介绍。
1. (Case 1)叔叔是红色
1.1 现象说明
当前节点(即,被插入节点)的父节点是红色,且当前节点的祖父节点的另一个子节点(叔叔节点)也是红色。
1.2 处理策略
(01) 将“父节点”设为黑色。
(02) 将“叔叔节点”设为黑色。
(03) 将“祖父节点”设为“红色”。
(04) 将“祖父节点”设为“当前节点”(红色节点);即,之后继续对“当前节点”进行操作。
下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
“当前节点”和“父节点”都是红色,违背“特性(4)”。所以,将“父节点”设置“黑色”以解决这个问题。
但是,将“父节点”由“红色”变成“黑色”之后,违背了“特性(5)”:因为,包含“父节点”的分支的黑色节点的总数增加了1。 解决这个问题的办法是:将“祖父节点”由“黑色”变成红色,同时,将“叔叔节点”由“红色”变成“黑色”。关于这里,说明几点:第一,为什么“祖父节点”之前是黑色?这个应该很容易想明白,因为在变换操作之前,该树是红黑树,“父节点”是红色,那么“祖父节点”一定是黑色。 第二,为什么将“祖父节点”由“黑色”变成红色,同时,将“叔叔节点”由“红色”变成“黑色”;能解决“包含‘父节点’的分支的黑色节点的总数增加了1”的问题。这个道理也很简单。“包含‘父节点’的分支的黑色节点的总数增加了1” 同时也意味着 “包含‘祖父节点’的分支的黑色节点的总数增加了1”,既然这样,我们通过将“祖父节点”由“黑色”变成“红色”以解决“包含‘祖父节点’的分支的黑色节点的总数增加了1”的问题; 但是,这样处理之后又会引起另一个问题“包含‘叔叔’节点的分支的黑色节点的总数减少了1”,现在我们已知“叔叔节点”是“红色”,将“叔叔节点”设为“黑色”就能解决这个问题。 所以,将“祖父节点”由“黑色”变成红色,同时,将“叔叔节点”由“红色”变成“黑色”;就解决了该问题。
按照上面的步骤处理之后:当前节点、父节点、叔叔节点之间都不会违背红黑树特性,但祖父节点却不一定。若此时,祖父节点是根节点,直接将祖父节点设为“黑色”,那就完全解决这个问题了;若祖父节点不是根节点,那我们需要将“祖父节点”设为“新的当前节点”,接着对“新的当前节点”进行分析。
1.3 示意图
2. (Case 2)叔叔是黑色,且当前节点是右孩子
2.1 现象说明
当前节点(即,被插入节点)的父节点是红色,叔叔节点是黑色,且当前节点是其父节点的右孩子
2.2 处理策略
(01) 将“父节点”作为“新的当前节点”。
(02) 以“新的当前节点”为支点进行左旋。
下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
首先,将“父节点”作为“新的当前节点”;接着,以“新的当前节点”为支点进行左旋。 为了便于理解,我们先说明第(02)步,再说明第(01)步;为了便于说明,我们设置“父节点”的代号为F(Father),“当前节点”的代号为S(Son)。
为什么要“以F为支点进行左旋”呢?根据已知条件可知:S是F的右孩子。而之前我们说过,我们处理红黑树的核心思想:将红色的节点移到根节点;然后,将根节点设为黑色。既然是“将红色的节点移到根节点”,那就是说要不断的将破坏红黑树特性的红色节点上移(即向根方向移动)。 而S又是一个右孩子,因此,我们可以通过“左旋”来将S上移!
按照上面的步骤(以F为支点进行左旋)处理之后:若S变成了根节点,那么直接将其设为“黑色”,就完全解决问题了;若S不是根节点,那我们需要执行步骤(01),即“将F设为‘新的当前节点’”。那为什么不继续以S为新的当前节点继续处理,而需要以F为新的当前节点来进行处理呢?这是因为“左旋”之后,F变成了S的“子节点”,即S变成了F的父节点;而我们处理问题的时候,需要从下至上(由叶到根)方向进行处理;也就是说,必须先解决“孩子”的问题,再解决“父亲”的问题;所以,我们执行步骤(01):将“父节点”作为“新的当前节点”。
2.2 示意图
3. (Case 3)叔叔是黑色,且当前节点是左孩子
3.1 现象说明
当前节点(即,被插入节点)的父节点是红色,叔叔节点是黑色,且当前节点是其父节点的左孩子
3.2 处理策略
(01) 将“父节点”设为“黑色”。
(02) 将“祖父节点”设为“红色”。
(03) 以“祖父节点”为支点进行右旋。
下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
为了便于说明,我们设置“当前节点”为S(Original Son),“兄弟节点”为B(Brother),“叔叔节点”为U(Uncle),“父节点”为F(Father),祖父节点为G(Grand-Father)。
S和F都是红色,违背了红黑树的“特性(4)”,我们可以将F由“红色”变为“黑色”,就解决了“违背‘特性(4)’”的问题;但却引起了其它问题:违背特性(5),因为将F由红色改为黑色之后,所有经过F的分支的黑色节点的个数增加了1。那我们如何解决“所有经过F的分支的黑色节点的个数增加了1”的问题呢? 我们可以通过“将G由黑色变成红色”,同时“以G为支点进行右旋”来解决。
2.3 示意图
提示:上面的进行Case 3处理之后,再将节点"120"当作当前节点,就变成了Case 2的情况。
红黑树的基本操作(三) 删除
将红黑树内的某一个节点删除。需要执行的操作依次是:首先,将红黑树当作一颗二叉查找树,将该节点从二叉查找树中删除;然后,通过"旋转和重新着色"等一系列来修正该树,使之重新成为一棵红黑树。详细描述如下:
第一步:将红黑树当作一颗二叉查找树,将节点删除。
这和"删除常规二叉查找树中删除节点的方法是一样的"。分3种情况:
① 被删除节点没有儿子,即为叶节点。那么,直接将该节点删除就OK了。
② 被删除节点只有一个儿子。那么,直接删除该节点,并用该节点的唯一子节点顶替它的位置。
③ 被删除节点有两个儿子。那么,先找出它的后继节点;然后把“它的后继节点的内容”复制给“该节点的内容”;之后,删除“它的后继节点”。在这里,后继节点相当于替身,在将后继节点的内容复制给"被删除节点"之后,再将后继节点删除。这样就巧妙的将问题转换为"删除后继节点"的情况了,下面就考虑后继节点。 在"被删除节点"有两个非空子节点的情况下,它的后继节点不可能是双子非空。既然"的后继节点"不可能双子都非空,就意味着"该节点的后继节点"要么没有儿子,要么只有一个儿子。若没有儿子,则按"情况① "进行处理;若只有一个儿子,则按"情况② "进行处理。
第二步:通过"旋转和重新着色"等一系列来修正该树,使之重新成为一棵红黑树。
因为"第一步"中删除节点之后,可能会违背红黑树的特性。所以需要通过"旋转和重新着色"来修正该树,使之重新成为一棵红黑树。
删除操作的伪代码《算法导论》
RB-DELETE(T, z) 01 if left[z] = nil[T] or right[z] = nil[T] 02 then y ← z // 若“z的左孩子” 或 “z的右孩子”为空,则将“z”赋值给 “y”; 03 else y ← TREE-SUCCESSOR(z) // 否则,将“z的后继节点”赋值给 “y”。 04 if left[y] ≠ nil[T] 05 then x ← left[y] // 若“y的左孩子” 不为空,则将“y的左孩子” 赋值给 “x”; 06 else x ← right[y] // 否则,“y的右孩子” 赋值给 “x”。 07 p[x] ← p[y] // 将“y的父节点” 设置为 “x的父节点” 08 if p[y] = nil[T] 09 then root[T] ← x // 情况1:若“y的父节点” 为空,则设置“x” 为 “根节点”。 10 else if y = left[p[y]] 11 then left[p[y]] ← x // 情况2:若“y是它父节点的左孩子”,则设置“x” 为 “y的父节点的左孩子” 12 else right[p[y]] ← x // 情况3:若“y是它父节点的右孩子”,则设置“x” 为 “y的父节点的右孩子” 13 if y ≠ z 14 then key[z] ← key[y] // 若“y的值” 赋值给 “z”。注意:这里只拷贝z的值给y,而没有拷贝z的颜色!!! 15 copy y's satellite data into z 16 if color[y] = BLACK 17 then RB-DELETE-FIXUP(T, x) // 若“y为黑节点”,则调用 18 return y
结合伪代码以及为代码上面的说明,先理解RB-DELETE。理解了RB-DELETE之后,接着对 RB-DELETE-FIXUP的伪代码进行说明
RB-DELETE-FIXUP(T, x) 01 while x ≠ root[T] and color[x] = BLACK 02 do if x = left[p[x]] 03 then w ← right[p[x]] // 若 “x”是“它父节点的左孩子”,则设置 “w”为“x的叔叔”(即x为它父节点的右孩子) 04 if color[w] = RED // Case 1: x是“黑+黑”节点,x的兄弟节点是红色。(此时x的父节点和x的兄弟节点的子节点都是黑节点)。 05 then color[w] ← BLACK ▹ Case 1 // (01) 将x的兄弟节点设为“黑色”。 06 color[p[x]] ← RED ▹ Case 1 // (02) 将x的父节点设为“红色”。 07 LEFT-ROTATE(T, p[x]) ▹ Case 1 // (03) 对x的父节点进行左旋。 08 w ← right[p[x]] ▹ Case 1 // (04) 左旋后,重新设置x的兄弟节点。 09 if color[left[w]] = BLACK and color[right[w]] = BLACK // Case 2: x是“黑+黑”节点,x的兄弟节点是黑色,x的兄弟节点的两个孩子都是黑色。 10 then color[w] ← RED ▹ Case 2 // (01) 将x的兄弟节点设为“红色”。 11 x ← p[x] ▹ Case 2 // (02) 设置“x的父节点”为“新的x节点”。 12 else if color[right[w]] = BLACK // Case 3: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的左孩子是红色,右孩子是黑色的。 13 then color[left[w]] ← BLACK ▹ Case 3 // (01) 将x兄弟节点的左孩子设为“黑色”。 14 color[w] ← RED ▹ Case 3 // (02) 将x兄弟节点设为“红色”。 15 RIGHT-ROTATE(T, w) ▹ Case 3 // (03) 对x的兄弟节点进行右旋。 16 w ← right[p[x]] ▹ Case 3 // (04) 右旋后,重新设置x的兄弟节点。 17 color[w] ← color[p[x]] ▹ Case 4 // Case 4: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的右孩子是红色的。(01) 将x父节点颜色 赋值给 x的兄弟节点。 18 color[p[x]] ← BLACK ▹ Case 4 // (02) 将x父节点设为“黑色”。 19 color[right[w]] ← BLACK ▹ Case 4 // (03) 将x兄弟节点的右子节设为“黑色”。 20 LEFT-ROTATE(T, p[x]) ▹ Case 4 // (04) 对x的父节点进行左旋。 21 x ← root[T] ▹ Case 4 // (05) 设置“x”为“根节点”。 22 else (same as then clause with "right" and "left" exchanged) // 若 “x”是“它父节点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。 23 color[x] ← BLACK
下面对删除函数进行分析。在分析之前,我们再次温习一下红黑树的几个特性:
(1) 每个节点或者是黑色,或者是红色。
(2) 根节点是黑色。
(3) 每个叶子节点是黑色。 [注意:这里叶子节点,是指为空的叶子节点!]
(4) 如果一个节点是红色的,则它的子节点必须是黑色的。
(5) 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
前面我们将"删除红黑树中的节点"大致分为两步,在第一步中"将红黑树当作一颗二叉查找树,将节点删除"后,可能违反"特性(2)、(4)、(5)"三个特性。第二步需要解决上面的三个问题,进而保持红黑树的全部特性。
为了便于分析,我们假设"x包含一个额外的黑色"(x原本的颜色还存在),这样就不会违反"特性(5)"。为什么呢?
通过RB-DELETE算法,我们知道:删除节点y之后,x占据了原来节点y的位置。 既然删除y(y是黑色),意味着减少一个黑色节点;那么,再在该位置上增加一个黑色即可。这样,当我们假设"x包含一个额外的黑色",就正好弥补了"删除y所丢失的黑色节点",也就不会违反"特性(5)"。 因此,假设"x包含一个额外的黑色"(x原本的颜色还存在),这样就不会违反"特性(5)"。
现在,x不仅包含它原本的颜色属性,x还包含一个额外的黑色。即x的颜色属性是"红+黑"或"黑+黑",它违反了"特性(1)"。
现在,我们面临的问题,由解决"违反了特性(2)、(4)、(5)三个特性"转换成了"解决违反特性(1)、(2)、(4)三个特性"。RB-DELETE-FIXUP需要做的就是通过算法恢复红黑树的特性(1)、(2)、(4)。RB-DELETE-FIXUP的思想是:将x所包含的额外的黑色不断沿树上移(向根方向移动),直到出现下面的姿态:
a) x指向一个"红+黑"节点。此时,将x设为一个"黑"节点即可。
b) x指向根。此时,将x设为一个"黑"节点即可。
c) 非前面两种姿态。
将上面的姿态,可以概括为3种情况。
① 情况说明:x是“红+黑”节点。
处理方法:直接把x设为黑色,结束。此时红黑树性质全部恢复。
② 情况说明:x是“黑+黑”节点,且x是根。
处理方法:什么都不做,结束。此时红黑树性质全部恢复。
③ 情况说明:x是“黑+黑”节点,且x不是根。
处理方法:这种情况又可以划分为4种子情况。这4种子情况如下表所示:
现象说明 | 处理策略 | |
Case 1 | x是"黑+黑"节点,x的兄弟节点是红色。(此时x的父节点和x的兄弟节点的子节点都是黑节点)。 | (01) 将x的兄弟节点设为“黑色”。 |
Case 2 | x是“黑+黑”节点,x的兄弟节点是黑色,x的兄弟节点的两个孩子都是黑色。 | (01) 将x的兄弟节点设为“红色”。 |
Case 3 | x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的左孩子是红色,右孩子是黑色的。 | (01) 将x兄弟节点的左孩子设为“黑色”。 |
Case 4 | x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的右孩子是红色的,x的兄弟节点的左孩子任意颜色。 | (01) 将x父节点颜色 赋值给 x的兄弟节点。 |
1. (Case 1)x是"黑+黑"节点,x的兄弟节点是红色
1.1 现象说明
x是"黑+黑"节点,x的兄弟节点是红色。(此时x的父节点和x的兄弟节点的子节点都是黑节点)。
1.2 处理策略
(01) 将x的兄弟节点设为“黑色”。
(02) 将x的父节点设为“红色”。
(03) 对x的父节点进行左旋。
(04) 左旋后,重新设置x的兄弟节点。
下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
这样做的目的是将“Case 1”转换为“Case 2”、“Case 3”或“Case 4”,从而进行进一步的处理。对x的父节点进行左旋;左旋后,为了保持红黑树特性,就需要在左旋前“将x的兄弟节点设为黑色”,同时“将x的父节点设为红色”;左旋后,由于x的兄弟节点发生了变化,需要更新x的兄弟节点,从而进行后续处理。
1.3 示意图
2. (Case 2) x是"黑+黑"节点,x的兄弟节点是黑色,x的兄弟节点的两个孩子都是黑色
2.1 现象说明
x是“黑+黑”节点,x的兄弟节点是黑色,x的兄弟节点的两个孩子都是黑色。
2.2 处理策略
(01) 将x的兄弟节点设为“红色”。
(02) 设置“x的父节点”为“新的x节点”。
下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
这个情况的处理思想:是将“x中多余的一个黑色属性上移(往根方向移动)”。 x是“黑+黑”节点,我们将x由“黑+黑”节点 变成 “黑”节点,多余的一个“黑”属性移到x的父节点中,即x的父节点多出了一个黑属性(若x的父节点原先是“黑”,则此时变成了“黑+黑”;若x的父节点原先时“红”,则此时变成了“红+黑”)。 此时,需要注意的是:所有经过x的分支中黑节点个数没变化;但是,所有经过x的兄弟节点的分支中黑色节点的个数增加了1(因为x的父节点多了一个黑色属性)!为了解决这个问题,我们需要将“所有经过x的兄弟节点的分支中黑色节点的个数减1”即可,那么就可以通过“将x的兄弟节点由黑色变成红色”来实现。
经过上面的步骤(将x的兄弟节点设为红色),多余的一个颜色属性(黑色)已经跑到x的父节点中。我们需要将x的父节点设为“新的x节点”进行处理。若“新的x节点”是“黑+红”,直接将“新的x节点”设为黑色,即可完全解决该问题;若“新的x节点”是“黑+黑”,则需要对“新的x节点”进行进一步处理。
2.3 示意图
3. (Case 3)x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的左孩子是红色,右孩子是黑色的
3.1 现象说明
x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的左孩子是红色,右孩子是黑色的。
3.2 处理策略
(01) 将x兄弟节点的左孩子设为“黑色”。
(02) 将x兄弟节点设为“红色”。
(03) 对x的兄弟节点进行右旋。
(04) 右旋后,重新设置x的兄弟节点。
下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
我们处理“Case 3”的目的是为了将“Case 3”进行转换,转换成“Case 4”,从而进行进一步的处理。转换的方式是对x的兄弟节点进行右旋;为了保证右旋后,它仍然是红黑树,就需要在右旋前“将x的兄弟节点的左孩子设为黑色”,同时“将x的兄弟节点设为红色”;右旋后,由于x的兄弟节点发生了变化,需要更新x的兄弟节点,从而进行后续处理。
3.3 示意图
4. (Case 4)x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的右孩子是红色的,x的兄弟节点的左孩子任意颜色
4.1 现象说明
x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的右孩子是红色的,x的兄弟节点的左孩子任意颜色。
4.2 处理策略
(01) 将x父节点颜色 赋值给 x的兄弟节点。
(02) 将x父节点设为“黑色”。
(03) 将x兄弟节点的右子节设为“黑色”。
(04) 对x的父节点进行左旋。
(05) 设置“x”为“根节点”。
下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
我们处理“Case 4”的目的是:去掉x中额外的黑色,将x变成单独的黑色。处理的方式是“:进行颜色修改,然后对x的父节点进行左旋。下面,我们来分析是如何实现的。
为了便于说明,我们设置“当前节点”为S(Original Son),“兄弟节点”为B(Brother),“兄弟节点的左孩子”为BLS(Brother's Left Son),“兄弟节点的右孩子”为BRS(Brother's Right Son),“父节点”为F(Father)。
我们要对F进行左旋。但在左旋前,我们需要调换F和B的颜色,并设置BRS为黑色。为什么需要这里处理呢?因为左旋后,F和BLS是父子关系,而我们已知BL是红色,如果F是红色,则违背了“特性(4)”;为了解决这一问题,我们将“F设置为黑色”。 但是,F设置为黑色之后,为了保证满足“特性(5)”,即为了保证左旋之后:
第一,“同时经过根节点和S的分支的黑色节点个数不变”。
若满足“第一”,只需要S丢弃它多余的颜色即可。因为S的颜色是“黑+黑”,而左旋后“同时经过根节点和S的分支的黑色节点个数”增加了1;现在,只需将S由“黑+黑”变成单独的“黑”节点,即可满足“第一”。
第二,“同时经过根节点和BLS的分支的黑色节点数不变”。
若满足“第二”,只需要将“F的原始颜色”赋值给B即可。之前,我们已经将“F设置为黑色”(即,将B的颜色"黑色",赋值给了F)。至此,我们算是调换了F和B的颜色。
第三,“同时经过根节点和BRS的分支的黑色节点数不变”。
在“第二”已经满足的情况下,若要满足“第三”,只需要将BRS设置为“黑色”即可。
经过,上面的处理之后。红黑树的特性全部得到的满足!接着,我们将x设为根节点,就可以跳出while循环(参考伪代码);即完成了全部处理。
至此,我们就完成了Case 4的处理。理解Case 4的核心,是了解如何“去掉当前节点额外的黑色”。
4.3 示意图
OK!至此,红黑树的理论知识差不多讲完了。后续再更新红黑树的实现代码!