一:set与map的底层结构
二:用红黑树封装set,map
2.1:如何区分传过来的数据类型
2.2:迭代器的实现
2.3:封装set,map
2.4: map中的operator[]
三:总结
///
///
一:set与map的底层结构:
set与map是关联容器,其底层都是通过红黑树封装,至于set,map的使用,可以通过下面的文档学习,在此不做过多的赘述;
set - C++ Reference map - C++ 参考资料 (cplusplus.com)
在之前红黑树博客中,我们实现一颗K-V模型的红黑树,节点里面存储的应该是键值对pair,
但是set是K模型,map是K-V模型,怎么一棵树可以实现两种不同的模型?不妨去STL库中学习源码,下面给出set,与map的大致源码结构:
通过对源码的学习,我们发现,set与map确实用了同一颗红黑树,但是传给红黑树的value值却不同,
set是<K,K>, 而map却是<K, pair<const K,V>>;
如何理解这两个不同的模版参数呢?
1:无论是set,还是map,第一个模版参数K,是拿到一个单独的K类型,这样是方便像Find,Erase等这样的接口,这些接口都是用K来进行搜索或者删除的操作;
2:而第二个模版参数则是决定了存储在红黑树中的数据类型,是K还是K-V类型;
所以,set与map的底层虽然都是红黑树,但是区别就在于给红黑树实例化的是什么类型的模版参数
map给红黑树传的模板参数是键值对pair<const Key, T>。而 set给红黑树传的模板参数是键值Key。
而对于红黑树而言,此时它就不能直接写死是K模型或者是K-V模型的树,所以只能通过模版解决,通过对第二个模版参数的解读,才能明白到底是set,还是map,同样,我们还是学习一下红黑树的源码,看看它是如何解决这一问题的;
通过对红黑树源码的观察,我们发现,它通过Value模版参数进行初始化,至于这个Value模版是什么,则需要看传过来的是什么,传过来的是K,那么Value就是K,如果传过来的是键值对K-V,那么Value就是键值对;根据这些,我们来改一改自己的红黑树节点构造,具体如下所示:
enum Colour
{
RED,
BLACK
};
template<class T>//将之前的 键值对 变成T,传过来什么模版参数就是什么模版参数
struct RBTreeNode
{
RBTreeNode<T>* _left;//左节点
RBTreeNode<T>* _right;//右节点
RBTreeNode<T>* _parent;//父节点
T _data;//节点中存储的数据
Colour _col;//节点的颜色;
RBTreeNode(const T& data)//节点构造
:_left(nullptr)
, _right(nullptr)
, _parent(nullptr)
, _data(data)
, _col(RED)
{
}
};
///
///
二:用红黑树封装set,map
2.1:如何区分传过来的数据类型:
如上文所说,红黑树是通过使用模板,通过泛型编程,解决K模型K-V模型的区别,但是在插入时,我们需要比较节点中数据的大小,决定其插入在左树还是右树,
set插入时,通过节点root->K就可以比较大小;
map插入时,则通过节点root->kv.first来进行比较大小(second不参与比较);
那么在比较处到底该写成map和set中的哪种比较方式呢?
要是按map比,不行,要知道set中的data不是键值对,是没有first的,无法比较,
要是按照set直接比,也不行,因为map中的data直接比较又不符合我们的要求。因为map中的data就是键值对pair,pair的比较方法不能满足我们的需要,
pair的比较中,second将会参与比较,我们只期望通过K(first)进行比较,所以,此时我们就不能很单纯的就觉得直接比较大小,我们需要明白搞清楚传过来的是什么,才好进行比较!
那么此时我们也不能自己重新定义键值对的比较方式,因为人家库中已经有了,我们无法再重载一个函数名,返回值,参数都相同的比较方式。
为了能够在红黑树中使用统一的比较方式,我们可以通过仿函数来解决:
在自己封装的set和map中,都加上一个仿函数setkeyOFT(mapkeyOPT),通过这个仿函数获取键值,set就获取K,map就获取KV.first;然后将这个仿函数当作模版参数传过红黑树,这样的话,当set实例化调用时,传的是set的K,map实例化调用时,传的就是map的KV.first,通过仿函数告知红黑树,那么红黑树就可以用统一的方式比较大小了,具体如下所示:
在插入函数inset中,创建仿函数对象koft。然后在需要进行键值key比较的位置,使用仿函数koft获取键值进行比较,然后决定插入左边还是右边。
下面给出插入的代码:
bool Insert(const T& data)
{
if (_root == nullptr)
{
_root = new Node(data);
_root->_col = BLACK;
return true;
}
Node* parent1 = nullptr;
Node* cur = _root;
KeyOFT kot;//定义仿函数,取出_data中要比较的数据,是K或者KV.first;
while (cur != nullptr)
{
if (kot(cur->_data) < kot(data))
{
parent1 = cur;
cur = cur->_right;
}
else if (kot(cur->_data) > kot(data))
{
parent1 = cur;
cur = cur->_left;
}
else
{
//cout << "要插入的值已经存在" << endl;
return false;
}
}
cur = new Node(data);
cur->_col = RED;
if (kot(parent1->_data) < kot(data))
{
parent1->_right = cur;
}
else //if (kot(parent1->_data) > kot(data))
{
parent1->_left = cur;
}
cur->_parent = parent1;
while (parent1 != nullptr && parent1->_col == RED)//插入新节点后开始调整
{
Node* grandfather = parent1->_parent;
if (grandfather->_left == parent1)
{
Node* uncle = grandfather->_right;
if (uncle != nullptr && uncle->_col == RED)
{
parent1->_col = BLACK;
uncle->_col = BLACK;
grandfather->_col = RED;
//接着向上调整
cur = grandfather;
parent1 = cur->_parent;
}
else
{
if (parent1->_left == cur)
{
// g
// p u
// c
RotateR(grandfather);
parent1->_col = BLACK;
grandfather->_col = RED;
}
else
{
// g
// p u
// c
RotateL(parent1);
RotateR(grandfather);
cur->_col = BLACK;
parent1->_col = RED;
grandfather->_col = RED;
}
break;
}
}
else if (grandfather->_right == parent1)
{
Node* uncle = grandfather->_left;
if (uncle != nullptr && uncle->_col == RED)
{
parent1->_col = BLACK;
uncle->_col = BLACK;
grandfather->_col = RED;
//向上调整
cur = grandfather;
parent1 = cur->_parent;
}
else
{
if (parent1->_right == cur)
{
// g
// u p
// c
RotateL(grandfather);
parent1->_col = BLACK;
grandfather->_col = RED;
}
else
{
// g
// u p
// c
RotateR(parent1);
RotateL(grandfather);
cur->_col = BLACK;
parent1->_col = RED;
grandfather->_col = RED;
}
}
}
}
_root->_col = BLACK;
return true;
}
像这样使用仿函数的方法,就不用关心比较的是键值还是键值对,因为set和map都会给红黑树传它自己获取键值的仿函数,然后插入时取出要比较的键值,这样最终比较的就都是键值K。所以仿函数在此是解决这类问题的重要手段;
///
2.2:迭代器的实现:
set和map的迭代器实现和List差不多,可以参考,红黑树的一个一个节点也都是通过指针进行链接的;我们重点只实现主要的,例如“*,->,!=,++,--”这些运算;
迭代器的具体代码如上所示,看起来其实和List差不多,有三个模版参数,同时,迭代器中有一个节点,通过它来控制迭代器的各种操作;
下面我们开始完善一些常见的运算:
1:“*”和“->”:
//三个模版参数,解决普通迭代器和const迭代器,
template<class T,class Ref,class Ptr>
struct RBTree_iterator_Node
{
typedef RBTreeNode<T> Node;
typedef RBTree_iterator_Node<T, Ref, Ptr> self;
Node* _node;
//构造函数;
RBTree_iterator_Node(Node* node)
:_node(node)
{
}
//解引用operator*重载
Ref operator*()
{
return _node->_data;
}
//箭头operator->重载
Ptr operator->()
{
return &_node->_data;
}
这里的opator*返回的是迭代器中的值,operator->返回的是数据的地址,这里和List是一模一样的,如果不是很明白的话,可以后头复习一下List;
2:“!=” 和“==”
bool operator!=(const self& t)
{
return _node != t._node;
}
bool operator==(const self& t)
{
return _node == t._node;
}
迭代器!=和==比较的指向的节点是否相同,而不是节点里面的值!
3:迭代器的++:
我们先想清楚,迭代器的使用是方便我们对容器进行遍历,那么红黑树也是二叉搜索树,其遍历是按照中序遍历整颗树,所以在我们迭代器的实现中,也一个遵循“左子树,根,右子树”这个基本原则,
同时,我们知道迭代器的开始一定是指向整颗树的最左节点,以上述的树为例,那么begin()一定是从1节点开始的,所以迭代器一开始应该在1节点这里,那么如何向后遍历呢?这里就有两种情况:
1:1节点肯定是最左节点,如果有右节点,那么则需要向右子树遍历,寻找右子树的最左节点;
2: 1节点肯定是最左节点,如果没有右节点,那么则直接去向父节点遍历
根据这两种不同的情况,我们需要不同的分析,
当右子树存在时:
当右子树存在时,迭代器it访问完1节点之后会跳到右子树的最左节点去,具体代码如下所示:
self& operator++()
{
if (_node->_right != nullptr)//右不为空,
{
Node* subleft = _node->_right;
//找右子树的最左节点,
while (subleft != nullptr && subleft->_left!= nullptr)
{
subleft = subleft->_left;
}
//最后更改迭代器位置
_node = subleft;
}
else//右为空
{
//。。。。。
}
}
当右为空时:
当右子树不存在时,1节点访问完将跳到其父节点8节点进行访问,当8节点访问完时,将跳到其右子树的最左节点进行访问,但是11节点没有左树,那么此时迭代器改访问哪一个节点呢?
11节点没有左右子树,那么当访问完11节点时,我们该怎样去访问父节点吗?再回头访问父节点8节点吗/
答案是不能的,因为11的父节点8节点我们已经访问过了,通过对图的观察,我们看到,当一个节点存在左子树时,那么它将不能先访问,只能将左树访问完才可能访问根和右子树,
那么对于8节点来说,它的左子树和根已经访问完了,此时迭代器也在它右树的最后一个节点上,所以,以8节点为根节点的整颗子树也就即将访问完了,而对于8节点为根节点的子树来说,它只是13节点的左树而已,当一个节点的左树全部访问完时,下一个就一个访问节点本身了;
所以,此时迭代器下一个位置就在迭代器本身父节点的父节点!
同时,当迭代器走到整颗树的最右节点时,下一步该怎么办呢?
当it走到了整颗树的最右节点,那么则说明it所在节点的右树的父节点已经访问过了,即25节点已经访问过了,同样,25节点也是作为右树的节点,当25节点整颗树访问结束时,也表明着其所在右树的父节点17节点已经访问过了,那么依次向上推,那么我们可以得到,此时整颗树的节点都已经被访问完了,当根节点被访问完时,我们将要访问它的父节点,而根节点13节点的父节点指向nullptr,那么所以,当迭代器指向nullptr时,表明整颗树已经迭代访问完了!具体代码如下所示:
self& operator++()
{
if (_node->_right != nullptr)//右不为空,
{
Node* subleft = _node->_right;
//找右子树的最左节点,
while (subleft != nullptr && subleft->_left!= nullptr)
{
subleft = subleft->_left;
}
//最后更改迭代器位置
_node = subleft;
}
else//右为空
{
Node* cur = _node;//记录一下当前节点
Node* parent = cur->_parent;//记录一下当前节点的父节点
while (parent != nullptr && parent->_right == cur)//当父节点存在且此时当前节点是父节点的右树,则表明以父节点为头节点的整颗子树访问完成,则更新节点的位置;
{
cur = parent;
parent = cur->_parent;
}
_node = parent;//最后让迭代器的位置调到父节点上去
}
return *this;
}
既然有了前置++,那么顺手实现一下后置++:
self& operator++(int)
{
self temp = self(_node);
if (_node->_right != nullptr)
{
Node* subleft = _node->_right;
while (subleft != nullptr && subleft->_left != nullptr)
{
subleft = subleft->_left;
}
_node = subleft;
}
else
{
Node* cur = _node;
Node* parent = cur->_parent;
while (parent != nullptr && parent->_right == cur)
{
cur = parent;
parent = cur->_parent;
}
_node = parent;
}
return temp;
}
};
前置++与后置++代码上说简直是一模一样,只不过是一个返回++之前的值,应该返回++之后的值;
2.4:迭代器的--:
迭代器的--是与迭代器的++是正好完全相反的,遵循“右子树,根,左子树”的规律即可,其--的原理和++一样;分为了两中情况,
1:当左子树存在,迭代器需要向去左树遍历,找到左子树的最右节点;
2:当左子树不存在,迭代器需要向父节点遍历
所以根据这两种不同的情况,我们需要不同的分析,
当左树不为空:
当左子树存在时,迭代器--就是将迭代器指向其左子树的最右节点,具体代码如下所示:
self operator--()
{
//左子树存在
if (_node->_left)
{
//寻找左子树最右边节点
Node* subRight = _node->_left;
while (subRight->_right)
{
subRight = subRight->_right;
}
_node = subRight;//it指向左子树最右节点
}
else//左子树不存在
{
//。。。。。。
}
}
当左树为空时:
这里的逻辑思想和前置++的思想差不多,就是不断向上遍历找父节点,判断父节点有没有左子树,如果有,按照有左子树的方式处理,如果没有,则继续向上寻找父节点;
同时当迭代器走到左树最小节点1节点时,--it会指向整棵树的根节点,13节点的父节点,也就是将会指向空节点,具体代码如下所示:
self operator--()
{
//左子树存在
if (_node->_left)
{
//寻找左子树最右边节点
Node* subRight = _node->_left;
while (subRight->_right)
{
subRight = subRight->_right;
}
_node = subRight;//it指向左子树最右节点
}
//左子树不存在
else
{
Node* cur = _node;
Node* parent = cur->_parent;
while (parent && cur == parent->_left)
{
cur = cur->_parent;
parent = parent->_parent;
}
_node = parent;
}
return *this;
}
既然有了前置--,那么顺手实现一下后置--:
self operator--(int)
{
//记录返回节点
self ret = Self(_node);
//左子树存在
if (_node->_left)
{
//寻找左子树最右边节点
Node* subRight = _node->_left;
while (subRight->_right)
{
subRight = subRight->_right;
}
_node = subRight;//it指向左子树最右节点
}
//左子树不存在
else
{
Node* cur = _node;
Node* parent = cur->_parent;
while (parent && cur == parent->_left)
{
cur = cur->_parent;
parent = parent->_parent;
}
_node = parent;
}
return ret;
}
同时,当迭代器的各种基本运算符搞定后,我们开始实现在红黑树内部的begin(),end(),等;具体代码如下:
iterator begin()//迭代器的开头一定是整颗树的最左节点,因为红黑树遍历时遵循中序遍历
{
Node* cur = _root;
while (cur != nullptr && cur->_left != nullptr)
{
cur = cur->_left;
}
return iterator(cur);
}
iterator end()
{
return iterator(nullptr);
}
//const迭代器
const_iterator begin() const
{
Node* cur = _root;
while (cur != nullptr && cur->_left != nullptr)
{
cur = cur->_left;
}
return const_iterator(cur);
}
const_iterator end() const
{
return const_iterator(nullptr);
}
///
2.3:封装set,map:
因为我们已经解决了在红黑树中区分K模型KV模型的问题,已经大致解决了迭代器的问题,那么我们先试着将set,map通过红黑树封装起来:
set和map一样,但是通过调用红黑树的接口来实现其各种功能,但是这里的迭代器却有所不同,为什么呢?
1: set中节点中存放的是key值,所以必然不能被修改,所以它的iterator和const_iterator的底层都是红黑树的迭代器的const迭代器,保证set中的数据不能被修改。
2: map中节点存放的是键值对,键值对中的key(也就是frist)值不可以被修改,但是另一个Value(也就是second)可以被修改。键值对的first的类型是const K类型,已经保证了key值不会被修改。所以map提供了普通和const两种迭代器,
同时,在set和map调用迭代器的时候需要注意加上typename;
如果不加typename编译器分不清它到底是类型还是静态变量;加上typename就是告诉编译器它是一个类型,
当类模板实例化之后,取这个类型,就不用再加typename去表面自己是一个类型了
到此,我们就可以正常的进行插入和迭代器打印了,下面给出个示例:
///
2.4: map中的operator[]:
map中的operator[]功能十分强大,可以完成修改,插入,查找多项功能,接下来我们实现一下:
V& operator[](const K& key)//只通过K去检索
{
//返回的是一个键值对,iterator表示返回的迭代器的位置
//插入成功返回true,插入失败false;
pair<iterator, bool> ret = _m.Insert(make_pair(key, V()));//V是默认构造;
return ret.first->second;
}
接下来给出一个示例验证一下:
我们发现,运行报错,因为operator[]返回的是pair<iterator,bool> ,而我们底层自己实现的红黑树返回的是bool值,所以我们需要将底层的插入改一改;
底层的插入改动了,那么封装的set,map的插入也需要跟着改动:
当全部改好后,我们在运行:
set的插入报错,这是为什么呢?
因为在上文中,我们提过set中节点里面存的数据是K,K是不允许修改的;所以普通迭代器和const迭代器封装的都是红黑树的const迭代器;
set不像map,map节点里面存的数据是pair<const K,V>,KV.first是不能修改的,但是我们在给模版参数时,就已经给K加上了const,同时,KV.second是支持修改的,所以map的普通迭代器是普通迭代器,const迭代器就还是const迭代器;
而set插入时,调用的是红黑树的插入节点,红黑树的插入接口中,返回值是普通迭代器,而set插入接口的返回类型是const迭代器,所以,才会有上面的报错,无法从普通的迭代器转换为const迭代器!
那么如何解决这个问题呢?我们学习一下红黑树迭代器的源码:
通过这个用普通迭代器构造const迭代器的构造函数,就可以解决set的返回值和返回值类型不同的问题,当set返回一个普通迭代器,返回类型为const迭代器时,单参数的构造函数支持隐式类型的转换,将普通迭代器转换为const迭代器,完美的解决了这一问题!所以我们将底层红黑树的迭代器完善一下:
那么我们再运行一下示例:
此时,我们就可以正常的使用map中的operator[]了;
三:总结
///
///
至此,我们大概完成了,set,map的插入与迭代器的实现,我们的目的不是创建一个更好的轮子,将其全部完善,把默认成员函数,查找,删除等都补齐写上,没有必要,我们主要是学会如何通过红黑树去构建的思想,比如插入比较时的仿函数,迭代器中的用普通迭代器构造const迭代器的思想,是值得我们借鉴学习的;