关联式容器没有顺序容器那么百家齐放,STL中之定义了两种关联式容器:set和map。这两种关联式容器的底层均是使用红黑树去进行实现的,或者说这两个关联式容器更像是对红黑树外加的适配器。由set和map延伸出来的还有multiset和multimap,与set和map不同的是,这两个可以存在重复的键值。
这里,由于无序容器和关联容器的行为相同,我就不在对于无序容器另作介绍了。无需容器和关联容器的差别在于底层实现,无序容器采用hash_table,而关联容器采用红黑树。 hash_table比较简单,我这里主要讲红黑树。
在下边我会实现这两个容器底层的红黑树,至于容器本身,我只介绍他们的行为和区别。
红黑树是一种特殊的二叉搜索树,它需要服从以下规则:
在讨论时,将空节点NULL视为黑色,可以大幅度降低复杂度
根据规则4,新增节点必须为红色;根据规则三,新增节点的父节点必须为黑色。若我们插入一个节点通过二叉搜索树的规则找到了插入点但是并不满足上述条件,就必须调整颜色或改变树形,如下图:
我们需要注意的是叶子节点不一定都要是红色,在调整之后也可以是黑色。但在插入时需要是红色。这里插入3时,根据二叉搜索树的条件,确实需要在这个位置插入3,但是插入之后却并不满足红黑树的条件。此时就需要对树形进行改变。
旋转分为单旋转和双旋转,单旋转一般用于外侧插入后的失衡,双旋转一般用于内侧插入之后的失衡。
内侧插入和外侧插入如下图:
单旋转分为左旋转和右旋转:
左旋转:以某个结点作为支点(图中为A),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。
右旋转:以某个结点作为支点(图中为A),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。
注:图中节点的红黑并非节点属性,只是用来区分新加节点和原有节点
双旋转则是进行两次单旋转,有可能是先左后右,也可能是先右后左,如下图:
注:图中节点的红黑并非节点属性,只是用来区分新加节点和原有节点
下边我们看二叉树的插入时的几种情况:
为了方便描述,我先为将会用到的节点起几个名字:
新增节点:X
新增节点父节点:P
新增节点的祖父节点:G 曾祖父节点:GG
父节点的兄弟节点:S
情况1:S为黑且X为外侧插入。此时我们先对P、G做一次单旋转,然后改变颜色就可以满足规则,如下图:
这里进行旋转式因为节点G不满足二叉搜索树的条件:两子树的深度相差不能大于1
进行变色是因为P节点不满足红黑树的条件:红色节点的子节点为黑色
情况2:S为黑且X为内侧插入。此时我们必须先对P、X做一次单旋转并改变G、X的颜色,再将结果对G做一次单旋转。如下图:
通过第一次旋转可以将这种情况变成上一种的外侧插入,然后根据情况一的方法在进行调整。
情况3:S为红且X为外侧插入。此时先对P和G做一次单旋转并改变X的颜色。若此时GG为黑则万事大吉直接结束,若GG为红,则见下一种情况。如图:
这里不能只改变颜色,若只改变颜色的话,G左子树的黑节点就比右子树多了,所以需要先进行旋转再改变颜色。此时若GG为黑色则满足条件,若GG为红,则又会违反规则3,我们下面再介绍这种情况。
情况4:S为红且X为外侧插入。此时先对P和G做一次单旋转并改变X的颜色。若此时GG也为红色,则需要继续向上进行,直到不再有父子连续为红色的情况为止,如下图:
为了避这种情况的发生,我们可以采用一个自上而下的程序。假设新增节点为A,那么就沿着A的路径,只要看到有某个节点X的两根子节点都为红色,那么就吧X改为红色,并且把他的两个子节点都改成黑色。如下图:
做完这个动作后再次进行插入操作,就会类似于上边的三种情况了。
我先把基层节点的定义放出来
//全局的定义,用来储存红黑树的颜色属性
typedef bool color_type;//红黑树颜色的变量类型
const color_type red = false;//红色为0
const color_type black = true;//黑色为1
//红黑树节点的 基层节点
struct _rb_tree_node_base
{
typedef _rb_tree_node_base* base_ptr;
_rb_tree_node_base()//构造函数
:color(false), parent(nullptr), left(nullptr), right(nullptr)
{};
color_type color;//节点的颜色
base_ptr parent;//父节点
base_ptr left;//左子树
base_ptr right;//右子树
//寻找最小值,由于二叉搜索树的最小值永远在左边,所以一直向左走就行
static base_ptr minimum(base_ptr x) {
while(x->left != nullptr)
x = x->left;
return x;
}
//寻找最大值,由于二叉搜索树的最大值永远在右边,所以一直向右走就行
static base_ptr maximum(base_ptr x) {
while(x->right != nullptr)
x = x->right;
return x;
}
};
这里并没有定义它的数值域,只是定义了指针域,所以到这里,节点的形式应该如下图:
而红黑树真正的节点,为这个基层节点增加了一个数值域,定义如下
//红黑树的节点
template<class value>
struct _rb_tree_node : public _rb_tree_node_base
{//继承自基层节点
typedef _rb_tree_node<value>* link_type;
_rb_tree_node(value x = 0) : value_field(x) {};
value value_field;//数值域
};
这里的value采用模板参数,因为这个红黑树将来需要为容器进行服务,容器的元素并不是给定的。对于set来说,这个value既是数值也是键值,对于map来说,这应该是个由数值和键值组成的pair。
由于红黑树是树形结构,他在内存中肯定不是顺序存储的,所以我们的迭代器在设计时就有必要使++操作可以按照一定的顺序遍历整个树,这个顺序在STL的红黑树中是按照键值的从小到大。最小值的位置设置为begin(),根节之上的空节点(在下边主类的实现中会提到)设置为end()。
先来看红黑树迭代器的基类:
//红黑树迭代器的 基层节点
struct _rb_tree_iterator_base
{
typedef _rb_tree_node_base::base_ptr base_ptr;
typedef ptrdiff_t difference_type;
//指向红黑树的一个节点
base_ptr node;
void increment()
{//移动到下一个更大的节点
if (node->right != nullptr) {//若当前节点存在右子树
node = node->right;//从节点的右子树开始寻找
while (node->left != nullptr)//找到右子树的最左节点
node = node->left;
}
else//若没有右子节点
{
base_ptr y = node->parent;//找出父节点
while (node == y->right)//若当前node是它父节点的右子节点
{//一直上移,直到当前node是它父节点的左子节点
node = y;
y = y->parent;
}
if (node->right != y)//若此时node的右子节点等于他的父节点,则父节点为解答
node = y; //否则node为解答
}
}
void decrement()
{//移动到上一个更小的节点
if (node->color == red && node->parent->parent == node)//如果是根节点或end()时
node = node->right;
else if (node->left != 0)//若当前节点存在左子树
{
base_ptr y = node->left;//从左子树开始
while (y->right != nullptr)//找到他的最右子树
y = y->right;
node = y;
}
else//既不是根节点,也没有左子树
{
base_ptr y = node->parent;//从父节点开始
while (node == y->left)//直到当前节点node不是它父节点的左子节点
{
node = y;
y = y->parent;
}
node = y;//此时的父节点就是解答
}
}
};
这里使用一个指向红黑树基层节点的指针作为迭代器和容器之间的联系,并且实现了两个函数:increment()和decrement(),这两个函数的作用是将当前的迭代器移动到下一个更大(或上一个更小)的节点,这是为了让迭代器重载++和- -的操作符使用。这个两个函数利用了红黑树主类中的一种定义,我会在下边提及,这里先去看迭代器主类的定义:
//红黑树的迭代器
template<class value>
struct _rb_tree_iterator : public _rb_tree_iterator_base
{//继承自基层迭代器
//定义型别
typedef value value_type;
typedef value& reference;
typedef value* pointer;
typedef _rb_tree_iterator<value> iterator;
typedef _rb_tree_iterator<value> self;
typedef _rb_tree_node<value>* link_type;
//构造与析构
_rb_tree_iterator() { node = nullptr; };
_rb_tree_iterator(link_type x) { node = x; }
_rb_tree_iterator(const iterator& x) { node = x.node; }
//函数与重载
reference operator*() const { return link_type(node)->value_field; }
pointer operator->() const { return &(operator()); }
self& operator++() { increment(); return *this; }
self operator++(int) { self temp = *this; increment(); return temp; }
self& operator--() { decrement(); return *this; }
self operator--(int) { self temp = *this; decrement(); return temp; }
bool operator!=(const self& x) const { return x.node != node; }
bool operator==(const self& x) const { return x.node == node; }
};
在这里我们可以看到,主迭代器在移动上只是调用基层迭代器的两个函数。
首先介绍下STL主类的结构方式: 根节点的父节点指向一个空白的header节点。header的parent指向根节点,left指向最小的节点,right指向最大的节点,如下图所示:
在主类中只通过header一根指针来对树进行关联,所有的的操作都将借由这跟指针展开。我们先看主类的定义:
/红黑树类
//key表示键值的类型 value是key和value_field的组合类型
//keyofvalue是从value中找出key的方法 compare是比较两个value的方法
template<class key, class value, class keyofvalue, class compare>
class MyRbTree
{
protected:
typedef void* void_pointer;
typedef _rb_tree_node_base* base_ptr;
typedef _rb_tree_node<value> rb_tree_node;
public:
//定义型别
typedef key key_type;
typedef value value_type;
typedef value* pointer;
typedef value& reference;
typedef rb_tree_node* link_type;
typedef rb_tree_node& reference_type;
typedef size_t size_type;
typedef ptrdiff_t difference_type;
typedef _rb_tree_iterator<value> iterator;
protected:
//数据成员
size_type node_count;//节点数
link_type header;//指向红黑树
compare key_compare;//用来比较节点大小的仿函数对象
}
这里的四个模板参数中,前两个分别是数据的类型和键值的类型;第三和第四两个模板参数应该是两个谓词,keyofvalue表述怎么从value中找出key,而comp记录比较两个key的方法。
key和value应该是存放在一起的,也就是说,如果使用的是map,需要某个key值进行索引,那么传递给底层红黑树的应该是一个pair;如果是set的话,在keyofvalue的谓词中,应该能返回value自己。
下边首先看一些获取节点内容的函数,他们都比较简单,我就不做解释了:
//获取header节点的成员
link_type& root() const { return (link_type&)header->parent; }
link_type& leftmost() const { return (link_type&)header->left; }
link_type& rightmost() const { return (link_type&)header->right; }
//获取某一个节点的成员
static link_type& left(link_type x) { return (link_type&)x->left; }
static link_type& right(link_type x) { return (link_type&)x->right; }
static link_type& parent(link_type x) { return (link_type&)x->parent; }
static value_type& value(link_type x) { return (value_type&)x->value_field; }
static color_type& color(link_type x) { return (color_type&)x->color; }
static key_type key(link_type x) { return keyofvalue()(value(x)); }
//获取节点x所在子树的最小值和最大值
static link_type minimum(link_type x) { return _rb_tree_node_base::minimum(x); }
static link_type maximum(link_type x) { return _rb_tree_node_base::maximum(x); }
//一些成员函数
iterator begin()const { return leftmost(); }//rb_tree的开始为最小的元素
iterator end() const { return header; }//rb_tree的终点为header
bool empty() const { return node_count == 0; }
size_type size() const { return node_count; }
接下来是构造函数和析构函数:
MyRbTree(const compare& comp = compare())//构造函数
:node_count(0), key_compare(comp)
{ init(); }
~MyRbTree() {//析构函数
clear();//调用析构函数
put_node(header);//释放header的空间
}
构造函数和析构函数的主体分别转交给其他的函数完成,这些函数的定义如下
void init() {//初始化树
header = get_node();//为header分配一个节点的空间
color(header) = red;//令header的颜色为红色
root() = nullptr;
header->left = header;
header->right = header;
}
void clear() {//清理函数
iterator a = begin();
while ( a != end() )
{
iterator temp = a;
a++;
destroy_node((link_type)temp.node);//释放节点的空间
}
}
init中只是创建了一个空白的header,而clear函数则借助迭代器访问所有的节点,并调用他们的析构函数。
下边展示的是对于内存进行操作的函数:
link_type get_node() { return (link_type)alloc.allocate(sizeof(rb_tree_node)); }//申请一个节点的空间
void put_node(link_type p) { alloc.deallocate(p, sizeof(rb_tree_node)); }//释放一个节点的空间
link_type create_node(const value_type& x) {//创建一个节点
link_type temp = get_node();//申请一个节点的空间
_construct(&temp->value_field, x);//创建节点的值
return temp;
}
link_type clone_node(link_type x) {//创建一个节点的复制
link_type temp = create_node(x->value_field);//创建一个节点(颜色和值)
temp->color = x->color;
temp->left = nullptr;
temp->right = nullptr;
temp->parent = nullptr;
return temp;
}
void destroy_node(link_type x) {//释放一个节点的空间
put_node(x);
}
_construct的定义和之前vector中使用的相同,这里我就再把定义放出来:
template<typename T1, typename T2>
inline void _construct(T1* p, const T2 value) {
new(p) T2(value);
}
这里对于空间的操作是利用我在之前的文章中定义的那个分配器,具体行为参照我之前的文章:C++STL详解二:萃取器与分配器
接下来是红黑树中最重要的插入操作。由于需要适配set、map和multiset、multimap,所以红黑树在定义插入的时候也定义了可重复的键值和不可重复键值的两种插入,这里我都把他列出来:
//键值不可重复的插入操作
//返回值pair 第一个元素是指向新增节点的迭代器
//第二个元素表示是否插入
template<class key, class value, class keyofvalue, class compare>//模板参数
std::pair< typename MyRbTree<key, value,keyofvalue,compare>::iterator, bool>//返回值
MyRbTree<key, value, keyofvalue, compare>::insert_unique(const value_type& v)
{
link_type y = header;//待插入节点的父节点
link_type x = root();//需要插入的节点
bool comp = true;
while (x != nullptr)//从根节点开始寻找插入点
{
y = x;//y始终是x的父节点
comp = key_compare(keyofvalue()(v), key(x));//判断需要插入的v的键值是否小于当前节点x的键值
x = comp ? left(x) : right(x);// 小则左移(true),大于等于则右移(false)
}
iterator j = iterator(y);
if (comp)//离开while循环, 若comp为真,则证明进行了左移,需要插入的地方为左子节点且不等于父节点
if (j == begin())//若待插入节点的父节点是最左节点
return std::pair<iterator, bool>(_insert(x, y, v), true);//x插入的位置 y插入位置的父节点 v新值
else//否则就不是最左节点
--j;//令j指向x的父节点的父节点,准备后续判断
if(key_compare( key((link_type)j.node), keyofvalue()(v)) != 0 )//新键值不与旧键值重复
return std::pair<iterator, bool>(_insert(x, y, v), true);//x插入的位置 y插入位置的父节点 v新值
return std::pair<iterator, bool>(j, false);//到此时 新键值应与旧键值有重复,不予插入
}
//键值可重复的插入操作
//返回值为指向新增节点的迭代器
template<class key, class Value, class keyofvalue, class compare>//模板参数
typename MyRbTree<key, Value, keyofvalue, compare>::iterator//返回值
MyRbTree<key, Value, keyofvalue, compare>::insert_equal(const value_type& v)
{
link_type y = header;//待插入节点的父节点
link_type x = root();//需要插入的节点
while (x != nullptr)//从根节点开始寻找插入点
{
y = x;//y始终是x的父节点
//判断需要插入的v的键值是否小于当前节点x的键值
// 小则左移(true),大于等于则右移(false)
x = key_compare(keyofvalue()(v), key(x)) ? left(x) : right(x);
}
return _insert(x, y, v);//x插入的位置 y插入位置的父节点 v新值
}
这两个函数所做的只是找到按照二叉搜索树的规则应该插入的位置同时判断键值的重复性,真正实现插入的是下边的函数:
//真正进行插入操作的函数
//x_插入的位置 y_插入位置的父节点 v新值
//返回指向新插入位置的迭代器
template<class key, class value, class keyofvalue, class compare>
typename MyRbTree<key, value, keyofvalue, compare>::iterator
MyRbTree<key, value, keyofvalue, compare>::_insert(base_ptr x_, base_ptr y_, const value_type & v)
{
link_type x = (link_type)x_;
link_type y = (link_type)y_;
link_type z;
if (y == header || x != nullptr || key_compare(keyofvalue()(v), key(y)) ) {//插入节点为左节点
z = create_node(v);//产生一个新节点
left(y) = z;
if (y == header) {//如果插入位置的父节点是header 即插入节点为根节点
root() = z;
rightmost() = z;
}
else if (y == leftmost())//如果插入位置的父节点是最小的节点
leftmost() = z;
}
else {//插入右节点
z = create_node(v);//产生一个新节点
right(y) = z;
if (y == rightmost())//如果插入节点是最大的节点
rightmost() = z;
}
//设置新节点的指向
parent(z) = y;
left(z) = nullptr;
right(z) = nullptr;
//设置新节点的颜色并调整红黑树重新平衡
__rb_tree_rebalance(z, header->parent);//z为新加节点, header->parent()为根节点
++node_count;//节点数加1
return iterator(z);
}
这里通过将键值和给定位置的父节点的大小作比较,判定应该插入的是左节点还是右节点,从而完成指针的连接。但是连接之后插入并没有结束,因为我们只是保证了二叉搜索树的特性,红黑树的特性还没有完成维护,在__rb_tree_rebalance函数中,我们调整插入之后的红黑树,使他满足要求:
//在每次插入后,调整红黑树的平衡和颜色
//x为新插入的节点 root为红黑树的根节点
inline void __rb_tree_rebalance(_rb_tree_node_base* x, _rb_tree_node_base*& root)
{
x->color = red;//新增节点默认为红色
while (x != root && x->parent->color==red){//当x不为根节点且父节点为红色
if (x->parent == x->parent->parent->left) {//若父节点为祖父节点的左节点
_rb_tree_node_base* y = x->parent->parent->right;//y指向伯父节点
if (y != nullptr && y->color == red) {//若伯父节点存在且为红色
y->color = black;//更改伯父节点为黑色
x->parent->color = black;//更改父节点为黑色
x->parent->parent->color = red;//更改祖父节点为红色
x = x->parent->parent;//跳转至祖父节点
}
else {//无伯父节点或伯父节点为黑色
if (x == x->parent->right){//若新节点为父节点的右节点
//在父节点处进行一次左旋转
x = x->parent;
_rb_tree_rotate_left(x, root);//旋转点,根节点
}
x->parent->color = black;//更改父节点为黑色
x->parent->parent->color = red;//更改祖父节点的颜色
_rb_tree_rotate_right(x->parent->parent, root);
}
}
else {//若父节点为祖父节点的右节点
_rb_tree_node_base* y = x->parent->parent->left;//y指向伯父节点
if (y && y->color == red) {//若伯父节点存在且为红色
y->color = black;//更改伯父节点为黑色
x->parent->color = black;//更改父节点为黑色
x->parent->parent->color = red;//更改祖父节点为红色
x = x->parent->parent;//跳转至祖父节点
}
else {//无伯父节点或伯父节点为黑色
if (x == x->parent->left) {//若新节点为父节点的左节点
//对父节点进行一次右旋转
x = x->parent;
_rb_tree_rotate_right(x,root);
}
x->parent->color = black;//父节点改为黑色
x->parent->parent->color = red;//祖父节点改为红色
_rb_tree_rotate_left(x->parent->parent, root);
}
}
}
root->color = black;
}
这函数所处理的就是我在上边行为中所提到的那个自上而下的程序。而对于旋转的判断也和我上边提到的四种情况相同。完成旋转的代码定义如下:
//以x为旋转点,对树root进行一次左旋转
inline void _rb_tree_rotate_left(_rb_tree_node_base* x, _rb_tree_node_base*& root)
{
_rb_tree_node_base* y = x->right;//令y为旋转点的右节点
x->right = y->left;
if (y->left != nullptr)
y->left->parent = x;
y->parent = x->parent;
//令y完全顶替x的位置
if (x == root)//x为根节点
root = y;
else if (x == x->parent->left)//x为他父节点的左节点
x->parent->left = y;
else//x为他父节点的右节点
x->parent->right = y;
y->left = x;
x->parent = y;
}
//以x为旋转点,对树root进行一次右旋转
inline void _rb_tree_rotate_right(_rb_tree_node_base* x, _rb_tree_node_base*& root)
{
_rb_tree_node_base* y = x->left;//令y为旋转点的右节点
x->left = y->right;
if (y->right != nullptr)
y->right->parent = x;
y->parent = x->parent;
//令y完全顶替x的位置
if (x == root)//x为根节点
root = y;
else if (x == x->parent->right)//x为他父节点的左节点
x->parent->right = y;
else//x为他父节点的右节点
x->parent->left = y;
y->right = x;
x->parent = y;
}
这里还剩下一个函数,就是对于红黑树键值的查找,之所以使用红黑树的原因也正是因为这个函数所带来的优势:
//查找给定的key值所在的位置
//返回指向的迭代器,若没找到目标则返回end()
template<class key, class value, class keyofvalue, class compare>
typename MyRbTree<key, value, keyofvalue, compare>::iterator//返回值
MyRbTree<key, value, keyofvalue, compare>::find(const key_type &v)
{
link_type x = header;
link_type y = root();
while(x != nullptr)//一直向下寻找,直到x走到尽头
if( !key_compare(key(x),v))//若当前节点x的key值不小于(大于等于)给定的v
y = x, x = left(x);//则令y等于当前节点,x移向左子树
else
x = right(x);//否则x移向右子树
iterator j = iterator(y);
return (j == end() || key_compare(k, key(j.node))) ? end() : j;
}
由于二叉搜索树的结构,所以只要一直判断当前元素的键值大于或是小于需要搜索的键值,取其中一种情况和等于合并并记录,当我们查到一个null时,最后记录的那个元素的键值若和所需查找的不同,则不存在这个元素,反正则正是目标。
至此,红黑树的大部分动作都已经完成了,无序容器只是对红黑树的行为进行约束和修改模板参数而已,下边我们分析set和map的行为(multiset和multimap与之相同,只是插入时选择可重复键值的函数)。
set的特性是,所有的元素都会根据本身的数值被排序。set的元素只有一个值,它既是value又是key,所以在实现set的时候,keyofvalue应该可以返回value本身。
此外,set的迭代器不支持下标访问,因为对于一个红黑树的随机访问是不必要的。并且用户也不能够直接通过set的迭代器去修改红黑树中元素的值,因为那样会改变红黑树的性质,整个set也就失去了存在的意义。
由于红黑树的效率相当优秀,所以set基本上只是转而调用红黑树的接口而已。
map的行为和set基本上相同,是指map中key值和value是分开的。map中的元素都是key和value组成的pair,所以map的keyofvalue应该有能力从pair中提取出key。