目录
一 序列式容器和关联式容器
前面我们已经接触过 STL 中的部分容器,例如 string、vector、list、deque、array、forward_list 等,这些容器统称为序列式容器。
序列式容器的逻辑结构为线性序列数据结构,两个位置存储的值之间通常没有紧密的关联关系。例如交换两个元素的位置,容器的本质特性不会改变,它依旧是序列式容器。同时,序列式容器中的元素是按照其在容器中的存储位置来顺序保存和访问的。
关联式容器同样用于存储数据,但与序列式容器有明显区别。关联式容器的逻辑结构通常是非线性结构,两个位置的元素之间存在紧密的关联关系。如果交换元素位置,容器原有的存储结构会被破坏。并且,关联式容器中的元素是按照关键字(key) 来保存和访问的,而非存储位置。
在 STL 中,关联式容器主要分为两大系列:
map/set系列unordered_map/unordered_set系列
本章节讲解的 map 和 set,其底层数据结构是红黑树。红黑树是一颗平衡二叉搜索树,能保证数据的有序性和高效的增删查操作。
set对应 key 搜索场景 的结构,容器中仅存储关键码 key,核心作用是判断 key 是否存在。map对应 key/value 搜索场景 的结构,容器中存储 key 与对应的 value(value 可为任意类型),核心作用是通过 key 快速查找对应的 value。
二 set系列使用
1 set和multiset参考文档
https://legacy.cplusplus.com/reference/set/
2 set类的介绍
- set 的声明如下,其中
T代表 set 底层存储的关键字类型。 - set 默认要求类型
T支持小于(<)比较操作。若T不支持该操作,或需要自定义比较规则,可自行实现仿函数,并将其作为第二个模板参数传入。 - set 底层存储数据的内存,默认通过 STL 提供的空间配置器申请。若有特殊需求,也可自行实现内存池,将其作为第三个模板参数传入。
- 一般情况下,无需手动指定后两个模板参数,使用 set 的默认配置即可满足需求。
- set 底层基于红黑树实现,其增、删、查操作的时间复杂度均为 O(logN)。此外,set 的迭代器遍历遵循红黑树的中序遍历规则,因此遍历结果是有序的。
- 此前我们已学习 vector、list 等容器的使用,而 STL 容器的接口设计具有高度相似性。因此,本节不再逐一介绍 set 的所有接口,而是直接结合文档,挑选关键接口进行重点讲解。
3 set的常用接口
(1)迭代器遍历&&删除
#include<set>
void test_set1()
{
set<int> s;
s.insert(3);
s.insert(1);
s.insert(2);
s.insert(5);
s.insert(3);
s.insert(5);
s.insert(6);
// 遍历结果:去重+有序
set<int>::iterator it = s.begin();
while (it != s.end())
{
// *it = 1; // 不能修改
cout << *it << " ";
++it;
}
cout << endl;
for (auto e: s)
{
cout << e << " ";
}
cout << endl;
int x = 0;
cin >> x;
cout << s.erase(x) << endl;
/*auto pos = s.find(x);
if (pos != s.end())
{
s.erase(pos);
}*/
// 单纯判断在不在
if (s.count(x))
{
}
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
}
运行结果为:
1 2 3 5 6
1 2 3 5 6
3 // 手动输入 x=3
1 // s.erase(x) 返回删除的元素个数(成功删除1个,返回1)
1 2 5 6 // 删除3后的遍历结果
如果删除成功,s.earse(x)就会返回1,如果删除的数值不存在,就会返回0
上述代码注释掉的部分:
是先需要用find找到数值在不在,如果在的话,再用erase删除。但是如果只需要判断某个值是否存在,可以使用count这个接口,单纯使用来判断寻找的数值在不在
(2)删除某一个区间的值
void test_set2()
{
set<int> s;
//s.insert(3);
s.insert(1);
s.insert(2);
s.insert(5);
//s.insert(3);
s.insert(5);
s.insert(6);
s.insert(7);
s.insert(9);
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
// 删除[3, 8]区间值
// >= 3
auto it1 = s.lower_bound(3);
// > 8
auto it2 = s.upper_bound(8);
s.erase(it1, it2);
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
}
运行结果:
1 2 5 6 7 9 // 初始遍历:set自动去重+有序,插入元素后结果为1,2,5,6,7,9
1 2 9 // 删除[3,8]区间元素后,剩余1,2,9
其中使用了两个接口:lower_bound和upper_bound
lower_bound (const T& val):找 “第一个 >= val” 的元素迭代器
-
功能:在有序的
set中,查找第一个关键字 >= val 的元素,并返回指向该元素的迭代器。 -
若未找到:返回
s.end()(指向set末尾的无效迭代器)。
upper_bound (const T& val):找 “第一个 > val” 的元素迭代器
-
功能:在有序的
set中,查找第一个关键字 > val 的元素,并返回指向该元素的迭代器。 -
若未找到:返回
s.end()(指向set末尾的无效迭代器) -
it1指向5,it2指向9→ 区间是 [5, 9)。
- 该区间内的元素是
5,6,7,恰好对应 “[3,8] 范围内的元素
为什么不使用erase(3)呢?
因为虽然是区间[3,8),但是集合中不一定就存在元素3,所以我们要找到3或者第一个大于三的数字
(3)multiset&&pair
void test_set3()
{
multiset<int> s;
s.insert(3);
s.insert(1);
s.insert(2);
s.insert(5);
s.insert(3);
s.insert(5);
s.insert(6);
s.insert(3);
s.insert(3);
// 遍历结果:有序
multiset<int>::iterator it = s.begin();
while (it != s.end())
{
// *it = 1; // 不能修改
cout << *it << " ";
++it;
}
cout << endl;
// 查找中序第一个3
auto pos = s.find(3);
while (pos != s.end() && *pos == 3)
{
cout << *pos << " ";
++pos;
}
cout << endl;
// [)
//std::pair<multiset<int>::iterator, multiset<int>::iterator> ret = s.equal_range(3);
auto ret = s.equal_range(3);
cout << s.count(3) << endl;
cout << s.erase(3) << endl;
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
}
代码运行结果:
1 2 3 3 3 3 5 5 6 // 初始遍历:有序且保留所有重复元素
3 3 3 3 // find(3)找到第一个3后,遍历所有连续的3
4 // count(3):统计3的总个数(共4个)
4 // erase(3):删除所有值为3的元素,返回删除个数(4个)
1 2 5 5 6 // 最终遍历:所有3被删除,剩余元素有序
到这里可能有uu会有疑惑,为什么这里的count统计的是3出现的次数呢?
count的本质作用是返回容器中 关键字等于 key 的元素个数。
因为在set中,set会自动去重,所以如果count判断的数字存在,也只会返回数字1,但是multiset是不去重的,就会返回对应数值出现的次数
multiset 是 set 的 “允许重复元素” 版本,底层同样是红黑树,保持中序遍历有序,但不去重。
pair在map中使用的更多,他是一个类模板的结构体
equal_range (const T& val):获取 val 所有元素的迭代器区间(代码中注释部分)
- 功能:返回一个
pair迭代器,其中:
pair.first:等价于lower_bound(val)→ 第一个 >= val 的元素迭代器(即第一个3的位置)。pair.second:等价于upper_bound(val)→ 第一个 > val 的元素迭代器(即第一个5的位置)。
注意区分:set是会自动去重,但是multiset不会自动去重

4 met相关OJ题
(1)环形链表
https://leetcode.cn/problems/intersection-of-two-arrays

我们在数据结构部分学习过这道题,当时使用的是快慢指针的方法,但是这样的方法太过于麻烦,现在学习了set,那我们可以用set怎么做呢?
ListNode *detectCycle(ListNode *head) {
set<ListNode*> s;
ListNode* cur = head;
while (cur) {
auto it = s.find(cur);
if (it == s.end()) {
s.insert(cur);
} else {
return *it; // 有环:返回入口节点
}
cur = cur->next;
}
// 无环:必须返回 nullptr(符合返回类型要求)
return nullptr;
}
(2) 两个数组的交集
https://leetcode.cn/problems/intersection-of-two-arrays

思路:

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());
auto it1=s1.begin();
auto it2=s2.begin();
vector<int> v;
while(it1!=s1.end()&& it2!=s2.end())
{
if(*it1>*it2)
{
it2++;
}
else if(*it2>*it1)
{
it1++;
}
else
{
v.push_back(*it1);
it1++;
it2++;
}
}
return v;
}
};
差集逻辑:
差集的核心是 “找属于 A 但不属于 B 的元素”。在双指针遍历(假设数组已排序)时,规则是:
- 比较两个指针指向的元素,小的元素属于 “差集候选”,小的指针后移;
- 若元素相等,说明是 “交集元素”,两个指针同时后移;
- 当一个指针遍历到末尾时,另一个指针剩余的元素也属于 “差集”。
二、如何将差集思路转化为 “交集” 解法?
题目要求 “交集(两个数组都有的元素,且去重)”,我们可以反向利用差集逻辑,把 “找交集” 转化为 “排除差集”:
步骤 1:排序数组
为了使用双指针遍历,先将两个数组升序排序。例如:nums1 = [1,2,2,1] 排序后 → [1,1,2,2]nums2 = [2,2] 排序后 → [2,2]
步骤 2:双指针遍历找 “相等元素”(即交集)
定义指针 i 指向 nums1 起始,j 指向 nums2 起始,遍历过程中:
- 若
nums1[i] < nums2[j]:说明nums1[i]是 “nums1 独有的元素(差集)”,i++; - 若
nums1[i] > nums2[j]:说明nums2[j]是 “nums2 独有的元素(差集)”,j++; - 若
nums1[i] == nums2[j]:说明是交集元素,将其加入结果集,且i++、j++(避免重复统计,保证去重)。
步骤 3:去重与结果收集
由于题目要求 “输出元素唯一”,在找到相等元素时,只需添加一次(因为数组已排序,后续重复的相等元素会被指针后移跳过)。
三 map系列的使用
1 map和multimap参考文档
https://legacy.cplusplus.com/reference/map/
2 map类的介绍
map 的声明如下:Key 是 map 底层关键字的类型,T 是 map 底层 value 的类型。set 默认要求 Key 支持小于比较,若不支持或有需要,可自行实现仿函数传给第二个模板参数。map 底层存储数据的内存从空间配置器申请,一般情况下无需传递后两个模板参数。map 底层由红黑树实现,增删查改效率为 O(logN),迭代器遍历采用中序方式,因此会按 key 的有序顺序遍历。
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;
3 pair类型介绍
map底层的红黑树节点中的数据,使用pair<Key, T>存储键值对数据,作用是封装一对不同类型的数据

在C++98标准下,插入键值对的3种核心方法:
1)先创建pair对象,再插入
pair<string, string> kv1("sort", "排序");
dict.insert(kv1);
2)直接构造pair临时对象插入
dict.insert(pair<string, string>("left", "左边"));
3)使用make_pair自动推导类型插入
模板函数
make_pair,它接受两个类型分别为T1和T2的参数x和y,然后通过构造pair<T1, T2>类型的对象并返回,从而实现了 “自动推导类型并生成pair实例” 的功能。
dict.insert(make_pair("left", "左边"));
make_pair("left", "左边") 是该函数的调用示例,这里会自动推导 T1 为 const char*、T2 为 const char*,最终返回一个 pair<const char*, const char*> 类型的对象
但是有些同学可能会有疑问,这里会转化为char*类型的对象,但是底层是string类型的啊?
pair模板结构体有三种构造方法,如下。其中第二种就是应对这种情况的

模板拷贝构造:
template<class U, class V> pair (const pair<U,V>& pr);这是一个模板构造函数,允许从另一个不同类型的pair<U, V>对象pr拷贝构造当前pair。要求U类型可转换为当前pair的first_type,V类型可转换为当前pair的second_type(例如pair<string, string>可从pair<const char*, const char*>拷贝构造,因const char*可隐式转为string)。
注意:这里只允许能够隐式转化的类型,像int就不能转化为string类型
4 insert的使用
(1)插入不同数量键对的区别
a.插入单个键值对(外层单大括号)
dict.insert({ "right", "右边" });
- 调用的是
pair<iterator, bool> insert (const value_type& val);这个重载。 - 这里的
{ "right", "右边" }会隐式构造一个value_type(即pair<const Key, T>,此处为pair<const string, string>)对象,作为单个元素插入map。
b. 批量插入多个键值对(外层双大括号)
dict.insert({ {"string", "字符串"}, {"map", "地图,映射"} });
- 调用的是
void insert (initializer_list<value_type> il);这个重载。 - 外层大括号
{...}是initializer_list<value_type>(初始化列表),内层每个{key, value}都是一个value_type对象,从而实现一次性插入多个键值对。
简单来说,单大括号用于插入 “一个键值对”,双大括号用于插入 “一组键值对”,本质是 insert 函数的不同重载(分别处理单个元素和初始化列表)导致的语法差异。

迭代器还重载了一个运算符叫做箭头(在链表部分的迭代器有讲过),当容器里存的data数据是一个结构的时候,因为迭代器模仿的是指针的行为,普通的迭代器(如int)获取对象就解引用,但是如果是一个结构的迭代器,访问成员可以用另一个运算符 箭头 ->
(2)遍历
迭代器的箭头在其他地方用的不是很多,但是在map这个地方频繁使用
map<string, string>::iterator it = dict.begin();
while (it != dict.end()) {
cout << it->first << ":" << it->second << endl; // 推荐:-> 直接访问成员
// 等价写法:(*it).first / (*it).second(先解引用迭代器,再访问成员)
++it;
}
通过迭代器 it 遍历,it 指向 map 中的 pair<const string, string> 节点,it->first 是 key,it->second 是 value
5 范围for
(1)传统方式
for (auto& e : dict)
{
cout << e.first << ":" << e.second << endl;
}
(2)C++17 结构化绑定
for (const auto&[k, v] : dict)
{
cout << k << ":" << v << endl;
}
通过 [k, v] 直接将 pair 的 first(key)和 second(value)绑定到变量 k 和 v,无需通过 e.first/e.second 访问
6 erase
auto pos = dict.find("left");
if(pos != dict.end())
{
dict.erase(pos);
}
for (const auto& [k, v] : dict)
{
cout << k << ":" << v << endl;
}
cout << endl;
7 operator [ ](面试可能会问)
void test_map2()
{
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
map<string, int> countMap;
//for (auto& e : arr)
//{
// /*auto it = countMap.find(e);
// if (it != countMap.end())
// {
// it->second++;
// }
// else
// {
// countMap.insert({ e, 1 });
// }*/
countMap[e]++;
}
我们在面对string类的数组遍历的时候,当出现第一次的字符串就插入,出现过的就对value++,代码如注释部分,但是如果我们使用方括号,就只有上述的一句代码就能完成该意思,那么operator [ ]的底层逻辑是什么呢?


底层逻辑用到了this->insert,那我们就需要了解insert的返回值

insert的底层逻辑是这样的:
那我们来分析一下operator[ ]的底层原理:

我们按照图示一层一层分析:
第一层:insert返回了pair<iterator, bool>
第二层:pair<iterator, bool> 和first返回了iterator
第三层:iterator解引用返回iterator的pair<key_type,map_type>
第四层:pair<key_type,map_type>和second返回map_type(也就是value)
所以[ ]既可以查找,也可以插入,修改
map<string, string> dict;
// 插入
dict["sort"];
// 插入+修改
dict["left"] = "左边";
// 修改
dict["sort"] = "排序";
// 查找
cout << dict["sort"] << endl;
需要注意的是这⾥有两个pair,不要混淆了,⼀个是map底层红⿊树节点中存的pair<key, T>,另⼀个是insert返回值pair<iterator,bool>
8 at
// 纯粹的查找+修改
// at
dict.at("left") = "xxxxx";
// key不存在,会抛异常
// dict.at("insert") = "xxxxx";
}
9 multimap和map的差异
multimap和map的使⽤基本完全类似,主要区别点在于multimap支持关键值key冗余,那么
insert/find/count/erase都围绕着⽀持关键值key冗余有所差异,这里跟set和multiset完全⼀样,比如find时,有多个key,返回中序第⼀个。其次就是multimap不支持[ ],因为支持key冗余,[]就只能支持插入了,不能支持修改。
四 map相关OJ题
1 随机链表的复制
https://leetcode.cn/problems/copy-list-with-random-pointer/description


分两大步骤实现拷贝:
- 第一步:拷贝链表的
next指针关系,创建所有新节点,并建立「原节点 → 新节点」的映射;- 第二步:根据映射关系,修复新链表的
random指针。
#include <map> // 包含map容器的头文件(原代码使用map存储映射关系)
using namespace std; // 简化命名空间使用
// 定义链表节点类(题目隐含的节点结构,必须补充才能编译通过)
class Node {
public:
int val; // 节点存储的值
Node* next; // 指向下一个节点的指针
Node* random; // 随机指针(可指向链表中任意节点或nullptr)
// 节点构造函数:初始化值,next和random默认指向nullptr
Node(int _val) {
val = _val;
next = nullptr;
random = nullptr;
}
};
class Solution {
public:
// 函数功能:深拷贝带有random指针的链表,返回拷贝链表的头节点
Node* copyRandomList(Node* head) {
// 1. 定义map容器:key=原链表节点,value=对应的拷贝节点,建立两者映射关系
// 作用:后续通过原节点快速找到对应的拷贝节点,处理random指针
map<Node*, Node*> nodeMap;
// 2. 定义拷贝链表的头指针(copyhead)和尾指针(copytail),初始均为nullptr(空链表)
Node* copyhead = nullptr;
Node* copytail = nullptr;
// 3. 定义原链表的遍历游标cur,从原链表头节点开始遍历
Node* cur = head;
// 第一阶段:拷贝链表的next指针 + 建立原节点→拷贝节点的映射
while (cur) { // 当cur不为nullptr时,说明还未遍历完原链表
if (copytail == nullptr) { // 情况1:拷贝链表为空(首次创建节点)
// 创建第一个拷贝节点,值与原节点cur的val相同
// 拷贝链表的头指针和尾指针均指向这个新节点(此时链表只有一个节点)
copyhead = copytail = new Node(cur->val);
} else { // 情况2:拷贝链表已有节点,在尾部新增节点
copytail->next = new Node(cur->val); // 尾节点的next指向新创建的拷贝节点
copytail = copytail->next; // 尾指针后移,指向新的尾部节点
}
// 关键步骤:将当前原节点cur和对应的拷贝节点copytail存入map
// 后续处理random指针时,通过原节点就能快速找到拷贝节点
nodeMap[cur] = copytail;
cur = cur->next; // 原链表游标后移,处理下一个原节点
}
// 第二阶段:处理拷贝链表的random指针(核心依赖第一阶段的map映射)
cur = head; // 原链表游标重置,重新从原链表头节点开始遍历
Node* copy = copyhead; // 定义拷贝链表的遍历游标copy,从拷贝链表头节点开始
while (cur) { // 遍历原链表,逐个同步random指针关系
if (cur->random == nullptr) { // 情况1:原节点的random指针指向nullptr
copy->random = nullptr; // 拷贝节点的random也指向nullptr
} else { // 情况2:原节点的random指向原链表中的某个节点
// 通过map找到原节点random指向的节点(cur->random)对应的拷贝节点
// 将拷贝节点的random指针指向该节点,完成random关系同步
copy->random = nodeMap[cur->random];
}
cur = cur->next; // 原链表游标后移
copy = copy->next; // 拷贝链表游标同步后移,保持与原链表遍历进度一致
}
// 返回拷贝链表的头节点,即深拷贝的结果
return copyhead;
}
};
2 前K个高频单词
https://leetcode.cn/problems/top-k-frequent-words

(1)思路一:
我们使用排序法筛选前k个高频单词,核心逻辑如下:
由于map容器会自动按key(单词)的字典序对元素排序,这意味着遍历map时,出现次数相同的单词会保持字典序从小到大的顺序(字典序小的在前,字典序大的在后)。
我们将map中的<单词, 次数>键值对转入vector后,需要通过排序让高频单词排在前面。但注意,标准库的sort函数底层基于快速排序,属于不稳定排序——若直接使用,可能会打乱“次数相同单词的字典序顺序”。因此我们选择stable_sort(稳定排序),它能在按次数降序排序的同时,**保留原有相同次数单词的相对顺序(即map中已排好的字典序)**,从而满足题目对“次数相同按字典序排列”的特殊要求。
#include <vector>
#include <string>
#include <map>
#include <algorithm>
using namespace std;
// 链表节点类(如果不需要可删除,此处保留是为了兼容之前的代码上下文,若仅运行当前题目可删掉)
class Node {
public:
int val;
Node* next;
Node* random;
Node(int _val) {
val = _val;
next = nullptr;
random = nullptr;
}
};
class Solution {
public:
// 修复:将仿函数Compare移到Solution类作用域中(函数外部)
struct Compare {
// 重载()运算符,定义排序规则:按出现次数降序
bool operator()(const pair<string, int>& x, const pair<string, int>& y) const {
return x.second > y.second;
}
};
// 主函数:找出words中出现频率前k高的字符串
vector<string> topKFrequent(vector<string>& words, int k) {
// 1. 统计每个字符串的出现频率:map<字符串, 出现次数>(自动去重)
map<string, int> countMap;
for (auto& e : words) {
countMap[e]++; // 存在则次数+1,不存在则初始化1
}
// 2. 将map的键值对转为vector,便于按次数排序(map不支持自定义排序)
vector<pair<string, int>> v(countMap.begin(), countMap.end());
// 3. 稳定排序:按出现次数降序,频率相同时保持原相对顺序
stable_sort(v.begin(), v.end(), Compare()); // 此处可正常使用Compare仿函数
// 4. 提取前k个字符串,存入结果数组
vector<string> strV;
for (int i = 0; i < k; ++i) {
strV.push_back(v[i].first);
}
return strV;
}
};
(2)思路二:
将map统计出的次数的数据放到vector中排序,或者放到priority_queue中来选出前k个。利⽤仿函数强行控制次数相等的,字典序小的在前⾯。
class Solution {
public:
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);;
}
};
vector<string> topKFrequent(vector<string>& words, int k) {
map<string, int> countMap;
for(auto& e : words)
{
countMap[e]++;
}
vector<pair<string, int>> v(countMap.begin(), countMap.end());
// 仿函数控制降序,仿函数控制次数相等,字典序⼩的在前⾯
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;
}
};
class Solution {
public:
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);
}
};
vector<string> topKFrequent(vector<string>& words, int k) {
map<string, int> countMap;
for(auto& e : words)
{
countMap[e]++;
}
// 将map中的<单词,次数>放到priority_queue中,仿函数控制⼤堆,次数相同按照字典
序规则排序
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();
}
return strV;
}
};
1195





