【C++第二十一章】set与map封装

前言 🚀

setmap 表面上看只是 STL 里两个常用关联式容器,但如果继续往下追,就会发现它们并不是"单独设计出来的两个类",而是建立在同一套有序搜索树框架 之上的两种不同封装。真正需要想清楚的,不只是"它们怎么用",而是:为什么二者都能保持有序、为什么查找和插入复杂度能稳定在对数级、为什么 set 里元素像只读、为什么 map 里键不能改、以及为什么底层插入返回值最后要从结点再转成迭代器。

这份笔记原始内容缺失较多(电脑非正常关机全丢了= =),只保留了两个关键抓手:一个是迭代器 operator-- 的实现思路 ,另一个是**insert 返回值里 pair::first 为什么要先用结点而不是直接用迭代器**。所以这一章就沿着这两个核心问题,把 set / map 的封装主线重新补全,尽量还原出这一部分真正该建立起来的实现框架。

整章最重要的一条线其实很清楚:先有一棵可复用的平衡搜索树,再通过值类型、取键方式、迭代器包装和接口限制,把它封装成 setmap


一. setmap 的本质不是两套容器,而是一套树结构的两种封装 🧠

先抓本质:setmap 都属于关联式容器 ,它们的核心特点不是"能动态扩容",而是"元素始终按 key 有序组织"。这决定了它们背后不能随便用顺序表或链表,而更适合用一棵支持有序查找、插入、删除的平衡二叉搜索树来实现。

1.1 为什么底层通常选红黑树

因为普通二叉搜索树在极端情况下可能退化成链表,而红黑树通过额外的颜色规则和旋转调整,能够把树高控制在对数级附近,从而保证:

  • 查找是 O(logN)
  • 插入是 O(logN)
  • 删除是 O(logN)

也就是说,set / map 的性能稳定性,来自它们底层所依赖的平衡搜索树语义,而不只是某几个成员函数的设计。

1.2 二者到底有什么区别

它们最大的区别,不在于树结构不同,而在于树里存的值不同

  • set:树中存的就是 key
  • map:树中存的是 pair<const K, V>

因此从封装视角看,二者其实只是"同一棵树,值类型不同,取 key 方式不同,外层接口限制不同"。

💡 避坑指南:

不要把 setmap 理解成两个完全独立的数据结构。
它们更像是同一套红黑树框架上的两种业务视角。


二. 想复用一棵树,必须先把"值"和"键"分开看 🧱

如果想让同一棵树既能支持 set,又能支持 map,那底层实现就不能把"值就是 key"写死。因为:

  • set 来说,值本身就是 key
  • map 来说,值是 pair<const K, V>,而真正参与比较和排序的是 first

2.1 统一抽象:树存 T,比较靠取键器完成

更合理的做法是让红黑树模板只关心"树里存什么类型 T",然后额外传一个取键仿函数 ,用于告诉树:如何从 T 中取出真正参与比较的 Key

例如:

cpp 复制代码
template<class T>
struct SetKeyOfT
{
    const T& operator()(const T& key)
    {
        return key;
    }
};
cpp 复制代码
template<class K, class V>
struct MapKeyOfT
{
    const K& operator()(const pair<const K, V>& kv)
    {
        return kv.first;
    }
};

这样,底层红黑树只需要做一件事:通过 KeyOfT 从值里取 key,然后按 key 建立有序结构。

2.2 这一步为什么关键

因为只有把"树如何维护有序性"和"业务上值长什么样"拆开,底层才能真正复用。否则你写一个只适合 set 的树,后面做 map 时基本只能再复制一遍逻辑。


三. setmap 的封装差异,首先体现在值类型上 🔍

3.1 set 的值类型就是 key

set<K> 中,每个结点里通常直接存一个 K。比较大小时,直接比较这个值本身即可。

cpp 复制代码
template<class K>
class set
{
    typedef RBTree<K, K, SetKeyOfT<K>> tree;
};

3.2 map 的值类型是 pair<const K, V>

map<K, V> 中,结点里存的不是单独的 key,也不是单独的 value,而是:

cpp 复制代码
pair<const K, V>

这里最关键的是 const K

3.3 为什么 map 的 key 必须是 const

因为 map 底层是按 key 排序的。若插入后允许用户随便修改 key,就会直接破坏整棵红黑树的有序性。

例如,本来结点按 1, 3, 5, 7 排好序了,用户突然把某个结点的 key 从 3 改成 100,那这个结点仍停留在原位置,树结构语义立刻失效。

所以 map 不允许改 key,本质上不是"接口故意严格",而是底层有序结构不允许这么做

💡 避坑指南:
map 中不能改 first,不是语法上的偶然限制,而是为了维护红黑树的有序性不被破坏。


四. set 为什么看起来也像"元素只读" 🧩

很多人第一次用 set 时会发现:set 里的元素拿出来后也不让改。这和 map 不能改 key 是同一个底层逻辑。

4.1 set 的值本身就是 key

因为 set 中元素本身就承担排序键的角色,所以一旦允许修改元素本身,就等价于修改 key,树结构同样会被破坏。

4.2 因此 set 迭代器通常只提供只读语义

从实现角度看,set 的迭代器一般会让解引用结果表现为 const K&,从而限制用户通过迭代器去改底层结点的值。


五. 迭代器真正难的地方,不是 ++,而是"如何在树上按中序走" 💻

这份残留笔记第一页保留的第一个关键点,就是迭代器 operator-- 的实现。这说明这一章原本的重点之一,就是把树结构上的"前驱 / 后继"移动逻辑想清楚。

5.1 为什么树迭代器的本质是"中序遍历定位器"

set / map 的遍历顺序,本质上就是底层搜索树的中序遍历顺序。所以迭代器做的不是"按数组下标加一减一",而是:

  • ++:找当前结点的中序后继
  • --:找当前结点的中序前驱

5.2 operator++ 的典型思路

若当前结点有右子树,则后继就是右子树的最左结点;若没有右子树,就沿着父链往上找,直到找到某个祖先,使当前结点位于它的左侧,此时该祖先就是后继。

5.3 operator-- 的典型思路

根据你保留下来的图示,operator-- 的逻辑可以概括为:

  1. 若左子树不为空

    前驱就是左子树的最右结点。

  2. 若左子树为空

    沿着到根的路径往上找,直到找到一个祖先,使当前结点位于该祖先的右侧,那么这个祖先就是前驱。

这正是中序遍历"前一个元素"的树结构解释。

5.4 为什么这一块特别容易卡住

因为迭代器表面上只是 ++it--it,但底层其实是在做树上的局部路径搜索。如果没有把"前驱 / 后继"的概念和中序序列对应起来,就会觉得逻辑很绕。


六. 树迭代器通常为什么要持有结点指针,而不是值引用 🧱

要让迭代器支持 ++--,它通常不能只保存一个"当前值",而必须能定位到整棵树中的真实位置。

6.1 最常见做法:迭代器内部保存 Node*

cpp 复制代码
template<class T, class Ref, class Ptr>
struct __TreeIterator
{
    typedef TreeNode<T> Node;
    Node* _node;
};

因为只有拿到真实结点,才能:

  • 访问左右孩子
  • 访问父结点
  • 做前驱 / 后继跳转
  • 解引用时返回 _node->_data

6.2 为什么这也是后面返回值转换的基础

因为如果迭代器本质上就是"对结点指针的一层包装",那么底层红黑树插入后只要返回结点指针,外层就有机会再把它包装成对应容器自己的迭代器。


七. insert 的返回值为什么总是 pair<iterator, bool> 🔍

7.1 布尔值表示有没有插入成功

set / map 都不允许 key 重复,所以插入操作要区分两种情况:

  • 新 key 不存在:插入成功,bool == true
  • 新 key 已存在:不插入,bool == false

7.2 迭代器表示"最终落点"

不论成功失败,调用者通常都希望知道"当前这个 key 最终对应的是谁"。因此返回值里还需要一个迭代器,指向:

  • 新插入结点,或
  • 已存在的那个等价 key 结点

所以标准接口才会设计成:

cpp 复制代码
pair<iterator, bool>

八. 为什么底层树插入先返回 pair<Node*, bool>,而不是直接返回 pair<iterator, bool> ⚠️

这是你保留下来的第二个关键点,也是这章最值得专门讲清楚的地方。

8.1 残留笔记的核心结论

笔记里明确写了:

pairfirst 改成结点而不是迭代器,不用迭代器是因为类型不相同

这句话背后的本质含义是:

底层红黑树层不应该直接依赖外层容器的迭代器类型。

8.2 为什么不能在树层直接写死 iterator

因为底层红黑树是通用组件,它既可能服务 set,也可能服务 map,甚至还可能服务别的封装。如果树层直接返回某个具体容器的 iterator,那就等于底层反过来依赖外层业务包装,层次关系会被写反。

8.3 更自然的做法:树层返回原始语义更强的结点指针

cpp 复制代码
pair<Node*, bool> Insert(const T& data);

这样做有两个好处:

  1. 树层语义纯粹

    它只负责树本身,不关心外层容器接口长什么样。

  2. 外层更容易适配

    外层 set / map 可以根据自己的迭代器定义,把 Node* 再包装成对应的 iterator

8.4 那 pair<Node*, bool> 怎么变成 pair<iterator, bool>

这里就用到了 pair 的模板拷贝构造特性。只要你的 iterator 支持从 Node* 构造,那么:

cpp 复制代码
pair<Node*, bool> ret = _t.Insert(key);
return pair<iterator, bool>(iterator(ret.first), ret.second);

甚至某些写法里还能借助 pair 的模板构造完成转换。

8.5 这一步真正体现了什么设计思想

它体现的是一个非常重要的封装原则:

底层返回"底层最自然的结果",外层再包装成"用户接口最自然的结果"。

💡 避坑指南:
Insert 不先返回迭代器,不是做不到,而是树层不该认识容器层的业务迭代器类型

返回 Node*,既保留了底层语义,又方便外层再转成 iterator


九. 为什么这里会专门提到 pair 的构造函数 💻

残留图片里还专门保留了 std::pair 的模板构造函数截图,这说明原笔记本来就在强调一个点:pair 支持不同模板参数之间的可转换构造。

9.1 pair 的模板构造本质

pair 有类似这样的构造语义:

cpp 复制代码
template<class U, class V>
pair(const pair<U, V>& pr);

只要:

  • U 能转换成当前的 first_type
  • V 能转换成当前的 second_type

那就可以完成 pair 之间的类型转换。

9.2 在封装里的实际意义

如果底层树返回的是:

cpp 复制代码
pair<Node*, bool>

而外层容器想返回的是:

cpp 复制代码
pair<iterator, bool>

那么只要 iterator 能由 Node* 构造,转换就具备成立基础。


十. setmap 的外层接口封装,本质上是在"限制访问 + 暴露语义" 🗺️

10.1 set 封装重点

set 对外更强调:

  • 元素唯一
  • 元素有序
  • 元素不可改
  • 插入、查找、删除都按 key 工作

10.2 map 封装重点

map 对外更强调:

  • key 唯一
  • key 有序
  • value 可改,但 key 不可改
  • 支持通过 key 找到映射值

10.3 operator[] 为什么只适合 map

因为 map 的底层值是 pair<const K, V>,通过 key 查到结点后,自然可以返回对应的 second。而 set 压根没有"映射值"这一层语义,所以不存在 [] 的意义。


十一. 这一章真正要建立起来的实现主线 📌

如果把整章内容压缩成一条主线,可以这样理解:

  1. setmap 都建立在红黑树之上
  2. 红黑树真正维护的是按 key 排序的有序结构
  3. 为了复用,树层应存值 T,再通过 KeyOfT 取 key
  4. set 的值就是 key,map 的值是 pair<const K, V>
  5. 因为 key 参与排序,所以 set 元素不可改,mapfirst 也不可改
  6. 迭代器本质上是对树结点的包装,++ / -- 依赖中序前驱后继规则
  7. 树层插入返回 pair<Node*, bool> 更合理,因为它不应依赖容器层迭代器类型
  8. 外层容器再把 Node* 包装成 iterator,最终形成标准接口 pair<iterator, bool>

总结 📝

这一章虽然原始笔记几乎丢失了,但从仅剩的两个残片------迭代器 --pair<Node*, bool>pair<iterator, bool> 的转换 ------反而能更清楚地抓住 set / map 封装时最核心的两个难点:

  • 一是树迭代器到底如何按中序规则在结构中移动
  • 二是底层通用树和外层容器接口之间,到底应该怎样做解耦与包装

真正把这两个问题想通之后,setmap 的封装主线就会非常清晰:底层是一棵按 key 维护平衡与有序性的红黑树;中层通过取键器解决 setmap 的值模型差异;外层再用迭代器和容器接口把它包装成用户习惯的 STL 风格。

所以,这一章最值得记住的不是某几个成员函数名字,而是这句总纲:

先把树做成通用组件,再把"值类型、取键方式、迭代器形式、接口约束"一层层封上去,最终才得到 setmap

这也是后面继续实现 unordered_setunordered_map,或者自己封装 STL 容器时,最重要的一种抽象思路。

相关推荐
扶苏-su2 小时前
Java--获取 Class 类对象
java·开发语言
96772 小时前
C++多线程2 如何优雅地锁门 (lock_guard) 多线程里的锁的种类
java·开发语言·c++
chushiyunen3 小时前
python实现skip-gram(跳词)示例
开发语言·python
dddddppppp1233 小时前
mfc实现的贪吃蛇游戏
c++·游戏·mfc
笨笨饿3 小时前
26_为什么工程上必须使用拉普拉斯变换
c语言·开发语言·人工智能·嵌入式硬件·机器学习·编辑器·概率论
酉鬼女又兒3 小时前
零基础快速入门前端ES6 核心特性详解:Set 数据结构与对象增强写法(可用于备赛蓝桥杯Web应用开发)
开发语言·前端·javascript·职场和发展·蓝桥杯·es6
kyle~3 小时前
ROS2 --- WaitSet(等待集) 等待实体就绪,管理执行回调函数
大数据·c++·机器人·ros2
人大博士的交易之路3 小时前
数据结构算法——python数据结构
开发语言·数据结构·python