目录
- 一、为什么你需要 map 和 set?
- 二、set——自动排序的去重集合
- 三、multiset——排序但不做去重
- 四、实战一:用 set 解 LeetCode 经典题
- 五、map——键值对的红黑树字典
- 六、operator[]——map 最强大也最容易踩坑的接口
- 七、multimap——允许键重复的 map
- 八、实战二:用 map 解 LeetCode 经典题
- 九、总结与选择指南
一、为什么你需要 map 和 set?
回想一下你初学编程时写过的"查找"代码:一个数组,一个 for 循环,最坏情况下需要把所有元素遍历一遍。数据量小的时候还行,当数据涨到百万级别,使用O(N) 复杂度的算法显然不够明智。
C++ STL 给我们提供了一类专门的关联式容器——map 和 set。不同于 vector、list 这些序列式容器,它们内部基于平衡二叉搜索树(红黑树)组织数据,查找、插入、删除的时间复杂度都是 O(logN),而且元素天然有序。
它们的“分工”方式是:
- set:只存 key,自动去重 + 排序
- map:存 key-value 对,key 唯一,按 key 排序
这篇文章会带你从基础操作一步步走到 LeetCode 实战,把这两个容器吃透。
二、set——自动排序的去重集合
2.1 模板参数一览
先看一眼 set 的模板声明,这能帮你理解它的设计哲学:
template < class T, // 键的类型
class Compare = less<T>, // 比较器,默认升序
class Alloc = allocator<T> // 空间配置器
> class set;
T 既是 key_type 也是 value_type——在 set 里,key 就是 value,value 就是 key,二者合一。
2.2 第一个例子:插入 + 遍历
#include <iostream>
#include <set>
using namespace std;
int main()
{
// 默认升序排列
set<int> s;
s.insert(5);
s.insert(2);
s.insert(7);
s.insert(5); // 重复的 5
s.insert(7); // 重复的 7
s.insert(3);
auto it = s.begin();
while (it != s.end())
{
// *it = 1; // 编译错误!不能修改 set 中的元素
cout << *it << " ";
++it;
}
cout << endl;
// 输出:2 3 5 7
return 0;
}
即使插入了 6 次,最终只保留了 4 个元素,而且从小到大排好了序。这就是 set 的两个核心特性:去重 + 升序排序。
这里有个值得留意的细节:set 的迭代器是指向 const value_type 的,也就是你不能通过迭代器去修改元素的值。试想一下,如果你偷偷把 *it 改成了一个别的值,整棵红黑树的排序结构就会被破坏。
2.3 改排序规则:降序 set
如果你想让元素从大到小排列,换一个比较器即可:
set<int, greater<int>> s;
s.insert(5);
s.insert(2);
s.insert(7);
s.insert(5);
s.insert(3);
for (auto e : s)
cout << e << " ";
cout << endl;
// 输出:7 5 3 2
greater<int> 是 STL 内置的仿函数。你也可以自己写一个比较器,实现任意自定义排序——比如对某个结构体按特定字段排序。
2.4 构造函数:多种初始化方式
set 提供了多种构造方式:
// 方式一:构造空 set,再逐个插入
set<int> s1;
s1.insert(5);
s1.insert(2);
// 方式二:用迭代器区间构造
int arr[] = {4, 2, 7, 2, 8, 5, 9};
set<int> s2(arr, arr + 7);
// 方式三:拷贝构造
set<int> s3(s2);
// 方式四:initializer_list(C++11,最推荐)
set<int> s4 = {4, 2, 7, 8, 5, 9};
// string 的 set——按 ASCII 排序
set<string> strset = {"sort", "insert", "add"};
for (auto& e : strset)
cout << e << " ";
cout << endl;
// 输出:add insert sort
2.5 insert 详解:单个插入和批量插入
set<int> s = {4, 2, 7};
// 单个插入:返回 pair<iterator, bool>
// iterator 指向插入位置(或已存在元素的位置)
// bool 表示是否插入成功
auto ret = s.insert(5);
// 批量插入:传入 initializer_list
s.insert({2, 8, 3, 9, 2}); // 重复的 2 不会被再次插入
for (auto e : s)
cout << e << " ";
cout << endl;
// 输出:2 3 4 5 7 8 9
注意,insert 返回值是一个 pair<iterator, bool>,在后面讲 operator[] 的时候要考。
2.6 erase:删除的三种方式
set<int> s = {4, 2, 7, 2, 8, 5, 9};
// 此时 s 中:2 4 5 7 8 9
// 方式一:通过迭代器删除(删除最小值)
s.erase(s.begin());
// 剩余:4 5 7 8 9
// 方式二:直接按值删除,返回删除个数(0 或 1)
int x;
cin >> x;
int num = s.erase(x);
if (num == 0)
cout << x << "不存在!" << endl;
else
cout << x << "删除成功!" << endl;
// 方式三:先 find,再按迭代器删除
cin >> x;
auto pos = s.find(x);
if (pos != s.end())
{
s.erase(pos); // 删除后 pos 失效,不要再使用
}
else
{
cout << x << "不存在!" << endl;
}
一个易错点:erase(pos) 之后 pos 就失效了,不要尝试解引用它。
2.7 find 和 count:O(logN) vs O(N)
这是新同学很容易忽视的性能差异:
set<int> s = {4, 2, 7, 2, 8, 5, 9};
int x = 7;
// 算法库的 find —— 通用算法,线性遍历,O(N)
auto pos1 = find(s.begin(), s.end(), x);
// set 自己的 find —— 利用红黑树结构查找,O(logN)
auto pos2 = s.find(x);
100 万元素时,std::find 最坏要比较 100 万次,而 set::find 只需约 20 次。
相比之下,count(x) 更适合只关心"存在与否"的场景:
cin >> x;
if (s.count(x))
cout << x << "在!" << endl;
else
cout << x << "不存在!" << endl;
set 中 count 的返回值只能是 0 或 1,天然适合做布尔判断,可以用来快速查找。
2.8 lower_bound 和 upper_bound:精确定位区间
这对接口是 set 的进阶武器:
lower_bound(x):返回第一个 >= x 的元素位置upper_bound(x):返回第一个 > x 的元素位置
set<int> myset;
for (int i = 1; i < 10; i++)
myset.insert(i * 10); // 10 20 30 40 50 60 70 80 90
// 删除 [30, 60] 范围内的元素
auto itlow = myset.lower_bound(30); // 第一个 >= 30 → 指向 30
auto itup = myset.upper_bound(60); // 第一个 > 60 → 指向 70
// 左闭右开区间:[itlow, itup),删除 30 40 50 60
myset.erase(itlow, itup);
for (auto e : myset)
cout << e << " ";
cout << endl;
// 输出:10 20 70 80 90
区间删除、范围查询等场景中,lower_bound + upper_bound 一对组合拳下来非常高效。
三、multiset——排序但不做去重
有时候我们既要排序,又不想丢失重复数据,multiset 正好填补这个空缺。
3.1 和 set 的核心差异
multiset<int> s = {4, 2, 7, 2, 4, 8, 4, 5, 4, 9};
// 输出:2 2 4 4 4 4 5 7 8 9
元素有序,但重复值全部保留。三个接口的行为随之变化:
| 操作 | set | multiset |
|---|---|---|
find(x) | 返回 x 的位置 | 返回中序遍历第一个 x 的位置 |
count(x) | 0 或 1 | 实际出现次数 |
erase(x) | 最多删 1 个 | 删除所有等于 x 的元素 |
3.2 遍历重复元素
multiset<int> s = {4, 2, 7, 2, 4, 8, 4, 5, 4, 9};
int x;
cin >> x;
auto pos = s.find(x);
while (pos != s.end() && *pos == x)
{
cout << *pos << " ";
++pos;
}
cout << endl;
cout << s.count(x) << endl; // 返回 x 的实际个数
s.erase(x); // 删除所有 x
如果你只想删除其中某一个,应该用迭代器方式:s.erase(s.find(x))。
四、实战一:用 set 解 LeetCode 经典题
4.1 LeetCode 349——两个数组的交集
题目:给定两个数组,返回它们的交集(结果中去掉重复元素)。
思路很直接:把两个数组分别扔进 set(自动去重 + 排序),然后用双指针同时遍历两个有序集合,相等的元素就是交集。
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
set<int> s1(nums1.begin(), nums1.end());
set<int> s2(nums2.begin(), nums2.end());
vector<int> ret;
auto it1 = s1.begin();
auto it2 = s2.begin();
while (it1 != s1.end() && it2 != s2.end())
{
if (*it1 < *it2)
it1++;
else if (*it1 > *it2)
it2++;
else
{
ret.push_back(*it1);
it1++;
it2++;
}
}
return ret;
}
};
这题的精妙之处在于,set 同时解决了去重和排序两个问题,让后续的双指针遍历变得非常优雅。两个有序序列求交集,就像翻两本按字典序排好的书页,谁小谁翻页,一样就记录——时间复杂度 O(N+M)。
4.2 LeetCode 142——环形链表 II
题目:给定一个链表,判断是否有环,若有环返回环的入口节点。
经典的快慢指针解法能办到 O(1) 空间,但如果一时间想不起来,用 set 也能写出直观又清晰的解法:
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
set<ListNode*> s;
ListNode* cur = head;
while (cur)
{
auto ret = s.insert(cur);
if (ret.second == false) // 插入失败说明 cur 已经出现过
return cur; // 这就是环的入口
cur = cur->next;
}
return nullptr;
}
};
这里利用 insert 返回值的 second 判断节点是否第一次出现:每个节点指针如果是第一次出现插入成功,是第二次出现说明有环。虽然不是最优解法,但胜在思路清晰、不易出错,笔试时能快速拿到分数。
五、map——键值对的红黑树字典
如果说 set 是"一堆有序的钥匙",那 map 就是"一把钥匙配一个箱子的管理库"——每个 key(键)映射到一个 value(值),底层同样是红黑树,按 key 排序。
5.1 模板参数一览
template < class Key, // 键的类型
class T, // 值的类型
class Compare = less<Key>, // 比较器
class Alloc = allocator<pair<const Key, T>>
> class map;
注意 value_type 是 pair<const Key, T>——key 自带 const 属性,天然不可修改。
5.2 pair 是什么?
在接触 map 之前,有必要先认识一下 pair——它是 map 中存储数据的基本单元:
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) {}
};
结构很简单——两个成员 first 和 second,支持默认构造、参数构造和拷贝构造。C++ 还提供了 make_pair 函数模板,帮你自动推导类型:
template <class T1, class T2>
inline pair<T1, T2> make_pair(T1 x, T2 y)
{
return pair<T1, T2>(x, y);
}
5.3 map 的插入:四种方式
#include <iostream>
#include <map>
#include <string>
using namespace std;
int main()
{
// 构造时用 initializer_list 初始化
map<string, string> dict = {
{"left", "左边"}, {"right", "右边"},
{"insert", "插入"}, {"string", "字符串"}
};
// 方式一:显式构造 pair 对象再插入
pair<string, string> kv1("first", "第一个");
dict.insert(kv1);
// 方式二:匿名 pair 对象
dict.insert(pair<string, string>("second", "第二个"));
// 方式三:make_pair(自动类型推导,推荐)
dict.insert(make_pair("sort", "排序"));
// 方式四:C++11 花括号(最简洁,最推荐)
dict.insert({"auto", "自动的"});
// key 重复时,insert 不会覆盖
dict.insert({"auto", "自动的xxxx"});
// "auto" 对应的值仍然是 "自动的"
}
四种方式功能等价,可读性上 {"key", "value"} 最干净利落。
insert 的返回值是 pair<iterator, bool>:
first(iterator):指向插入元素(或已存在元素)的迭代器second(bool):true 表示插入成功,false 表示 key 已存在
5.4 遍历 map
map<string, string> dict = {
{"left", "左边"}, {"right", "右边"},
{"insert", "插入"}, {"string", "字符串"}
};
dict.insert({"auto", "自动的"});
auto it = dict.begin();
while (it != dict.end())
{
// it->first 是 key(const,不可修改)
// it->second 是 value(可以修改)
// it->first += 'x'; // 编译报错!
it->second += 'x'; // 修改 value 没问题
cout << it->first << ":" << it->second << endl;
++it;
}
输出按 key 的字典序排列:
auto:自动的x
first:第一个x
insert:插入x
left:左边x
right:右边x
second:第二个x
sort:排序x
string:字符串x
关于 it->first 的写法:迭代器指向的是 pair 对象,it->first 本质上是 (*it).first 的语法糖。编译器生成的 operator-> 返回的是 pair* 的指针,再通过 -> 访问其成员。这个链式调用 C++ 会在编译期帮你展开。
5.5 用 map 做个简易词典
map<string, string> dict = {
{"left", "左边"}, {"right", "右边"},
{"insert", "插入"}, {"string", "字符串"}
};
string str;
while (cin >> str)
{
auto ret = dict.find(str);
if (ret != dict.end())
cout << "->" << ret->second << endl;
else
cout << "词库中未收录" << endl;
}
输入 left 输出 ->左边,输入 hello 输出 词库中未收录。一个极简的词典就成型了
六、operator[]——map 最强大也最容易踩坑的接口
6.1 operator[] 的内部实现
map 的 operator[] 既是查找接口,也是插入接口。它的源码等价于:
mapped_type& operator[] (const key_type& k)
{
// 1. 调用 insert({k, mapped_type()})
// mapped_type() 是 value 类型的默认值(int 是 0,string 是 "")
// 2. insert 返回 pair<iterator, bool>
// - 如果 k 不存在,插入 {k, 默认值},bool 为 true
// - 如果 k 已存在,什么也不做,bool 为 false
// 3. 不管存不存在,最终都返回 it->second 的引用
pair<iterator, bool> ret = insert({ k, mapped_type() });
iterator it = ret.first;
return it->second;
}
关键信息提炼:
- key 不存在:插入一个默认 value,然后返回这个 value 的引用
- key 存在:直接返回已有 value 的引用
6.2 operator[] 的三种用途
map<string, string> dict;
dict.insert(make_pair("sort", "排序"));
// 用法一:插入(key 不存在时插入默认值)
dict["insert"]; // 插入 {"insert", ""}
// 用法二:插入 + 赋值
dict["left"] = "左边"; // key 不存在,先插入再赋值
// 用法三:修改(key 存在时直接修改 value)
dict["left"] = "左边,剩余"; // key 存在,原地修改
// 危险用法:以下代码本意是"查找",但 key 不在时会偷偷插入!
cout << dict["right"] << endl; // right 本不存在,却被插入了一个空值!
这是使用 map 时最常见的坑:如果你只想"看看"某个 key 在不在,请用 find 或 count,不要在不确定 key 是否存在时用 [],否则它会给 map 塞入一个"空数据"。
6.3 operator[] 实战:统计词频
有了 operator[],统计任务变得极其简洁。比如统计一堆水果出现的次数:
用传统方式需要 7 行:
string arr[] = {
"苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果",
"西瓜", "苹果", "香蕉", "苹果", "香蕉"
};
map<string, int> countMap;
// 旧式写法——先 find,再判断
for (const auto& str : arr)
{
auto ret = countMap.find(str);
if (ret == countMap.end())
countMap.insert({str, 1});
else
ret->second++;
}
用 operator[] 只需 3 行:
map<string, int> countMap;
for (const auto& str : arr)
{
countMap[str]++; // 一行搞定!
}
for (const auto& e : countMap)
cout << e.first << ":" << e.second << endl;
// 苹果:6
// 西瓜:3
// 香蕉:2
这里的执行逻辑是:
- 第一次遇到"苹果":key 不存在,
[]插入{"苹果", 0},然后++使其变为 1 - 之后再遇"苹果":key 已存在,
[]返回 value 引用,++直接递增
代码量减了一半,可读性还更高,这是 map 在设计上最令人称道的地方。
七、multimap——允许键重复的 map
multimap 和 map 的关系,就像 multiset 和 set 的关系一样——允许 key 出现多次。
multimap<string, string> dict;
// 同一个 key 可以对应多个不同的 value
dict.insert({"sort", "排序"});
dict.insert({"sort", "排序2"});
dict.insert({"sort", "排序1"});
dict.insert({"sort", "排序3"});
dict.insert({"sort", "排序"});
dict.insert({"string", "字符串"});
dict.erase("sort"); // 删除所有 key 为 "sort" 的元素
multimap 没有 operator[]。这很合理——如果同一个 key 对应多个 value,[] 该返回哪个呢?当你需要一对多映射时,就选它,但也得接受不能直接用 [] 的事实。
八、实战二:用 map 解 LeetCode 经典题
8.1 LeetCode 138——复制带随机指针的链表
题目:给定一个链表,每个节点除了 next 指针还有一个 random 指针指向链表中的任意节点。请深拷贝这个链表。
这是 map 的经典应用场景:建立"旧节点 → 新节点"的映射关系。
class Solution {
public:
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;
}
};
map 在这道题里的作用是建立新旧节点的对应关系:复制时用 nodeMap[cur] = copytail 绑定,设置 random 时通过 nodeMap[cur->random] 找到对应的新节点。这个"映射表"的思路在很多需要做对象间转换的题目中都能派上用场。
8.2 LeetCode 692——前 K 个高频单词
题目:给定一个单词列表,返回出现频率最高的前 K 个单词,频率相同时按字母顺序排列。
这道题展示了 map + STL 排序算法的组合威力:
class Solution {
public:
struct Compare
{
bool operator()(const pair<string, int>& x,
const pair<string, int>& y) const
{
return x.second > y.second; // 按频率降序
}
};
vector<string> topKFrequent(vector<string>& words, int k) {
// 第一步:用 map 统计词频
map<string, int> countMap;
for (auto& e : words)
countMap[e]++;
// 第二步:转存到 vector 用于排序
vector<pair<string, int>> v(countMap.begin(), countMap.end());
// 第三步:稳定排序——频率相同的按 map 已有的字典序保留
stable_sort(v.begin(), v.end(), Compare());
// 第四步:取前 K 个
vector<string> strV;
for (int i = 0; i < k; ++i)
strV.push_back(v[i].first);
return strV;
}
};
这里有两个精巧的设计细节:
第一,为什么用 stable_sort 而不是 sort?
因为 map 已经按 key(单词)的字典序排好了。当我们按频率排序时,频率相同的单词需要保持字典序。stable_sort 保证相等元素的相对顺序不变,恰好保留了 map 给出的字典序。
第二,能不能用 priority_queue 替代 vector + sort?
可以,而且更适合数据量巨大的场景:
// 用 priority_queue 取 Top K
priority_queue<pair<string, int>, vector<pair<string, int>>, Compare>
p(countMap.begin(), countMap.end());
vector<string> strV;
for (int i = 0; i < k; ++i)
{
strV.push_back(p.top().first);
p.pop();
}
vector + sort 建堆是 O(N),而 priority_queue 逐个插入是 O(NlogN)。但 priority_queue 取 Top K 只需要 K 次 pop,在处理海量数据时效率优势会更明显。
两种方案都行,主要看数据规模和具体需求来定。
九、总结与选择指南
9.1 四容器速查表
| 容器 | key 唯一? | 自动排序? | 有 operator[]? | 底层 |
|---|---|---|---|---|
| set | 是 | 是(默认升序) | 无 | 红黑树 |
| multiset | 否 | 是 | 无 | 红黑树 |
| map | 是 | 是(按 key) | 有 | 红黑树 |
| multimap | 否 | 是(按 key) | 无 | 红黑树 |
9.2 选容器三步法
-
需要 key-value 映射吗?
- 不需要 → set 或 multiset
- 需要 → map 或 multimap
-
key 需要唯一吗?
- 需要唯一 → set 或 map
- 可重复 → multiset 或 multimap
-
需要
operator[]吗?- 需要 → 只能是 map
9.3 关键要点回顾
- set 的元素不可修改:迭代器指向 const 值,修改会破坏树结构
- 用 set::find,别用 std::find:O(logN) vs O(N),差了几个数量级
- insert 返回值是宝藏:
pair<iterator, bool>告诉你插入成功与否 - operator[] 会插入:不确定 key 在不在时,先用 find/count
- erase(pos) 后 pos 失效:不要再使用被删除的迭代器
- multiset/multimap 的 count 返回实际个数:不只是 0 或 1
9.4 进阶延伸
当你掌握了这些,自然会产生新的问题:如果我不需要排序,只想要更快的平均查找速度呢?那就是 unordered_map / unordered_set 的主场了——它们基于哈希表,平均 O(1) 的查找时间。但哈希表不做排序,迭代遍历没有固定顺序,各有取舍。
再往底层走,map/set 系列的红黑树实现(AVL 树的平衡优化、旋转与变色)也是一个非常值得深入的方向。理解了底层,你对这些容器"为什么这么设计、为什么有这些限制"就会有更深刻的体会。
如果有问题或建议,欢迎评论区交流!
2034



被折叠的 条评论
为什么被折叠?



