文章目录
- [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",含义完全不同------
- 节点里存的 pair :
pair<const Key, T>,红黑树节点的数据,key+value 键值对。 - 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\[\] 同时具备三种能力:
-
k 不存在 :
insert({k, mapped_type()})会插入一个新节点,key 是 k,value 是默认构造的值(int 是 0,string 是空串),插入成功返回的 bool 是 true,iterator 指向新节点。operator\[\] 返回这个新节点的 value 引用,你就能给它赋值。→ 所以 \[\] 有"插入"能力。 -
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 | 可重复 | 红黑树 | 否 | 一键对多值的映射 |
几个必须记住的结论:
- map/set 底层都是红黑树,增删查 O(logN),迭代器遍历走中序所以有序。
- set 的迭代器不能改值,map 能改 value 但不能改 key------都是为了保护底层搜索树结构。
- operator\[\] 是 map 的灵魂:靠 insert"失败也返回迭代器"的特性,一个接口同时实现插入、查找、修改。
- 判断存在性用
count或find(O(logN)),别用全局find(O(N)),也别乱用[](会插入脏数据)。
掌握这些,map/set 就不再是"三板斧工具",而是你处理有序数据、键值关联问题的第一选择。