1. 从序列式容器到关联式容器
在 STL 的庞大体系中,我们最先接触的往往是 vector、list、deque、array 以及 string 这类容器。它们有一个共同的特征:数据在逻辑结构上呈现为一条线 。 比如一个 vector<int> 存放了 10, 20, 30, 40,每个元素凭借其存储位置就能确定彼此的前后关系;即使我们交换其中两个元素的位置,比如变成 10, 30, 20, 40,它仍然是一个有效的线性结构。 这类容器被 STL 归类为序列式容器(Sequence Container),它们最关心的问题是"元素放在哪里",也就是位置关系。
但在真实的软件开发中,我们经常遇到另一类需求:判断一个数字是否曾经出现过、统计一篇英文文章中每个单词出现的频率、或者根据学生编号快速查找对应的成绩。在这些场景里,我们并不在意数据在内存中如何排列,而是更关心数据之间是否存在某种映射关系 ------比如"编号 1001 对应成绩 95"。这种"键值对"式的关联关系,是序列式容器难以高效表达的。为此,STL 专门提供了另一大类容器------关联式容器(Associative Container) ,其中最为核心的就是 map、set 以及它们的无序版本 unordered_map 和 unordered_set。前两者(map / set)的底层实现是一棵红黑树,后两者则基于哈希表。本章我们先深入探讨 set,因为它是理解"键值搜索"场景的基石,而 map 则是在 set 的基础上扩展出了"键值对"的映射关系。
2. set 的基本概念与核心特性
set 是 STL 中一种非常纯粹的关联式容器,它的设计目标就是存储一组互不相同的元素,并且能够按照某种顺序自动维护这些元素 。这种"自动排序"加上"元素唯一"的特性,使得 set 在很多需要去重和有序遍历的场景中显得格外顺手。
举个例子,如果我们向一个 set<int> 中依次插入 10、10、20,那么最终容器里只会保留一个 10 和一个 20------第二次插入的 10 会被忽略。与此同时,当我们用迭代器遍历这个 set 时,输出的顺序一定是 10, 20,而不是插入时的先后顺序。这种有序性并非巧合,而是直接源于 set 的底层数据结构------红黑树 。红黑树本质是一棵平衡的二叉搜索树,它满足"左子树所有节点的值小于根节点,右子树所有节点的值大于根节点"的性质。当我们对这样一棵树进行中序遍历(左 → 根 → 右)时,得到的结果自然就是升序序列。因此,set 的迭代器遍历是有序的,而且增删查操作都能在 O(logN) 的时间复杂度内完成,这比线性容器的 O(N) 查找要高效得多。
3. 为什么底层选择红黑树而非其他结构?
这是一个非常经典的问题,也是很多初学者容易困惑的地方。既然 set 只需要存储唯一元素并支持快速查找,为什么不用哈希表(平均 O(1) 查找)?又为什么不用 vector 或链表?
我们来逐一比较:vector 的查找需要遍历整个数组,复杂度为 O(N);如果要在中间插入元素,还需要移动后续所有元素,代价很高。 链表虽然插入和删除很快(O(1)),但查找依然是 O(N),因为链表没有随机访问能力,也无法利用有序性进行二分查找。哈希表的确能提供平均 O(1) 的查找速度,但它有一个致命的弱点------无法保证元素的有序性 。例如,向哈希表中插入 5、2、8,遍历时得到的顺序可能是 8、5、2,这完全取决于哈希函数和冲突解决策略。
而 set 的设计目标之一就是"有序遍历",因此哈希表并不符合要求。红黑树恰好在这三者之间取得了完美的平衡:它既保证了 O(logN) 的查找、插入和删除效率,又天然维持了元素的有序性,同时它的树高被严格控制在 logN 级别,避免了二叉搜索树在最坏情况下退化为链表的窘境。正是这些综合优势,让 STL 最终选择红黑树作为 set 和 map 的底层实现。
红黑树
| 功能 | 复杂度 |
|---|---|
| 查找 | O(logN) |
| 插入 | O(logN) |
| 删除 | O(logN) |
| 有序遍历 | 支持 |
4. set 的模板参数解析
翻开 set 的声明,我们会看到这样的模板定义:
cpp
template < class T,
class Compare = less<T>,
class Alloc = allocator<T> >
class set;
虽然有三个模板参数,但绝大多数情况下我们只需要关注前两个。第一个参数 T 非常直观------它表示 set 中存储的元素类型,比如 set<int> 就是存放整数的集合。
第二个参数 Compare 才是理解 set 排序机制的关键。它默认是 less<T>,这是一个函数对象(仿函数),其内部重载了 operator(),默认行为是使用 < 运算符比较两个 T 类型的值。红黑树在构建和插入节点时,正是依靠这个比较器来决定新节点应该放在左子树还是右子树。当使用 less<T> 时,树的结构遵循"左小右大",中序遍历得到升序;如果我们换成 greater<T>,比较逻辑变成 a > b,树的结构就会逆转,中序遍历自然得到降序。
所以,想要让 set 降序排列,只需这样声明:
cpp
set<int, greater<int>> s;
至于第三个参数 Alloc,它通常用于定制内存分配策略,在一般应用中极少改动,我们暂且略过。理解 Compare 的作用,其实就是在理解红黑树的比较规则是如何影响整个容器的行为,这也为你后续学习自定义类型如何放入 set(需要重载 < 运算符或提供自定义仿函数)打下了基础。
五、set 的构造与迭代器:遍历有序,但只读
5.1 set 的构造方式
set 的构造方式和我们之前学过的 vector、list 非常相似,STL 容器在设计上保持了高度的一致性,这让我们学习新容器的成本大大降低。
最常用的是默认构造 ,创建一个空的 set,后续再通过 insert 填入数据:
set<int> s;
迭代器区间构造 则体现了 STL 容器之间通过迭代器解耦连接的设计思想。你可以从一个 vector 中取出一段迭代器范围,直接用来构造一个 set,从而实现"去重 + 排序"一步到位:
cpp
vector<int> v = {1, 2, 3, 4, 2, 3};
set<int> s(v.begin(), v.end());
vector 提供 begin() 和 end(),set 的构造函数接收两个迭代器作为 first 和 last,逐个插入元素。至于这组迭代器来自 vector、list 还是原生数组,set 并不关心------这就是迭代器作为"容器之间的通用桥梁"的意义。
C++11 之后还支持了初始化列表构造,写法更加简洁直观:
cpp
set<int> s = {5, 2, 8, 2, 1};
这里插入了两次 2,但最终 s 中只会保留一个 2,因为 set 的核心性质就是键值唯一。遍历这个 s,你会看到 1 2 5 8------已经自动排好序了。
课件中还提到了拷贝构造,用法跟其他容器完全一样,这里不再赘述。
5.2 迭代器遍历与中序
set 支持正向迭代器和反向迭代器,这意味着你可以用 begin()/end() 正向遍历,也可以用 rbegin()/rend() 反向遍历。同时,支持迭代器也就意味着支持范围 for 循环:
cpp
set<int> s = {5, 2, 8, 1};
for (auto it = s.begin(); it != s.end(); ++it) {
cout << *it << " ";
}
// 输出:1 2 5 8
关键问题来了:为什么输出是
1 2 5 8,而不是插入顺序5 2 8 1?答案藏在红黑树的结构里。当我们依次插入
5、2、8、1时,红黑树内部会按照二叉搜索树的规则组织节点:
插入
5作为根节点插入
2,比5小,放到左子树插入
8,比5大,放到右子树插入
1,比5小,往左走到2,比2小,放到2的左子树
最终树的结构大致如下(忽略红黑树的颜色平衡细节):
5 / \ 2 8 / 1
当我们用迭代器遍历 set 时,底层走的是红黑树的中序遍历 ------先左子树,再根节点,最后右子树。所以遍历顺序是:1 → 2 → 5 → 8,天然升序。
课件里有一句话非常精炼地概括了这一点:
set 底层用红黑树实现,迭代器遍历走搜索树中序,因此元素有序。
5.3 迭代器为什么不能修改数据?
这是一个极其重要且容易踩坑的问题。我们来看这段代码:
cpp
set<int> s = {1, 2, 3};
auto it = s.begin();
*it = 10; // 编译报错
编译器会直接报错,提示无法给常量赋值。为什么 set 的迭代器不允许修改元素?
根本原因在于:修改 set 中的元素,本质上就是在修改红黑树节点的键值(key)。而红黑树的整个结构------哪个节点在左子树、哪个在右子树------完全依赖于这些键值的大小关系。 一旦你随意修改了某个节点的值,整棵树的二叉搜索性质就可能被破坏。
举个具体的例子。假设红黑树目前的结构是:
5 / \ 3 8
如果我们把根节点
5修改成100,树就变成了:
100 / \ 3 8
现在问题来了:100 的左子树里放着 3 和 8,它们都比 100 小,这符合"左子树 < 根节点"的规则,看起来似乎没问题。但你再想一下------节点 8 原本在 5 的右子树,现在却成了 100 的左子树,而 100 的右子树是空的。这种结构混乱会导致后续所有的查找、插入、删除操作全部失效。
为了避免这种灾难,STL 的设计者直接把 set 的迭代器设计成了只读模式。无论是 iterator 还是 const_iterator,解引用后得到的都是 const T& 引用,从语法层面彻底禁止了修改。
这里可以提前跟
map做一个对比,帮助你建立清晰的概念边界:
set中只存键(key),修改键会破坏树结构,所以键不可改
map中存的是键值对(key-value),修改值(value)不影响树结构,所以值可以改,但键(key)同样不可改
这个区别会在后面讲 map 时反复体现,现在先留个印象。
六、插入操作:insert 的返回值为什么是 pair?
6.1 基本插入行为
set 的插入接口很直观,支持单个元素插入、初始化列表插入、以及迭代器区间插入:
对于单个元素的 insert,由于 set 保证了键值唯一,第二次插入相同的值会被静默忽略。比如上面的代码中,2 和 8 在初始化列表插入时已经存在于 set 中,所以实际插入的只有 3 和 9。
把插入和遍历放在一起演示:
cpp
cpp
#include <iostream>
#include <set>
using namespace std;
int main() {
// 去重 + 升序排序
set<int> s;
s.insert(5);
s.insert(2);
s.insert(7);
s.insert(5); // 重复,插入失败
auto it = s.begin();
while (it != s.end()) {
// *it = 1; // 编译错误:不能给常量赋值
cout << *it << " ";
++it;
}
cout << endl;
// 插入 initializer_list,已存在的值插入失败
s.insert({2, 8, 3, 9});
for (auto e : s) {
cout << e << " ";
}
cout << endl;
set<string> strset = {"sort", "insert", "add"};
for (auto& e : strset) {
cout << e << " "; // 按 ASCII 码字典序输出
}
cout << endl;
return 0;
}
运行结果:
2 5 7 2 3 5 7 8 9 add insert sort
这个样例同时演示了三个关键点:去重(重复的 5 被忽略)、排序(升序输出)、以及迭代器只读(注释掉的赋值语句编译失败) 。最后对 string 类型的 set 进行遍历,输出按字典序排列,这验证了 set 的比较器默认使用 < 来比较元素。
6.2 insert 的返回值
接下来是重点:insert 单个元素的返回值类型是 pair<iterator, bool>。很多初学者第一次看到这个时会困惑------为什么不直接返回 bool 表示成功或失败?
我们来看两种场景。
场景一:插入成功
set<int> s;
auto ret = s.insert(10);
此时 ret.first 是指向新插入元素 10 的迭代器,ret.second 是 true。
场景二:插入失败(元素已存在)
s.insert(10); // 第一次插入,成功
auto ret = s.insert(10); // 第二次插入,失败
此时 ret.first 指向已经存在于 set 中的那个 10,ret.second 是 false。
所以 pair<iterator, bool> 的设计意义在于:一次插入操作,同时告诉你两个信息------元素在哪里(迭代器),以及插入是否成功(bool)。如果插入失败,你还可以通过返回的迭代器拿到已存在的那个元素,做进一步处理。这种"状态 + 结果"打包返回的设计,在 STL 中屡见不鲜。
使用时可以这样判断:x'x
cpp
auto ret = s.insert(10);
if (ret.second) {
cout << "插入成功,元素位于:" << *ret.first << endl;
} else {
cout << "插入失败,元素已存在:" << *ret.first << endl;
}
课件中把这一点放在了 map 的 operator[] 实现中重点展开,因为 map 的 [] 正是利用 insert 的这个特性来实现"查找 + 插入 + 修改"三合一的。我们现在先把 set 的接口吃透,后面讲 map 的时候就能顺理成章地理解。
七、查找操作:优先用容器自己的 find
7.1 set::find 与算法库 std::find
set 提供了 find 成员函数,用于快速查找某个键是否存在:
cpp
set<int> s = {4, 2, 7, 8, 5, 9};
auto pos = s.find(7);
if (pos != s.end()) {
cout << "找到了:" << *pos << endl;
}
set::find 利用红黑树的搜索特性,时间复杂度是 O(log N)。
但很多初学者容易犯一个错误------使用算法库中的 std::find:
cpp
#include <algorithm>
auto pos = find(s.begin(), s.end(), 7);
这个 find 是泛型算法,它不知道底层是红黑树,只能从 begin 到 end 一个一个地遍历比较,时间复杂度是 O(N)。
两种写法:
cpp
// 算法库的查找 O(N)
auto pos1 = find(s.begin(), s.end(), x);
// set 自身实现的查找 O(logN)
auto pos2 = s.find(x);
务必记住:对于关联式容器,永远优先使用容器自带的 find 成员函数。 这是性能和正确性兼得的最佳实践。
7.2 count 也可以用来查找
set 还提供了 count 成员函数,返回某个值的个数。由于 set 不允许重复,返回值只能是 0 或 1,所以它也可以用来判断元素是否存在:
cpp
if (s.count(x)) {
cout << x << " 存在" << endl;
} else {
cout << x << " 不存在" << endl;
}
count 的时间复杂度也是 O(log N),用法比 find 更简洁------如果你只需要知道"在不在",而不需要获取迭代器做后续操作,用 count 更省事。
样例完整展示了 find、erase 和 count 的配合使用:
cpp
#include <iostream>
#include <set>
using namespace std;
int main() {
set<int> s = {4, 2, 7, 2, 8, 5, 9};
for (auto e : s) {
cout << e << " ";
}
cout << endl;
// 删除最小值
s.erase(s.begin());
for (auto e : s) {
cout << e << " ";
}
cout << endl;
// 直接删除指定值 x
int x;
cin >> x;
int num = s.erase(x);
if (num == 0) {
cout << x << "不存在!" << endl;
}
for (auto e : s) {
cout << e << " ";
}
cout << endl;
// 先用 find 查找,再用迭代器删除
cin >> x;
auto pos = s.find(x);
if (pos != s.end()) {
s.erase(pos);
} else {
cout << x << "不存在!" << endl;
}
for (auto e : s) {
cout << e << " ";
}
cout << endl;
// 利用 count 间接实现快速查找
cin >> x;
if (s.count(x)) {
cout << x << "在!" << endl;
} else {
cout << x << "不存在!" << endl;
}
return 0;
}
这个样例覆盖了三种删除方式(删除迭代器位置、删除指定值、删除区间)和两种查找方式(find 和 count),建议你自己运行一遍,观察每一步的输出,对 set 的行为建立直观感受。
八、区间查找:lower_bound 与 upper_bound
8.1 这两个接口是做什么的?
lower_bound 和 upper_bound 是 set 提供的两个区间查找接口,它们经常配合使用来处理一段连续的有序区间。
-
lower_bound(val):返回第一个 大于等于val的元素的迭代器 -
upper_bound(val):返回第一个 大于val的元素的迭代器
两者结合,可以精确锁定一个左闭右开的区间 [lower_bound(val1), upper_bound(val2))。
8.2 典型使用场景:删除一个值区间
课件中有一个非常经典的例子。假设 set 中存放了 10, 20, 30, 40, 50, 60, 70, 80, 90,现在要删除所有在 [30, 60] 闭区间内的元素------也就是删掉 30, 40, 50, 60。
如果用遍历加判断的方式,代码啰嗦且效率低。用 lower_bound 和 upper_bound 可以一行定位区间:
cpp
#include <iostream>
#include <set>
using namespace std;
int main() {
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;
// lower_bound(30) 返回第一个 >= 30 的位置 → 指向 30
auto itlow = myset.lower_bound(30);
// upper_bound(60) 返回第一个 > 60 的位置 → 指向 70
auto itup = myset.upper_bound(60);
// 删除 [itlow, itup) 区间,即 30 40 50 60
myset.erase(itlow, itup);
for (auto e : myset) {
cout << e << " ";
}
cout << endl;
return 0;
}
运行结果:
10 20 30 40 50 60 70 80 90 10 20 70 80 90
这里的关键在于:erase 接收的是左闭右开的迭代器区间 [first, last)。lower_bound(30) 恰好指向 30(包含),upper_bound(60) 指向 70(不包含 70,但 60 在区间内)。所以 erase(itlow, itup) 正好删掉了 30, 40, 50, 60。
如果需求改成删除 (30, 60) 开区间(不包含 30 和 60),那就用 upper_bound(30) 作为起点,lower_bound(60) 作为终点------起点不包含 30,终点包含 60 但 lower_bound(60) 指向 60 本身,区间 [40, 60) 就只包含 40 和 50。灵活运用这两个接口,可以精确控制区间边界。
这两个函数的底层也是红黑树查找,时间复杂度 O(log N),比从头遍历要高效得多。
九、multiset:允许重复的 set
multiset 和 set 几乎一模一样,唯一的区别是:multiset 允许键值冗余,即多个相同的元素可以共存。
这个看似微小的差异,导致了一系列行为上的不同,需要你特别留意。
课件中给出了一个完整的 multiset 使用样例:
cpp
#include <iostream>
#include <set>
using namespace std;
int main() {
// multiset 排序但不去重
multiset<int> s = {4, 2, 7, 2, 4, 8, 4, 5, 4, 9};
auto it = s.begin();
while (it != s.end()) {
cout << *it << " ";
++it;
}
cout << endl;
// find 查找中序的第一个
int x;
cin >> x;
auto pos = s.find(x);
while (pos != s.end() && *pos == x) {
cout << *pos << " ";
++pos;
}
cout << endl;
// count 返回实际个数
cout << s.count(x) << endl;
// erase 按值删除会删除所有匹配的元素
s.erase(x);
for (auto e : s) {
cout << e << " ";
}
cout << endl;
return 0;
}
我们来逐一拆解这些差异:
第一,insert 永远成功。 向 multiset 中插入一个已经存在的值,不会像 set 那样被忽略,而是会新增一个副本。所以 multiset 中的元素是排序的,但不去重。
第二,find 返回中序的第一个匹配位置。 当有多个相同的值时,find 返回的是中序遍历中第一个等于该值的位置。如果你想遍历所有相同的值,需要从 find 返回的位置开始往后走,直到遇到不同的值为止(如样例中的 while 循环所示)。
第三,count 返回实际个数。 在 set 中 count 只能返回 0 或 1,但在 multiset 中它会返回匹配元素的实际数量。
第四,erase 按值删除会删除所有匹配的元素。 s.erase(x) 会把所有值为 x 的元素全部删掉,返回删除的个数。这一点和 set 的"最多删一个"完全不同。
multiset 的使用场景是:你需要保留所有数据(不去重),同时又希望它们始终保持有序。比如记录一组考试成绩,允许并列分数存在,但需要按分数从低到高遍历。
十、两道力扣题:set 如何让复杂问题变得简单
最后用两道力扣题展示了 set 的实战价值,我们简要拆解一下,让你感受"用对工具"的力量。
两个数组的交集
题目要求返回两个数组的交集,且结果中每个元素只能出现一次。
set 的解法极其简洁:
cpp
cpp
#include <iostream>
#include <set>
using namespace std;
int main() {
// multiset 排序但不去重
multiset<int> s = {4, 2, 7, 2, 4, 8, 4, 5, 4, 9};
auto it = s.begin();
while (it != s.end()) {
cout << *it << " ";
++it;
}
cout << endl;
// find 查找中序的第一个
int x;
cin >> x;
auto pos = s.find(x);
while (pos != s.end() && *pos == x) {
cout << *pos << " ";
++pos;
}
cout << endl;
// count 返回实际个数
cout << s.count(x) << endl;
// erase 按值删除会删除所有匹配的元素
s.erase(x);
for (auto e : s) {
cout << e << " ";
}
cout << endl;
return 0;
}
两个 set 各自完成了"去重 + 排序",然后双指针同步遍历,值相等就是交集。这个解法的优雅之处在于:你不用手动排序、不用手动去重、不用考虑重复元素会多次加入结果------set 把底层脏活累活全包了。
142. 环形链表 II
这道题的常规解法需要快慢指针加数学推导,证明过程比较绕。用 set 来解,思路直接降维到"记录已访问节点":
cpp
class Solution {
public:
ListNode* detectCycle(ListNode* head) {
set<ListNode*> s;
ListNode* cur = head;
while (cur) {
auto ret = s.insert(cur);
if (!ret.second) {
return cur; // 插入失败,说明 cur 之前访问过,这就是环的入口
}
cur = cur->next;
}
return nullptr;
}
};
遍历链表,把每个节点的地址存入 set。如果某个节点已经存在,说明链表有环,而且这个节点就是环的入口。这就是 set 的"去重 + 快速查找"能力在算法题中的降维打击------把复杂问题变成了"查重"问题。
十一、小结
这一部分我们完整覆盖了 set 的接口使用层面:构造方式、迭代器遍历(以及为什么不能修改)、插入(重点理解 pair<iterator, bool> 的设计意图)、查找(优先用容器自带的 find)、删除(三种形式的适用场景)、以及 lower_bound/upper_bound 配合处理区间的技巧。最后通过 multiset 的对比和两道力扣题,帮你建立起"什么场景用什么工具"的判断力。
理解 set 的接口设计,本质上是在理解一个原则:底层数据结构(红黑树)的特性,决定了上层接口的行为边界。 红黑树有序,所以 set 有序;红黑树靠比较规则维护结构,所以 set 的键不能改;红黑树查找 O(log N),所以 set::find 比 std::find 快得多。把这条线索理清,set 就不再是一个需要死记硬背接口的容器,而是一个你可以自如运用的工具。
