C++ 红黑树封装(二):迭代器、const_iterator 与 operator\[\] 完整实现 mymap 和 myset

🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能
前言:为什么有了红黑树,还不能称为 map/set?
1. 容器真正好用的地方在接口
上一篇我们已经完成了:
text
RBTree<K,T,KeyOfT>
并利用它封装:
text
myset
mymap
此时我们已经能够:
cpp
s.insert(10);
m.insert({"apple", 5});
但是如果想像 STL 一样:
cpp
for (auto e : s)
{
cout << e << " ";
}
或者:
cpp
for (auto& kv : m)
{
cout << kv.first
<< ":"
<< kv.second
<< endl;
}
就必须继续实现:
text
iterator
同时 map 还需要:
cpp
m["apple"]
这样的接口。
所以这一篇主要解决:
text
1. 红黑树 iterator 怎么走
2. begin/end 怎么表示
3. ++ 和 -- 怎么实现
4. const_iterator 怎么实现
5. 为什么 set 不能修改元素
6. 为什么 map 只能修改 value
7. insert 为什么返回 pair<iterator,bool>
8. operator[] 为什么依赖 insert
一、红黑树迭代器本质上是什么?
1. 先回忆 list 的 iterator
对于链表,我们可能写过:
cpp
template<class T, class Ref, class Ptr>
struct ListIterator
{
Node* _node;
};
它本质上就是:
用一个对象封装结点指针,然后通过运算符重载让这个对象表现得像指针。
例如:
cpp
operator*
operator->
operator++
operator--
operator==
operator!=
红黑树 iterator 的整体思路其实完全一样。
2. RBTreeIterator
可以设计:
cpp
template<class T, class Ref, class Ptr>
struct RBTreeIterator
{
typedef RBTreeNode<T> Node;
typedef RBTreeIterator<
T,
Ref,
Ptr
> Self;
Node* _node;
Node* _root;
};
其中:
text
_node
表示当前迭代器指向哪个结点。
而:
text
_root
主要用于:
cpp
--end()
这种特殊情况。
二、为什么红黑树迭代器要按照中序遍历?
红黑树本质上仍然是:
text
Binary Search Tree
因此:
text
左子树 key
<
当前 key
<
右子树 key
所以执行:
text
中序遍历
即:
text
左子树
↓
根
↓
右子树
得到的就是:
text
升序序列
例如:
text
18
/ \
10 30
\ / \
15 25 40
/ \
35 50
中序遍历:
text
10
15
18
25
30
35
40
50
因此:
cpp
for (auto e : set)
实际上就是不断寻找:
text
中序遍历的下一个结点
三、begin() 应该指向哪里?
既然迭代器按照:
text
中序遍历
访问,那么:
cpp
begin()
必须指向:
text
中序遍历的第一个结点
而一棵 BST 中序遍历的第一个结点就是:
text
整棵树最左边的结点
因此:
cpp
Iterator Begin()
{
Node* leftMost = _root;
while (leftMost
&& leftMost->_left)
{
leftMost = leftMost->_left;
}
return Iterator(
leftMost,
_root
);
}
例如:
text
18
/
10
那么:
cpp
begin()
指向:
text
10
四、end() 应该怎么表示?
1. 简化实现:nullptr
我们可以规定:
cpp
End()
返回:
cpp
Iterator(nullptr, _root);
即:
cpp
Iterator End()
{
return Iterator(
nullptr,
_root
);
}
于是:
cpp
it == end()
本质就是:
text
it._node == nullptr
2. STL 源码中的 header 哨兵结点
经典 SGI-STL 的红黑树实现会额外设计:
text
header
哨兵结点。
它通常会维护类似:
text
header.parent → root
header.left → 最左结点
header.right → 最右结点
这样做以后:
text
begin
end
最左
最右
等操作可以更加统一。
我们自己学习实现时,可以暂时使用:
text
nullptr 作为 end
结构更简单。
代价就是:
cpp
--end()
需要单独特殊处理。
五、iterator++ 到底在找什么?
++it 并不是:
text
地址 + 1
而是:
找当前结点在中序遍历中的后继结点。
假设当前:
text
it → x
我们要寻找:
text
x 的中序后继
只需要分析两种情况。
六、情况一:当前结点有右子树
例如:
text
30
\
40
/
35
当前:
text
it → 30
按照中序:
text
30
35
40
所以下一个不是:
text
40
而是:
text
右子树中最左边的结点
即:
text
35
所以:
cpp
if (_node->_right)
{
Node* leftMost =
_node->_right;
while (leftMost->_left)
{
leftMost =
leftMost->_left;
}
_node = leftMost;
}
可以概括为一句:
有右子树,后继就是右子树的最左结点。
七、情况二:当前结点没有右子树
这个情况稍微难一些。
假设:
text
18
/
10
\
15
当前:
text
it → 15
15 没有右子树。
中序顺序:
text
10
15
18
那么:
text
15 的下一个
是:
text
18
但 18 并不是 15 的父亲。
所以不能简单写:
cpp
_node = _node->_parent;
八、向祖先寻找"第一次从左边上来"的位置
从:
text
15
向上:
text
15 是 10 的右孩子
说明:
text
10 这整棵子树已经访问完成
继续往上:
text
10 是 18 的左孩子
根据中序:
text
左子树
↓
根
说明:
text
18
就是下一个结点。
所以算法是:
cpp
Node* cur = _node;
Node* parent = cur->_parent;
while (parent
&& cur == parent->_right)
{
cur = parent;
parent = cur->_parent;
}
_node = parent;
也就是:
没有右子树时,一直向祖先寻找,直到找到"当前路径是从某个父结点左子树上来的"那个父结点。
九、完整 operator++
因此:
cpp
Self& operator++()
{
if (_node->_right)
{
Node* leftMost =
_node->_right;
while (leftMost->_left)
{
leftMost =
leftMost->_left;
}
_node = leftMost;
}
else
{
Node* cur = _node;
Node* parent =
cur->_parent;
while (parent
&& cur == parent->_right)
{
cur = parent;
parent =
cur->_parent;
}
_node = parent;
}
return *this;
}
整个逻辑只需要记两句话:
text
有右:
找右子树最左
无右:
向上找第一次从左边上来的祖先
十、为什么最大结点 ++ 会变成 end()?
例如最大结点:
text
50
没有右子树。
它可能沿祖先一直:
text
50 是 40 的右
40 是 30 的右
30 是 18 的右
最后:
text
parent == nullptr
于是:
cpp
_node = parent;
也就是:
cpp
_node = nullptr;
正好变成:
cpp
end()
所以:
cpp
++最后一个元素
自然得到:
text
end()
十一、operator-- 与 ++ 完全对称
--it 实际上是在寻找:
text
中序前驱
因此逻辑和 ++ 几乎镜像。
十二、情况一:当前结点有左子树
例如:
text
30
/
20
\
25
当前:
text
it → 30
中序:
text
20
25
30
所以前驱是:
text
25
也就是:
text
左子树最右边的结点
代码:
cpp
Node* rightMost =
_node->_left;
while (rightMost->_right)
{
rightMost =
rightMost->_right;
}
_node = rightMost;
十三、情况二:当前结点没有左子树
此时就要向上寻找:
第一次从父结点右边上来的祖先。
代码:
cpp
Node* cur = _node;
Node* parent =
cur->_parent;
while (parent
&& cur == parent->_left)
{
cur = parent;
parent =
cur->_parent;
}
_node = parent;
所以:
text
++:
向上找从左边来的祖先
--:
向上找从右边来的祖先
正好完全对称。
十四、--end() 为什么需要特殊处理?
由于我们的:
cpp
end()
是:
text
nullptr
那么:
cpp
--end()
不能直接访问:
cpp
_node->_left
否则:
text
空指针解引用
所以需要单独处理。
--end() 在正常迭代语义中应该得到:
text
中序遍历最后一个结点
也就是:
text
整棵 BST 最右边的结点
因此:
cpp
if (_node == nullptr)
{
Node* rightMost = _root;
while (rightMost
&& rightMost->_right)
{
rightMost =
rightMost->_right;
}
_node = rightMost;
}
这也是为什么我们的迭代器除了 _node 以外,还保存了:
cpp
_root
十五、完整 operator--
cpp
Self& operator--()
{
if (_node == nullptr)
{
Node* rightMost = _root;
while (rightMost
&& rightMost->_right)
{
rightMost =
rightMost->_right;
}
_node = rightMost;
}
else if (_node->_left)
{
Node* rightMost =
_node->_left;
while (rightMost->_right)
{
rightMost =
rightMost->_right;
}
_node = rightMost;
}
else
{
Node* cur = _node;
Node* parent =
cur->_parent;
while (parent
&& cur == parent->_left)
{
cur = parent;
parent =
cur->_parent;
}
_node = parent;
}
return *this;
}
可以压缩记忆为:
text
--end()
→ 整棵树最右
有左
→ 左子树最右
无左
→ 向上找第一次从右边上来的祖先
十六、operator* 和 operator->
iterator 必须像指针一样使用。
所以:
cpp
Ref operator*()
{
return _node->_data;
}
以及:
cpp
Ptr operator->()
{
return &_node->_data;
}
1. 为什么 operator-> 返回地址?
例如:
cpp
it->second
实际上等价于:
cpp
(&(**it))->second
所以:
cpp
operator->
应该返回当前元素的:
text
指针
十七、iterator 为什么有 Ref 和 Ptr?
这是一个非常经典的泛型技巧。
定义:
cpp
template<class T, class Ref, class Ptr>
struct RBTreeIterator;
普通 iterator:
cpp
RBTreeIterator<
T,
T&,
T*
>
const_iterator:
cpp
RBTreeIterator<
T,
const T&,
const T*
>
这样只用:
text
一份代码
就可以实现:
text
iterator
const_iterator
十八、RBTree 中定义两种迭代器
cpp
typedef RBTreeIterator<
T,
T&,
T*
> Iterator;
typedef RBTreeIterator<
T,
const T&,
const T*
> ConstIterator;
于是:
text
Iterator
解引用返回:
cpp
T&
而:
text
ConstIterator
返回:
cpp
const T&
这正是我们以前在:
text
list
vector
迭代器中学到的同一种模板复用思想。
十九、Begin 和 End 的 const 版本
普通成员:
cpp
Iterator Begin()
{
Node* leftMost = _root;
while (leftMost
&& leftMost->_left)
{
leftMost =
leftMost->_left;
}
return Iterator(
leftMost,
_root
);
}
const 版本:
cpp
ConstIterator Begin() const
{
Node* leftMost = _root;
while (leftMost
&& leftMost->_left)
{
leftMost =
leftMost->_left;
}
return ConstIterator(
leftMost,
_root
);
}
以及:
cpp
Iterator End()
{
return Iterator(
nullptr,
_root
);
}
ConstIterator End() const
{
return ConstIterator(
nullptr,
_root
);
}
二十、set 的 iterator 为什么应该是只读的?
对于:
cpp
set<int>
元素:
text
10
20
30
本身就是:
text
key
如果允许:
cpp
*it = 100;
就相当于修改红黑树的搜索依据。
例如:
text
20
/ \
10 30
如果直接把:
text
10 → 100
得到:
text
20
/ \
100 30
显然已经不是 BST。
因此:
text
set 的元素不能通过 iterator 修改
我们可以让底层保存:
cpp
const K
即:
cpp
RBTree<
K,
const K,
SetKeyOfT
> _t;
二十一、map 为什么 key 不能修改,但 value 可以?
map 中一个元素:
cpp
pair<const K, V>
例如:
cpp
("apple", 5)
其中:
text
apple
决定这个结点在红黑树中的位置。
所以:
cpp
it->first = "banana";
必须禁止。
但是:
text
5
并不参与树的排序。
所以:
cpp
it->second = 10;
完全合法。
于是 map 保存:
cpp
pair<const K, V>
恰好满足:
text
first 不能修改
second 可以修改
二十二、为什么 insert 不能只返回 bool?
最开始我们可能写:
cpp
bool Insert(const T& data);
插入成功返回:
text
true
失败返回:
text
false
但是实现:
cpp
map::operator[]
以后,这个返回值已经不够用了。
我们不仅想知道:
text
是否插入成功
还需要知道:
text
对应元素在哪里
因此更合理的返回值是:
cpp
pair<Iterator, bool>
二十三、pair<iterator,bool> 分别表示什么?
例如:
cpp
pair<Iterator, bool> ret;
其中:
text
ret.first
↓
目标元素 iterator
ret.second
↓
是否新插入成功
假设:
cpp
m.insert({"apple", 5});
如果:
text
apple 不存在
那么:
text
first → 新插入的 apple
second → true
如果已经存在:
text
first → 原来的 apple
second → false
这正是 map::operator[] 需要的行为。
二十四、Insert 的返回值改造
空树:
cpp
if (_root == nullptr)
{
_root = new Node(data);
_root->_col = BLACK;
return make_pair(
Iterator(_root, _root),
true
);
}
发现重复 key:
cpp
else
{
return make_pair(
Iterator(cur, _root),
false
);
}
成功插入以后:
cpp
return make_pair(
Iterator(newnode, _root),
true
);
注意为什么需要单独保存:
cpp
Node* newnode = cur;
因为红黑树后面在调整过程中:
text
cur
可能被不断改成:
text
grandfather
所以如果最后直接返回:
cpp
Iterator(cur, _root)
有可能已经不是:
text
真正插入的结点
因此插入后立即:
cpp
Node* newnode = cur;
保存它。
二十五、map::operator\[\] 是怎么实现的?
现在终于可以实现:
cpp
m[key]
代码非常短:
cpp
V& operator[](const K& key)
{
pair<iterator, bool> ret =
insert(
make_pair(key, V())
);
return ret.first->second;
}
虽然只有几行,但这里的设计非常巧妙。
二十六、operator\[\] 的第一种情况:key 已经存在
假设:
cpp
map<string, int> m;
m.insert({"apple", 5});
然后:
cpp
m["apple"];
调用:
cpp
insert(
make_pair(
"apple",
int()
)
);
因为:
text
apple 已经存在
Insert 返回:
text
iterator → 原来的 ("apple",5)
bool → false
于是:
cpp
ret.first->second
就是:
text
5
并不会真正插入新的结点。
二十七、operator\[\] 的第二种情况:key 不存在
如果:
cpp
m["banana"];
但是:
text
banana
不存在。
此时:
cpp
make_pair(
"banana",
int()
)
其中:
cpp
int()
得到:
text
0
所以会插入:
text
("banana", 0)
然后返回:
cpp
second
的引用。
因此:
cpp
m["banana"] = 20;
实际上可以理解为:
text
1. 查找 banana
2. 不存在
↓
插入
("banana",0)
3. 返回 value 的引用
4. value = 20
最终:
text
("banana",20)
二十八、为什么 operator\[\] 可能"偷偷插入元素"?
这是 map 使用时一个非常重要的细节。
下面这句:
cpp
cout << m["hello"];
很多初学者会认为:
text
只是查询
但如果:
text
"hello" 不存在
实际上会先插入:
text
("hello", V())
所以:
text
operator[]
不是一个纯查找操作。
如果只是想查询:
text
是否存在
更适合:
cpp
find()
二十九、扩展理解:为什么 const map 不能随便使用 operator\[\]?
这也能从前面的原理直接推出来。
因为:
cpp
operator[]
在 key 不存在时可能:
text
插入新元素
也就是说它可能:
text
修改容器
因此对一个:
cpp
const map
来说,这种操作自然不能作为普通只读访问使用。
这个现象从:
text
operator[] = 查找 + 必要时插入
就很好理解了。
三十、find 如何实现?
find 依然利用 BST 搜索。
泛型封装以后建议继续使用:
text
KeyOfT
而不是重新假设:
text
T 一定是 pair
例如:
cpp
Iterator Find(const K& key)
{
KeyOfT kot;
Node* cur = _root;
while (cur)
{
if (kot(cur->_data) < key)
{
cur = cur->_right;
}
else if (kot(cur->_data) > key)
{
cur = cur->_left;
}
else
{
return Iterator(
cur,
_root
);
}
}
return End();
}
这一点尤其值得注意。
既然 RBTree 已经泛型化成:
cpp
RBTree<K,T,KeyOfT>
那么后面的:
text
Insert
Find
Erase
都应该统一通过:
cpp
KeyOfT
提取 key。
不要在某个函数中又重新写死:
cpp
_data.first
否则:
text
map 可以工作
set 可能就无法复用
三十一、完整 myset 封装
cpp
#pragma once
#include "RBTree.h"
namespace bit
{
template<class K>
class set
{
struct SetKeyOfT
{
const K& operator()(
const K& key)
{
return key;
}
};
typedef RBTree<
K,
const K,
SetKeyOfT
> Tree;
public:
typedef typename Tree::Iterator
iterator;
typedef typename Tree::ConstIterator
const_iterator;
iterator begin()
{
return _t.Begin();
}
iterator end()
{
return _t.End();
}
const_iterator begin() const
{
return _t.Begin();
}
const_iterator end() const
{
return _t.End();
}
pair<iterator, bool>
insert(const K& key)
{
return _t.Insert(key);
}
iterator find(const K& key)
{
return _t.Find(key);
}
private:
Tree _t;
};
}
三十二、测试 myset
cpp
void test_set()
{
bit::set<int> s;
int a[] =
{
4, 2, 6, 1, 3,
5, 15, 7, 16, 14
};
for (auto e : a)
{
s.insert(e);
}
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
}
理论上输出:
text
1 2 3 4 5 6 7 14 15 16
注意:
输出顺序不是插入顺序,而是红黑树的中序遍历顺序。
因此:
text
set 天然有序
三十三、完整 mymap 封装
cpp
#pragma once
#include "RBTree.h"
namespace bit
{
template<class K, class V>
class map
{
typedef pair<const K, V>
ValueType;
struct MapKeyOfT
{
const K& operator()(
const ValueType& kv)
{
return kv.first;
}
};
typedef RBTree<
K,
ValueType,
MapKeyOfT
> Tree;
public:
typedef typename Tree::Iterator
iterator;
typedef typename Tree::ConstIterator
const_iterator;
iterator begin()
{
return _t.Begin();
}
iterator end()
{
return _t.End();
}
const_iterator begin() const
{
return _t.Begin();
}
const_iterator end() const
{
return _t.End();
}
pair<iterator, bool>
insert(const ValueType& kv)
{
return _t.Insert(kv);
}
iterator find(const K& key)
{
return _t.Find(key);
}
V& operator[](const K& key)
{
pair<iterator, bool> ret =
insert(
make_pair(
key,
V()
)
);
return ret.first->second;
}
private:
Tree _t;
};
}
三十四、测试 mymap
cpp
void test_map()
{
bit::map<string, string> dict;
dict.insert(
{"sort", "排序"}
);
dict.insert(
{"left", "左边"}
);
dict.insert(
{"right", "右边"}
);
dict["left"] =
"左边,剩余";
dict["insert"] =
"插入";
dict["string"];
for (auto it = dict.begin();
it != dict.end();
++it)
{
cout
<< it->first
<< ":"
<< it->second
<< endl;
}
}
注意:
cpp
it->second += "x";
可以。
但是:
cpp
it->first += "x";
应该无法通过编译。
原因就是:
cpp
pair<const K, V>
中的:
text
first
是 const K。
三十五、整个封装过程其实是一条完整的演化路线
现在回过头,可以看到我们的代码不是一次性设计出来的。
而是逐步演化:
text
第一阶段
RBTree<K,V>
结点写死 pair<K,V>
↓
第二阶段
RBTree<K,T>
T 决定结点存什么
↓
第三阶段
RBTree<K,T,KeyOfT>
通过仿函数获得 key
↓
第四阶段
支持 Iterator
Begin
End
↓
第五阶段
Iterator<T,Ref,Ptr>
复用 iterator / const_iterator
↓
第六阶段
set:
const K
map:
pair<const K,V>
解决 key 不允许修改
↓
第七阶段
Insert
返回 pair<iterator,bool>
↓
第八阶段
map::operator[]
三十六、时间复杂度分析
由于底层仍然是:
text
红黑树
高度保持:
text
O(logN)
因此:
1. insert
寻找插入位置:
text
O(logN)
红黑树调整:
text
O(logN)
所以整体:
text
O(logN)
2. find
只沿一条搜索路径:
text
O(logN)
3. operator\[\]
本质执行:
text
insert
所以:
text
O(logN)
4. begin
我们当前实现需要一路寻找:
text
最左结点
因此:
text
O(logN)
如果像经典 STL 红黑树一样额外使用:
text
header
直接维护最左结点,那么 begin() 可以进一步简化。
三十七、常见错误总结
1. ++ 直接移动到父结点
错误:
cpp
_node = _node->_parent;
因为中序后继并不一定是父亲。
正确判断:
text
有右子树
→ 右子树最左
无右子树
→ 向祖先寻找
2. 有右子树时直接进入右孩子
错误:
cpp
_node = _node->_right;
因为真正的下一个结点应该是:
text
右子树中最左结点
3. 忘记特殊处理 --end()
我们使用:
text
nullptr
作为 end()。
因此:
cpp
--end()
必须单独定位:
text
整棵树最右结点
4. set iterator 返回 K&
如果允许:
cpp
*it = ...
可能破坏红黑树结构。
所以:
text
set 元素必须只读
5. map 使用 pair<K,V>
如果写:
cpp
pair<K,V>
则:
text
first 可以修改
这是不合理的。
应该:
cpp
pair<const K,V>
6. Insert 只返回 bool
这样无法方便实现:
cpp
operator[]
更好的接口是:
cpp
pair<iterator,bool>
7. operator\[\] 直接写一套搜索代码
这样会重复:
text
查找
插入
逻辑。
更好的方式:
cpp
insert(make_pair(key, V()))
直接复用 insert。
8. 泛型化后 Find 又写死 pair
例如:
cpp
cur->_data.first
这种写法会重新把:
text
RBTree
绑定到 map。
应该统一:
cpp
KeyOfT kot;
kot(cur->_data)
这才能保持:
text
set/map 共用一棵红黑树
的设计目标。