C++ map/set 深度解析:从关联式容器的本质到 operator[] 的三重身份

文章目录

  • [C++ map/set 深度解析:从关联式容器的本质到 operator\[\] 的三重身份](#C++ map/set 深度解析:从关联式容器的本质到 operator[] 的三重身份)
    • [一、序列式容器 vs 关联式容器:先分清两类容器](#一、序列式容器 vs 关联式容器:先分清两类容器)
    • [二、set 系列:有序去重集合](#二、set 系列:有序去重集合)
      • [2.1 set 的模板声明,藏着三个信息](#2.1 set 的模板声明,藏着三个信息)
      • [2.2 一个必须记住的铁律:迭代器不能改值](#2.2 一个必须记住的铁律:迭代器不能改值)
      • [2.3 遍历为什么是有序的](#2.3 遍历为什么是有序的)
      • [2.4 用 count 还是 find 查存在性](#2.4 用 count 还是 find 查存在性)
      • [2.5 lower_bound / upper_bound:区间操作利器](#2.5 lower_bound / upper_bound:区间操作利器)
      • [2.6 multiset:排序但不去重](#2.6 multiset:排序但不去重)
    • [三、map 系列:有序键值对](#三、map 系列:有序键值对)
      • [3.1 map 的模板声明](#3.1 map 的模板声明)
      • [3.2 先搞清楚 pair:map 的存储单元](#3.2 先搞清楚 pair:map 的存储单元)
      • [3.3 map 的四种插入方式](#3.3 map 的四种插入方式)
      • [3.4 operator\[\]:map 最强大也最容易被误解的接口](#3.4 operator[]:map 最强大也最容易被误解的接口)
      • [3.5 迭代器遍历:it->first 和 it->second 的背后](#3.5 迭代器遍历:it->first 和 it->second 的背后)
      • [3.6 multimap:不支持 operator\[\]](#3.6 multimap:不支持 operator[])
    • [四、map/set 的实战价值:降维打击](#四、map/set 的实战价值:降维打击)
      • [4.1 环形链表 II:set 记录,一行搞定入口判断](#4.1 环形链表 II:set 记录,一行搞定入口判断)
      • [4.2 随机链表的复制:map 建立原节点→新节点的映射](#4.2 随机链表的复制:map 建立原节点→新节点的映射)
    • [五、总结:一张表记住 map/set 全家](#五、总结:一张表记住 map/set 全家)

C++ map/set 深度解析:从关联式容器的本质到 operator\[\] 的三重身份

学 STL 的时候,绝大多数人把 string、vector、list 用得飞起,一到 map 和 set 就只会 insert、erase、find 三板斧。可 map 和 set 才是实际工程里最值钱的容器------凡是"按唯一标识查关联数据""维护一个有序无重复集合"的场景,几乎都绕不开它俩。这篇文章不罗列接口(接口和 vector 高度相似,查文档就行),而是把 map/set 区别于序列式容器的本质 、最容易踩的operator\[\] 三重身份、以及 multiset/multimap 的差异一次性讲透。


一、序列式容器 vs 关联式容器:先分清两类容器

前面学的 string、vector、list、deque、array、forward_list 统称序列式容器。它们的逻辑结构是一条线性序列,元素按存储位置顺序排列、按位置访问,两个元素之间没有紧密关联------你随便交换两个位置,它还是那个容器。

关联式容器则不同。它的逻辑结构是非线性的,元素之间靠**关键字(key)**建立紧密关联,交换两个元素会直接破坏底层结构。关联式容器按 key 来保存和访问,分两大类:

分类 底层结构 代表容器 特点
有序关联容器 红黑树(平衡二叉搜索树) map / set / multimap / multiset 增删查 O(logN),遍历有序
无序关联容器 哈希表 unordered_map / unordered_set 增删查 O(1),遍历无序

本文讲的是 map 和 set,它们底层都是红黑树。set 是纯 key 搜索场景的结构(只存关键字),map 是 key/value 搜索场景的结构(存键值对)。理解了红黑树,就理解了 map/set 的一切行为。


二、set 系列:有序去重集合

2.1 set 的模板声明,藏着三个信息

cpp 复制代码
template < class T,                        // set::key_type/value_type
           class Compare = less<T>,        // set::key_compare/value_compare
           class Alloc = allocator<T>      // set::allocator_type
           > class set;

拆开看三个模板参数:

  • T:key 的类型。set 里 key 和 value 是同一个东西(纯 key 模型)。
  • Compare :比较规则,默认 less<T>(升序)。set 默认要求 T 支持 < 比较,如果你想让元素降序,或者 T 是自定义类型不支持 <,就自己写个仿函数传给这个参数。
  • Alloc :空间配置器,底层内存从这里申请。想自己实现内存池才需要传,日常根本不用管。

所以绝大多数情况下,你只需要 set<int> s 就完事,后两个参数让编译器用默认值。

2.2 一个必须记住的铁律:迭代器不能改值

cpp 复制代码
set<int> s = { 5, 2, 7, 5 };
// set<int>::iterator it = s.begin();
auto it = s.begin();
while (it != s.end())
{
    // *it = 1;   // error C3892: 不能给常量赋值
    cout << *it << " ";
    ++it;
}

上面这行 *it = 1 直接编译报错。原因是 set 的 iterator 和 const_iterator 都不允许修改数据------set 底层是二叉搜索树,元素按 key 有序排列,你一旦改了 key 的值,整棵树的排序结构就被破坏了。所以 set 的迭代器本质上是"只读"的,标准库干脆把它设计成 const。

这一点和 vector/list 完全相反,是很多人第一次用 set 时的迷惑点。记住:set 里的元素是不可变的,想改就先 erase 再 insert。

2.3 遍历为什么是有序的

set 底层是红黑树(二叉搜索树),迭代器遍历走的是中序遍历,而二叉搜索树的中序遍历结果天然有序。所以:

cpp 复制代码
set<int> s;
s.insert(5); s.insert(2); s.insert(7); s.insert(5);   // 5 重复,插不进去
for (auto e : s)
    cout << e << " ";   // 输出:2 5 7

插入时 5 出现了两次,最终只有一次生效------set 自带去重 + 升序排序 。想让元素降序,就给第二个模板参数传 greater<int>:

cpp 复制代码
set<int, greater<int>> s;   // 去重 + 降序

2.4 用 count 还是 find 查存在性

很多人查"某个值在不在 set 里"会这么写:

cpp 复制代码
// 错误示范:算法库的 find 是 O(N) 的线性遍历
auto pos1 = find(s.begin(), s.end(), x);

// 正确:set 自己实现的 find 是 O(logN) 的
auto pos2 = s.find(x);

// 更简洁:count 也能判断,set 里 count 只返回 0 或 1
if (s.count(x))
    cout << x << "在!" << endl;

关键区别:全局 find 是泛型算法,它不知道 set 的内部结构,只能从头到尾线性遍历,复杂度 O(N);而 s.find() 和 s.count() 是 set 的成员函数,利用红黑树结构二分查找,O(logN)。元素多了以后这是数量级的差距 。判断存在性推荐用 count,最简洁。

2.5 lower_bound / upper_bound:区间操作利器

cpp 复制代码
std::set<int> myset;
for (int i = 1; i < 10; i++)
    myset.insert(i * 10);   // 10 20 30 40 50 60 70 80 90

// 返回 >= 30 的第一个位置
auto itlow = myset.lower_bound(30);
// 返回 > 60 的第一个位置
auto itup = myset.upper_bound(60);
// 删除 [30, 60] 这段区间
myset.erase(itlow, itup);
// 结果:10 20 70 80 90

lower_bound(val) 返回第一个 >= val 的位置,upper_bound(val) 返回第一个 > val 的位置。配合 erase(first, last) 就能精确删除一个值区间 [lower_bound, upper_bound)。这种"先定位区间、再区间操作"的思路,是红黑树有序性带来的直接红利,vector 做不到这么优雅。

2.6 multiset:排序但不去重

cpp 复制代码
multiset<int> s = { 4,2,7,2,4,8,4,5,4,9 };
for (auto e : s) cout << e << " ";   // 2 2 4 4 4 4 5 7 8 9(排序但不去重)

multiset 和 set 用法几乎一样,唯一区别是允许 key 冗余,因此几个接口的语义有微妙差异:

接口 set multiset
insert key 存在则失败 永远成功,允许重复
find 返回 key 所在迭代器 返回中序第一个 key 的迭代器
count 返回 0 或 1 返回 key 的实际个数
erase(x) 删一个 删掉所有值为 x 的元素

multiset 的 erase(x) 会删掉所有等于 x 的元素,这个和 set 不一样,用的时候要注意。


三、map 系列:有序键值对

3.1 map 的模板声明

cpp 复制代码
template < class Key,                                     // map::key_type
           class T,                                       // map::mapped_type
           class Compare = less<Key>,                     // map::key_compare
           class Alloc = allocator<pair<const Key,T> >    // map::allocator_type
           > class map;

和 set 最大的不同:map 多了一个 T(mapped_type,映射值) 。map 底层红黑树每个节点存的是 pair<const Key, T>------注意 key 前面有 const,这是 map 不允许改 key 的根本原因。

3.2 先搞清楚 pair:map 的存储单元

cpp 复制代码
template <class T1, class T2>
struct pair
{
    typedef T1 first_type;
    typedef T2 second_type;
    T1 first;
    T2 second;

    pair() : first(T1()), second(T2()) {}
    pair(const T1& a, const T2& b) : first(a), second(b) {}
    template<class U, class V>
    pair(const pair<U,V>& pr) : first(pr.first), second(pr.second) {}
};

template <class T1, class T2>
inline pair<T1,T2> make_pair(T1 x, T2 y)
{
    return pair<T1,T2>(x, y);
}

pair 就是两个值绑在一起的简单结构,first 存 key,second 存 value。make_pair 是它的工厂函数,用于自动推导类型。

这里有个高频混淆点,务必分清:map 里会出现两个"pair",含义完全不同------

  1. 节点里存的 pair :pair<const Key, T>,红黑树节点的数据,key+value 键值对。
  2. insert 返回的 pair :pair<iterator, bool>,iterator 指向 key 所在节点,bool 表示是否插入成功。

这两个 pair 一个是"数据",一个是"返回值",名字一样但完全不是一回事。理解不清,后面 operator\[\] 就搞不懂。

3.3 map 的四种插入方式

cpp 复制代码
map<string, string> dict;

// 方式1:先构造 pair 对象再插入
pair<string, string> kv1("first", "第一个");
dict.insert(kv1);

// 方式2:匿名 pair
dict.insert(pair<string, string>("second", "第二个"));

// 方式3:make_pair 自动推导
dict.insert(make_pair("sort", "排序"));

// 方式4:initializer_list 隐式构造,最方便
dict.insert({ "auto", "自动的" });

// key 已存在,插入失败
dict.insert({ "first", "第一个(改)" });   // 不生效

四种方式效果一样,日常推荐方式 4 (insert({key, value})),最简洁。注意:key 已存在时 insert 会失败,不会覆盖旧值,返回值的 bool 是 false。

3.4 operator\[\]:map 最强大也最容易被误解的接口

这是 map 区别于 set 的杀手锏,也是面试最爱问的点。先看文档里 operator\[\] 的等价实现:

cpp 复制代码
mapped_type& operator[] (const key_type& k)
{
    // 无论 k 在不在,insert 返回的 pair.first 都指向 k 所在节点
    pair<iterator, bool> ret = insert({ k, mapped_type() });
    iterator it = ret.first;
    return it->second;
}

拆解这段代码,理解为什么 operator\[\] 同时具备三种能力:

  1. k 不存在 :insert({k, mapped_type()}) 会插入一个新节点,key 是 k,value 是默认构造的值(int 是 0,string 是空串),插入成功返回的 bool 是 true,iterator 指向新节点。operator\[\] 返回这个新节点的 value 引用,你就能给它赋值。→ 所以 \[\] 有"插入"能力。

  2. k 已存在 :insert 失败,bool 是 false,但返回的 iterator 仍然指向 k 所在节点。operator\[\] 返回该节点的 value 引用,你就能读取或修改它。→ 所以 \[\] 有"查找 + 修改"能力。

正是靠 insert 这个"失败也返回节点迭代器"的特性,operator\[\] 把插入、查找、修改三件事合并成了一个操作。这就是为什么统计词频能写成这样:

cpp 复制代码
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
map<string, int> countMap;
for (const auto& str : arr)
{
    countMap[str]++;   // 不存在则插入{str,0}再++,存在则直接++
}

对比一下不用 \[\] 的传统写法:

cpp 复制代码
for (const auto& str : arr)
{
    auto ret = countMap.find(str);
    if (ret == countMap.end())
        countMap.insert({ str, 1 });   // 第一次出现,插入 1
    else
        ret->second++;                  // 已存在,次数++
}

\[\] 一行顶三行,这就是它的价值。但要警惕一个坑 :dict["key"] 即使你只是"查一下",如果 key 不存在,它也会默默插入一个默认值 。所以只读查询时别乱用 \[\],想判断存在性用 find 或 count,否则会插入一堆脏数据。

3.5 迭代器遍历:it->first 和 it->second 的背后

cpp 复制代码
map<string, string> dict = { {"left", "左边"}, {"right", "右边"} };
auto it = dict.begin();
while (it != dict.end())
{
    cout << it->first << ":" << it->second << endl;
    ++it;
}

it->first 这行其实省略了一个 ->。完整写法是 it.operator->()->first:第一个 -> 是迭代器重载的 operator->,返回 pair*;第二个 -> 是普通结构体指针解引用,取出 pair 的 first。理解这个能帮你彻底搞懂"迭代器为什么能像指针一样用"。

注意 :map 遍历时 key 是有序的(升序),value 可以改,但 key 不能改 (it->first 是 const 的),因为改 key 会破坏红黑树结构。

3.6 multimap:不支持 operator\[\]

multimap 和 map 的差异,和 multiset/set 的差异完全对应(允许 key 冗余、find 返回中序第一个、count 返回实际个数、erase 删所有)。但多了一条:multimap 不支持 operator\[\]。

原因是 multimap 允许 key 冗余,同一个 key 可能对应多个 value,[] 这个"通过 key 拿到唯一 value 引用"的语义就不成立了------它只能插入、不能精确"找到那个唯一的 value"。所以标准库干脆不给 multimap 提供 []。


四、map/set 的实战价值:降维打击

map/set 的价值不止是"能查",很多算法题用它能把思路从"想破头"变成"几行代码"。

4.1 环形链表 II:set 记录,一行搞定入口判断

经典题是快慢指针 + 数学证明,推导很绕。用 set 记录走过的节点,遇到重复的就是环入口:

cpp 复制代码
ListNode *detectCycle(ListNode *head)
{
    set<ListNode*> s;
    ListNode* cur = head;
    while (cur)
    {
        auto ret = s.insert(cur);
        if (ret.second == false)   // 插入失败说明这个节点已经走过,就是环入口
            return cur;
        cur = cur->next;
    }
    return nullptr;
}

利用 insert 返回值的 second(是否插入成功)来判断节点是否重复出现,快慢指针那套证明直接省掉。这就是 set 的"有序 + 去重 + O(logN) 查找"三重能力叠加的威力。

4.2 随机链表的复制:map 建立原节点→新节点的映射

cpp 复制代码
Node* copyRandomList(Node* head)
{
    map<Node*, Node*> nodeMap;   // 原节点 -> 新节点
    Node* copyhead = nullptr, *copytail = nullptr;
    Node* cur = head;
    while (cur)
    {
        if (copytail == nullptr)
            copyhead = copytail = new Node(cur->val);
        else
        {
            copytail->next = new Node(cur->val);
            copytail = copytail->next;
        }
        nodeMap[cur] = copytail;   // 记录映射
        cur = cur->next;
    }
    // 第二遍:用映射关系处理 random 指针
    cur = head;
    Node* copy = copyhead;
    while (cur)
    {
        if (cur->random == nullptr)
            copy->random = nullptr;
        else
            copy->random = nodeMap[cur->random];   // 直接查映射
        cur = cur->next;
        copy = copy->next;
    }
    return copyhead;
}

深拷贝带 random 指针的链表,难点在 random 指向的节点可能还没创建。用 map 建立"原节点 → 新节点"的映射,第一遍复制 value 并记录映射,第二遍直接查映射填 random,思路瞬间清晰。map 的"key-value 关联查找"在这里就是降维打击。


五、总结:一张表记住 map/set 全家

容器 key 唯一性 底层 是否支持 \[\] 典型场景
set 唯一 红黑树 否 有序去重集合、判断存在性
multiset 可重复 红黑树 否 有序但允许重复的集合
map 唯一 红黑树 是 键值映射、字典、词频统计
multimap 可重复 红黑树 否 一键对多值的映射

几个必须记住的结论:

  1. map/set 底层都是红黑树,增删查 O(logN),迭代器遍历走中序所以有序。
  2. set 的迭代器不能改值,map 能改 value 但不能改 key------都是为了保护底层搜索树结构。
  3. operator\[\] 是 map 的灵魂:靠 insert"失败也返回迭代器"的特性,一个接口同时实现插入、查找、修改。
  4. 判断存在性用 count 或 find(O(logN)),别用全局 find(O(N)),也别乱用 [](会插入脏数据)。

掌握这些,map/set 就不再是"三板斧工具",而是你处理有序数据、键值关联问题的第一选择。

相关推荐
Chen—LSN1 小时前
C语言——文件操作(2)
c语言·开发语言·c++·经验分享·笔记·算法·c#
纪念 2291 小时前
C++类和对象(最终篇)
开发语言·c++
程序员yu1 小时前
会编网络:别被入门教程骗了,Python实用能力不在语法本身
开发语言·python·学习·算法
Bruce_Liuxiaowei2 小时前
Python 字典默认值完全指南:从 KeyError 到 defaultdict
开发语言·数据结构·python·字典
troy1282 小时前
Java 技术栈全景指南:从基础到微服务的完整知识体系
java·开发语言·微服务
LuminousCPP2 小时前
从零开始学 C++(三):类和对象入门|从 struct 到 class,理解封装、对象与 this 指针
c++·笔记·学习·类和对象
Escalating_xu2 小时前
【C 语言】深入理解指针(2):数组名、数组传参、二级指针与指针数组
java·c语言·开发语言
郝学胜-神的一滴2 小时前
游戏引擎原理与实践 01:聊聊游戏引擎的前世今生
开发语言·c++·游戏引擎·产品运营·软件工程·产品经理·untiy
bkspiderx3 小时前
Qt 的消息机制:事件驱动与信号槽的底层逻辑
开发语言·qt·信号槽·事件驱动·qt 的消息机制