1. 序列式容器和关联式容器
序列式容器:
- 序列式容器: string (字符串动态数组/顺序表)、vector (动态数组/顺序表)、list (双向链表)、deque (双端队列)、array (静态数组/顺序表)、forward_list (单向链表) 等。
序列容器逻辑结构上都是线性的,通过元素在容器中的位置/索引来组织数据 (进行线性存储或访问)。元素的位置取决于插入顺序或移除顺序,而不是它们的值。任意位置的两个元素之间没有关联关系。随意交换容器内的元素位置,不会破坏容器的结构与性质。
关联式容器:
- 有序 (红黑树系列): map (key/value搜索) /set (key搜索) /multimap (支持冗余) /multiset (支持冗余)。这类容器通常采用红黑树,一种平衡二叉搜索树。存储元素时,容器根据键进行大小排序,默认排升序,允许自定义比较规则。
- 无序 (哈希系列): unordered_map (key/value搜索) /unordered_set (key搜索) /unordered_multimap (支持键冗余) /unordered_multiset (支持键冗余)。这类容器在C++11引入,通常称为哈希容器,均采用哈希表。元素位置由哈希函数确定,通常未排序。
关联容器逻辑结构上都是⾮线性的,用于存储键值对 (key/value) 或单键 (key),++通过 key 进行元素的存储与访问++ ,根据键 快速搜索值 (单键的"键"同时也是"值")。位置之间有紧密关联关系,交换⼀下元素,容器的结构或性质可能就被破坏了。例如二叉搜索树的规则是:左子树所有结点的值 <= 根结点的值 <= 右子树所有结点的值,随意交换就可能破坏搜索树的这种大小关系。
本章节将学习 map/set/multimap/multiset。我们知道,普通二叉搜索树增/删/查的时间复杂度为O(N),实际效率取决于深度,最好情况是完全二叉树高度为logN,最坏情况是单边树高度为N。因此,普通二叉搜索树非常依赖插入/删除顺序,无法保证平衡性 (越接近完全二叉树平衡性越好)。红黑树是一种平衡搜索树,使二叉搜索树经过平衡接近完全二叉树,效率保证在O(logN)。
2. set 系列的使用
2.1 set/multiset 参考文档
set 和 multiset 都是key搜索场景,存储单键,所以结点的值 value 同时也是键 key。

set 的相关接口

2.2 set 类的介绍


- set 的声明如上,T 就是 set 底层关键字 (键key) 的类型 key_type,同时 T 也是值 value 的类型 value_type
- set 默认要求 T 类型⽀持⼩于⽐较 less<T>,如果不⽀持或者需要自定义比较规则,可⾃⾏实现仿函数传给第⼆个模版参数 Compare
- set 底层存储数据的内存是从空间配置器申请的,如果需要可以⾃⼰实现内存池,传给第三个参数 Alloc
- 后两个模版参数通常不需要传
- set 底层⽤红⿊树实现,增删查效率是O(logN)
- set 容器的迭代器 (iterator) 遍历规则是搜索树的中序遍历,默认排升序。
- STL容器接⼝设计⾼度相似,set 容器的接口可参考前⾯的 string/vector/list
注意:set 容器存储数据并非有序,内存地址本质也是线性的。但搜索树的性质,加上中序遍历,使得 set 的遍历一定有序,很多接口的实现就依靠这一点。
2.3 set 的构造和迭代器
构造
- set 的构造关注以下⼏个接⼝即可。

- empty(1):empty这里有两个构造。第一个是默认构造 ,两个形参 comp 和 alloc 都有缺省值 (两个形参都是自定义类型,可理解为调用自定义类型的默认构造初始化匿名对象,再调用它们的拷贝构造初始化comp/alloc)。一个仿函数 comp,一个空间配置器 alloc,一般无需传参;第二个是带参构造 ,只有一个 alloc,允许单独传参 alloc (缺省值必须从右向左给,传参则必须从左向右,所以默认构造必须先传参comp,才能传参alloc)。
- range (2):用一个左闭右开的迭代器区间 [first, last) 进行初始化。该构造函数为模板,可用 set 迭代器,也可以用其他容器迭代器。
- copy (3):拷贝构造,有两个。可以单独传参 set 对象,也能再传一个 alloc。
- move (4):移动构造。类和对象章节说过:类实际有8个默认成员函数,已学过的有6个:构造/析构/拷贝构造/赋值重载/取地址重载/const取地址重载。还有两个C++11新增的移动构造和移动赋值,暂不说明。
- initializer list (5):initializer列表初始化。il 相当于一个轻量级的只读的临时数组,本身不存储数据,通过指针指向编译器生成的临时数组。可用于临时存储实参列表,例如 vector<int> v = { 1, 2, 3 },il 可临时存储实参列表 { 1, 2, 3 },set 的构造函数就可以通过 il 初始化 v,实现上述像数组一样初始化容器对象 v 的效果。
迭代器
- set ⽀持正向/反向迭代,迭代遍历默认排升序;⽀持迭代器就⽀持范围 for ,set 的 iterator 和 const_iterator 都不⽀持修改数据,因为二叉搜索树都不支持修改 Key (键)。

2.4 set 的增删查
增

- single element (1):两个重载。第一个,const value_type& val,插入一个元素,val 既是值 value 又是键 key (返回值pair后续map处再说明);第二个,value_type&& val,&& 是右值引用,涉及C++11的移动语义,暂不介绍。
- with hint (2):支持在特定位置 position 插入元素 val。
- range (3):依次插入迭代器区间中的所有元素,左闭右开 [first, last),允许其他容器迭代器。
- initializer list (4):插入initializer列表中的所有元素。
set 不支持冗余,有自动去重的性质,相同值都会插入失败。
代码示例 2.4.1
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
// 一、默认构造与插入
// 自动去重 (遇到相等值则插入失败) + 默认排升序
set<int> s;
// 降序排序:利用仿函数自定义比较规则,例如构造初始化时传一个 greater 仿函数
//set<int, greater<int>> s;
s.insert(5);
s.insert(2);
s.insert(7);
s.insert(5); // 相等值插入失败
// 二、遍历:set 迭代器,中序遍历,默认升序
auto it = s.begin(); // it 类型为 set<int>::iterator
while (it != s.end())
{
// set 迭代器不支持修改,无论普通迭代器还是const迭代器
// *it = 1; // error C3892: "it": 不能给常量赋值
cout << *it << " ";
++it;
}
cout << endl;
// 三、initializer列表 (构造和insert都支持)
// s 已有:2、5、7。自动去重,只成功插入8、3、9
s.insert({ 2,8,3,9 }); // 构造也支持initializer列表
for (auto e : s) // 有迭代器就支持范围for遍历
{
cout << e << " ";
}
cout << endl;
// 四、字符串同样可以比较,字符相当于整型,底层是ASCLL码 (0~255)
set<string> strset = {"sort", "insert", "add"};
/*
按语义,这里先构造一个 set 匿名对象,再拷贝构造 strset。
大多编译器会优化为一个构造,直接构造 strset。
相当于下面这种写法 strset({ "sort", "insert", "add" })。
两种写法结果相同,但语义不同。前者形式上像赋值,实际效果和后者一致。
*/
/* set(initializer_list<value_type> il,
const key_compare& comp = key_compare(),
const allocator_type& alloc = allocator_type());
直接调用 initializer list (5)
{ "sort", "insert", "add" } 隐式类型转换为 initializer_list<value_type> il
*/
for (auto& e : strset)
{
cout << e << " ";
}
cout << endl;
// 字符串或 string 的比较:按字符的ASCLL码比较,从第一个字符开始,逐位对应
/*
sort 的 s 大于 insert 的 i,相当于 sort 字符串大于 insert
insert 的 i 大于 add 的 a,相当于 insert 字符串大于 add
若相等则继续逐个比较各字符,例如继续比较第2个字符 sort 的 o 和 insert 的 n
默认排升序:add < insert < sort
*/
return 0;
}
查

- 查找指定值 val,成功则返回一个指向该元素位置的迭代器对象,失败则返回 std::end()。
- val 既是键 key 也是值 value。value_type == key_type == 模板参数 T。
- val 大于根的 key,往右走;val 小于根的 key,往左走。查找次数和搜索树的高度相关,时间复杂度为O(logN)。

set 没有相等值,但为了和 multiset 兼容统一,出现了 count。
- count 可返回 val 的个数。set 中只可能为0或1。multiset 中可能有多个相等值。
- size_type 即 unsigned integral type 无符号整型 ,和 size_t 类似。
代码示例 2.4.2
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
set<int> s = { 4,2,7,2,8,5,9 }; // 2 4 5 7 8 9
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
// 利用 find 查找判断 7 存在不存在
auto pos = s.find(7);
if (pos != s.end())
{
cout << "find success!!" << endl;
}
else
{
cout << "find fail!!" << endl;
}
// 利⽤ count 快速判断 7 存在不存在,更简洁
if (s.count(7))
{
cout << "find success!!" << endl;
}
else
{
cout << "find fail!!" << endl;
}
return 0;
}
运行结果:
删

- (1):删除 position 位置的元素。返回值是迭代器:指向被删除元素的下一元素 (默认排升序,返回后一元素),如果删除的是容器中的最后一个元素,则返回 set::end。
- (2):删除指定值。如果 val 存在则删除,不存在直接返回。返回值是 size_type 类型,表示删除的元素个数 ,成功返回1,失败返回0。
- (3):依次删除迭代器区间 [first, last) 中的所有元素 (默认升序),左闭右开,返回值同 (1),但是最后一个被删除元素的下一位置,即 last。
注:已知 set 默认排升序。因此,std::begin 指向中序遍历的第一个元素,即最小元素;std::end 指向的是中序遍历的最后一个元素的下一位置,即最大元素的下一位置。erase (1) 中"被删除元素的下一元素"是指升序序列中的后一元素。erase (3) 同理。
迭代器失效问题
代码示例 2.4.3
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
// 自动去重 + 升序排序 :2 4 5 7 8 9
set<int> s = { 4, 2, 7, 2, 8, 5, 9 };
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
// 一、删除最⼩值:调用 erase (1) 传 set 迭代器对象
// begin 返回中序遍历的第一个元素,即最小元素
s.erase(s.begin());
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
// 二、删除指定值:调用 erase (2)
int x;
cin >> x;
int num = s.erase(x); // 删除 x,返回值是被删除的元素个数,0或1
if (num == 0)
{
cout << x << " erase fail!!" << endl;
}
else if (num == 1)
{
cout << x << " erase success!!" << endl;
}
// erase (2) 可理解为 find + erase (1),找到了就删,没找到就不删,并不是报错
// 删除8
auto pos = s.find(8); // 注意 pos 迭代器失效问题
s.erase(pos);
cout << "8 erase success!!" << endl;
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
return 0;
}
运行结果:
这里需要再次说明迭代器失效问题,之前 vector 章节也遇到过类似情况。当时是两种情况:
- 插入元素,例如 insert 或 push_back 可能导致底层数组进行扩容,通常是异地扩容,所以指向原位置的 pos 已失效。
- 删除元素,例如 erase 可能导致原本位置被其他元素覆盖,例如删除中间位置的元素,后面的元素会前移一步覆盖 pos 位置。
- 同理,插入元素可能导致其他元素后移,同样认为迭代器失效。
若使用了失效的迭代器对象,Visual Studio系列编译器统一处理为编译报错,即使第二种或第三种情况 *pos 仍然可以得到有效数据。
因此,只要进行了插入/删除操作,就必须对迭代器对象进行更新,否则再次使用迭代器对象就会报错。
- set 同理。虽然 set::insert 没有扩容概念,但搜索树的插入需要比较键的大小进行排序,可能改变搜索树的结构,进而导致中序遍历中元素位置变化。
- set::erase 同理。不管是直接删除叶子结点,还是替代法删除其他结点,前者导致迭代器指向空位置,后者导致迭代器指向另一个有效结点。
只需记住:对于容器,包括 set,只要用了迭代器,涉及插入/删除操作,就必须注意迭代器失效问题,操作完注意更新迭代器对象。
erase (3) 和 lower/upper_bound

传参任意值 val。
- lower_bound:返回第一个大于等于 val 的元素的位置。
- upper_bound:返回第一个大于 val 的元素的位置。
返回值是 set 迭代器对象。val 可能存在,也可能不存在。
这两个函数可用于查找迭代器区间。
代码示例 2.4.4
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
std::set<int> myset;
for (int i = 1; i < 10; i++)
myset.insert(i * 10); // 10 20 30 40 50 60 70 80 90
for (auto e : myset)
{
cout << e << " ";
}
cout << endl;
// 要求删除区间 [30, 50],边界元素存在,即删除30、40、50
//auto itlow = myset.lower_bound(30); // >= 30,得到30
//auto itup = myset.upper_bound(50); // > 50 的下一元素,得到60
//myset.erase(itlow, itup);
// 要求删除区间 [25, 55],边界元素不存在,即删除30、40、50
auto itlow = myset.lower_bound(25); // >= 25,得到30
auto itup = myset.upper_bound(55); // > 55 的下一元素,得到60
// 调用 erase (3) 删除这段区间 [25, 55] 的值,即删除左闭右开区间 [30, 60)
myset.erase(itlow, itup);
for (auto e : myset)
{
cout << e << " ";
}
cout << endl;
return 0;
}
运行结果:
find 的效率对比
代码示例 2.4.5
cpp
#include <iostream>
#include <set>
#include <algorithm>
using namespace std;
int main()
{
set<int> s = { 4,2,7,2,8,5,9 }; // 2 4 5 7 8 9
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
int x;
cin >> x;
// 算法库 <algorithm> 的 find 查找 O(N)
auto pos1 = find(s.begin(), s.end(), x);
cout << "pos1: " << *pos1 << endl;
// set 自己实现的 find 查找 O(logN)
auto pos2 = s.find(x);
cout << "pos2: " << *pos2 << endl;
return 0;
}
运行结果:


- 标准库实现的 std::find 面向各种容器,底层数据结构各不相同,不管是序列式容器还是关联式容器,都是按部就班的逐一遍历各个元素,其时间复杂度为O(N)。
- 但对于 set/multiset/map/multimap 等关联式容器,底层是红黑树,得到一定平衡的二叉搜索树,所以 find 查找的时间复杂度可达到O(logN)。
严格来说,std::find 只是给 vector/list 等序列容器偶尔查找使用的。如果是大量查找,为了效率问题,一般建议使用红黑树系列容器,例如 set,使用它自带的 find 查找。
牢记:对于关联式容器,红黑树系列,使用自带的 find 查找即可。
其他 set 接口

- swap 交换:交换 set 对象 *this 和 x 的内容。直接交换搜索树的根结点 _root 成员变量即可。

- clear 清空所有元素。

- 返回 set 容器中使用的仿函数对象的一个拷贝
- set 存储单键,键即值,key_compare 相当于 value_compare,即 set 的第二个模板参数 Compare,默认为 less<T>。
2.5 multiset 和 set 的差异
multiset 和 set 接口完全一致,其使⽤也基本类似,区别在于 multiset ⽀持冗余,insert/erase/find/count (增删查) 的一些细节因此有所差异,参照下⾯代码示例理解。
注:前面说过,如果支持冗余,相等值可以在左,也可以在右,保持统一即可。因为经过旋转平衡,其左右位置可能发生变化。
- 不同点一:multiset 排序,但不去重。
代码示例 2.5.1
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
// 不同点一:multiset 不去重
multiset<int> s = { 4, 2, 7, 2, 4, 8, 4, 5, 4, 9 }; // 2 2 4 4 4 4 5 7 8 9
auto it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
cout << endl;
return 0;
}
运行结果:
- 不同点二:multiset 可以存在多个相等值,find 查找默认找中序遍历的第一个元素。
如何判断遇到的元素就是中序遍历的第一个?
中序遍历一定是:左子树---根结点---右子树。如果当前结点的值就是需要的值,右子树就不需要再关心,因为根比右子树先遍历。但需要判断当前结点的左子树是否还有目标值。如果有,左子树比根先遍历,说明当前结点不是目标结点。必须确保当前相等值结点的左子树不再存在相等值,才可返回。
注:由于旋转平衡,相等值的左右并非固定,如果固定在右,遇到第一个相等值就可以直接返回了,因为根比右子树先遍历,但并非如此。
代码示例 2.5.2
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
multiset<int> s = { 4, 2, 7, 2, 4, 8, 4, 5, 4, 9 };
// 不同点二:find 查找中序的第⼀个相等值
int x;
cin >> x;
auto pos = s.find(x);
while (pos != s.end() && *pos == x) // 可打印所有相等值
{
cout << *pos << " ";
++pos;
}
cout << endl;
return 0;
}
运行结果:
要求查找中序遍历的第一个相等值,好处就是可以继续遍历后续的其他相等值。
- 不同点三:multiset 可能存在多个相等值,erase (2) 删除指定值 val,默认删除所有相等值。
代码示例 2.5.3
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
multiset<int> s = { 4, 2, 7, 2, 4, 8, 4, 5, 4, 9 };
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
// 不同点三:erase 会删除所有相等值 x
int x;
cin >> x;
s.erase(x);
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
return 0;
}
运行结果:
- 不同点四:multiset 可能存在多个相等值,count 返回值可能大于1。
代码示例 2.5.4
cpp
#include <iostream>
#include <set>
using namespace std;
int main()
{
multiset<int> s = { 4, 2, 7, 2, 4, 8, 4, 5, 4, 9 };
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
// 不同点四:count 返回值可能大于1
int ret = s.count(4);
cout << "ret: " << ret << endl;
ret = s.count(2);
cout << "ret: " << ret << endl;
ret = s.count(9);
cout << "ret: " << ret << endl;
return 0;
}
运行结果:
2.6 set 的OJ练习题
2.6.1 两个数组的交集

解析:找交集,只返回一个相等值,结果必定需要去重 。利用 set,不仅可以完成去重,还可以自动排序,得到两个不重复的升序序列,此时就能利用双指针轻松获取交集。cur1 和 cur2 从第一个元素开始,同时遍历两序列,判断当前值是否相等:1. 相等则同时向后走一步;2. 不相等则较小值向后走一步。 因为是升序,cur1 和 cur2 中的较大值往后走一定是更大的值,不可能遇到交集,只能较小值单独往后走,才可能遇到相等值。
代码示例 2.6.1
cpp
class Solution
{
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2)
{
set<int> set1(nums1.begin(), nums1.end());
set<int> set2(nums2.begin(), nums2.end());
set<int>::iterator cur1 = set1.begin(); // 4 5 9
set<int>::iterator cur2 = set2.begin(); // 4 8 9
vector<int> ret;
while (cur1 != set1.end() && cur2 != set2.end())
{
if (*cur1 == *cur2)
{
ret.push_back(*cur1); // 找到交集,保存下来
cur1++;
cur2++;
}
else
{
*cur1 < *cur2 ? cur1++ : cur2++;
}
}
return ret;
}
};
延申:找差集。反过来即可,不属于交集,就属于差集。相等,说明属于交集,cur1 和 cur2 同时向后走;不相等,较小值一定不属于交集,那就一定属于差集,保存下来即可。需要注意的是,两个序列不一定同时走到空,循环结束,剩下序列没有遍历完的元素也属于差集。
2.6.2 环形链表Ⅱ

这道题是之前数据结构链表部分的OJ题,找链表中环形的"入口结点",给出了两种思路:
- 思路一:通过距离关系式 L = (n - 1) * R + (R - X) 。p1 从H走L步到达E,相当于 p2 从M绕环走n-1圈,然后走R-X步到达E。

代码示例 2.6.2
cpp
class Solution
{
public:
ListNode *detectCycle(ListNode *head)
{
ListNode *slow = head, *fast = head;
while (fast != nullptr && fast->next != nullptr)
{
slow = slow->next;
fast = fast->next->next;
if (slow == fast)
{
ListNode *meet = slow; // 快慢指针相遇结点
while (head != meet)
{
// 分别从头结点head处和相遇结点meet处,一起走L步会相遇
head = head->next;
meet = meet->next;
}
return meet;
}
}
return nullptr;
}
};
- 思路二:直接从相遇结点截断,转为判断两个链表是否相交的问题。例如下图,meet 是相遇结点,newhead 是它的下一结点 meet->next,直接从中间截断。分成链表ab 和链表cb ,它们的相交部分就是 b 部分,两个链表的交点就是入环结点。

代码示例 2.6.3
cpp
class Solution
{
// 求相交链表的交点并返回
ListNode* getIntersectionNode(ListNode* headA, ListNode* headB)
{
ListNode *curA = headA, *curB = headB;
int lenA = 1, lenB = 1;
while (curA->next != nullptr) // 计算链表A的长度
{
lenA++;
curA = curA->next;
}
while (curB->next != nullptr) // 计算链表B的长度
{
lenB++;
curB = curB->next;
}
// 尾节点不同,说明不相交
if (curA != curB) return nullptr;
// 计算长度差,让长链表先走差值步,先同步长度再找交点
int sub = std::abs(lenA - lenB);
ListNode *longList = headA, *shortList = headB;
if (lenA < lenB)
{
longList = headB;
shortList = headA;
}
while (sub--) longList = longList->next;
// 同步遍历,找交点
while (longList && shortList)
{
if (longList == shortList) return longList;
longList = longList->next;
shortList = shortList->next;
}
return nullptr;
}
public:
ListNode *detectCycle(ListNode *head)
{
ListNode* slow = head;
ListNode* fast = head;
while (fast != nullptr && fast->next != nullptr)
{
slow = slow->next;
fast = fast->next->next;
if (slow == fast)
{
// 找到相遇点,断环,改为求相交链表问题
ListNode* meet = slow;
ListNode* newHead = meet->next;
// 1. 断开环
meet->next = nullptr;
// 2. 求 "head到meet" 与 "newHead到meet" 的交点
ListNode* entry = getIntersectionNode(head, newHead);
// 3. 恢复链表结构(题目不允许修改链表)
meet->next = newHead;
return entry;
}
}
return nullptr;
}
};
- 用 set 去重的特性解决更简单:遍历链表将所有结点都存入 set,如果存在环,遍历完一遍就会回到环的入口,此时只需要判断 set 中是否存在当前结点即可,判断方式也很简单,利用 set::count 即可。

代码示例 2.6.4
cpp
class Solution
{
public:
ListNode *detectCycle(ListNode *head)
{
set<ListNode*> s;
ListNode *cur = head;
// 遍历链表,插入所有结点
while (cur)
{
// 如果返回0,说明结点不存在,直接插入
// 如果返回1,说明当前结点即入环结点,返回当前结点
if (s.count(cur) == 1) return cur;
else if (s.count(cur) == 0) s.insert(cur);
cur = cur->next;
}
return nullptr;
}
};
3. map 系列及其差异
3.1 map/multimap 参考文档
map 系列和 set 系列,区别在于:
- map 系列是key/value搜索场景,结点中存储的是键值对 (key_value) ,键和值分离。set 系列是键值不分离,键同时也是值。
map 系列和 set 系列,共同点是:
- map 系列同样基于红黑树,遵守二叉搜索树的结构,根据键 (Key) 进行排序:左子树所有结点的 key <= 根结点的 key <= 右子树所有结点的 key。
- map 系列将键 (Key)/值 (Value) 分离,好处是值不再影响排序,对于搜索树结构而言,值如何毫不关心。(例如下图,不管是map还是multimap,值x/y/z随意重复、毫无规律,但键1/2/3/4/5受搜索树的规则约束)。
简而言之,set 系列中,键就是我们需要的数据 (值),根据这些数据就能在搜索树中完成排序。但在 map 系列中,键和值分离,值才是我们需要的数据,值不需要参与比较与排序。键和值一一对应 (multimap支持键冗余,结点的键可以重复,一个键对多个值),通过键完成比较与排序,值的获取依赖于键。

map 的相关接口
set/multiset/map/multimap 的接口基本一致,map 会多一个 operator\[\]。
和数组不同,数组通过下标区分元素的位置,也通过下标+\[\]获取元素。
map 通过键 (Key) 进行比较排序,唯一区分元素的位置,通过键+\[\]获取值 (Value)。

3.2 map 类的介绍

- map 的声明如上,Key 就是 map 底层关键字 (键) 的类型,T 是 map 底层 value 值的类型。
- map 同样要求键 (Key) ⽀持⼩于⽐较 less<Key>,如果不⽀持或者需要自定义比较规则,传一个仿函数给模板参数 Compare。
- map 底层同样有从空间配置器申请内存的需求。
- 同样地,Compare/Alloc 一般不需要传。
- map 底层同样⽤红⿊树实现,增删查改效率同样是 O(logN) (可以修改值value,仍然不可以修改键key,会破坏搜索树结构)。
- 迭代器同样走中序遍历,默认按照键 (Key) 升序排序。
- map 支持 operator\[\]。可以通过键+\[\]快速获取值 value,而且支持修改 value。类似数组,但不是下标,而是键 (Key)。迭代器同理,现在非const迭代器允许进行修改了,只能通过非const迭代器修改值 (Value)。

需要特别注意的是:
- T 单独表示值的类型 value_type,这里口头说的 value_type 在标准库中的正式名称其实叫做 mapped_type。
真正的 value_type,是 pair<const key_type, mapped_type>。
3.3 pair 类型的介绍
介绍
map 底层的红⿊树结点中,存储的数据是键值对,为什么是"对"呢?
实际上,键与值确实分离了,但并不意味着一个结点就必须分别存储两个变量 _key 和 _value。反而通过 pair 将它们组合成了**"一对"** ,所以叫键值对。相当于仍然只有一个变量,但这个变量的类型是 pair<键的类型, 值的类型>,它相当于两个变量的组合。

- pair 是一个类模板,有两个模板参数 T1 和 T2。T1/T2 类型可以相同,也可以不同,代表需要组合的两个值的类型。
- pair 中分别有两个成员变量 first 和 second。first_type 对应 T1,second_type 对应 T2 (map的key_type传给T1,mapped_type传给T2,相当于first是键,second是值)。
- 注意 map 对象中存储的是 pair 键值对,必须通过 first 和 second 单独访问变量。不可以通过 cin/cout 直接输入/输出一个 pair 对象,因为 pair 这个类不是内置类型,而且没有重载流插入/流提取。
将两个变量存入一个 pair 对象,使它两变成一对,可以组合使用,也可以访问 first/second 单独使用。这完美的保证了键与值的对应关系。
下面通过代码示例介绍 pair 的使用。
pair 对象的4种获取方法
代码示例 3.3.1
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
// 用 map 实现一个水果数量统计表:
// 第一个是键的类型 string,对应水果的名字;第二个是值的类型 int,对应水果的数量
map<string, int> fruitCount;
// 用 pair 组织数据,组合成一个键值对 kv1
pair<string, int> kv1("apple", 20); // 20颗苹果
// 插入 kv1 到 fruitCount 表中
fruitCount.insert(kv1);
// 插入更多数据
fruitCount.insert(pair<string, int>("banana", 40)); // 40根香蕉
fruitCount.insert(make_pair("pear", 100)); // 100颗梨子
fruitCount.insert( {"cherry", 10} ); // 10颗樱桃
// for循环:逐一取出表中的键值对,即e,打印键与值
for (auto& e : fruitCount)
{
cout << "first: " << e.first << " second: " << e.second << endl;
}
return 0;
}
运行结果:
可以构造有名对象 kv1 进行插入,也可以构造匿名对象进行插入。
值得说明的是 make_pair:

- make_pair 是标准库实现的一个函数模板。它的作用是返回一个 pair 对象。相当于,任意两个对象,我们传参给 make_pair,可以自动完成类型推导,并将这两个对象组合成一个 pair 对象并返回。
无论是构建有名对象 pair<string, int> kv1("apple", 20),还是构建匿名对象 pair<string, int>("pear", 100), 都比较麻烦。因为需要我们手动写明两个对象的类型,make_pair 则不需要,传参的同时自动完成了类型推导,函数返回值就是 pair 对象。

cpp
template <class T1, class T2>
struct pair
{
// pair 的构造函数,两个参数分别为 T1 和 T2 类型
pair(const T1& a, const T2& b) : first(a), second(b)
{
// ...
}
};
第四种写法是多参数隐式类型转换 (C++11才开始支持多参数转化):
- 在类和对象章节中说过,通过构造函数可以实现内置类型到类类型的隐式类型转换 (构造函数前加explicit可禁止该隐式类型转换)。前面是单参数,C++11后支持 {"cherry", 10} 这种多参数的情况,实参的类型和个数刚好与 pair 的构造函数 (没有加explicit) 对应,const T1& a = "cherry",const T2& b = 10。因此,{"cherry", 10} 可以隐式类型转换成一个 pair 对象。
创建或获取 pair 对象大致以上4种写法,一般优先后面两种。
pair 的大小比较
优先比较 first,再比较 second。
cpp
// 等于==
// first 和 second 同时相等
template <class T1, class T2>
bool operator== (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return lhs.first==rhs.first && lhs.second==rhs.second; }
// 不等于!=
// first 或 second 有一个不相等
template <class T1, class T2>
bool operator!= (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return !(lhs==rhs); }
// 小于<
// first 小就小,大于或等于就看 second,second 小就小
template <class T1, class T2>
bool operator< (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return lhs.first<rhs.first || (!(rhs.first<lhs.first) && lhs.second<rhs.second); }
// 小于等于<=
// 完全相等,或者 first 小于等于,或者 first 等于但 second 小于等于
template <class T1, class T2>
bool operator<= (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return !(rhs<lhs); }
// 大于>
// first 大就大,小于或等于就看 second,second 大就大
template <class T1, class T2>
bool operator> (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return rhs<lhs; }
// 大于等于>=
// 完全相等,或者 first 大于等于,或者 first 等于但 second 大于等于
template <class T1, class T2>
bool operator>= (const pair<T1,T2>& lhs, const pair<T1,T2>& rhs)
{ return !(lhs<rhs); }
3.4 map 的构造和迭代器
迭代器修改值

- map、multimap 的构造和 set/multiset 都是完全一致的。有默认构造,有迭代器区间构造,有拷贝构造,也有initializer列表构造。
- map/multimap/set/multiset 的迭代器也完全一致,都是双向迭代器 (bidirectional iterator),支持正向迭代也支持反向迭代。需要注意的是,set/multiset 不支持修改数据,无论普通迭代器还是const迭代器。map/multimap 支持修改值,但不支持修改键。
代码示例 3.4.1
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
map<string, int> fruitCount;
fruitCount.insert({ "apple", 20 });
fruitCount.insert({ "banana", 40 });
fruitCount.insert({ "pear", 100 });
fruitCount.insert({ "cherry", 10 });
// map<string, int>::iterator it = fruitCount.begin();
auto it = fruitCount.begin();
while (it != fruitCount.end())
{
// map/multimap 只能修改值
// 注意!*it 解引用后不是键或值,不是数据
// map 存储键值对,it 指向 pair 对象,解引用后还要单独访问 first/second
(*it).first = "watermelon"; // first 是键,不可修改,报错
(*it).second = 5; // second 是值,可以修改
it++;
}
return 0;
}
报错:
operator-> 重载
- pair 重载了 operator-> 运算符,map/multimap 迭代器可以直接使用 operator-> 访问 pair 中的 first/second。
代码示例 3.4.2
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
map<string, int> fruitCount;
fruitCount.insert({ "apple", 20 });
fruitCount.insert({ "banana", 40 });
fruitCount.insert({ "pear", 100 });
fruitCount.insert({ "cherry", 10 });
auto it = fruitCount.begin();
while (it != fruitCount.end())
{
// pair 重载了 operator-> 运算符
// it 指向的是 pair 对象,因此 it 可以调用 pair 的 operator->
cout << it->first << ": " << it->second << endl;
// 实际,it->first == it.operator->()->first
// 相当于省略了一个 ->
// 第一个->,it.operator->(),返回 pair* 对象
// 第二个->,相当于 pair*->first
cout << it.operator->()->first << ": " << it.operator->()->second << endl;
// 该问题在类和对象,讲默认成员函数,运算符重载时有过说明
it++;
}
return 0;
}
运行结果:
initializer列表+多参数隐式类型转换
关于 map 的initializer列表构造需要特别说明,例如下面这种玩法。
代码示例 3.4.3
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
auto kv1 = make_pair("apple", 20);
auto kv2 = make_pair("banana", 40);
auto kv3 = make_pair("pear", 100);
auto kv4 = make_pair("cherry", 10);
// 同时传入多个元素,调用initializer列表构造进行初始化
map<string, int> fruitCount = { kv1, kv2, kv3, kv4 };
auto it = fruitCount.begin();
while (it != fruitCount.end())
{
cout << it->first << ": " << it->second << endl;
it++;
}
return 0;
}
运行结果:


已知,initializer_list<value_type> il 是轻量级的、只读的、临时的数组,可以临时存储传过来的多个实参,该数组由编译器生成,形参 il 只是用指针指向该数组,il 本身不存储实参数据。
通过initializer列表,构造函数可以同时接收多个元素,完成初始化。
如果结合C++11,initializer列表+多参数隐式类型转换:
代码示例 3.4.4
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
map<string, int> fruitCount = { {"apple", 20}, {"banana", 40},
{"pear", 100}, {"cherry", 10} };
auto it = fruitCount.begin();
while (it != fruitCount.end())
{
cout << it->first << ": " << it->second << endl;
it++;
}
return 0;
}
首先,四个实参 {"apple", 20}、{"banana", 40}、{"pear", 100}、{"cherry", 10},都是多参数 (两个参数,一个string类型,一个int类型)。map 的元素类型是 pair<string, int>。每个实参,例如 {"apple", 20},两个参数类型与 pair<string, int> 一一对应,因此可以调用 pair 的构造函数,完成隐式类型转化。
然后,相当于四个实参都变成了 pair 对象,再调用 map 的initializer列表构造完成初始化。
initializer_list<T> 是模板类,value_type 是 map 的元素类型------键值对 pair<const key_type, mapped_type>,即 pair<string, int>,所以 pair<string, int> == value_type == T 。il 的类型是 initializer_list< pair<string, int> >,因此 il 指向的数组可以存储这4个 pair<string, int> 对象,完成 map<string, int> 对象 fruitCount 的初始化。
3.5 map 的增删查及其差异
增

insert 插入主要分为以下几种:插入单个元素、在指定位置插入、插入一段迭代器区间中的元素。
插入单个元素:
- set 系列存储单键 (Key)。元素类型就是键的类型 T,键同时也是值。map 系列存储键值对 (Key/Value)。元素类型是 value_type,键 (Key) 的类型是 key_type,值 (Value) 的类型是 mapped_type,真正存储的元素是 pair<const key_type, mapped_type> 对象,键和值的组合。
- 插入成功后,insert 会返回一个迭代器,指向新元素的位置。如果插入失败,则返回已存在元素的位置。
- multiset 和 multimap 支持冗余,允许重复的键 (Key),不需要判断插入成功或失败,只返回了迭代器。set 和 map 不支持冗余,不允许键重复,用 pair<iterator, bool> 作为返回值类型。first 是迭代器,second 是插入结果 (true/false)。
插入一段迭代器区间中的元素:
- 实现为函数模板,允许其他容器的迭代器区间。
- 相当于依次取出区间内的元素,逐个插入。也就是循环加上面的插入单个元素。
代码示例 3.5.1
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
map<string, int> fruitCount = { {"apple", 20}, {"banana", 40},
{"pear", 100}, {"cherry", 10} };
// set/map 不支持冗余,同样地,如果键 (Key) 重复,则会插入失败
auto retPair = fruitCount.insert({ "apple", 30 }); // 即使值不同也插入失败
// set/map 可能插入失败,因此返回值类型是 pair<iterator, bool>
// pair<map<string, int>::iterator, bool>
if (retPair.second == false)
{
// 返回 false,插入失败,返回的迭代器指向已存在元素的位置
cout << retPair.first->first << ": " << retPair.first->second << endl;
cout << "insert fail" << endl;
}
auto it = fruitCount.begin();
while (it != fruitCount.end())
{
cout << it->first << ": " << it->second << endl;
it++;
}
return 0;
}
运行结果:
需要强调:
- map 和 set 都不支持冗余。元素不存在,才能插入,返回新插入元素的位置;元素存在,则插入失败,返回已存在元素的位置。
- multimap 和 multiset 支持冗余。键重复也能插入,只要插入就会成功,统一返回新插入元素的位置;
删

erase 删除共3种:指定位置删除、指定键值删除 、指定区间删除。
注:"键值"是键的值,单纯指键存储的数据,而不是键和值。
指定位置删除:
- map/multimap/set/multiset 完全一致。获取到某个元素的位置后,传参迭代器即可删除该位置上的元素。
指定键值删除:
- 需要说明,键 (Key) 是搜索树比较排序的依据,唯一区分数据所在位置。删除与值 (Value) 无关,只与键 (Key) 有关。map/multimap/set/multiset 中的形参 val 和 k 指的都是键 (Key)。
- map/multimap/set/multiset 需要区分是否支持冗余。如果 k 存在,就删除,支持冗余则删除所有重复元素;如果 k 不存在,就不删除。最终返回实际删除的元素个数,不支持冗余只返回0或1,支持冗余可能返回0/1/多个。
指定区间删除:
- map/multimap/multiset 和 set 一致,这里的区间指中序遍历得到的序列的区间,默认是升序序列。可以通过 lower_bound(30)、upper_bound(50) 分别获取左闭右开区间 [first, last) 的两端。
- 区间中,元素存在就删除。multimap/multiset 支持冗余,若有多个重复元素,则全部删除。如果区间中有元素不存在,就不删除。该重载不返回删除元素个数。
代码示例 3.5.2
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
// 水果编号表:first 代表水果编号,second 代表水果名称
map<int, string> table;
table.insert({ 1, "apple" });
table.insert({ 2, "banana" });
table.insert({ 3, "pear" });
table.insert({ 4, "apple" });
table.insert({ 5, "apple" });
table.insert({ 6, "apple" });
table.insert({ 7, "cherry" });
auto it = table.begin();
while (it != table.end())
{
cout << it->first << ": " << it->second << endl;
it++;
}
cout << "\nAfter erase: " << endl;
// 键 (Key) 是唯一区分元素位置的依据,根据键删除元素
// 根据键划分区间:删除区间[4, 6]
table.erase(table.lower_bound(4), table.upper_bound(6));
it = table.begin();
while (it != table.end())
{
cout << it->first << ": " << it->second << endl;
it++;
}
return 0;
}
运行结果:
代码示例 3.5.3
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
// 水果编号表:first 代表水果编号,second 代表水果名称
multimap<int, string> table;
table.insert({ 1, "apple" });
table.insert({ 2, "banana" });
table.insert({ 3, "pear" });
table.insert({ 4, "apple" }); // 多个重复键
table.insert({ 4, "banana" });
table.insert({ 4, "pear" });
table.insert({ 5, "apple" });
table.insert({ 6, "apple" });
table.insert({ 7, "cherry" });
auto it = table.begin();
while (it != table.end())
{
cout << it->first << ": " << it->second << endl;
it++;
}
cout << "\nAfter erase: " << endl;
// 键重复的一起删除
// 根据键划分区间:删除区间[4, 6]
table.erase(table.lower_bound(4), table.upper_bound(6));
it = table.begin();
while (it != table.end())
{
cout << it->first << ": " << it->second << endl;
it++;
}
return 0;
}
运行结果:
查

find 查找只有1种:根据键值查找。
根据键值查找:
- val 和 k 都是键 (Key),根据键查找到元素 (set/multiset 的元素就是键,也是值;map/multimap 的元素是键值对,通过键,找到键值对,找到值)。
总结以及其他接口
- map/multimap/set/multiset 的增删查都与键 (Key) 有关,因为键 (Key) 决定了元素在搜索树中的位置。
map/multimap/set/multiset 的其他接口基本一致,没什么好说的。例如:empty、size、swap、clear、key_comp、value_comp、count、lower_bound、upper_bound。
- set/multiset 存储单键,键就是值,因此 key_comp 和 value_comp 仿函数其实就是同一个。map/multimap 存储键值对,键值分离,key_comp 和 value_comp 就是键和值各自所属类型的比较规则了。
- count 计算指定元素在搜索树中的个数,并返回。同样与键 (Key) 有关。键重复就算作同一元素,即使 map/multimap 存储键值对的值 (Value) 不同。
- lower_bound 返回大于等于的元素的迭代器,找左闭;upper_bound 返回大于的元素的迭代器,找右开。唯一区别就是迭代器指向的元素,set/multiset 的元素是键/值,map/multimap 的元素是键值对。

还有个 equal_range 接口没讲,简单说明一下。
这个接口和 lower_bound/upper_bound 类似,也是找左闭右开区间。但找的是相等元素的左闭右开。
代码示例 3.5.4
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
map<char, int> mymap;
mymap['a'] = 10;
mymap['b'] = 20;
mymap['c'] = 30;
// 根据键 (Key) 划分区间:找'b'的左闭右开
// 相当于 lower_bound('b') + upper_bound('b')
// 前者找大于等于,后者找大于
auto ret = mymap.equal_range('b'); // ['b', 'c')
// 右端是开区间,区间内的元素不包括'c'
cout << "lower bound points to: ";
cout << ret.first->first << "=>" << ret.first->second << endl;
cout << "upper bound points to: ";
cout << ret.second->first << "=>" << ret.second->second << endl;
return 0;
}
运行结果:
equal_range 更多是为 multimap 和 multiset 设计的。multimap/multiset 支持冗余,可能有多个相等元素,左闭右开区间 [first, last) 可以包含所有重复元素。但 map/set 不支持冗余,元素不可重复,区间内只有一个元素。
3.6 map 的数据修改

set/multiset 不支持修改,因为存储单键,键也是值。map/multimap 支持修改,因为存储键值对,不可修改键,可以修改值。
- 第一种修改⽅式是通过迭代器 (map/multimap通用)。
- 第二种修改方式是通过修改接⼝ operator\[\] (multimap不支持,只有map支持)。
同时,operator\[\] 还⽀持插⼊与查找。所以它是⼀个多功能复合接⼝。传给 operator\[\] 函数一个 key,返回一个值 value。注意,返回类型是引用,因此可以直接修改 value。
代码示例 3.6.1
cpp
#include <iostream>
#include <map>
using namespace std;
int main()
{
// 水果名称: 出现次数
map<string, int> countMap;
// key 不存在,插入
countMap["apple"]; // value = int(),内置类型初始化不确定,此处为0
// key 不存在,插入+修改
countMap["pear"] = 10; // value = 10
countMap["banana"] = 20; // value = 20
cout << "插入完成:" << endl;
for (auto& e : countMap)
{
cout << e.first << ": " << e.second << endl;
}
// key 存在,查找:返回 key 对应的 value
cout << "查找完成:" << endl;
cout << countMap["pear"] << endl;
// key 存在,修改:operator[] 返回 value 的引用
countMap["banana"] *= 10; // value *= 10
cout << "修改完成:" << endl;
for (auto& e : countMap)
{
cout << e.first << ": " << e.second << endl;
}
return 0;
}
运行结果:
我们知道,对于 insert 插入,插入成功会返回新插入元素的位置,插入失败则返回已存在元素的位置。
insert 接口本身就复合了插入与查找。
因此,insert 可以用来实现 operator\[\]:
- map::insert 有两层 pair,第一层是 insert 返回的 pair<iterator, bool>,第二层是 iterator 指向的 map 元素 pair<key_type, mapped_type> first。operator\[\] 需要先获取 pair<iterator, bool> 的 first,然后再返回 first->second。
代码示例 3.6.2
cpp
// map 的 operator[] 实现
// 元素不存在,insert 充当插入功能,返回新插入元素的位置,并返回对应的 value
// 元素存在,insert 充当查找功能,返回已存在元素的位置,并返回对应的 value
mapped_type& operator[] (const key_type& k)
{
// value = mapped_type() 相当于用缺省值初始化 value
pair<iterator, bool> ret = insert({ k, mapped_type() });
// it 指向新插入的元素
iterator it = ret.first;
// map 的元素类型是 pair<const key_type, mapped_type>,返回 second
return it->second;
}
注意,insert 不能替代 find,除非确定 key 存在。当然,map::insert 会同时返回插入结果,我们可以根据插入结果判断元素原本是否存在。
- 对于 multimap,键可以重复,一个键可以对应多个值,并且可能各不相同,operator\[\] 无法确定返回哪个值,因此 multimap 无法实现 operator\[\]。
3.7 map 的OJ练习题
3.7.1 随机链表的复制

难点:由于随机结点,我们不能在遍历链表时,一边完成拷贝一边完成结点的链接关系。因为 random 可能指向未拷贝结点。
之前C语言实现的一般解法:
- 第一次遍历,先对各个结点进行拷贝,拷贝结点统一放在原结点后⾯。再进行第二次遍历,对各个拷贝结点进行 random 关系的链接。最后一次遍历,将拷贝结点从原链表解离,并完成拷贝结点 next 关系的链接,得到原本的链表和拷贝链表。
代码示例 3.7.1
cpp
/**
* Definition for a Node.
* struct Node {
* int val;
* struct Node *next;
* struct Node *random;
* };
*/
struct Node* copyRandomList(struct Node* head)
{
// 第一次遍历
struct Node* cur = head;
while (cur)
{
struct Node* ptail = (struct Node*)malloc(sizeof(struct Node));
ptail->val = cur->val;
ptail->next = cur->next;
cur->next = ptail;
cur = ptail->next;
}
// 第二次遍历
cur = head;
while (cur)
{
struct Node* copy = cur->next;
if (cur->random != NULL)
{
copy->random = cur->random->next;
}
else
{
copy->random = NULL;
}
cur = copy->next;
}
// 第三次遍历
cur = head;
struct Node* copyHead = NULL;
while (cur)
{
struct Node* tail = cur->next;
if (!copyHead)
{
copyHead = tail;
}
cur->next = tail->next;
cur = tail->next;
if (cur)
{
tail->next = cur->next;
}
}
return copyHead;
}
使用 map 的C++解法:
- 直接让 { 原结点, 拷⻉结点 } 建⽴映射关系存入 map。控制随机指针就⾮常⽅便简单。这体现了 map 在解决⼀些问题时的价值,完全是降维打击。
代码示例 3.7.2
cpp
class Solution
{
public:
Node* copyRandomList(Node* head)
{
if (head == nullptr) return head;
// 原结点: 拷贝结点
map<Node*, Node*> m;
Node* cur = head;
while (cur)
{
Node* copy = new Node(cur->val);
m[cur] = copy; // 建立原结点与拷贝结点的映射关系
cur = cur->next;
}
cur = head;
while (cur)
{
// 通过 m[cur] 找到当前拷贝结点,m[cur->next] 即对应的后继拷贝结点
m[cur]->next = m[cur->next]; // 建立当前拷贝结点与后继拷贝结点的链接关系
// 同理,m[cur->random] 即当前拷贝结点 m[cur] 对应的随机拷贝结点
m[cur]->random = m[cur->random];
cur = cur->next;
}
// m[head] 得到拷贝链表的头结点
// 注意 head 为空 nullptr,键 nullptr 不存在,m[head] 不是查找,而是插入
return m[head];
}
};
3.7.2 前K个高频单词

题目分析:该题我们同样可以利⽤ map,建立单词与出现频率的映射关系。第一个要求是,按出现频率由⾼到低排序并返回前K个,这个不难,不管是排序还是优先级队列 (堆) 都可以解决。问题在于第二个条件:如果出现频率相同,不同的单词要按字典顺序排序。
思路一:由于频率可能重复,所以建立 { 单词, 出现频率 } 的映射关系存入 map。map 在存储时,会先按 key 进行排序,即按字典顺序排序。理论上,我们只需要再按出现频率排序即可。这里的问题是排序的稳定性。像 std::sort 底层是快排,快排是不稳定排序,按出现频率排序的同时会破坏字典顺序 (类似的还有堆排和希尔排序)。对于该问题,有两种解决办法:
- 一、继续使用不稳定排序,但需要控制排序的比较逻辑,同时比较字典顺序和出现频率。
- 二、使用稳定排序,只需要比较出现频率。稳定排序可用 stable_sort,和 sort 差不多,但底层基于归并排序,是稳定排序。
代码示例 3.7.3
cpp
class Solution
{
// 仿函数,自定义比较规则
struct Compare1
{
bool operator()(const pair<string, int>& x, const pair<string, int>& y) const
{
// 只比较 出现频率
return x.second > y.second;
}
};
struct Compare2
{
bool operator()(const pair<string, int>& x, const pair<string, int>& y) const
{
// 同时比较 字典顺序 和 出现频率
return x.second > y.second ||
(x.second == y.second && x.first < y.first);
}
};
// 思路一:排序 (不稳定排序和稳定排序)
public:
vector<string> topKFrequent(vector<string>& words, int k)
{
// 单词: 出现频率
// 出现频率可能重复,因此用单词作为 key
map<string, int> m;
for (auto &word : words)
{
// 插入+修改
m[word]++;
}
// sort 和 stable_sort 都需要使用随机迭代器
// set/map/multiset/multimap 都是双向迭代器
// 因此需要将 map 中的键值对存入 vector 数组中
vector<pair<string, int>> v(m.begin(), m.end());
// 1. 使用稳定排序,只按出现频率排序
// stable_sort(v.begin(), v.end(), Compare1()); // 传仿函数1的匿名对象
// 2. 使用不稳定排序,添加比较逻辑,同时按字典顺序和出现频率进行比较
sort(v.begin(), v.end(), Compare2()); // 传仿函数2的匿名对象
// 取前K个
vector<string> ret;
for (int i = 0; i < k; i++) ret.push_back(v[i].first);
return ret;
}
};
思路二:和思路一的不稳定排序基本一致,但采用优先级队列 priority_queue 进行堆排 (不稳定排序),同时控制比较逻辑。
代码示例 3.7.4
cpp
class Solution
{
// 仿函数,自定义比较规则
struct Compare
{
bool operator()(const pair<string, int>& x, const pair<string, int>& y) const
{
// 同时比较 字典顺序 和 出现频率
// 大堆用小于,小堆用大于
// return x.second > y.second || (x.second == y.second && x.first < y.first);
// 这里使用大堆,比较逻辑需要反过来
return x.second < y.second ||
(x.second == y.second && x.first > y.first);
}
};
// 思路二:堆排 + 控制比较逻辑
public:
vector<string> topKFrequent(vector<string>& words, int k)
{
map<string, int> m;
for (auto &word : words)
{
m[word]++;
}
// 将键值对存入大堆,存储过程中根据仿函数自定义的比较规则自动完成堆排
// priority_queue 是适配器,有三个模板参数依次为:元素类型,底层容器,仿函数
// 分别对应:pair<string, int>元素类型、vector<pair<string, int>>容器类型、Compare()仿函数
priority_queue<pair<string, int>, vector<pair<string, int>>, Compare> pq(m.begin(), m.end());
// 取前K个
vector<string> ret;
for (int i = 0; i < k; i++)
{
// 大堆,根结点是最大的
ret.push_back(pq.top().first);
// 删除根结点,第二大的结点通过调整算法成为新的根结点
pq.pop();
}
return ret;
}
};
关于思路二的代码优化问题:可以选择改用小堆。
因为大堆是对所有映射关系进行二次排序,实际返回结果只需要前K个,排序的数据量越多越浪费。
用小堆中只需要存储K个映射关系,既减少了数据量,也降低了每次堆排的销毁,只需要排K个数据即可。
根结点是最小的,只需要遍历所有映射关系,不断与根结点比较,堆的根结点更小,就用更大的映射关系置换根结点。堆中最终留下的就是前K个最大结点。