【C++第二十一章】set与map封装
前言 set和map表面上看只是STL里两个常用关联式容器但如果继续往下追就会发现它们并不是“单独设计出来的两个类”而是建立在同一套有序搜索树框架之上的两种不同封装。真正需要想清楚的不只是“它们怎么用”而是为什么二者都能保持有序、为什么查找和插入复杂度能稳定在对数级、为什么set里元素像只读、为什么map里键不能改、以及为什么底层插入返回值最后要从结点再转成迭代器。这份笔记原始内容缺失较多电脑非正常关机全丢了 只保留了两个关键抓手一个是迭代器operator--的实现思路另一个是**insert返回值里pair::first为什么要先用结点而不是直接用迭代器**。所以这一章就沿着这两个核心问题把set/map的封装主线重新补全尽量还原出这一部分真正该建立起来的实现框架。整章最重要的一条线其实很清楚先有一棵可复用的平衡搜索树再通过值类型、取键方式、迭代器包装和接口限制把它封装成set与map。一.set和map的本质不是两套容器而是一套树结构的两种封装 先抓本质set和map都属于关联式容器它们的核心特点不是“能动态扩容”而是“元素始终按 key 有序组织”。这决定了它们背后不能随便用顺序表或链表而更适合用一棵支持有序查找、插入、删除的平衡二叉搜索树来实现。1.1 为什么底层通常选红黑树因为普通二叉搜索树在极端情况下可能退化成链表而红黑树通过额外的颜色规则和旋转调整能够把树高控制在对数级附近从而保证查找是O(logN)插入是O(logN)删除是O(logN)也就是说set/map的性能稳定性来自它们底层所依赖的平衡搜索树语义而不只是某几个成员函数的设计。1.2 二者到底有什么区别它们最大的区别不在于树结构不同而在于树里存的值不同set树中存的就是keymap树中存的是pairconst K, V因此从封装视角看二者其实只是“同一棵树值类型不同取 key 方式不同外层接口限制不同”。 避坑指南不要把set和map理解成两个完全独立的数据结构。它们更像是同一套红黑树框架上的两种业务视角。二. 想复用一棵树必须先把“值”和“键”分开看 如果想让同一棵树既能支持set又能支持map那底层实现就不能把“值就是 key”写死。因为对set来说值本身就是 key对map来说值是pairconst K, V而真正参与比较和排序的是first2.1 统一抽象树存T比较靠取键器完成更合理的做法是让红黑树模板只关心“树里存什么类型T”然后额外传一个取键仿函数用于告诉树如何从T中取出真正参与比较的Key。例如templateclassTstructSetKeyOfT{constToperator()(constTkey){returnkey;}};templateclassK,classVstructMapKeyOfT{constKoperator()(constpairconstK,Vkv){returnkv.first;}};这样底层红黑树只需要做一件事通过KeyOfT从值里取 key然后按 key 建立有序结构。2.2 这一步为什么关键因为只有把“树如何维护有序性”和“业务上值长什么样”拆开底层才能真正复用。否则你写一个只适合set的树后面做map时基本只能再复制一遍逻辑。三.set和map的封装差异首先体现在值类型上 3.1set的值类型就是 keysetK中每个结点里通常直接存一个K。比较大小时直接比较这个值本身即可。templateclassKclassset{typedefRBTreeK,K,SetKeyOfTKtree;};3.2map的值类型是pairconst K, VmapK, V中结点里存的不是单独的 key也不是单独的 value而是pairconstK,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.1set的值本身就是 key因为set中元素本身就承担排序键的角色所以一旦允许修改元素本身就等价于修改 key树结构同样会被破坏。4.2 因此set迭代器通常只提供只读语义从实现角度看set的迭代器一般会让解引用结果表现为const K从而限制用户通过迭代器去改底层结点的值。五. 迭代器真正难的地方不是而是“如何在树上按中序走” 这份残留笔记第一页保留的第一个关键点就是迭代器operator--的实现。这说明这一章原本的重点之一就是把树结构上的“前驱 / 后继”移动逻辑想清楚。5.1 为什么树迭代器的本质是“中序遍历定位器”set/map的遍历顺序本质上就是底层搜索树的中序遍历顺序。所以迭代器做的不是“按数组下标加一减一”而是找当前结点的中序后继--找当前结点的中序前驱5.2operator的典型思路若当前结点有右子树则后继就是右子树的最左结点若没有右子树就沿着父链往上找直到找到某个祖先使当前结点位于它的左侧此时该祖先就是后继。5.3operator--的典型思路根据你保留下来的图示operator--的逻辑可以概括为若左子树不为空前驱就是左子树的最右结点。若左子树为空沿着到根的路径往上找直到找到一个祖先使当前结点位于该祖先的右侧那么这个祖先就是前驱。这正是中序遍历“前一个元素”的树结构解释。5.4 为什么这一块特别容易卡住因为迭代器表面上只是it、--it但底层其实是在做树上的局部路径搜索。如果没有把“前驱 / 后继”的概念和中序序列对应起来就会觉得逻辑很绕。六. 树迭代器通常为什么要持有结点指针而不是值引用 要让迭代器支持和--它通常不能只保存一个“当前值”而必须能定位到整棵树中的真实位置。6.1 最常见做法迭代器内部保存Node*templateclassT,classRef,classPtrstruct__TreeIterator{typedefTreeNodeTNode;Node*_node;};因为只有拿到真实结点才能访问左右孩子访问父结点做前驱 / 后继跳转解引用时返回_node-_data6.2 为什么这也是后面返回值转换的基础因为如果迭代器本质上就是“对结点指针的一层包装”那么底层红黑树插入后只要返回结点指针外层就有机会再把它包装成对应容器自己的迭代器。七.insert的返回值为什么总是pairiterator, bool7.1 布尔值表示有没有插入成功set/map都不允许 key 重复所以插入操作要区分两种情况新 key 不存在插入成功bool true新 key 已存在不插入bool false7.2 迭代器表示“最终落点”不论成功失败调用者通常都希望知道“当前这个 key 最终对应的是谁”。因此返回值里还需要一个迭代器指向新插入结点或已存在的那个等价 key 结点所以标准接口才会设计成pairiterator,bool八. 为什么底层树插入先返回pairNode*, bool而不是直接返回pairiterator, bool⚠️这是你保留下来的第二个关键点也是这章最值得专门讲清楚的地方。8.1 残留笔记的核心结论笔记里明确写了pair的first改成结点而不是迭代器不用迭代器是因为类型不相同这句话背后的本质含义是底层红黑树层不应该直接依赖外层容器的迭代器类型。8.2 为什么不能在树层直接写死iterator因为底层红黑树是通用组件它既可能服务set也可能服务map甚至还可能服务别的封装。如果树层直接返回某个具体容器的iterator那就等于底层反过来依赖外层业务包装层次关系会被写反。8.3 更自然的做法树层返回原始语义更强的结点指针pairNode*,boolInsert(constTdata);这样做有两个好处树层语义纯粹它只负责树本身不关心外层容器接口长什么样。外层更容易适配外层set/map可以根据自己的迭代器定义把Node*再包装成对应的iterator。8.4 那pairNode*, bool怎么变成pairiterator, bool这里就用到了pair的模板拷贝构造特性。只要你的iterator支持从Node*构造那么pairNode*,boolret_t.Insert(key);returnpairiterator,bool(iterator(ret.first),ret.second);甚至某些写法里还能借助pair的模板构造完成转换。8.5 这一步真正体现了什么设计思想它体现的是一个非常重要的封装原则底层返回“底层最自然的结果”外层再包装成“用户接口最自然的结果”。 避坑指南Insert不先返回迭代器不是做不到而是树层不该认识容器层的业务迭代器类型。返回Node*既保留了底层语义又方便外层再转成iterator。九. 为什么这里会专门提到pair的构造函数 残留图片里还专门保留了std::pair的模板构造函数截图这说明原笔记本来就在强调一个点pair支持不同模板参数之间的可转换构造。9.1pair的模板构造本质pair有类似这样的构造语义templateclassU,classVpair(constpairU,Vpr);只要U能转换成当前的first_typeV能转换成当前的second_type那就可以完成pair之间的类型转换。9.2 在封装里的实际意义如果底层树返回的是pairNode*,bool而外层容器想返回的是pairiterator,bool那么只要iterator能由Node*构造转换就具备成立基础。十.set和map的外层接口封装本质上是在“限制访问 暴露语义” ️10.1set封装重点set对外更强调元素唯一元素有序元素不可改插入、查找、删除都按 key 工作10.2map封装重点map对外更强调key 唯一key 有序value 可改但 key 不可改支持通过 key 找到映射值10.3operator[]为什么只适合map因为map的底层值是pairconst K, V通过 key 查到结点后自然可以返回对应的second。而set压根没有“映射值”这一层语义所以不存在[]的意义。十一. 这一章真正要建立起来的实现主线 如果把整章内容压缩成一条主线可以这样理解set和map都建立在红黑树之上红黑树真正维护的是按 key 排序的有序结构为了复用树层应存值T再通过KeyOfT取 keyset的值就是 keymap的值是pairconst K, V因为 key 参与排序所以set元素不可改map的first也不可改迭代器本质上是对树结点的包装/--依赖中序前驱后继规则树层插入返回pairNode*, bool更合理因为它不应依赖容器层迭代器类型外层容器再把Node*包装成iterator最终形成标准接口pairiterator, bool总结 这一章虽然原始笔记几乎丢失了但从仅剩的两个残片——迭代器--和pairNode*, bool到pairiterator, bool的转换——反而能更清楚地抓住set/map封装时最核心的两个难点一是树迭代器到底如何按中序规则在结构中移动二是底层通用树和外层容器接口之间到底应该怎样做解耦与包装真正把这两个问题想通之后set和map的封装主线就会非常清晰底层是一棵按 key 维护平衡与有序性的红黑树中层通过取键器解决set与map的值模型差异外层再用迭代器和容器接口把它包装成用户习惯的STL风格。所以这一章最值得记住的不是某几个成员函数名字而是这句总纲先把树做成通用组件再把“值类型、取键方式、迭代器形式、接口约束”一层层封上去最终才得到set和map。这也是后面继续实现unordered_set、unordered_map或者自己封装STL容器时最重要的一种抽象思路。