前言
在 C++ 标准模板库(STL)中,容器可以分为两大类:序列式容器 和关联式容器 。序列式容器如 vector、list、deque 等,元素按线性顺序存储,每个位置没有内在的"意义",交换两个元素不会破坏容器的结构。关联式容器则不同,它们通常基于树或哈希表实现,元素之间通过**关键字(key)**建立紧密的关联关系,交换两个元素可能完全破坏容器的逻辑结构。
map 和 set 是 STL 中最经典的关联式容器,底层采用红黑树 (一种自平衡二叉搜索树)实现。它们提供了对数时间复杂度的插入、查找、删除操作,并且能够按照关键字的有序 方式进行遍历。本文将从零开始,详细讲解 set / multiset 和 map / multimap 的使用方法、内部原理、常见陷阱,并结合力扣题目展示它们的实战技巧。全文约 5000 字,适合所有 C++ 开发者阅读。
阅读本文前,建议先了解二叉搜索树的基本概念(可参考我的前一篇文章《深入剖析二叉搜索树》)。
一、序列式容器 vs 关联式容器
在学习具体容器之前,我们先明确两类容器的本质区别。
1.1 序列式容器
逻辑结构 :线性序列。
访问方式 :按元素在容器中的存储位置 (下标或迭代器)访问。
典型代表 :vector、list、deque、array、forward_list。
特点:元素之间没有强制性的比较或顺序关系,你可以随意交换两个元素,容器依然合法。
vector<int> v = {3, 1, 4};
swap(v[0], v[2]); // 变成 {4, 1, 3},仍然是合法的vector
1.2 关联式容器
逻辑结构 :非线性结构(通常是树或哈希表)。
访问方式 :按元素的关键字(key) 访问。
典型代表 :set、map、multiset、multimap(红黑树实现);unordered_set、unordered_map(哈希表实现)。
特点:元素的位置由关键字决定,交换两个不同的关键字元素会破坏容器的排序性质,导致未定义行为。
set<int> s = {3, 1, 4};
// swap(*s.begin(), *s.rbegin()); // 绝对不要这样做!会破坏有序性
关联式容器的两大分类:
-
有序关联容器(本章重点):底层为红黑树,元素始终按关键字排序,查找时间复杂度 O(log n)。
-
无序关联容器(下一章讲解):底层为哈希表,元素无序,查找平均 O(1)。
二、set 系列容器详解
set 代表"集合",它的特点是:存储唯一的关键字,并且按关键字升序排列 。如果你需要存储重复的关键字,应该使用 multiset。
2.1 set 的模板声明
template < class T, // 关键字类型
class Compare = less<T>, // 比较仿函数(默认升序)
class Alloc = allocator<T> // 空间配置器(一般不改)
> class set;
-
T:存储在 set 中的元素类型,也就是 key 的类型。 -
Compare:用于比较两个关键字的函数对象,默认std::less<T>要求T支持operator<。如果你想实现降序,可以传std::greater<T>。 -
Alloc:内存分配器,几乎永远使用默认值。
2.2 set 的核心特性
-
唯一性:不允许重复的关键字。插入重复值时,插入操作失败。
-
有序性 :按照
Compare规则,元素总是有序的。默认升序,中序遍历红黑树得到递增序列。 -
不可修改 :因为修改关键字会破坏有序结构,所以
set的迭代器是常量迭代器 (const_iterator),不能通过迭代器修改元素值。 -
效率:插入、删除、查找都是 O(log n)。
2.3 set 的构造与遍历
常用构造函数:
| 构造函数 | 说明 |
|---|---|
set() |
空构造 |
set(InputIterator first, InputIterator last) |
迭代器区间构造 |
set(const set& x) |
拷贝构造 |
set(initializer_list<value_type> il) |
列表初始化(C++11) |
迭代器:
-
begin() / end():正向迭代器(双向迭代器,但只能读)。 -
rbegin() / rend():反向迭代器。
示例代码:
#include <iostream>
#include <set>
using namespace std;
int main() {
// 默认升序
set<int> s1 = {5, 2, 8, 2, 5, 1}; // 重复值自动去重
for (int x : s1) cout << x << " "; // 输出:1 2 5 8
cout << endl;
// 降序
set<int, greater<int>> s2 = {5, 2, 8, 2, 5, 1};
for (int x : s2) cout << x << " "; // 输出:8 5 2 1
cout << endl;
// 迭代器遍历
set<string> strSet = {"apple", "banana", "cherry"};
auto it = strSet.begin();
while (it != strSet.end()) {
// *it = "xxx"; // 错误:不能修改
cout << *it << " ";
++it;
}
return 0;
}
2.4 set 的插入
set 提供了多个 insert 重载:
pair<iterator,bool> insert (const value_type& val); // 插入单个元素
void insert (initializer_list<value_type> il); // 插入列表
template <class InputIterator>
void insert (InputIterator first, InputIterator last); // 插入区间
返回值 :单个元素插入返回 pair<iterator, bool>,其中 bool 表示是否插入成功(true=成功插入新元素,false=已存在且未插入),iterator 指向已存在元素或新插入元素的位置。
示例:
set<int> s;
auto ret = s.insert(10);
if (ret.second) cout << "插入成功,元素:" << *ret.first << endl;
else cout << "已存在" << endl;
s.insert({20, 30, 10}); // 10 已存在,不会重复插入
2.5 set 的查找与计数
| 成员函数 | 说明 |
|---|---|
find(const key_type& k) |
返回指向 k 的迭代器,若不存在则返回 end()。 |
count(const key_type& k) |
返回 k 在 set 中的个数(只能是 0 或 1,对 multiset 有用)。 |
lower_bound(const key_type& k) |
返回第一个 >= k 的迭代器。 |
upper_bound(const key_type& k) |
返回第一个 > k 的迭代器。 |
为什么推荐使用 set 自己的 find 而不是全局 find?
全局 std::find 是线性遍历 O(n),而 set::find 利用红黑树特性 O(log n)。数据量大时差异巨大。
set<int> s = {10, 20, 30, 40, 50};
auto pos = s.find(30);
if (pos != s.end()) cout << "找到了" << endl;
// 用 count 快速判断存在
if (s.count(25)) cout << "存在" << endl;
else cout << "不存在" << endl;
2.6 set 的删除
iterator erase(const_iterator position); // 删除迭代器指向的元素
size_type erase(const key_type& k); // 删除所有等于 k 的元素(返回删除个数)
iterator erase(const_iterator first, const_iterator last); // 删除区间
注意 :erase(key) 返回 size_type,对于 set 最多返回 1;对于 multiset 可能返回 >1。
set<int> s = {1,2,3,4,5};
s.erase(3); // 删除 3
auto it = s.find(2);
if (it != s.end()) s.erase(it); // 迭代器删除
// 删除区间 [lower, upper)
auto low = s.lower_bound(2);
auto up = s.upper_bound(4);
s.erase(low, up); // 删除 2,3,4(注意 upper_bound(4) 指向 5)
2.7 multiset 的特点
multiset 与 set 几乎一样,唯一区别是 允许关键字重复。这导致以下行为差异:
-
insert永远成功(不会因为重复而失败)。 -
find返回中序遍历下第一个等于该值的元素的迭代器。 -
count返回实际重复个数。 -
erase(value)会删除所有等于 value 的元素,返回删除个数。 -
没有
operator[](set 本来就没有,multiset 也没有)。multiset
ms = {1,2,2,3,2};
for (int x : ms) cout << x << " "; // 1 2 2 2 3
cout << ms.count(2); // 输出 3
ms.erase(2); // 删除所有 2
2.8 实战:力扣 349. 两个数组的交集
题目 :给定两个数组,返回它们的交集(每个元素唯一,顺序任意)。
思路 :将两个数组分别存入 set 去重并排序,然后利用双指针(因为 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(), 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;
}
};
时间复杂度 O(n log n)(主要是构造 set 的开销),比暴力 O(n²) 优雅得多。
2.9 实战:力扣 142. 环形链表 II
题目 :给定一个链表,返回链表开始入环的第一个节点。如果不带环返回 nullptr。
传统解法 :快慢指针 + 数学推导。
使用 set :遍历链表,将每个节点的地址存入 set,第一个出现重复地址的节点就是环的入口。代码极其简洁,体现了关联容器的"降维打击"。
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
set<ListNode*> s;
ListNode* cur = head;
while (cur) {
if (s.count(cur)) return cur; // 或者用 find
s.insert(cur);
cur = cur->next;
}
return nullptr;
}
};
注意:空间复杂度 O(n),而快慢指针法 O(1)。但在面试或竞赛中,如果空间充足,使用 set 可以大幅降低编码难度。
三、map 系列容器详解
map 是"映射"表,存储的是键值对(key-value pair) 。每个 key 唯一,通过 key 快速访问对应的 value。multimap 允许 key 重复。
3.1 map 的模板声明
template < class Key, // 关键字类型
class T, // 映射值类型
class Compare = less<Key>, // 比较仿函数
class Alloc = allocator<pair<const Key,T>> >
class map;
-
Key:键的类型,不能修改。 -
T:值的类型,可以修改。 -
内部存储的节点类型是
pair<const Key, T>,键部分为 const,确保不会破坏排序。
3.2 pair 类型
pair 是一个简单的结构体,定义在 <utility>(但 <map> 已包含)。它有两个公有成员:first 和 second。
template <class T1, class T2>
struct pair {
T1 first;
T2 second;
pair();
pair(const T1& a, const T2& b);
// ... 其他构造函数
};
常用辅助函数:make_pair 可以自动推导类型。
3.3 map 的构造与基本使用
构造函数与 set 类似,支持空构造、区间构造、拷贝构造、列表初始化。
map<string, string> dict = {
{"left", "左边"},
{"right", "右边"},
{"insert", "插入"}
};
// 或者使用 initializer_list
dict.insert({"auto", "自动的"});
// 遍历
for (const auto& kv : dict) {
cout << kv.first << " -> " << kv.second << endl;
}
迭代器的使用注意 :迭代器指向的是 pair<const Key, T>,所以可以通过 it->second 修改值,但不能修改 it->first。
3.4 map 的插入
insert 的签名与 set 类似,但参数是 pair<const Key, T>:
pair<iterator,bool> insert (const value_type& val);
多种插入写法:
map<string, int> m;
// 1. 使用 pair 构造函数
m.insert(pair<string, int>("apple", 1));
// 2. 使用 make_pair
m.insert(make_pair("banana", 2));
// 3. 使用 {}(C++11 统一初始化)
m.insert({"cherry", 3});
// 4. 直接用 [] 赋值(后面会讲)
m["durian"] = 4;
返回值与 set 含义相同:first 指向已存在或新插入的迭代器,second 表示是否插入成功。
3.5 map 的查找与删除
-
find(key):返回指向 key 所在节点的迭代器,若不存在返回end()。 -
count(key):返回 key 出现的次数(0 或 1)。 -
erase(key):删除 key 及其映射值,返回删除个数(0 或 1)。 -
其他与
set基本一致。auto it = dict.find("left");
if (it != dict.end()) {
cout << it->second << endl; // 输出:左边
it->second = "左侧"; // 修改 value 合法
}int n = dict.erase("right"); // n == 1
3.6 map 的 operator\[\] ------ 多功能神器
map 最独特也最容易让人困惑的接口就是 operator[]。它的声明如下:
mapped_type& operator[] (const key_type& k);
行为:
-
如果
k在 map 中,返回对应value的引用。 -
如果
k不在 map 中,则自动插入一个键值对(k, mapped_type()),其中mapped_type()是值类型的默认构造值(例如 int 为 0,string 为空字符串),然后返回该value的引用。
内部实现原理(简化):
mapped_type& operator[](const key_type& k) {
// insert 返回 pair<iterator, bool>,无论插入成功还是失败,iterator 都指向 k 所在节点
pair<iterator, bool> ret = insert({k, mapped_type()});
return ret.first->second; // 返回 value 的引用
}
因此 operator[] 实际上完成了:查找 + 插入(若不存在)+ 返回 value 引用。这使得代码可以非常简洁。
典型应用:统计单词频率
map<string, int> freq;
string words[] = {"apple", "banana", "apple", "apple", "banana"};
for (const auto& w : words) {
freq[w]++; // 第一次遇到 w 时插入并初始化为 0,然后 ++ 变成 1;后续遇到直接 ++
}
for (const auto& p : freq) {
cout << p.first << " : " << p.second << endl;
}
注意陷阱:
-
operator[]在访问不存在的 key 时会插入 一个新元素。如果只是想查询,用find更安全。 -
不要试图用
operator[]来遍历 map,它会插入新的默认元素,改变容器大小。
3.7 multimap 的特点
multimap 允许 key 重复,因此:
-
insert总是成功。 -
find返回中序第一个匹配的迭代器。 -
count返回重复次数。 -
erase(key)删除所有该 key 的元素。 -
不支持
operator[],因为 key 不唯一,无法通过一个 key 确定唯一的 value。
multimap 常用于一对多的映射,例如一个学生可以选修多门课程。
multimap<string, string> courses;
courses.insert({"张三", "数学"});
courses.insert({"张三", "英语"});
auto range = courses.equal_range("张三"); // 返回 pair<iterator,iterator> 表示区间
for (auto it = range.first; it != range.second; ++it)
cout << it->second << " ";
3.8 实战:力扣 138. 随机链表的复制
题目 :一个链表,每个节点除了 next 指针,还有一个 random 指针指向链表中的任意节点或空。要求深拷贝原链表。
常规解法 :在原链表中穿插拷贝节点,再拆分,极其繁琐。
使用 map :第一遍遍历,建立原节点 → 拷贝节点的映射;第二遍遍历,利用映射设置 next 和 random。代码清晰易懂。
class Solution {
public:
Node* copyRandomList(Node* head) {
if (!head) return nullptr;
map<Node*, Node*> nodeMap;
Node* cur = head;
// 第一遍:创建新节点,建立映射
while (cur) {
nodeMap[cur] = new Node(cur->val);
cur = cur->next;
}
// 第二遍:连接 next 和 random
cur = head;
while (cur) {
nodeMap[cur]->next = nodeMap[cur->next];
nodeMap[cur]->random = nodeMap[cur->random];
cur = cur->next;
}
return nodeMap[head];
}
};
时间复杂度 O(n),空间复杂度 O(n)。比传统方法简单了不止一个数量级。
3.9 实战:力扣 692. 前K个高频单词
题目:给一个单词列表,返回出现频率最高的 k 个单词。若频率相同,按字典序从小到大排列。
分析 :先用 map 统计频率(map 自动按单词字典序排序),然后将键值对放入 vector,再按照频率降序、字典序升序的自定义规则排序,取前 k 个。
解法一:使用 stable_sort
由于 map 已经按照字典序排好,我们只需要按频率稳定排序即可(相同频率时保持原来的字典序顺序)。stable_sort 是稳定的排序算法。
class Solution {
public:
vector<string> topKFrequent(vector<string>& words, int k) {
map<string, int> cnt;
for (auto& w : words) cnt[w]++;
vector<pair<string, int>> v(cnt.begin(), cnt.end());
// 按频率降序,稳定排序保证相同频率时字典序小的在前面
stable_sort(v.begin(), v.end(),
[](const pair<string,int>& a, const pair<string,int>& b) {
return a.second > b.second;
});
vector<string> res;
for (int i = 0; i < k; ++i) res.push_back(v[i].first);
return res;
}
};
解法二:自定义比较的 sort
直接使用 sort,在比较函数中同时判断频率和字典序。
vector<string> topKFrequent(vector<string>& words, int k) {
map<string, int> cnt;
for (auto& w : words) cnt[w]++;
vector<pair<string, int>> v(cnt.begin(), cnt.end());
sort(v.begin(), v.end(),
[](const pair<string,int>& a, const pair<string,int>& b) {
return a.second > b.second || (a.second == b.second && a.first < b.first);
});
vector<string> res;
for (int i = 0; i < k; ++i) res.push_back(v[i].first);
return res;
}
解法三:使用优先队列(最小堆)
也可以用小根堆只保留前 k 个,但注意自定义比较器的写法(优先队列默认大堆,比较函数与 sort 相反)。这里不再展开。
四、总结与进阶建议
4.1 核心知识点回顾
| 容器 | 是否有序 | 是否允许重复 key | 是否允许修改 key | 是否允许修改 value | operator\[\] |
|---|---|---|---|---|---|
| set | 是(升序/降序) | 否 | 否(const迭代器) | --- | 无 |
| multiset | 是 | 是 | 否 | --- | 无 |
| map | 是(按key升序) | 否 | 否 | 是(通过迭代器或\[\]) | 有(多功能) |
| multimap | 是 | 是 | 否 | 是 | 无 |
4.2 使用建议
-
需要集合(元素唯一且有序) →
set。 -
需要字典映射(key 唯一,value 可变) →
map。 -
需要一对多映射或允许重复 key →
multimap或multiset。 -
只关心存在性,不关心顺序 →
unordered_set/unordered_map(哈希实现,O(1) 平均)。 -
不要再自己手写二叉搜索树,除非学习目的。工程上直接用 STL 容器。
4.3 性能与注意事项
-
红黑树的插入、删除、查找都是 O(log n),但常数较大。对于小数据量(< 1000),
vector+ 线性查找可能更快。 -
避免在循环中频繁使用
operator[]进行查找,因为它会在缺失时插入,改变容器大小。 -
erase在迭代器失效方面:map的erase(iterator)只使被删迭代器失效,其他迭代器(包括end())仍然有效。这是与vector的重要区别。 -
尽量使用
const_iterator如果你不打算修改元素,以表明意图。
4.4 延伸学习
-
无序关联容器 :
unordered_set、unordered_map。它们基于哈希表,查找平均 O(1),但元素无序,且需要提供哈希函数。 -
红黑树原理 :理解旋转、颜色规则,对理解
map的平衡机制有帮助。 -
自定义比较函数 :当你的 key 类型不支持
operator<或者你需要特殊比较逻辑时,可以传入仿函数或 lambda。
结语
map 和 set 是 C++ STL 中最常用、最强大的容器之一。掌握它们不仅能让你的代码更加简洁高效,还能在算法竞赛和日常开发中如虎添翼。本文从基本概念到进阶实战,涵盖了所有常用接口和典型场景。希望你能够亲自敲一遍示例代码,并尝试解决文中的力扣题目。如果遇到问题,欢迎在评论区交流讨论。