前面我们学习了普通二叉搜索树,但是普通 BST 存在退化风险。
set / multiset / map / multimap属于 STL 有序关联式容器,底层是红黑树(平衡二叉搜索树),保证增删查稳定(O(logN))。本文把接口、底层原理、坑点、力扣例题全部讲透,同时补充拓展知识。
一、序列式容器 vs 关联式容器
1. 序列式容器
vector、list、deque、string、array属于序列式容器:元素按照存储位置来保存访问,交换两个元素,容器逻辑不会被破坏。
2. 关联式容器
set/map/multiset/multimap、unordered_set/unordered_map属于关联式容器:
- 元素依靠key 关键字保存、查找;
- 底层非线性树形结构,交换元素会直接破坏容器逻辑;
set/map底层红黑树(平衡 BST),有序;unordered_xxx底层哈希表,无序。
区分:
- set/multiset:纯 key 模型,对应手写 BST 的 key 版本;
- map/multimap:key‑value 模型,对应手写 BST 的 key‑value 版本。
二、set /multiset
2.1 set 模板原型
cpp
template<class T, class Compare = less<T>, class Alloc = allocator<T>>
class set;
T:元素类型,key 和 value 是同一个;Compare=less<T>:比较仿函数,默认升序;传greater<T>可以改为降序;Alloc:空间配置器,一般不用传。
核心特性:
- 元素唯一,自动去重,不允许重复 key;
- 底层红黑树,迭代器遍历等价于中序遍历,输出有序;
- 迭代器是双向迭代器 ,支持
++/--,不支持随机访问(不能it+n、不能下标[]); - ✨迭代器
*it是const T,禁止修改元素,修改 key 直接破坏红黑树结构。
2.2 set 核心接口总览
| 接口 | 功能 | 重点说明 |
|---|---|---|
insert(val) |
插入元素 | 返回pair<iterator,bool>;已存在则插入失败,second=false |
find(val) |
查找元素 | 找到返回迭代器,找不到返回end();\(O(logN)\),优于算法库std::find\(O(N)\) |
count(val) |
统计个数 | set 只能返回 0 或者 1,可以用来间接判断存在性 |
erase(val / pos / [first,last]) |
删除 | 传值:删除该 key;传迭代器:删除迭代器指向元素;传区间删除[first,last) |
lower_bound(val) |
返回≥val 第一个元素迭代器 | 大于等于 |
upper_bound(val) |
返回 > val 第一个元素迭代器 | 严格大于 |
lower_bound与upper_bound配合,可以截取区间[itlow, itup)做删除或者遍历。
cpp
set<int> myset = {10,20,30,40,50,60,70,80,90};
// 删除[30,60]闭区间
auto itlow = myset.lower_bound(30); // >=30
auto itup = myset.upper_bound(60); // >60
myset.erase(itlow, itup);
2.3 multiset:允许元素重复
multiset模板参数、接口和 set 几乎一模一样,唯一区别允许 key 重复。由此带来 3 个高频坑点:
find(x)返回中序遍历第一个等于 x 的迭代器,不是全部;想要遍历全部相同值,需要循环while(pos!=end() && *pos==x);count(x)返回 x 实际出现的次数,不再只是 0/1;- ⚠️大坑:
ms.erase(x)(传值)会删除容器中所有等于 x 的元素;只删一个,必须传迭代器ms.erase(pos)。
cpp
multiset<int> ms={4,2,7,2,4,8,4,5,4,9};
auto pos = ms.find(4);
if(pos != ms.end())
{
ms.erase(pos); // ✅只删除一个4
// ms.erase(4); // ❌会把全部4删掉
}
2.4 set 代码示例
cpp
void test_set1()
{
set<int> s = { 5,1,5,3,4,2,6,83,9,10,22 };
// 中序遍历,自动排序+去重
set<int>::iterator it = s.begin();
while (it != s.end())
{
// *it = 1; // 编译报错!不能修改set元素
cout << *it << " ";
++it;
}
cout << endl;
s.insert(60);
s.erase(6);
auto ret1 = s.find(3); // O(logN)
// auto ret1 = find(s.begin(), s.end(), 3); // 算法库find O(N),效率差很多
// lower_bound upper_bound区间删除
auto it1 = s.lower_bound(3);
auto it2 = s.upper_bound(9);
s.erase(it1, it2);
}
2.5 力扣实战例题
- 349. 两个数组的交集:两个数组分别构建 set,双迭代器有序遍历求交集;
- 142. 环形链表 II:把链表节点指针存入 set,遇到重复节点即为环入口。
cpp
//两个数组的交集
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());
// 因为set遍历是有序的,有序值,依次⽐较
// ⼩的++,相等的就是交集
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;
}
}
cpp
//环形链表 II
class Solution {
public:
ListNode* detectCycle(ListNode* head) {
set<ListNode*> s;
ListNode* cur = head;
while (cur)
{
auto ret = s.insert(cur);
if (ret.second == false)
return cur;
cur = cur->next;
} r
eturn nullptr;
}
}
set 存储指针,指针本身作为 key,判断节点是否已经访问过,代码简洁。
三、map /multimap
3.1 map 模板原型
cpp
template<class Key, class T, class Compare = less<Key>, class Alloc = allocator<pair<const Key,T>>>
class map;
Key:关键字;T:映射的 value;- 底层节点存储类型:
value_type = pair<const Key, T>;
⚠️pair 里面的 first(key)是 const!不能修改 key,但 second(value)可以修改,修改 key 破坏红黑树。
3.2 pair 简介(map 存储键值对的基础)
cpp
template<class T1,class T2>
struct pair
{
T1 first;
T2 second;
pair(const T1& a,const T2& b):first(a),second(b){}
};
// 辅助函数make_pair,构造pair对象
template<class T1,class T2>
pair<T1,T2> make_pair(T1 x,T2 y);
map 插入的 4 种等价写法:
cpp
map<string,string> dict;
dict.insert(pair<string,string>("sort","排序"));
dict.insert(make_pair("left","左边"));
dict.insert({"insert","插入"}); // C++11 initializer_list
dict.emplace("string","字符串");// emplace原地构造,减少拷贝
3.3 map 核心接口
find/count/lower_bound/upper_bound/erase接口语义和 set 几乎完全一样,区别:
find(key)返回迭代器,迭代器指向pair<const Key,T>;it->first是 key,it->second是 value;- 迭代器
it->first不能修改,it->second可以修改; - 重磅接口
operator[],multimap 没有这个接口!
operator \[\] 底层原理(面试高频)
cpp
// 伪代码,标准库内部实现逻辑
mapped_type& operator[](const key_type& k)
{
// 如果k不存在,insert插入{k,value默认构造对象};
// 如果k已经存在,insert直接返回已有节点迭代器,second=false
pair<iterator,bool> ret = insert({k, mapped_type()});
return ret.first->second;
}
[]三种行为:
- 修改 :
dict["left"] = "左边";key 存在,返回 value 引用赋值; - 插入 :
dict["test"];key 不存在,插入 key,value 使用类型默认值; - 查找 :
cout << dict["left"];key 存在读取 value;
⚠️重大坑点:不要用
[]单纯判断 key 是否存在! 如果 key 不存在,[]会悄悄插入一条数据,污染 map!判断存在性优先使用find()或者count()。
cpp
// ❌错误写法,如果"apple"不存在,会插入{"apple",0}
if(countMap["apple"]) {}
// ✅正确判断存在
if(countMap.find("apple") != countMap.end()){}
if(countMap.count("apple")){}
3.4 经典场景:词频统计
两种写法,find版本和[]版本:
cpp
void test_map1()
{
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
map<string, int> countMap;
//写法1 find
for (auto& str : arr)
{
auto it = countMap.find(str);
if (it != countMap.end())
{
it->second++;
}
else
{
countMap.insert({ str, 1 });
}
}
//写法2 [],代码极度简洁,也是刷题最常用
for (auto& str : arr)
{
countMap[str]++;
}
// C++17结构化绑定遍历
for (auto& [k, v] : countMap)
{
cout << k << ":" << v << endl;
}
}
3.5 multimap:允许 key 重复
- key 允许重复,value 可以各不相同;
- ❌不支持
operator[]:同一个 key 对应多个 value,[]不知道返回哪一个 value; find(key)返回中序第一个匹配 key 的迭代器;想要遍历全部同 key,循环迭代器判断。
四、底层原理:红黑树是什么?
set/map 底层是红黑树,一种自平衡二叉搜索树,解决普通 BST 有序插入退化成链表的缺陷,保证增删查稳定\(O(logN)\)。
红黑树五条核心性质:
- 每个节点颜色红色或者黑色;
- 根节点是黑色;
- 所有 NIL 叶子(空哨兵节点)是黑色;
- 红色节点的两个子节点必须是黑色(不能连续红节点);
- 任意节点到它所有后代 NIL 叶子,路径黑色节点数量相等(黑高相同)。
面试不需要手写红黑树旋转,但是要说出:红黑树通过变色 + 旋转控制黑高,限制树高度,避免 BST 退化。
迭代器失效规则
- insert :set/map 插入,原有迭代器全部不会失效,只有新增节点;
- erase :只有被删除节点的迭代器失效,其余迭代器全部有效;
和 vector 完全不同:vector 插入可能全部迭代器失效。原因红黑树只是旋转变色,节点内存地址不会改变。
五、set/map vs unordered_set/unordered_map 对比
| 特性 | set/map(红黑树) | unordered_set/unordered_map(哈希表) |
|---|---|---|
| 底层 | 红黑树平衡 BST | 哈希表 |
| 时间复杂度 | 稳定\(O(logN)\) | 平均\(O(1)\),最坏\(O(N)\)哈希冲突 |
| 有序性 | 按键有序,中序遍历输出有序 | 完全无序,输出顺序不可预测 |
| 迭代器 | 双向迭代器++ -- |
前向迭代器,只支持++ |
| 接口 | 支持lower_bound / upper_bound区间查找 |
不支持区间查找,只能精确查找 |
| key 要求 | 需要<严格弱序比较 |
需要哈希函数 + ==相等比较 |
使用选择:
- 需要有序输出、范围查找、稳定性能:选
set/map;- 只需要快速查找,不在乎顺序:选
unordered系列。
六、高频坑点汇总
- set 的迭代器元素是
const,禁止修改元素 ;map 只能修改second,不能修改first; - multiset
erase(值)删除全部匹配元素,删除单个必须传迭代器; - map 不要用
[]做存在性判断,不存在就会插入;multimap 没有[]; std::find算法库是线性遍历\(O(N)\);优先使用容器自带find()\(O(logN)\);- insert 对于已经存在 key 不会覆盖;
[]会覆盖 value; - lower_bound 是
>=,upper_bound 是>,区间是左闭右开[itlow,itup); - 迭代器是双向迭代器,不支持
it + n,不能下标访问; - 红黑树插入删除迭代器失效范围很小,只有 erase 被删的迭代器失效。
七、面试高频问答
set 和 multiset 区别?map 和 multimap 区别?
set/map key 唯一;multiset/multimap 允许 key 重复;multimap 没有
operator[]。
map 的 operator \[\] 底层实现?为什么 multimap 不能有 \[\]?
\[\] 内部调用 insert;key 不存在插入默认 value,返回 value 引用;multimap 一个 key 对应多个 value,无法确定返回哪一个 value。
set 为什么不能修改迭代器指向的元素?
set 元素本身就是 key,修改 key 直接破坏红黑树的有序结构,导致容器未定义行为。
map/set 底层是什么?为什么不用普通二叉搜索树?
底层红黑树;普通 BST 有序插入会退化成链表最坏 O (N);红黑树自平衡,保证稳定\(O(logN)\)。
lower_bound upper_bound 作用?区间是什么?
lower_bound 返回第一个 >=key 迭代器;upper_bound 返回第一个 > key 迭代器;区间
[itlow,itup)左闭右开,用来遍历、删除区间元素。
map insert 和 \[\] 的区别?
insert:key 存在不会覆盖,返回 pair 标记是否插入成功;\[\]:key 不存在自动插入,key 存在直接修改 value。
八、力扣实战案例简单说明
- **138. 复制随机链表:**map 保存原节点与拷贝节点映射,快速处理 random 指针;
- **692. 前 K 个高频单词:**map 统计词频,结合 stable_sort/priority_queue 处理排序规则。
cpp
//复制随机链表
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;
}
// 原节点和拷⻉节点map kv存储
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;
}
}
cpp
//前 K 个高频单词
//解决思路1
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<string, int> countMap;
for (auto& e : words)
{
countMap[e]++;
}
vector<pair<string, int>> v(countMap.begin(), countMap.end());
// 仿函数控制降序
stable_sort(v.begin(), v.end(), Compare());
//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;
}
}
//解决思路2
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;
}
};
//解决思路3
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;
}
};