C++ STL之set/multiset详解:从使用到底层,再到面试八股
本文面向面试和日常开发,先讲调用,再讲原理,最后给口语化面试答案。
一、用法速查
1.1 头文件与初始化
#include <set>
#include <iostream>
using namespace std;
int main() {
// 默认构造(升序)
set<int> s1;
// 初始化列表(C++11)
set<int> s2{3, 1, 4, 1, 5, 9};
for (int x : s2) cout << x << " "; // 1 3 4 5 9(自动去重+排序)
cout << "\n";
// 拷贝构造
set<int> s3(s2);
set<int> s4 = s2;
// 迭代器区间构造
vector<int> v{2, 3, 1, 2};
set<int> s5(v.begin(), v.end()); // 1 2 3
// 降序
set<int, greater<int>> s6{3, 1, 4};
for (int x : s6) cout << x << " "; // 4 3 1
cout << "\n";
// 自定义比较器(lambda + C++17 CTAD)
auto cmp = [](int a, int b) { return a > b; };
set<int, decltype(cmp)> s7(cmp);
s7.insert({1, 2, 3});
for (int x : s7) cout << x << " "; // 3 2 1
cout << "\n";
}
1.2 插入:insert() 返回值
insert() 返回 pair<iterator, bool>——这是 set 区别于其他容器的标志性特征。second 指示是否真正插入了新元素。
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s{1, 2, 3};
auto [it, inserted] = s.insert(2); // C++17 结构化绑定
cout << inserted << "\n"; // 0,插入失败(已存在)
cout << *it << "\n"; // 2,指向已有元素
auto [it2, inserted2] = s.insert(0);
cout << inserted2 << "\n"; // 1,插入成功
cout << *it2 << "\n"; // 0
// multiset 没有 pair<iterator,bool>——总是成功
multiset<int> ms{1, 2, 2, 3};
auto mit = ms.insert(2); // 返回迭代器,无 bool
cout << *mit << "\n"; // 2
}
1.3 删除
#include <set>
#include <iostream>
using namespace std;
void print(const set<int>& s) {
for (int x : s) cout << x << " ";
cout << "\n";
}
int main() {
set<int> s{1, 2, 3, 4, 5};
s.erase(3); // 按值删除,返回删除个数(0或1)
print(s); // 1 2 4 5
auto it = s.find(2);
s.erase(it); // 按迭代器删除
print(s); // 1 4 5
// 区间删除 [first, last)
auto first = s.lower_bound(1);
auto last = s.upper_bound(4);
s.erase(first, last);
print(s); // 5
// multiset 注意:erase(val) 会删除所有相同值的元素
multiset<int> ms{1, 2, 2, 2, 3};
ms.erase(2); // 删掉所有 2
for (int x : ms) cout << x << " "; // 1 3
cout << "\n";
// 只删一个:用迭代器
multiset<int> ms2{1, 2, 2, 2, 3};
ms2.erase(ms2.find(2));
for (int x : ms2) cout << x << " "; // 1 2 2 3
cout << "\n";
}
1.4 查找
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s{10, 20, 30, 40, 50};
// find() ——返回迭代器
auto it = s.find(20);
if (it != s.end())
cout << "found: " << *it << "\n"; // found: 20
// count() ——set 中返回 0 或 1
cout << s.count(20) << "\n"; // 1
cout << s.count(99) << "\n"; // 0
// contains() ——C++20,语义最清晰
if (s.contains(30))
cout << "30 exists\n";
// multiset 中 count 可能 > 1
multiset<int> ms{1, 2, 2, 2, 3};
cout << ms.count(2) << "\n"; // 3
}
1.5 边界查询:lower_bound / upper_bound / equal_range
这是有序关联容器独有的优势,unordered_set 做不到。
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s{10, 20, 30, 40, 50};
// lower_bound(val):第一个 >= val 的元素
auto lb = s.lower_bound(25);
cout << *lb << "\n"; // 30
// upper_bound(val):第一个 > val 的元素
auto ub = s.upper_bound(30);
cout << *ub << "\n"; // 40
// equal_range(val):返回 [lb, ub) 区间
auto [lo, hi] = s.equal_range(30);
cout << *lo << " " << *hi << "\n"; // 30 40
// 区间查询:找出所有值在 [25, 45] 之间的元素
auto start = s.lower_bound(25);
auto end = s.upper_bound(45);
for (auto it = start; it != end; ++it)
cout << *it << " "; // 30 40 50
cout << "\n";
// multiset 中 equal_range 可能返回多元素区间
multiset<int> ms{1, 2, 2, 2, 3, 4};
auto [mlo, mhi] = ms.equal_range(2);
for (auto it = mlo; it != mhi; ++it)
cout << *it << " "; // 2 2 2
cout << "\n";
}
1.6 遍历与有序性证明
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s{5, 1, 4, 2, 3};
// 迭代器遍历——结果始终升序(中序遍历)
for (auto it = s.begin(); it != s.end(); ++it)
cout << *it << " "; // 1 2 3 4 5
cout << "\n";
// 范围 for
for (int x : s) cout << x << " "; // 1 2 3 4 5
cout << "\n";
// 反向遍历
for (auto it = s.rbegin(); it != s.rend(); ++it)
cout << *it << " "; // 5 4 3 2 1
cout << "\n";
}
1.7 常用函数速查
| 方法 | 含义 | 复杂度 |
|---|---|---|
s.insert(val) | 插入,返回 pair<iterator,bool> | O(logN) |
s.erase(val) | 按值删除,返回删除个数 | O(logN) |
s.erase(iter) | 按迭代器删除 | O(logN) 均摊 O(1) |
s.find(val) | 查找,返回迭代器 | O(logN) |
s.count(val) | set 返回 0/1,multiset 返回实际个数 | O(logN) |
s.contains(val) | C++20,存在返回 true | O(logN) |
s.lower_bound(val) | 第一个 ≥ val 的迭代器 | O(logN) |
s.upper_bound(val) | 第一个 > val 的迭代器 | O(logN) |
s.equal_range(val) | 返回 [lower_bound, upper_bound) | O(logN) |
s.size() | 元素个数 | O(1) |
s.empty() | 判空 | O(1) |
s.clear() | 清空所有元素 | O(N) |
1.8 set vs multiset 对比
#include <set>
#include <iostream>
using namespace std;
int main() {
// set:唯一 key
set<int> s{1, 2, 2, 3};
cout << s.size() << "\n"; // 3(去重了)
// multiset:允许重复 key
multiset<int> ms{1, 2, 2, 3};
cout << ms.size() << "\n"; // 4(保留重复)
// insert 返回值不同
auto [it, ok] = s.insert(2); // ok == false
auto mit = ms.insert(2); // 直接返回迭代器,无 bool
cout << *mit << "\n"; // 2
// erase(val) 行为不同
ms.erase(2); // 删掉所有 2
cout << ms.size() << "\n"; // 2(只剩 1 和 3)
// equal_range 在 multiset 中更实用
multiset<int> ms2{1, 2, 2, 2, 3, 4};
auto [lo, hi] = ms2.equal_range(2);
cout << distance(lo, hi) << "\n"; // 3(三个 2)
}
二、底层原理
2.1 红黑树:从 map 到 set 是同一棵树的复用
set 和 map 的底层是同一套红黑树模板——gcc 中叫 _Rb_tree。set<K> 实际上就是一个 _Rb_tree<K, K, _Identity<K>, Compare>,而 map<K,V> 是 _Rb_tree<K, pair<const K, V>, _Select1st<pair<const K,V>>, Compare>。
两者的差异只体现在节点存储的数据类型上:set 的节点只存 key,map 的节点存 pair<const K, V>。树的搜索、插入、旋转逻辑完全共用同一份代码。
红黑树为 set 带来的三个核心特性:
- 有序遍历 O(N):中序遍历左→根→右得到升序序列
- 查找/插入/删除 O(logN):树高 2log₂(n+1),不需要遍历所有元素
- 迭代器不因插入失效:指针连接的结构,插入新节点不影响已有节点的地址
2.2 红黑树五条性质
红黑树不是一棵任意的二叉搜索树,它靠五条规则来保证平衡性:
- 每个节点要么红色要么黑色
- 根节点永远是黑色
- 每个叶子节点(NIL)是黑色
- 红色节点的子节点必须是黑色(不能有连续红色)
- 从任一节点到其所有后代叶子节点的路径上,黑色节点数量相同
这五条规则保证了最长路径不超过最短路径的两倍。证明:最短路径是全黑路径,最长路径是红黑交替路径(规则 4),且黑色数量相同(规则 5),所以最长路径 ≤ 2 × 最短路径。
2.3 插入平衡的旋转机制(简略)
插入新节点默认是红色(避免立刻打破规则 5)。如果父节点也是红色(打破规则 4),就需要修复:
- 情况 1:叔叔节点是红色——变色即可,把父节点和叔叔节点变黑、祖父节点变红,问题向上传递
- 情况 2:叔叔节点是黑色,且新节点、父节点、祖父节点呈"之"字形(LR 或 RL)——先左旋或右旋变成直线型,再按情况 3 处理
- 情况 3:叔叔节点是黑色,且呈直线型(LL 或 RR)——对祖父节点做一次旋转,然后变色
删除也类似,但更复杂。关键认知:红黑树的插入最多需要 2 次旋转,删除最多需要 3 次旋转,这就是它比 AVL 树写性能更好的原因(AVL 最坏需要 O(logN) 次旋转)。
2.4 红黑树插入平衡流程(Mermaid)
2.5 lower_bound / upper_bound / equal_range 精确语义
这三个函数是面试中容易混淆的点,理解它们的精确语义才能正确使用:
lower_bound(val):返回第一个>= val的元素的迭代器。如果所有元素都< val,返回end()upper_bound(val):返回第一个> val的元素的迭代器。如果所有元素都<= val,返回end()equal_range(val):返回pair(lower_bound(val), upper_bound(val)),即一个左闭右开区间[lb, ub)
对于 set,由于元素唯一,equal_range 返回的区间长度要么是 0(不存在),要么是 1(存在)。但在 multiset 中,这个区间可能包含多个相同值的元素——这也是 equal_range 最有价值的场景。
// 区间查询:找出所有 > 20 且 < 50 的元素
auto lo = s.lower_bound(20); // 第一个 ≥20 的元素
auto hi = s.upper_bound(49); // 第一个 >49 的元素(即 ≥50 的元素)
for (auto it = lo; it != hi; ++it) { /* 遍历 20 ≤ x ≤ 49 */ }
2.6 extract(C++17)与 merge(C++17)
extract —— 零拷贝节点句柄
C++17 引入的 extract 可以从 set 中"拔"出一个节点,得到一个独立于树的节点句柄(node handle)。节点句柄中的 key 不再是 const 的,你可以修改它然后重新插入——这是修改 set 中 key 的标准做法。
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s{1, 2, 3, 4, 5};
// extract 按值
auto nh = s.extract(3); // 拔出 key=3 的节点
nh.value() = 99; // 修改 key(节点已脱离树,不再 const)
s.insert(move(nh)); // 重新插入
for (int x : s) cout << x << " "; // 1 2 4 5 99
cout << "\n";
// extract 按迭代器
auto it = s.find(4);
auto nh2 = s.extract(it);
nh2.value() = 100;
s.insert(move(nh2));
// extract 不触发任何内存分配或拷贝——只是指针操作
}
merge —— 合并两个 set
merge 把另一个 set(或 multiset)中的元素转移到当前 set,源容器中的成功转移的元素会被删除。当遇到重复 key 时,该元素留在源容器中不被转移。
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s1{1, 2, 3};
set<int> s2{3, 4, 5};
s1.merge(s2); // 把 s2 的元素转移到 s1
for (int x : s1) cout << x << " "; // 1 2 3 4 5
cout << "\n";
for (int x : s2) cout << x << " "; // 3(冲突的 3 留在 s2)
cout << "\n";
}
2.7 为什么 set 的迭代器不失效?
这是红黑树结构带来的天然优势。set 的每个节点是以指针连接的树节点,插一个新节点只是在树上挂一片新的叶子——已有的节点地址不会改变。这与 vector 连续存储、扩容导致全部迭代器失效形成鲜明对比。
具体来说:
- 插入新元素:所有已有迭代器保持有效
- 删除某个元素:只有被删除元素的迭代器失效,其他全部有效
- 不触发任何 rehash 或内存重分配——红黑树永远在它已经分配的内存上调整指针
2.8 节点内存结构
set 的红黑树节点和 map 同源,只不过不存 value,只存 key:
┌─────────────────────────────────────────────────┐
│ _Rb_tree_node │
│ ┌─────────┬─────────┬───────────┬──────┬───────┐│
│ │ parent* │ left* │ right* │ color│ Key ││
│ │ (父节点) │ (左子) │ (右子) │(红/黑)│ 数据 ││
│ └─────────┴─────────┴───────────┴──────┴───────┘│
└─────────────────────────────────────────────────┘
x64 下三个指针各 8 字节,颜色枚举 4 字节(对齐后 8 字节),加上 key 本身。set<int> 一个节点约 32 字节,有效载荷 4 字节,空间利用率约 12.5%。
三、面试题 + 口语化答案
Q1:红黑树和 AVL 树比,为什么 STL 选红黑树?
“核心原因是写性能。AVL 树更严格平衡(左右子树高度差不超过 1),查找比红黑树略快,但插入和删除需要更多旋转——AVL 最坏 O(logN) 次旋转,红黑树插入最多 2 次旋转、删除最多 3 次。对于 STL 这种通用容器,插入删除和查找一样频繁,红黑树的写性能优势是决定性的。另外红黑树的高度是 2log₂(n+1),查找 O(logN) 和 AVL 的 1.44log₂(n) 差距在数据量不大时几乎没区别。面试官问这个,你从’旋转次数’和’高度常数因子’两个角度答就行。”
Q2:set 和 unordered_set 怎么选?
“看需求。需要有序遍历、范围查询、或者对迭代器稳定性有要求,选 set。只做纯粹的增删查、不关心顺序,选 unordered_set。但注意两点:第一,数据量小的时候(< 100 个),set 的 O(logN) 未必比 unordered_set 的 O(1) 差,因为哈希表有建表开销。第二,unordered_set 在哈希冲突严重时退化成 O(N),而且 rehash 会让所有迭代器失效——如果你在遍历过程中插入元素,用 set 更安全。一句话:有顺序需求或有迭代器稳定性要求,set;其他情况看场景选。”
Q3:insert() 的返回值怎么用?C++17 结构化绑定怎么写?
“insert() 返回 pair<iterator, bool>——second 表示是否真正插入了新元素,first 指向已存在或刚插入的元素。C++17 结构化绑定可以这样写:auto [it, inserted] = s.insert(val); if (!inserted) cout << *it << "already exists";。这在去重场景特别有用——你可以同时知道元素是否存在、以及获取它的迭代器,不需要先 find 再 insert,省了一次 O(logN) 查找。”
Q4:equal_range 的典型用途是什么?
“equal_range 最常见的场景有两个。第一,在 multiset 中找到所有相同值的元素——equal_range 返回的区间正好包含所有重复元素,用 distance(lo, hi) 可以知道有多少个。第二,做区间查询——比如找出所有 score 在 60 到 80 之间的元素,用 lower_bound(60) 和 upper_bound(80) 组合。面试官如果问’怎么找出 set 中某个范围内的所有元素’,你的答案就是 lower_bound + upper_bound。”
Q5:为什么 set 的 key 不可修改?
“因为修改 key 会破坏红黑树的有序性。set 的底层是红黑树——左子树都小于父节点,右子树都大于父节点。如果你拿到一个迭代器,然后改了 key,整棵树可能立即不再是合法的二叉搜索树。所以 set 的迭代器解引用返回的是 const T&,禁止通过迭代器修改元素。老办法是先删后插,C++17 之后用 extract——把节点拔出来,key 就不受 const 限制了,改完再塞回去。”
Q6:extract 的原理和场景是什么?
“extract 的原理是把红黑树上的一个节点摘下来,返回一个节点句柄。摘下来的节点完全脱离了树——key 不再 const,不会触发任何构造函数或析构函数,纯粹是指针操作。最典型的场景就是修改 set 中的 key:auto nh = s.extract(old_val); nh.value() = new_val; s.insert(move(nh))。比先 erase 再 insert 快在哪?省了一次元素析构和一次构造,对大对象有明显优势。另外 extract 也可以用来零拷贝地在两个 set 之间转移元素——node handle 的移动构造是 O(1) 的。”
Q7:set 和 multiset 在迭代器和删除行为上有什么差异?
“两个核心差异。第一,insert() 的返回值不同——set 返回 pair<iterator, bool> 告诉你是否重复,multiset 直接返回迭代器(因为总是成功插入)。第二,erase(val) 的行为不同——set 删除最多一个元素,multiset 会删除所有等于 val 的元素。如果想在 multiset 中只删一个,必须用迭代器版本:ms.erase(ms.find(val))。还有一个差异体现在 count()——set 中 count 返回值只能是 0 或 1,multiset 返回实际出现次数。equal_range 也一样:set 中区间长度 ≤ 1,multiset 中可能很长。”
Q8:merge 操作的时间复杂度是多少?
“merge 的复杂度是 N * log(size() + N),其中 N 是源容器的大小。注意 merge 不是把源容器的元素一个个 insert——它利用红黑树的节点句柄特性,直接把源容器的节点挂接到目标树上,避免了元素级别的拷贝。对于冲突的 key,对应的节点留在源容器不被转移。所以 merge 的代价主要来自在目标树上查找插入位置,每个节点 O(log(total)),但没有元素构造或析构的开销。”
一句话总结:set 和 map 共用同一棵红黑树模板,区别仅在于节点存储的数据类型——理解 insert() 的 pair 返回值、lower_bound/upper_bound 的精确语义、以及 extract/merge 在 C++17 中的零拷贝能力,就是掌握了 set 的全部面试考点。

415

被折叠的 条评论
为什么被折叠?



