C++进阶篇(六):map 和 set 的模拟实现——用红黑树封装 mymap/myset(迭代器 + operator[])

0.1 概述&序言

这里是白杨。上一篇把红黑树的插入、旋转、变色和验证写完了;这一篇把它变成能直接用的容器:bit::set 和 bit::map。

从"树"到"容器",要解决两个问题:

  1. 结点到底存什么? set 存 key,map 存 pair<const K, V>,同一棵红黑树不能写死类型;
  2. 迭代器怎么走? map/set 的迭代器走中序,++ 找后继,-- 找前驱,begin/end 还要照顾 --end()。

参考实现没有实现 erase,这篇也不硬塞;删除和红黑树删除是另一条线。我们先把 insert / find / iterator / const_iterator / operator[] 这条主线打通。

上一篇的 RBTree 还是把 pair<K,V> 写死在结点里的,Insert/Find 都默认自己在处理 map;这一篇先把它拆成 RBTree<K,T,KeyOfT>,再让 set/map 各自选择 T。代码会分成四个源文件,和真实工程的头文件分层一致。

这一篇的目标不是把 STL 抄一遍,而是把"泛型 + 迭代器 + 容器适配"这三件事串起来:以后看 unordered_map、看任何适配器,都是同一套思路。

老规矩,代码全部 g++ 实测,完整工程文末自取,也可以直接去仓库:gitee.com/BaiYang_SQ。

实测环境:w64devkit g++ 15.2.0(-std=c++11 -O2 -Wall -Wextra)+ VS2022;教学简化版。

标准容器参考:cppreference·map / set。

让我们开始吧。

一、三层框架:set/map 只是壳

先看整体结构:

set 和 map 都是组合(has-a)一棵红黑树,不是继承:

cpp 复制代码
template <class K, class T, class KeyOfT>
class RBTree
{
    typedef RBTreeNode<T> Node;
    // ...
};

三个模板参数各干各的:

参数 作用 set 传什么 map 传什么
K 查找用的键类型(将来 erase 也是它) K K
T 结点里真正存的数据 const K pair<const K, V>
KeyOfT 从 T 里取出 K 的仿函数 SetKeyOfT MapKeyOfT

为什么 K 和 T 要分开?对 set 来说两个都是 K,看起来可以合并;但 map 的结点存的是 pair,find 的形参却是 K。一个管"存什么",一个管"按什么找",分开才通用。

组合的好处是接口干净:set/map 只暴露自己的 insert/find/begin/end,红黑树的旋转、颜色、根指针都藏在 _t 里;如果继承,RotateL/RotateR 这些实现细节会漏给用户。SGI 的 rb_tree 也是组合,不是基类。

另外,set 和 map 的 insert 形参也不同:set 收 K,map 收 pair<K,V>;它们最后都转调同一个 Insert(const T&)。K/T 分开,正是为了让这两种调用都成立。

SGI 的命名是 rb_tree<Key, Value, KeyOfValue>,我们的 RBTree<K,T,KeyOfT> 一一对应:SGI 的 Key 是 K,Value 是 T,KeyOfValue 是 KeyOfT。看源码时别被 value_type 这个名字骗了------它指的是结点里存的数据类型,不是 map 的 mapped value。

【一句话记】set/map 是壳,RBTree 是芯;壳负责决定 T 和 KeyOfT,芯负责插入、旋转和迭代。

二、KeyOfT:只比较 key

map 的结点是 pair<const K, V>。如果直接拿 pair 比较,pair::operator< 会先比 first,first 相等再比 second------value 一变,key 的身份就变了。

所以 set/map 各提供一个仿函数,把 key 从 T 里抽出来:

cpp 复制代码
// set
struct SetKeyOfT
{
    const K& operator()(const K& key) const { return key; }
};

// map
struct MapKeyOfT
{
    const K& operator()(const pair<const K, V>& kv) const { return kv.first; }
};

pair 的 operator< 实现大致是:先比 first;first 相等时再比 second。对 map<string,int> 来说,("apple",1) 和 ("apple",2) 会被认为不同,但 map 的 key 只有一个 apple。所以必须把 second 从比较里拿掉。

MapKeyOfT 返回 const K& 而不是 K,是为了避免每次比较都拷贝字符串;SetKeyOfT 同理。

红黑树内部所有比较都走它:

cpp 复制代码
KeyOfT kot;
if (kot(cur->_data) < kot(data))      // 只比 key
    cur = cur->_right;
else if (kot(cur->_data) > kot(data))
    cur = cur->_left;
else
    return false;                      // key 重复

举个具体例子:("left", 1) 和 ("left", 2) 对 map 来说是同一个 key,value 不同;如果直接比 pair,第二个会被排在后面,insert 会当成新 key,map 里就出现两个 left。KeyOfT 把 second 丢掉,只留 first,才能保证 key 唯一。

这也是为什么不能简单用 set<pair<K,V>> 代替 map:pair 的默认比较语义是"first 相同再比 second",而 map 要的是"first 相同就是同一个 key"。

【必背】Insert 用 T,Find 用 K,比较用 KeyOfT。

【易错】map 的 T 改成 pair<const K, V> 后,MapKeyOfT 的形参也必须写 const pair<const K, V>&。还写 pair<K, V> 会多构造一个临时 pair,返回的 const K& 指向临时对象,比较时可能悬空。

三、结点泛型:T 是什么,结点就存什么

结点不再写死 pair<K,V>,而是存一个泛型 T:

cpp 复制代码
template <class T>
struct RBTreeNode
{
    T _data;
    RBTreeNode<T>* _left;
    RBTreeNode<T>* _right;
    RBTreeNode<T>* _parent;
    Colour _col;

    RBTreeNode(const T& data)
        : _data(data), _left(nullptr), _right(nullptr), _parent(nullptr), _col(RED)
    {}
};

RBTree 里还有一句 typedef RBTreeNode<T> Node;,后面所有 Node* 都是它。模板参数 T 一路从 set/map 传到结点,编译器会为 const K 和 pair<const K,V> 各生成一份代码。

set 实例化:

cpp 复制代码
RBTree<K, const K, SetKeyOfT> _t;

map 实例化:

cpp 复制代码
RBTree<K, pair<const K, V>, MapKeyOfT> _t;

于是 set 的 _data 是 const K,map 的 _data 是 pair<const K,V>。红黑树旋转只改 _left/_right/_parent,从不对 _data 赋值,所以 const 不会挡路。

set 的 T=const K 还有连锁反应:_data 是 const,结点构造完不能赋值;map 的 pair<const K,V> 同理,first 不能赋值,second 可以。结点的拷贝构造没问题,拷贝赋值会被删除,但树只 new/delete,不做结点赋值,所以能正常跑。

RBTreeNode<T> 里的 T _data 不是引用,也不是指针,结点构造时拷贝一份数据;对 map 来说拷贝的是 pair<const K,V>,first 的 const 也被拷贝过去。红黑树只负责链接和颜色,不关心 T 里有什么。

【易错】构造函数要顺手把 _col 初始化成 RED;不初始化就依赖 Insert 里赋值,某处直接读颜色就是未定义值。

四、迭代器 ++:中序下一个是谁

map/set 的迭代器走中序:左 → 根 → 右。begin() 返回最左结点,end() 用 nullptr 表示。

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;
}

拿图里的树举例:

  • it=10,右子树非空 → 右子树最左是 15;
  • it=15,无右 → 15 是 10 的右,10 是 18 的左 → 18;
  • it=25,无右 → 25 是 30 的左 → 30;
  • it=50,一路都是右孩子,找不到"左拐"祖先 → nullptr,也就是 end()。

把这棵树的中序写出来:10 → 15 → 18 → 25 → 30 → 35 → 40 → 50 → end。每一步怎么走,可以对照下表:

当前 下一个 走法
10 15 右子树非空,右子树最左
15 18 无右,向上 15→10→18,10 是左孩子
18 25 右子树最左(30→25)
25 30 无右,25 是 30 的左
30 35 右子树最左(40→35)
35 40 无右,35 是 40 的左
40 50 右孩子
50 end 一路右孩子,到根为空

迭代器的本质和 list 一样:包一个结点指针,重载 *、->、++、--、==、!=;区别是 list 的 ++ 走 next,红黑树的 ++ 要爬树。

RBTreeIterator 里只有两个指针:_node 指向当前结点,_root 指向整棵树的根。begin() 从根一路向左,end() 直接构造 (nullptr, _root);++ 不需要 _root,但 --end() 需要。

cpp 复制代码
iterator begin()
{
    Node* leftMost = _root;
    while (leftMost && leftMost->_left) leftMost = leftMost->_left;
    return iterator(leftMost, _root);
}
iterator end() { return iterator(nullptr, _root); }

【必背】右子树最左;否则向上找"左拐"的祖先;找不到就是 end()。

【易错】++end() 未定义,不要调用;循环条件里的 parent && 不能省,否则到根会空指针解引用。

五、迭代器 --:把 ++ 反过来

-- 和 ++ 完全对称:左子树最右;否则向上找"我是父亲右孩子"的祖先;--end() 特殊处理到整棵树的最右结点。

cpp 复制代码
Self& operator--()
{
    if (_node == nullptr)
    {
        // --end():整棵树的最右结点
        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;
}

图中 it=50 时,50 没有右子树,向上 50→40→30→18 全是右孩子,最后 it=nullptr,也就是 end()。

++ 和 -- 是一对镜像:一个找"左拐"的祖先,一个找"右拐"的祖先;一个从右子树最左,一个从左子树最右。写的时候把 _right 和 _left 对调,就能从 ++ 推到 --。

end() 有两种实现:STL 用 header 哨兵,header->parent 是根,header->left/right 是最左/最右,end() 就是 header;我们用 nullptr,少一个结点,但 --end() 必须用 _root 找最右。两种都能用,不能混。如果迭代器只存 _node 不存 _root,--end() 就不知道去哪棵树找最右,所以构造函数要带 root。

空树上 begin()==end(),此时 --end() 没有意义;标准也只保证 --end() 在非空容器上可用。我们测试里只对非空 set 做反向遍历。--begin() 和 ++end() 都是未定义行为,迭代器不是环。

【必背】--end() 到最右;有左子树走最右;否则向上找"右拐"的祖先。

【易错】迭代器必须保存 _root,否则 --end() 不知道该往哪棵树的最右走;--begin() 未定义,不要调用。

六、const_iterator 与 key 不可改

迭代器模板有三个参数:

cpp 复制代码
typedef RBTreeIterator<T, T&, T*> iterator;
typedef RBTreeIterator<T, const T&, const T*> const_iterator;
  • iterator 的 operator* 返回 T&;
  • const_iterator 的 operator* 返回 const T&,operator-> 返回 const T*;
  • begin() const / end() const 必须返回 const_iterator。
cpp 复制代码
const_iterator begin() const
{
    Node* leftMost = _root;
    while (leftMost && leftMost->_left) leftMost = leftMost->_left;
    return const_iterator(leftMost, _root);
}
const_iterator end() const { return const_iterator(nullptr, _root); }

const 重载不是装饰:没有它,const map 连遍历都编译不过;返回非 const 迭代器又会把只读对象漏成可写。

key 不可改,靠的是 T 自带的 const:

容器 T *it it->first it->second
set<K> const K const K& 只读 ---
map<K,V> pair<const K,V> pair<const K,V>& 只读 可改
const map const pair<const K,V> const pair<const K,V>& 只读 只读

const_iterator 不是另一个容器,只是把 Ref/Ptr 换成 const T&/const T*;begin() const 返回它,普通 begin() 返回 iterator。严格实现还会写 iterator → const_iterator 的转换构造,我们这里先把 const 遍历跑通,没加转换。

key 只读有三层保障:T 里的 const、迭代器的 Ref、以及 const_iterator 的 const T&。任何一层偷懒,it->first = ... 就可能编译通过。

const map 的 begin() const 返回 const_iterator,它的 operator* 是 const pair<const K,V>&,所以 first 和 second 都只读;普通 map 的 iterator 只读 first,second 可写。这就是 const 正确性在容器上的体现。

cpp 复制代码
map<string, string>::iterator it = dict.begin();
// it->first += 'x';   // 编译错误:first 是 const K
it->second += 'x';     // OK

set<int>::iterator sit = s.begin();
// *sit = 5;           // 编译错误:*sit 是 const int&

【必背】set 的 T 传 const K,map 的 T 传 pair<const K,V>;key 的只读是类型系统管的。

【易错】只改 map::value_type、不改 RBTree 的第二个模板参数,等于没改;it->first 仍然可写,运行时破坏有序性。

MySet/MyMap 的 begin() const/end() const 只是转调 _t 的 const 重载;普通对象走非 const 重载,const 正确性一路传到底。operator[] 只提供非 const 版本,const map 不能调用,所以 const 容器只能用 find 读,不能下标写。

七、insert 返回值与 operator\[\]

insert 不能只返回 bool,否则 operator[] 拿不到迭代器。它要返回 pair<iterator, bool>:

cpp 复制代码
pair<iterator, bool> Insert(const T& data)
{
    if (_root == nullptr)
    {
        _root = new Node(data);
        _root->_col = BLACK;
        return { iterator(_root, _root), true };
    }

    KeyOfT kot;
    Node* parent = nullptr;
    Node* cur = _root;
    while (cur)
    {
        if (kot(cur->_data) < kot(data))      { parent = cur; cur = cur->_right; }
        else if (kot(cur->_data) > kot(data)) { parent = cur; cur = cur->_left; }
        else return { iterator(cur, _root), false };   // 已存在
    }

    Node* newNode = new Node(data);
    // ... 接到 parent、调整颜色和旋转 ...
    return { iterator(newNode, _root), true };          // 新插入
}

三个返回点:

场景 迭代器 bool
空树 新根 true
key 已存在 已存在结点 false
新插入 新结点 true

insert 重复时返回已存在结点的迭代器,bool=false,不覆盖旧 value ;operator[] 才是"存在就改、不存在就插"。这两个语义一定分清:dict.insert({"left","新值"}) 不会改掉原来的 left,dict["left"]="新值" 才会。

拿测试里的 left 举例:先 dict.insert({"left","不该覆盖"}),返回 second=0,值仍是"左边";再 dict["left"]="左边, 剩余",值才被覆盖。insert 和 operator\[\] 的差别,在这两行里看得最清楚。

有了它,operator[] 三行就够:

cpp 复制代码
V& operator[](const K& key)
{
    pair<iterator, bool> ret = _t.Insert({ key, V() });
    return ret.first->second;
}
  • key 已存在:Insert 返回 {it,false},直接返回已有 second 的引用,可以覆盖;
  • key 不存在:插入 {key, V()},返回新 second 的引用,可以赋值。

countMap[word]++ 就是靠 operator[]:不存在时先插 {word,0},返回 0 的引用,再 ++ 变 1;存在时直接 ++。所以词频一行就够。

如果 insert 还返回 bool,operator[] 只能先 Find 再插入,多一次 O(logN);返回 pair<iterator,bool> 后,一次插入就拿到位置,效率更高。map::insert 和 map::operator[] 是两种语义:insert 不覆盖,operator\[\] 覆盖;写业务代码时要按需求选。

V() 还要求 V 可默认构造;如果 V 是只能带参构造的类型,operator[] 就用不了,这不是 bug,是接口约束。

所以词频可以写得非常短:

cpp 复制代码
bit::map<string, int> countMap;
for (const auto& w : arr) countMap[w]++;
text 复制代码
苹果:6 西瓜:3 香蕉:2

【必背】insert 重复不覆盖;operator[] 不存在就插入默认值,返回 V&。

【易错】operator[] 不是只读,const map 不能用;V 必须能默认构造,否则 V() 编译不过。

八、测试:set 和 map 一起跑

set 测试:

text 复制代码
正向: 1 2 3 4 5 6 7 14 15 16
重复插入 4: second = 0,Size=10
反向: 16 15 14 7 6 5 4 3 2 1
const 遍历: 1 2 3 4 5 6 7 14 15 16
find(7)=1,find(100)=0
平衡=1

反向那一行从 s.end() 开始,先 --it 到最右 16,一路走到 1,正好把 --end()、-- 和 const_iterator 都跑了一遍。

map 测试:

text 复制代码
重复插入 left: second = 0,值仍是 左边
insert:插入x left:左边, 剩余x right:右边x sort:排序x string:x
const 遍历: one:1 three:3 two:2
find(left)=1,find(nope)=0
Size=5,平衡=1

left 先被 operator[] 覆盖,再被迭代器 it->second += 'x' 修改;string 是 dict["string"] 插入的空串,+= 'x' 后变成 "x"。

set 这组输出覆盖了:插入去重、升序遍历、find 命中/未命中、const 遍历、--end() 反向;map 覆盖了重复插入不覆盖、operator[] 覆盖/插入、迭代器改 second、const 遍历。10 万那组再把正向/反向完整走一遍,确认迭代器没有漏结点。

没覆盖的是 erase、拷贝/赋值、自定义比较器;这些不是这篇的主线,留到需要时再补。

最后上 10 万规模:

text 复制代码
Size=100000,平衡=1,正向=100000,first=1,last=100000,反向=100000

正向、反向都完整走完 10 万个结点,底层四规则仍然通过------迭代器没有漏结点,树也没有被改坏。

10 万 set 的插入是递增的,红黑树会不断旋转/变色,但四规则仍然通过;正向/反向各走 10 万次,说明 begin/end/++/-- 在深树上也没有断链。如果迭代器 ++ 少了 parent &&,10 万这组会在根附近崩;小数据不一定暴露,大数据才稳。

这组测试还暴露一个工程习惯:小数据测功能,大数据测迭代器和边界;10 万这组如果 --end() 特判写错,反向计数会直接少一个或崩,不会蒙混过关。

【一句话记】set 管"去重 + 排序",map 管"key → value";底层都是同一棵 RBTree。

九、速记卡

考前扫一眼,封装不迷路:

附录 A:四个源文件

完整工程在 Gitee:

https://gitee.com/BaiYang_SQ

文件 内容
RBTree.h 泛型红黑树、结点、迭代器 ++/--、const_iterator、四规则自检
MySet.h bit::set:T = const K,insert/find/begin/end
MyMap.h bit::map:T = pair<const K,V>,insert/find/begin/end/operator[]
Test.cpp set/map 测试、词频、10 万正反向遍历
output.txt 全部真实运行输出
build.bat Windows 一键编译 + 运行

拆成四个源文件,是为了让"RBTree 是芯、set/map 是壳"这件事在目录结构上也看得见;单文件也能写,但头文件分层更接近真实工程。build.bat 只编译 Test.cpp,三个头文件通过 #include 进来。

编译:

bash 复制代码
g++ -std=c++11 -O2 -Wall -Wextra Test.cpp -o mymapset_sim && ./mymapset_sim

仓库里的 output.txt 就是 build.bat 真实跑出来的,和文中引用一致;想改测试直接改 Test.cpp 再跑即可。

最后把封装顺序压成三步:

text 复制代码
① RBTree<K, T, KeyOfT>:结点存 T,比较用 KeyOfT 取 K
② set / map 两层壳:set 传 const K,map 传 pair<const K,V>
③ 迭代器:begin 最左,end 是 nullptr,++/-- 走中序,const_iterator 管只读

参考实现没有实现 erase,这篇也没有硬塞;先把这条主线走顺,后面需要再补删除。

好了,这一篇到此完结!下一篇继续进阶篇主线------unordered_map 和 unordered_set 的使用 :哈希桶、负载因子和迭代器失效,都是新的坑。如果对你有帮助,不要忘记点赞三连一波哦,我是白杨,我们下期见。

相关推荐
kloc3 小时前
聊天室项目入口网关子服务的实现
linux·c++
饮水桶_T4 小时前
JAVA IO流笔记
开发语言·数据结构·算法
csdn_aspnet6 小时前
C++ 3个有序点的方向(Orientation of 3 ordered points)
c++·算法
Felven6 小时前
C. Brr Brrr Patapim
c语言·开发语言
小七在进步7 小时前
string的使用(2)
c++
路溪非溪7 小时前
python标准库使用总结
开发语言·python
青少儿编程课堂8 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会Python/C++考级:折半搜索优化子集枚举(Meet in the Middle)解析
c++·python·算法·bfs·信息学竞赛
SomeOtherTime9 小时前
Rust 面向对象介绍(AI回答)
开发语言·python·rust
wo_jack9 小时前
C++ 入门笔记(十七):list 入门 —— 为什么 list 不能 sort?
数据结构·c++·面试·stl·list