红黑树,是一种二叉搜索树,但在每个结点上增加一个存储位表示结点的颜色,可以是Red或Black。 通过对任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出俩倍,因而是接近平衡的。所以红黑树不是严格平衡的,只是近似平衡。
值得注意的是在红黑树中叶子结点不是没有孩子的结点,而是nullptr结点,即空结点。所以在红黑树中的路径是从根结点到空结点的路径。下面的红黑树中共有11条路径,因为有11个叶子结点。
下面的一棵树就不是红黑树,因为它不符合红黑树第4条性质。
思考:为什么满足上面的性质,红黑树就能保证:其最长路径中节点个数不会超过最短路径节点
个数的两倍?
答:因为红黑树中的最短路径就是全为黑色结点的路径,最长路径就是一黑一红,红黑相间的路径。而因为红黑树中每个结点到其所有后代叶结点的简单路径上,均包含相同数目的黑色结点。所以说红黑树的最长路径中结点个数最多为最短路径中结点个数的两倍,不可能超过两倍。例如下图中的情况。
通过上面的图片我们也能看到红黑树并不是严格平衡的,只是近似平衡。所以我们可以分析出AVL树查找效率比红黑树要高一些。
例如:
假设一棵红黑树的全部黑色结点有N个。
这棵红黑树的最短路径长度为: l o g ( N ) log (N) log(N)。
整棵树的结点个数:[N, 2N]之间。
最长路径长度: 2 l o g ( N ) 2log (N) 2log(N)。
那么假设有10亿个结点:
AVL树:最多查找30次左右
RB树:最多查找60次左右
但是正因为红黑树并不是严格的平衡树,所以当向红黑树中插入结点时,旋转的次数比较少。例如下面的树,如果是AVL树的话,那么肯定需要旋转的。如果是红黑树,那么就不需要旋转,因为已经满足红黑树的定义了。
下面我们来使用代码定义红黑树的结点。
红黑树的结点定义和AVL树结点的定义类似,只不过AVL树中使用平衡因子来严格控制平衡,而红黑树中使用颜色来控制本身近似为平衡树。
思考:在结点的定义中,为什么要将结点的默认颜色给成红色的?
答:因为如果新创建的结点默认为黑色的话,那么当新结点插入时,一定违反性质4,此时如果还想要满足红黑树的定义,就需要在每个路径上都加一个黑色结点,这是肯定不可能的。而如果新创建的结点默认为红色的话,当新结点插入时,可能违反性质3,并且违反性质3时,我们可以通过一些调整来使这棵树还满足红黑树。所以我们创建新结点时默认为红色结点。
性质3. 如果一个节点是红色的,则它的两个孩子结点是黑色的
性质4. 对于每个结点,从该结点到其所有后代叶结点的简单路径上,均 包含相同数目的黑色结点
下面我们继续实现红黑树的代码,因为红黑树也是一棵二叉搜索树,所以我们先实现二叉搜索树新结点插入的代码。
template<class K, class V>
class RBTree
{
typedef RBTreeNode<K, V> Node;
public:
bool Insert(const pair<K, V>& kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = BLACK; //红黑树规定根结点为黑色
return true;
}
//找新结点插入的位置
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < kv.first)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first)
{
parent = cur;
cur = cur->_left;
}
//找到key值相同的结点,插入失败
else
{
return false;
}
}
cur = new Node(kv);
if (parent->_kv.first > kv.first)
{
parent->_left = cur;
}
else
{
parent->_right = cur;
}
//更新父结点指针
cur->_parent = parent;
}
private:
Node* _root;
};
下面我们分析红黑树插入新结点时都会有什么情况发生。
约定
cur:当前结点
p:父结点
g:祖父结点
u:叔叔结点
因为新节点的默认颜色是红色,因此:如果其双亲节点的颜色是黑色,没有违反红黑树任何性质,则不需要调整;但当新插入节点的双亲节点颜色为红色时,就违反了性质三不能有连在一起的红色节点,此时需要对红黑树分情况来讨论:
上面的这种情况因为p和cur都是红色,所以是违反了红黑树的性质3的,这时我们可以将cur的父亲结点p和叔叔结点u变为黑色,然后将祖先结点g变为红色。接下来我们需要判断祖先结点g是否为根结点,如果g为根结点,那么需要将g变为黑色。然后就可以看到这样操作后又满足红黑树的性质了。但是此处所看到的树,并不一定就是一棵完整的树,也有可能只是一棵子树,即g结点不为根结点时,那么我们就不需要将g结点变为黑色了。
如果祖先结点g上面的结点是黑色的结点的话,那么这次调整就没有问题了。
而如果祖先结点g上面的结点为红色结点的话,那么此时又会出现两个红色结点的情况,即违反了红黑树性质3。此时我们就需要将g变为cur,然后继续进行上面的操作。
u不存在时
此时cur一定是新插入的结点,因为如果cur不是新插入的结点,则cur和p一定有一个结点的颜色是黑色,那么就不满足红黑树性质4每条路径上黑色结点个数相同了。
当p为g的左结点,cur为p的左结点时,此时将g结点进行右单旋,然后将p结点变黑,g结点变红,这样变化后就符合红黑树性质了。并且这种变化不需要考虑祖先结点g的上面是否还有结点,因为变化后的p结点为这棵子树的根结点了,而且p结点为黑色。如果祖先结点g上面没有父结点,那么p结点为黑色满足红黑树根结点为黑色的性质,如果祖先结点g上面有父结点,无论为红色还是黑色都符合红黑树的性质。
当p为g的右结点,cur为p的右结点时,此时需要将g结点进行左单旋,然后p结点变黑,g结点变红,这样变化就符合红黑树性质了。
u存在且为黑时
如果u结点存在,那么一定为黑色,并且cur结点原来肯定为黑色,而cur现在为红色的原因是因为cur的子树在调整的过程中将cur结点变为红色了,然后导致现在的情况不满足红黑树性质了。
当p为g的左结点,cur为p的左结点时,此时将g结点进行右单旋,然后将p结点变黑,g结点变红,这样变化后就符合红黑树性质了。
当p为g的右结点,cur为p的右结点时,此时将g结点进行左单旋,然后将p结点变黑,g结点变红,这样变化后就符合红黑树性质了。
u不存在时
此时cur一定是新插入的结点,因为如果cur不是新插入的结点,则cur和p一定有一个结点的颜色是黑色,那么就不满足红黑树性质4每条路径上黑色结点个数相同了。
当p结点为g结点的左孩子,cur结点为p结点的右孩子时,此时将p结点先进行左单旋,然后就变为上面我们分析的情况2了,此时将g结点进行右单旋,然后将cur结点变黑,g结点变红。
当p结点为g结点的右孩子,cur结点为p结点的左孩子时,此时将p结点先进行右单旋,然后就变为上面我们分析的情况2了,此时将g结点进行左单旋,然后将cur结点变黑,g结点变红。
u存在时
如果u结点存在,那么一定为黑色,并且cur结点原来肯定为黑色,而cur现在为红色的原因是因为cur的子树在调整的过程中将cur结点变为红色了,然后导致现在的情况不满足红黑树性质了。
当p结点为g结点的左孩子,cur结点为p结点的右孩子时,此时将p结点先进行左单旋,这样就变成上面我们分析的情况2了。此时将g结点进行右单旋,然后将cur结点变黑,g结点变红。
当p结点为g结点的右孩子,cur结点为p结点的左孩子时,此时将p结点先进行右单旋,这样就变成上面我们分析的情况2了。此时将g结点进行左单旋,然后将cur结点变黑,g结点变红。
经过上面的分析,我们总结后可以发现其实可以将红黑树的插入进行下面的分类。
下面我们先来写p结点为g结点的左孩子的情况。
下面为p结点为g结点的左孩子时,u结点存在并且为红时,此时只需要进行变色处理就可以,但是这种情况因为改变了g结点的颜色为红色,所以可能引起g结点和g结点的父结点都为红色,此时就需要继续向上处理。并且还有可能g结点为根结点,然后我们改变了g结点的颜色为红色,这是不符合红黑树性质的,所以我们在Insert函数的最后将根结点的颜色改为黑色,这样就保证了红黑树的根结点一直为黑色。
我们从图中可以看出当p结点为g结点的左孩子时,u结点存在为黑色和u结点不存在时,执行的操作是一样的,此时我们只需要判断cur结点是为p结点的左孩子还是右孩子,然后做出对应的操作即可。因为当u结点存在为黑色或u结点不存在时,调整时需要进行左单旋或右单旋操作,所以我们可以将前面AVL树中的左单旋和右单旋代码拿来进行复用,需要注意的是要将左单旋或右单旋中改变平衡因子的代码删去。
下面我们再来写p结点为g结点的右孩子的情况。
下面为p结点为g结点的右孩子时,u结点存在并且为红时,此时只需要进行变色处理就可以,我们的代码和上面实现的类似。
然后我们再来实现p结点为g结点的右孩子时,u结点存在为黑色和u结点不存在时,cur结点为p结点的左孩子或右孩子的情况。代码和上面的类似。只不过判断和操作的方向改变一下即可。
这样我们就基本实现了红黑树的新结点插入,下面我们来测试代码执行的结果是否正确。
我们先使用中序遍历看我们创建的红黑树是否是一颗二叉搜索树。
我们看到中序遍历的结果为升序,说明我们实现的红黑树是一棵二叉搜索树,但是这样的测试只能说明我们实现的红黑树是一棵二叉搜索树,并不能说明这棵树是否类似平衡。所以我们还需要进一步写方法来进行判断。
有的人可能会想到红黑树的一个特性是最长路径不超过最短路径二倍,但是我们不能通过这个特性来判断一棵树是否为红黑树,因为有可能满足最长路径不超过最短路径二倍,但是违反了红黑树的其它性质。例如下面的一棵树,虽然满足了最长路径不超过最短路径二倍,但是这棵树不是一棵红黑树,因为每条路径上的黑色结点的个数不同。
所以我们需要用红黑树的性质来判断,这样只要满足了红黑树的全部性质,那么就能做到最长路径不超过最短路径二倍了。
红黑树性质:
- 每个结点不是红色就是黑色。
- 根节点是黑色的
- 如果一个节点是红色的,则它的两个孩子结点是黑色的 。
- 对于每个结点,从该结点到其所有后代叶结点的简单路径上,均包含相同数目的黑色结点。
- 每个叶子结点都是黑色的(此处的叶子结点指的是空结点)
上面的5条性质中,第1条性质我们已经实现,因为枚举中只有红色和黑色两个颜色。第5条和代码实现没有关系,所以我们只需要判断2、3、4条性质就可以了。
下面我们来检查根结点是否为黑色,如果不是黑色那么就是红色,此时就提示出来错误。
然后我们再来检查红黑树中是否存在连续的红色结点。我们可以写一个_Check函数来递归检查每一个结点的父结点是否为红色结点,如果出现结点的父结点为红色结点,那么就出现了连续红色结点的情况,此时就要提示出错。
下面我们再来检查每个路径上的黑色结点数量是否相等。我们可以在_Check函数中再传入一个参数记录这条路径上的黑色结点数量,然后在访问到空结点时,就说明这条路径走完了,此时打印出来这条路径上的黑色结点数量。
我们运行测试后可以看到我们实现的红黑树中每一条路径上的黑色结点数量都相同,并且没有出现连续红色结点和根结点为黑色的情况,那么就说明我们的代码创建的红黑树满足了基本性质。
但是这种方法来检查红黑树是否正确是很麻烦的,例如如果红黑树中的数据很多时,此时我们需要一个一个进行检查,这样效率是很低的,而且人为检查还会出现错误。
此时我们有两种解决办法,第一种办法是创建一个容器,并且将这个容器的引用当作 _ Check函数的参数,然后在root为空时判断如果容器为空或者容器末尾的数据和现在的数据不一样时,就将现在的数据尾插到容器中,最后检查容器内有几个数据就知道这棵树的路径上黑色结点数量是否一致了。
但是上面的方法也比较麻烦,我们采用第二种办法,传一个基准值给 _Check 函数,每一条路径上的黑色结点数量都和这个基准值比较,如果不相等就说明这棵树不满足每个路径上的黑色结点数量一致,就打印出错误。这个基准值,我们可以计算这棵树的最左边或者最右边路径上的黑色结点数量,然后来当作基准值。
此时我们再进行测试就可以看到只会打印最后的结果告诉我们这棵树是否为红黑树了。
下面我们也写一个Height函数来求出当红黑树的高度。
然后我们来测试当插入大量数据时,AVL树和红黑树的高度,可以看到红黑树是比AVL树要高的。
下面我们继续完善红黑树,我们先实现红黑树的析构函数,将红黑树中创建的结点都进行释放。
然后再实现红黑树中的查找函数。
在stl库中,map和set容器的底层就是使用红黑树实现的,那么下面我们也使用自己实现的红黑树来构造map和set容器。
我们先看一下stl源码中map和set的源码。
在map.h中我们看到map的模板参数有Key和T,还有一个仿函数的模板参数,最后一个模板参数为内存申请,我们现在不需要研究。我们看到在map类中key_type的类似为Key,而value_type的类型的pair
在set.h中模板参数只有一个Key,同样也有一个仿函数参数和一个内存分配的参数。我们看到在set类中key_type的类似为Key,而value_type的类型也为Key,虽然set中只存一个key值,但是set为了也使用红黑树,所以将key_type和value_type都为Key类型了。
通过查看上面的源码我们看到了,set容器虽然只存储一个key值,但是为了底层也使用红黑树,所以set类中将key_type和value_type都为Key类型了,而在map中因为map存储的是键值对,所以map将value_type设置为一个pair类型用来存储键值对。这样当set和map都使用红黑树时,就可以只写一个红黑树的接口了。
下面是红黑树的源码。
从下面的图中我们可以弄清楚源码中其实第二个模板参数Value才决定了红黑树的结点里面存的是什么数据,即K or K/V。而第一个模板参数Key拿到单独的K的类型,因为set和map中都以Key值来进行插入和删除,并且find、erase这些接口函数的参数都是以Key的值来进行查找或删除结点的。可以看到源码中的红黑树结点的模板参数只有一个,即只存一个Value类型,而map要是想要在红黑树中存键值对,那么就传入一个pair类型,然后红黑树的结点中的模板参数Value就使用pair类型来存数据了。如果set想要在红黑树中存一个值,那么只需要传入想要存储的类型即可,红黑树的结点的模板参数Value就是这个类型了。这样做的好处就是set和map都可以复用红黑树来当作它们的底层。
但是我们自己实现的红黑树的结点有两个模板参数,因为我们默认使用pair类型来存数据。下面我们就需要修改一下我们的红黑树结点,让红黑树结点的模板参数只有一个。
这样当使用红黑树结点模板时,传递什么样的模板参数,我们的红黑树结点就存储什么样的类型。
然后我们将RBTree类中的Node也改为RBTreeNode< Value >类型,即我们将实例化RBTree类时的第二个模板参数Value为红黑树结点中存储数据的类型。例如当set实例化RBTree类时,传入的Value为int,那么红黑树结点中就使用int类型存储数据;当map实例化RBTree类时,传入的Value为pair类型,那么红黑树结点中就使用pair类型来存储键值对数据。
下面我们来使用RBTree来实现Set容器。
下面我们使用RBTree来实现Map容器。可以看到虽然Set容器存的是只有Key值,而Map容器中存Key-Value键值对,但是这两个容器都可以调用RBTree类模板。
并且Map容器和Set容器的插入、删除、查找等接口函数的实现直接调用RBTree类的对应接口函数就可以了。
当我们将红黑树的结点的模板参数改为只有一个后,那么红黑树中的插入新结点的Insert函数就需要进行改变了,因为新插入结点的类型不一定是pair类型了,那么Insert函数和Find等函数中使用kv.first和 _ kv.first进行比较的方法也不能适用于所有情况了。例如set容器实例化RBTree类时,传入的模板参数为< int >类型,那么int类型就没有first成员了。
此时我们可以在RBTree类模板中加一个仿函数的参数,然后将set中实现的仿函数就按照key值进行比较,而map中实现的仿函数按照pair类型的first来进行比较。
set中仿函数实现。
Map中仿函数实现。
然后我们将Find函数修改为适用仿函数,这样当set容器中使用Find函数查找时,在比较时kov会将传入的结点的key值传回来;当map容器中使用Find函数查找时,在比较时kov会将pair对象的first的数据传回来,这样就是key值和key值进行比较了。
然后我们将Insert函数中的比较也使用同样的方法,这样不管是否为pair类型,比较都是按照key值来进行比较了。
然后我们进行测试,可以看到set和map容器中都成功插入了数据。
下面就是set和map传入不同的模板参数,然后调用各自实现的仿函数进行比较的过程。
我们还可以给set容器和map容器也添加一个仿函数模板参数,这样用户使用set和map容器时,也可以自己指定比较的方法了。我们看到在源码中就实现了比较的仿函数模板参数。
下面我们查看源码中set和map容器的迭代器是怎样实现的。
可以看到源码中的map和set容器也是调用的rb_tree中的迭代器。
那么我们也只需要实现RBTree类中的迭代器即可,然后让set和map调用RBTree类的迭代器。下面我们来实现RBTree类的迭代器。
二叉搜索树中最左结点为第一个结点,所以begin()可以返回红黑树的第一个结点。而二叉搜索树的最后一个结点为根结点,end()又是指向最后一个结点的下一个结点,所以可以直接将end()返回一个空的迭代器。
我们使用迭代器遍历时会将迭代器进行++或- -的操作,那么下面我们就来实现迭代器的++操作。
因为二叉搜索树的中序遍历为升序,而且库里面实现的迭代器遍历是按照中序序列遍历的,所以我们也按照中序序列来遍历。迭代器++有两种情况。第一种情况,当当前结点的右不为空时,此时下一个结点就是右子树的最左节点。我们先来实现这种情况。
第二种情况,当当前结点的右为空时,此时我们需要沿着到根的路径向上找,直到找到cur为parent的左孩子时停止,因为此时下一个结点parent结点。
例如我们访问到6结点时,发现6结点的右为空,此时需要向上找。发现6结点是1结点的右孩子,继续向上找。然后发现1结点是8结点的左孩子,所以6结点的下一个结点就是8结点。
然后我们在Set和Map中使用RBTree类的迭代器。
然后我们测试时发现会报出错误,这是因为我们在Set和Map中typedef重命名迭代器时没有加typename关键字,然后编译器就无法区分是为一个变量还是一个类型进行重命名,所以我们在前面加typename关键字就是告诉编译器我们为一个类型进行重命名,即等这个类模板实例化以后才会有这个类型。
然后我们看到就可以使用迭代器来遍历Set和Map中的元素了。并且因为我们实现了begin()和end()函数接口,所以我们也可以使用范围for了。
因为库里面的map和set的迭代器为双向迭代器,所以我们也需要实现迭代器的 - -。而迭代器- - 和迭代器++刚好相反。迭代器++的访问顺序为左、根、右,而迭代器 - -的访问顺序为右、根、左。所以迭代器- - 也有两种情况,下面我们对比迭代器++的实现来将迭代器 - -也实现。
这样我们就实现了红黑树的普通迭代器,反向迭代器我们在这里就不进行实现了。但是我们实现的迭代器有一个bug,即我们在RBTree中实现end()方法时,直接使用nullptr创建了一个空迭代器来返回。所以当我们使用end()返回的迭代器进行- -操作访问红黑树的最后一个结点时,是会出错的。因为此时_node是一个nullptr,所以会出现错误。
这样的问题肯定是不可以出现的,下面我们来看看stl源码中是怎样解决这个问题的。
在源码中的红黑树结构中,其实多了一个header头结点,并且为了与根结点进行区分,将这个头结点给成了红色。这个结点的left指向红黑树的最左边结点,right指向红黑树的最右边结点,这个结点的parent指向红黑树的根结点,红黑树的根结点的parent指向头结点。这样实现就解决了上面我们遇到的迭代器 - -的问题,即让end()返回指向头结点的迭代器。 在源码中我们可以看到在迭代器 - -操作中,当判断进行 - -操作的迭代器为指向头结点的迭代器时,就将这个迭代器指向红黑树最右边的结点。这样加一个头结点实现时还有一个好处就是当想要得到红黑树的最大或最小值结点时直接访问头结点的left或right即可,但是这样实现在每次插入或者删除结点时,都要维护头结点,即看头结点的left或right指向的结点是否为红黑树最左或最右结点。
下面我们再来完善一个Insert插入接口的功能,前面我们介绍了在使用stl库提供的set和map容器时,Insert函数无论成功或者失败都会返回一个pair< iterator, bool >对象,这个pair对象的first为一个迭代器,指向我们插入失败或者成功的结点,second为一个bool类型,如果插入成功就为true,插入失败就为false。下面我们也将我们的Insert函数完善为这样的。
然后我们将Set和Map中的Insert函数也改为返回一个pair < iterator,bool > 对象。
然后我们进行测试,可以看到运行结果和我们预期的一样。
当我们完善了Insert后我们就可以实现Map的[]操作符重载函数了。在前面我们使用map容器时,知道了map容器的[]操作符根据pair对象的first值去红黑树中搜寻元素,如果没有搜到就会以传入的first值和second的默认值来构造一个红黑树结点,如果找到了就返回second的引用,然后用户可以根据map容器[]操作符重载函数根据pair对象的first的值来完成插入、插入+修改、修改、查找等操作。
然后我们使用统计水果个数的案例来测试我们实现的Map容器的[]操作符重载函数。我们看到程序的执行结果和我们预期的一样。
我们在前面学习set容器时,知道了set容器是不允许通过迭代器来访问set容器中的数据的,因为可能会破坏set容器底层的红黑树结构。但是我们可以发现我们自己实现的set容器可以根据迭代器来修改容器内元素的值,这是肯定不行的,因为会破坏底层的红黑树结构。
下面我们来看源码中是怎样解决这个问题的。可以看到set容器源码中将iterator迭代器也复用的__rb_tree的const_iterator迭代器,这样set容器中的iterator在__rb_tree中就是个const_iterator迭代器,这样就不可以通过set的迭代器来修改底层红黑树的结点的值了。
我们也仿照源码中的办法来解决我们遇到的问题,下面我们先实现RBTree中的const_iterator迭代器。然后我们将Set中的普通迭代器也复用RBTree的const_iterator迭代器。
可以看到这样修改后set中的元素的值无法被修改了。但是当我们运行时却报出了其它的错误。
这是因为begin()里面返回的是一个普通迭代器,即iterator迭代器,但是在set中将iterator迭代器也复用的RBTree里面的const_iterator,那么此时就会发生隐式类型转换,但是没有对应的构造函数,所以才会报错。
即从下面的图中我们可以看到set中的begin()的返回类型是一个const_iterator的RBTree迭代器,但是begin()函数里面的_t.begin()返回的是一个iterator的RBTree迭代器,所以这里会发生隐式类型转换,即将一个iterator类型转换为一个const_iterator类型。
当我们添加上这个函数后,可以看到代码就可以正常运行了。并且我们此时修改set容器中的元素的值也不能修改了。
我们知道在map中的first不能修改,因为first是作为红黑树的key值得,second可以修改,那么源码中是怎么做到first不能修改,second能修改的呢?
可以看到map源码中在调用rb_tree时,直接将传入的pair对象的first设置为const Key,那么在rb_tree中就不能修改pair对象中first的值了。
我们在实现map时也可以像源码中的一样,在传递pair对象给RBTree时就将pair对象的first使用const修饰。
这样我们就基本实现了使用红黑树封装map和set,但是我们只是简单的实现了。这个程序中肯定还是有很多bug的,所以我们在真正应用中还是使用stl库中提供的更安全,我们自己模拟实现一遍只是为了更好的理解stl库的源码中是怎么做的。