C++ STL关联式容器详解:set、multiset、map、multimap
摘要:本文详细讲解 C++ STL 中四种树形结构关联式容器------set、multiset、map 和 multimap。文章从关联式容器的概念入手,对比了树形结构与哈希结构的区别,介绍了键值对(pair)的基本用法。随后逐一深入讲解 set(去重与排序)、multiset(允许重复)、map(键值对存储与 operator\[\] 下标访问)和 multimap(一对多映射)的定义方式、常用接口、插入查找删除操作及代码示例。最后通过对比表格总结四者的核心差异,帮助读者在实际开发中快速选择合适的容器。
文章目录
- [C++ STL关联式容器详解:set、multiset、map、multimap](#C++ STL关联式容器详解:set、multiset、map、multimap)
-
- 关联式容器
- 树形结构与哈希结构
- 键值对
- set
-
- [set 的介绍](#set 的介绍)
- [set 的定义方式](#set 的定义方式)
- [set 的常用接口](#set 的常用接口)
- [set 的使用示例](#set 的使用示例)
- multiset
- map
-
- [map 的介绍](#map 的介绍)
- [map 的定义方式](#map 的定义方式)
- [map 的插入](#map 的插入)
- [map 的查找](#map 的查找)
- [map 的删除](#map 的删除)
- [map 的 operator\[\] 运算符重载](#map 的 operator[] 运算符重载)
- [map 的迭代器遍历](#map 的迭代器遍历)
- [map 的其他常用成员函数](#map 的其他常用成员函数)
- multimap
- 总结
关联式容器
C++ STL 中的容器大体可以分成两类:序列式容器和关联式容器。
序列式容器存储的是元素本身,底层更偏向线性序列结构,常见的有 vector、list、deque、forward_list 等。它们关注的是元素的先后位置,访问或遍历时通常按照插入顺序或物理存储顺序来处理。
关联式容器存储的通常是带有"关联关系"的数据,也就是通过某个关键值来组织和查找元素。典型容器包括 set、map、multiset、multimap,以及哈希版本的 unordered_set、unordered_map、unordered_multiset、unordered_multimap。
需要注意的是,stack、queue 和 priority_queue 并不是普通容器,而是容器适配器。它们底层会借助已有容器来完成工作,例如 stack 和 queue 默认使用 deque,priority_queue 默认使用 vector。
关联式容器最大的特点是:它不是单纯依靠下标或位置找数据,而是依靠关键值查找数据。因此在很多检索场景下,它比普通序列式容器更合适。
树形结构与哈希结构
STL 中的关联式容器按照底层结构不同,可以分成树形结构和哈希结构两类。
| 关联式容器 | 容器结构 | 底层实现 |
|---|---|---|
| set、map、multiset、multimap | 树形结构 | 平衡搜索树,通常是红黑树 |
| unordered_set、unordered_map、unordered_multiset、unordered_multimap | 哈希结构 | 哈希表 |
树形结构容器中的元素天然保持有序。比如向 set 中插入 3、1、2,遍历时得到的通常是 1、2、3。
哈希结构容器中的元素没有稳定的顺序,它更关注平均情况下的查找效率。如果只是想快速判断元素是否存在,哈希容器经常很方便;如果需要按顺序遍历,就更适合使用树形结构的容器。
本文重点整理树形结构的关联式容器:set、multiset、map 和 multimap。这些容器底层一般都是红黑树,所以查找、插入、删除的时间复杂度通常都是 O(log N)。
键值对
键值对用来表示一种对应关系。它一般包含两个成员:
| 成员 | 含义 |
|---|---|
| key | 用来查找或标识数据的关键值 |
| value | 与关键值对应的实际信息 |
比如做一个英汉词典时,英文单词可以看作 key,中文解释可以看作 value。通过英文单词,就能找到对应的中文含义。
在 STL 中,键值对常用 pair 表示。SGI-STL 中 pair 的基本结构可以理解成下面这样:
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)
{}
};
pair 中的 first 通常表示键,second 通常表示值。比如 map<int, string> 中存储的元素就可以理解成 pair<const int, string>。
set
set 的介绍
set 是一种按照特定顺序存储元素的关联式容器。使用迭代器遍历 set 时,得到的是一个有序序列。
set 有几个非常重要的特点:
- set 中的元素不能重复。
- set 中的元素会按照比较规则自动排序。
- set 中的元素不能被直接修改。
- set 底层通常由红黑树实现,查找效率为 O(log N)。
因为 set 中的元素唯一,所以它经常被用来去重。例如向 set 中插入多个重复数字,最后遍历时只会保留每个数字的一份。
set 和 map 的区别也很明显。map 中存储的是真正的 <key, value> 键值对,而 set 中看起来只存一个 value。不过从底层组织方式来看,set 可以理解成存储了 <value, value> 这种形式,元素本身既是键,也是值。
set 中的元素不能被修改,是因为它的底层是搜索树。如果随意修改树中某个结点的值,就可能破坏整棵搜索树的有序关系。比如原来某个结点应该在左子树,修改后可能应该跑到右子树,此时树结构就不再满足搜索树规则。
如果创建 set 时没有传入比较对象,默认会使用"小于"进行排序,也就是从小到大排列。也可以通过传入 greater 等比较方式改变排序规则。
set 的定义方式
方式一:构造一个指定类型的空容器。
cpp
set<int> s1;
方式二:拷贝构造一个同类型 set 容器。
cpp
set<int> s2(s1);
方式三:使用迭代器区间构造。
cpp
string str("abcdef");
set<char> s3(str.begin(), str.end());
方式四:指定比较规则。下面代码表示元素按照从大到小的顺序排列。
cpp
set<int, greater<int>> s4;
set 的常用接口
set 常用成员函数如下:
| 成员函数 | 功能 |
|---|---|
| insert | 插入指定元素 |
| erase | 删除指定元素 |
| find | 查找指定元素 |
| size | 获取容器中元素个数 |
| empty | 判断容器是否为空 |
| clear | 清空容器 |
| swap | 交换两个容器中的数据 |
| count | 统计指定元素的个数 |
set 常用迭代器接口如下:
| 成员函数 | 功能 |
|---|---|
| begin | 获取第一个元素的正向迭代器 |
| end | 获取最后一个元素下一个位置的正向迭代器 |
| rbegin | 获取最后一个元素的反向迭代器 |
| rend | 获取第一个元素前一个位置的反向迭代器 |
set 的使用示例
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
set<int> s;
// 插入元素,重复元素会被自动去掉
s.insert(1);
s.insert(4);
s.insert(3);
s.insert(3);
s.insert(2);
s.insert(2);
s.insert(3);
// 遍历方式一:范围 for
for (auto e : s)
{
cout << e << " ";
}
cout << endl; // 1 2 3 4
// 删除方式一:按值删除
s.erase(3);
// 删除方式二:找到迭代器后删除
set<int>::iterator pos = s.find(1);
if (pos != s.end())
{
s.erase(pos);
}
// 遍历方式二:正向迭代器
set<int>::iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
cout << endl; // 2 4
// 统计值为 2 的元素个数
cout << s.count(2) << endl; // 1
// 获取容器大小
cout << s.size() << endl; // 2
// 清空容器
s.clear();
// 判断容器是否为空
cout << s.empty() << endl; // 1
// 交换两个容器的数据
set<int> tmp{ 11, 22, 33, 44 };
s.swap(tmp);
// 遍历方式三:反向迭代器
set<int>::reverse_iterator rit = s.rbegin();
while (rit != s.rend())
{
cout << *rit << " ";
++rit;
}
cout << endl; // 44 33 22 11
return 0;
}
这个例子里最能体现 set 特点的是插入部分。虽然插入了多个 2 和 3,但最终容器里每个值只保留一份,并且遍历时结果自动有序。
multiset
multiset 和 set 的底层实现基本一样,通常也都是红黑树。它们提供的接口也非常接近,比如都支持 insert、erase、find、count、begin、end 等。
它们最核心的区别是:set 不允许元素重复,而 multiset 允许元素重复。
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
multiset<int> ms;
// multiset 允许重复元素
ms.insert(1);
ms.insert(4);
ms.insert(3);
ms.insert(3);
ms.insert(2);
ms.insert(2);
ms.insert(3);
for (auto e : ms)
{
cout << e << " ";
}
cout << endl; // 1 2 2 3 3 3 4
return 0;
}
由于 multiset 允许重复元素,所以 find 和 count 在 set 与 multiset 中的实际意义也不完全一样。
| 成员函数 | set 中的意义 | multiset 中的意义 |
|---|---|---|
| find | 返回值为 val 的元素迭代器;不存在则返回 end() | 返回底层搜索树中序遍历时第一个值为 val 的元素迭代器 |
| count | 元素存在返回 1,不存在返回 0 | 返回值为 val 的元素个数 |
对于 set 来说,因为元素唯一,所以 count 的结果只可能是 0 或 1。如果只是判断元素是否存在,用 find 也可以完成。
对于 multiset 来说,count 的价值就更明显了,因为它能统计某个值出现了多少次。
map
map 的介绍
map 是一种按照 key 来组织数据的关联式容器。它存储的是由 key 和 value 组成的键值对,并且会按照 key 的比较规则自动排序。
在 map 中:
- key 用来排序和唯一标识元素。
- value 用来保存与 key 关联的数据。
- key 不能被修改。
- value 可以被修改。
比如 map<string, string> 可以用来表示英汉词典,其中英文单词是 key,中文解释是 value。通过英文单词,就能找到对应的中文解释。
map 中元素的类型不是简单的 key 或 value,而是 value_type。在标准库中可以把它理解成下面这种形式:
cpp
typedef pair<const Key, T> value_type;
也就是说,map<int, string> 中每个元素的类型大致可以看成 pair<const int, string>。这里的 const int 表示 key 不能被修改,而 string 这个 value 是可以修改的。
map 和 set 一样,底层通常也是红黑树,因此插入、查找、删除的时间复杂度通常都是 O(log N)。它比 unordered_map 多了一个有序遍历能力,但在单纯查找时,平均效率通常不如哈希结构的 unordered_map。
另外,map 支持 operator\[\],也就是可以通过 mkey 的方式访问或修改对应的 value。
map 的定义方式
方式一:指定 key 和 value 类型,构造空容器。
cpp
map<int, double> m1;
方式二:拷贝构造同类型容器。
cpp
map<int, double> m2(m1);
方式三:使用迭代器区间构造。
cpp
map<int, double> m3(m2.begin(), m2.end());
方式四:指定 key 的比较规则。下面代码表示按照 key 从大到小排序。
cpp
map<int, double, greater<int>> m4;
map 的插入
map 的 insert 函数原型可以简化理解为:
cpp
pair<iterator, bool> insert(const value_type& val);
其中 value_type 本质上是一个 pair 类型:
cpp
typedef pair<const Key, T> value_type;
所以向 map 插入元素时,需要构造一个键值对。
方式一:直接构造匿名 pair 对象插入。
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
m.insert(pair<int, string>(2, "two"));
m.insert(pair<int, string>(1, "one"));
m.insert(pair<int, string>(3, "three"));
for (auto e : m)
{
cout << "<" << e.first << "," << e.second << "> ";
}
cout << endl; // <1,one> <2,two> <3,three>
return 0;
}
这种写法很直观,但是类型写得比较长,代码容易显得繁琐。
方式二:使用 make_pair 构造键值对。
标准库提供的 make_pair 可以根据传入参数自动推导类型,它的思路可以理解为:
cpp
template <class T1, class T2>
pair<T1, T2> make_pair(T1 x, T2 y)
{
return pair<T1, T2>(x, y);
}
使用 make_pair 后,代码会更简洁。
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
m.insert(make_pair(2, "two"));
m.insert(make_pair(1, "one"));
m.insert(make_pair(3, "three"));
for (auto e : m)
{
cout << "<" << e.first << "," << e.second << "> ";
}
cout << endl; // <1,one> <2,two> <3,three>
return 0;
}
insert 的返回值也是一个 pair,其中:
| 返回值成员 | 含义 |
|---|---|
| first | 指向对应元素的迭代器 |
| second | 插入是否成功的布尔值 |
如果待插入的 key 在 map 中不存在,插入成功,返回新插入元素的迭代器和 true。
如果待插入的 key 已经存在,插入失败,返回已有元素的迭代器和 false。
示例:
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
pair<map<int, string>::iterator, bool> ret1 = m.insert(make_pair(1, "one"));
cout << ret1.second << endl; // 1
pair<map<int, string>::iterator, bool> ret2 = m.insert(make_pair(1, "ONE"));
cout << ret2.second << endl; // 0
cout << ret2.first->second << endl; // one
return 0;
}
第二次插入相同 key 时不会覆盖原来的 value,因为 map 的 key 必须唯一。
map 的查找
map 的查找函数原型可以简化理解为:
cpp
iterator find(const key_type& k);
find 会根据传入的 key 在 map 中查找元素:
- 找到了,返回对应元素的迭代器。
- 没找到,返回 end()。
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
m.insert(make_pair(2, "two"));
m.insert(make_pair(1, "one"));
m.insert(make_pair(3, "three"));
map<int, string>::iterator pos = m.find(2);
if (pos != m.end())
{
cout << pos->second << endl; // two
}
return 0;
}
使用 find 时一定要判断返回值是否为 end()。如果没有判断就直接解引用,找不到元素时就会出现非法访问。
map 的删除
map 常用的删除接口有两种:
cpp
// 根据 key 删除
size_type erase(const key_type& k);
// 根据迭代器位置删除
void erase(iterator position);
根据 key 删除时,返回实际删除的元素个数。对于 map 来说,因为 key 唯一,所以返回值只可能是 0 或 1。
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
m.insert(make_pair(2, "two"));
m.insert(make_pair(1, "one"));
m.insert(make_pair(3, "three"));
// 方式一:根据 key 删除
m.erase(3);
// 方式二:根据迭代器删除
map<int, string>::iterator pos = m.find(2);
if (pos != m.end())
{
m.erase(pos);
}
return 0;
}
map 的 operator\[\] 运算符重载
map 支持使用 \[\] 访问元素,它的函数原型可以简化理解为:
cpp
mapped_type& operator[](const key_type& k);
这个函数的返回值是 key 对应的 value 的引用。
标准库中 operator\[\] 的核心逻辑可以理解为下面这一句:
cpp
(*((this->insert(make_pair(k, mapped_type()))).first)).second
这句代码看起来比较绕,拆开来看就是三步:
- 调用 insert 插入一个键值对。
- 从 insert 的返回值中拿到迭代器。
- 返回该迭代器位置元素的 value 引用。
模拟实现思路如下:
cpp
mapped_type& operator[](const key_type& k)
{
pair<iterator, bool> ret = insert(make_pair(k, mapped_type()));
iterator it = ret.first;
return it->second;
}
这里最关键的是 insert 的行为:
- 如果 key 不存在,insert 会插入 <key, mapped_type()>,也就是插入一个带默认值的键值对。
- 如果 key 已经存在,insert 不会插入新元素,而是返回已有元素的迭代器。
所以 operator\[\] 既能修改已有元素,也能插入新元素。
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
m.insert(make_pair(2, "two"));
m.insert(make_pair(1, "one"));
m.insert(make_pair(3, "three"));
m[2] = "dragon"; // 修改 key 为 2 的 value
m[6] = "six"; // 插入键值对 <6, "six">
for (auto e : m)
{
cout << "<" << e.first << "," << e.second << "> ";
}
cout << endl; // <1,one> <2,dragon> <3,three> <6,six>
return 0;
}
以 m2 = "dragon" 为例,m2 会先确保 map 中存在 key 为 2 的元素,然后返回它的 value 引用。因此给 m2 赋值,本质上就是修改 key 为 2 的元素的 value。
再看 m6 = "six",原来 map 中没有 key 为 6 的元素,所以 operator\[\] 会先插入 <6, string()>,然后返回这个默认字符串的引用,最后再把它赋值成 "six"。
可以总结成两句话:
- 如果 k 不在 map 中,mk 会先插入 <k, V()>,再返回这个 V 对象的引用。
- 如果 k 已经在 map 中,mk 会直接返回对应 V 对象的引用。
因此,如果只是想判断某个 key 是否存在,更推荐使用 find,不要随便使用 operator\[\]。因为 operator\[\] 可能会在查找失败时插入一个默认值。
map 的迭代器遍历
map 的迭代器相关接口如下:
| 成员函数 | 功能 |
|---|---|
| begin | 获取第一个元素的正向迭代器 |
| end | 获取最后一个元素下一个位置的正向迭代器 |
| rbegin | 获取最后一个元素的反向迭代器 |
| rend | 获取第一个元素前一个位置的反向迭代器 |
因为 map 会按照 key 排序,所以正向遍历时会按照 key 从小到大输出,反向遍历时会按照 key 从大到小输出。
遍历方式一:正向迭代器。
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
m.insert(make_pair(2, "two"));
m.insert(make_pair(1, "one"));
m.insert(make_pair(3, "three"));
map<int, string>::iterator it = m.begin();
while (it != m.end())
{
cout << "<" << it->first << "," << it->second << "> ";
++it;
}
cout << endl; // <1,one> <2,two> <3,three>
return 0;
}
遍历方式二:反向迭代器。
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
m.insert(make_pair(2, "two"));
m.insert(make_pair(1, "one"));
m.insert(make_pair(3, "three"));
map<int, string>::reverse_iterator rit = m.rbegin();
while (rit != m.rend())
{
cout << "<" << rit->first << "," << rit->second << "> ";
++rit;
}
cout << endl; // <3,three> <2,two> <1,one>
return 0;
}
遍历方式三:范围 for。
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
m.insert(make_pair(2, "two"));
m.insert(make_pair(1, "one"));
m.insert(make_pair(3, "three"));
for (auto e : m)
{
cout << "<" << e.first << "," << e.second << "> ";
}
cout << endl; // <1,one> <2,two> <3,three>
return 0;
}
范围 for 底层本质上还是迭代器。一个容器只要支持迭代器访问,通常就可以使用范围 for 来遍历。
如果希望在范围 for 中修改 value,可以写成引用形式:
cpp
for (auto& e : m)
{
e.second += "!";
}
这里不能修改 e.first,因为 map 的 key 是 const 的。
map 的其他常用成员函数
除了插入、查找、删除和 operator\[\],map 还有一些常用接口:
| 成员函数 | 功能 |
|---|---|
| size | 获取容器中元素个数 |
| empty | 判断容器是否为空 |
| clear | 清空容器 |
| swap | 交换两个容器中的数据 |
| count | 获取指定 key 的元素个数 |
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
map<int, string> m;
m.insert(make_pair(2, "two"));
m.insert(make_pair(1, "one"));
m.insert(make_pair(3, "three"));
// 获取元素个数
cout << m.size() << endl; // 3
// 统计 key 为 2 的元素个数
cout << m.count(2) << endl; // 1
// 清空容器
m.clear();
// 判断容器是否为空
cout << m.empty() << endl; // 1
// 交换两个容器中的数据
map<int, string> tmp;
m.swap(tmp);
return 0;
}
对于 map 来说,key 不允许重复,所以 count(key) 的结果也只可能是 0 或 1。
multimap
multimap 和 map 的底层实现基本一样,通常也都是红黑树。它们提供的很多接口也基本一致,例如 insert、erase、find、count、begin、end 等。
它们最核心的区别是:map 中的 key 唯一,而 multimap 允许 key 重复。
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
multimap<int, string> mm;
// multimap 允许 key 重复
mm.insert(make_pair(2, "two"));
mm.insert(make_pair(2, "double"));
mm.insert(make_pair(1, "one"));
mm.insert(make_pair(3, "three"));
for (auto e : mm)
{
cout << "<" << e.first << "," << e.second << "> ";
}
cout << endl; // <1,one> <2,two> <2,double> <3,three>
return 0;
}
因为 multimap 允许 key 重复,所以 find 和 count 的意义与 map 中也有差异。
| 成员函数 | map 中的意义 | multimap 中的意义 |
|---|---|---|
| find | 返回键值为 key 的元素迭代器;不存在则返回 end() | 返回底层搜索树中序遍历时第一个键值为 key 的元素迭代器 |
| count | key 存在返回 1,不存在返回 0 | 返回键值为 key 的元素个数 |
如果想遍历 multimap 中某个 key 对应的所有元素,可以配合 equal_range:
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main()
{
multimap<int, string> mm;
mm.insert(make_pair(2, "two"));
mm.insert(make_pair(2, "double"));
mm.insert(make_pair(1, "one"));
mm.insert(make_pair(3, "three"));
pair<multimap<int, string>::iterator, multimap<int, string>::iterator> range = mm.equal_range(2);
while (range.first != range.second)
{
cout << range.first->second << " ";
++range.first;
}
cout << endl; // two double
return 0;
}
multimap 不支持 operator\[\]。原因也很简单:当一个 key 对应多个 value 时,mmkey 到底应该返回哪一个 value 的引用?这个语义是不明确的,所以标准库没有给 multimap 实现下标访问。
总结
set、multiset、map 和 multimap 都属于树形结构的关联式容器,底层通常使用红黑树实现,因此它们的查找、插入、删除效率通常是 O(log N),并且遍历时可以得到有序结果。
| 容器 | 存储内容 | 是否允许重复 | 是否支持 operator\[\] |
|---|---|---|---|
| set | 单个值,值本身也作为排序依据 | 不允许 | 不支持 |
| multiset | 单个值,值本身也作为排序依据 | 允许 | 不支持 |
| map | <key, value> 键值对 | key 不允许重复 | 支持 |
| multimap | <key, value> 键值对 | key 允许重复 | 不支持 |
实际使用时可以这样选择:
- 只保存不重复的元素,并且希望有序遍历,用 set。
- 保存可重复元素,并且希望有序遍历,用 multiset。
- 保存一一对应的键值关系,用 map。
- 一个键需要对应多个值时,用 multimap。
这几个容器的核心并不难,重点是理解"是否存键值对""是否允许重复""是否支持下标访问"这几个差异。把这些点理清楚以后,再看它们的接口和使用场景就顺很多了。