C++ set容器详解:从红黑树原理到高效去重与有序集合实战
1. 从“容器”到“集合”:为什么你需要了解C++的set
如果你刚开始接触C++,或者已经写过一些代码,用过
vector
、
array
,那你对“容器”这个概念应该不陌生。它们就像一个个盒子,帮你把数据装起来,方便你存取。但当你需要处理一些特殊需求时,比如“自动去重”、“快速判断某个元素是否存在”、“按顺序遍历所有元素”,普通的“盒子”就显得有点力不从心了。这时候,你就需要认识一下C++标准库里的“集合”容器——
std::set
。
我第一次真正觉得
set
好用,是在处理一个用户标签系统的时候。后台会收到大量用户打上的标签,同一个用户可能重复提交相同的标签,我需要把这些标签整理成一个唯一的、有序的列表,方便后续的统计和展示。最开始我用的是
vector
,每次插入新标签前,都要遍历一遍整个列表,用
find
函数检查是否已经存在。当用户量上来,标签数量达到几万甚至几十万时,这个遍历操作就成了性能瓶颈,程序响应慢得让人抓狂。后来换成了
set
,代码简洁到只需要一句
tags.insert(newTag)
,不仅自动去重,而且内部元素始终是有序的,遍历输出时直接就是排好序的,性能更是提升了几个数量级。那一刻我才明白,选对工具,事半功倍。
简单来说,
std::set
是一个关联容器,它存储的是唯一的“键”(key),并且这些键会按照特定的顺序(默认是升序)自动排列。它的底层通常由红黑树(一种自平衡的二叉查找树)实现,这保证了插入、删除和查找操作的平均时间复杂度都是O(log n),对于需要频繁进行“存在性检查”和“有序遍历”的场景,效率远高于顺序容器(如
vector
、
list
的O(n)查找)。
这篇文章,我会从一个实际使用者的角度,带你彻底搞懂
set
。我们不只讲语法,更会深入它“为什么”要这么设计,以及在实际项目中“怎么用”才能避开那些常见的坑。无论你是正在准备面试,被“C++八股文”里关于STL的问题困扰,还是在实际开发中遇到了需要高效管理唯一集合的需求,这篇超详细的指南都能给你直接的帮助。
2. 核心特性与底层原理:它凭什么这么“聪明”?
在开始敲代码之前,我们必须先理解
set
的设计哲学。它不是一个简单的“数组”或“链表”,而是一个高度结构化、有自己规则的“智能集合”。它的核心特性决定了它的适用场景和性能表现。
2.1 三大核心特性:唯一、有序、不可直接修改
-
元素唯一性
:这是
set最直观的特性。在同一个set中,不允许存在两个值相同的元素。当你尝试插入一个已经存在的值时,插入操作会被忽略(不会报错,但插入失败)。这省去了我们手动去重的麻烦。 -
自动排序
:
set中的元素并非按照插入顺序存储,而是会根据元素的“比较规则”自动进行排序。默认情况下,它使用std::less(即<运算符)进行升序排列。你也可以在定义set时,传入自定义的比较函数或函数对象来指定排序规则。 -
键即值,不可直接修改
:在
set中,元素的值同时就是它的“键”(key)。这意味着你不能像修改vector里某个位置的值那样,直接通过迭代器去修改set中的元素(例如*it = newValue;)。因为修改元素可能会破坏set内部赖以维持有序性的红黑树结构。如果你需要修改一个元素,通常的做法是先删除旧元素,再插入新元素。
2.2 底层数据结构:红黑树简析
为什么
set
能同时做到O(log n)的查找、插入、删除,并且保持有序?秘密就在于它的底层实现——红黑树(Red-Black Tree)。
你可以把红黑树想象成一棵总是保持“相对平衡”的二叉树。它通过一套复杂的着色和旋转规则,确保从根节点到任意叶子节点的最长路径,不会超过最短路径的两倍。这种“平衡性”是高效操作的关键。
- 查找 :从根节点开始,比较目标值与当前节点值,根据比较结果决定进入左子树或右子树,每次比较都能排除掉大约一半的节点,因此效率是O(log n)。
- 插入 :先像查找一样找到合适的插入位置,插入新节点(初始为红色),然后通过一系列的颜色翻转和树旋转操作,修复可能被破坏的红黑树性质,维持平衡。这个过程也是O(log n)。
- 删除 :过程更复杂一些,但核心思想类似,在删除节点后通过调整来维持树的平衡,复杂度同样是O(log n)。
正因为底层是树形结构,
set
的迭代器进行
++
操作(中序遍历)时,得到的就是有序的序列。同时,这也解释了为什么
set
的元素不能直接修改——你无法保证修改后的新值还能放在树中正确的位置上,这会导致整棵树的结构错乱。
2.3 与其它容器的对比:什么时候该用set?
理解了原理,我们就能更理智地做技术选型。这里用一个表格来快速对比
set
和几个常用容器:
| 特性 |
std::set
|
std::vector
|
std::unordered_set
|
std::multiset
|
|---|---|---|---|---|
| 元素是否唯一 | 是 | 否 | 是 | 否(允许重复) |
| 内部顺序 | 有序(默认升序) | 插入顺序 | 无序(基于哈希) | 有序(默认升序) |
| 底层数据结构 | 红黑树 | 动态数组 | 哈希表 | 红黑树 |
| 平均查找复杂度 | O(log n) | O(n) | O(1) | O(log n) |
| 插入/删除平均复杂度 | O(log n) | 尾部O(1), 中间O(n) | O(1) | O(log n) |
| 是否需要可哈希 | 否(需要可比较) | 否 | 是 | 否(需要可比较) |
| 典型使用场景 | 需要有序、唯一元素的集合,频繁查找 | 随机访问频繁,尾部增删多 | 只需快速判断存在性,不关心顺序 | 需要有序但允许重复的集合 |
选择指南 :
-
当你需要
维护一个唯一且有序的集合
,并且会频繁地进行查找、插入、删除操作时,
set是你的首选。例如,维护一个系统的在线用户ID列表(需要快速判断用户是否在线,并且可能按ID顺序进行某些操作)。 -
如果你只关心元素
是否存在
,完全
不关心顺序
,并且你的元素类型提供了良好的哈希函数,那么
std::unordered_set(C++11引入)的平均O(1)操作会更快。 -
如果你需要
允许重复元素
,但同时要保持有序,那么应该选择
std::multiset。 -
如果你的操作以
随机访问
(通过下标)和
尾部插入
为主,那么
vector仍然是效率最高的选择。
注意 :
set的O(log n)复杂度是“平均”且“摊还”的。虽然它不如unordered_set的O(1)惊艳,但其有序性和稳定性(不受哈希冲突影响)在很多场景下是不可替代的。不要盲目追求O(1)而忽略了业务需求。
3. 从零开始使用set:完整API与实战示例
理论说再多,不如动手写一遍。这一部分,我们将像搭积木一样,从创建
set
对象开始,一步步掌握它的所有常用操作。我会用具体的代码示例和注释,让你看得明白,抄得放心。
3.1 基础操作:创建、插入与遍历
首先,要使用
set
,必须包含头文件
<set>
。
#include <iostream>
#include <set>
using namespace std;
int main() {
// 1. 创建一个空的set,存储int类型,默认按升序排列
set<int> mySet;
// 2. 插入元素 - insert() 方法
mySet.insert(3);
mySet.insert(1);
mySet.insert(4);
mySet.insert(1); // 重复插入1,会被忽略
mySet.insert(2);
// 3. 遍历set - 迭代器
cout << "Set elements (using iterator): ";
for (set<int>::iterator it = mySet.begin(); it != mySet.end(); ++it) {
cout << *it << " "; // 输出:1 2 3 4 (已自动排序)
}
cout << endl;
// 4. 使用范围for循环 (C++11) - 更简洁
cout << "Set elements (using range-for): ";
for (int num : mySet) {
cout << num << " ";
}
cout << endl;
// 5. 获取元素个数 - size()
cout << "Size of set: " << mySet.size() << endl; // 输出:4 (不是5,因为1重复了)
// 6. 判断是否为空 - empty()
set<int> emptySet;
cout << "Is mySet empty? " << (mySet.empty() ? "Yes" : "No") << endl;
cout << "Is emptySet empty? " << (emptySet.empty() ? "Yes" : "No") << endl;
return 0;
}
插入操作的返回值
:
insert
方法有一个非常重要的返回值,它是一个
pair<iterator, bool>
。
-
first:是一个迭代器,指向被插入的元素(如果插入成功)或指向set中已存在的那个等值元素(如果插入失败)。 -
second:是一个bool值,表示插入是否成功(true表示成功插入新元素,false表示元素已存在)。
这个返回值在需要知道插入是否真正发生时非常有用。
auto result = mySet.insert(5);
if (result.second) {
cout << "Element 5 inserted successfully." << endl;
} else {
cout << "Element 5 already exists. Iterator points to: " << *(result.first) << endl;
}
3.2 查找与删除:精准操作集合元素
找到了、用完了,有时候也需要请出去。
set
提供了高效的查找和删除方法。
#include <iostream>
#include <set>
using namespace std;
int main() {
set<int> mySet = {10, 20, 30, 40, 50}; // C++11 初始化列表
// 1. 查找元素 - find()
int target = 30;
set<int>::iterator it = mySet.find(target);
if (it != mySet.end()) { // 判断是否找到
cout << "Found element: " << *it << endl;
} else {
cout << "Element " << target << " not found." << endl;
}
// 2. 统计元素个数 - count()
// 对于set,返回值只能是0或1,因为元素唯一。
// 对于multiset,返回值可能大于1。
cout << "Count of 20: " << mySet.count(20) << endl; // 输出:1
cout << "Count of 99: " << mySet.count(99) << endl; // 输出:0
// 3. 删除元素 - erase()
// 方法一:通过值删除
size_t numRemoved = mySet.erase(20); // 返回删除的元素个数(对于set是0或1)
cout << "Removed " << numRemoved << " instance(s) of 20." << endl;
// 方法二:通过迭代器删除
it = mySet.find(40);
if (it != mySet.end()) {
mySet.erase(it); // 更高效,因为省去了二次查找
}
// 方法三:删除一个区间 [first, last)
// 例如,删除从30开始到末尾之前的所有元素
it = mySet.find(30);
if (it != mySet.end()) {
// erase(it, mySet.end()) 会删除从it指向的元素开始,直到end()(不包括end())
mySet.erase(it, mySet.end());
}
cout << "Set after deletions: ";
for (int num : mySet) {
cout << num << " "; // 输出:10 (只剩10了)
}
cout << endl;
// 4. 清空整个set - clear()
mySet.clear();
cout << "Size after clear: " << mySet.size() << endl; // 输出:0
return 0;
}
实操心得 :在删除已知迭代器位置的元素时, 优先使用通过迭代器删除的方式 (
erase(iterator))。通过值删除(erase(value))会先内部执行一次find操作,再执行删除。如果你已经通过find或其他方式获得了迭代器,直接用它删除能避免一次不必要的查找。
3.3 边界与范围操作:处理有序集合的利器
由于
set
是有序的,它提供了一些基于顺序的特殊查询方法,这在处理范围问题时非常高效。
#include <iostream>
#include <set>
using namespace std;
int main() {
set<int> mySet = {10, 20, 30, 40, 50, 60, 70};
// 1. 获取边界迭代器
if (!mySet.empty()) {
cout << "Smallest element: " << *mySet.begin() << endl; // 输出:10
cout << "Largest element: " << *mySet.rbegin() << endl; // 输出:70 (反向迭代器)
}
// 2. 上下界查找 - lower_bound & upper_bound
// 假设我们想找到所有 >= 35 且 < 65 的元素
int lowerVal = 35;
int upperVal = 65;
// lower_bound(val): 返回第一个 >= val 的元素的迭代器
set<int>::iterator lowIt = mySet.lower_bound(lowerVal);
// upper_bound(val): 返回第一个 > val 的元素的迭代器
set<int>::iterator upIt = mySet.upper_bound(upperVal);
cout << "Elements in range [" << lowerVal << ", " << upperVal << "): ";
for (auto it = lowIt; it != upIt; ++it) {
cout << *it << " "; // 输出:40 50 60
}
cout << endl;
// 3. 等值范围 - equal_range
// 对于set,因为元素唯一,这个范围最多包含一个元素。
// 对于multiset更有用,可以一次性获取所有等值元素的范围。
auto range = mySet.equal_range(50); // 返回一个pair<iterator, iterator>
if (range.first != range.second) {
cout << "Found element 50." << endl;
// range.first 是 lower_bound(50) 的结果
// range.second 是 upper_bound(50) 的结果
}
// 一个更直观的例子:删除某个范围内的所有元素
// 删除所有值在 [20, 50] 之间的元素
auto start = mySet.lower_bound(20); // 第一个 >=20 的,即20本身
auto end = mySet.upper_bound(50); // 第一个 >50 的,即60
mySet.erase(start, end);
cout << "Set after erasing [20, 50]: ";
for (int num : mySet) {
cout << num << " "; // 输出:10 60 70
}
cout << endl;
return 0;
}
lower_bound
和
upper_bound
是处理有序区间的核心工具。它们之所以高效(O(log n)),是因为底层红黑树支持快速的二分查找,而不是像在
vector
中那样需要线性扫描。
4. 进阶与定制:让set适应你的复杂数据类型
到目前为止,我们用的都是
int
这种内置类型。但实际项目中,我们更常需要存储自定义的结构体或类对象。这时,
set
的“排序”特性就带来了挑战:它怎么知道两个自定义对象谁大谁小呢?
4.1 存储自定义类型:定义比较规则
set
默认使用
std::less
,即
<
运算符来比较元素。对于自定义类型,你有两种方式来提供比较规则:
方法一:重载
<
运算符
这是最直接的方法。在你的类或结构体内部,重载小于运算符。
#include <iostream>
#include <set>
#include <string>
using namespace std;
class Person {
public:
string name;
int age;
Person(string n, int a) : name(n), age(a) {}
// 重载 < 运算符
// 这里我们定义按年龄升序排序。如果年龄相同,再按名字升序排序。
bool operator<(const Person& other) const {
if (age != other.age) {
return age < other.age;
}
return name < other.name;
}
// 为了方便打印,重载 << 运算符
friend ostream& operator<<(ostream& os, const Person& p) {
os << p.name << "(" << p.age << ")";
return os;
}
};
int main() {
set<Person> personSet;
personSet.insert(Person("Alice", 30));
personSet.insert(Person("Bob", 25));
personSet.insert(Person("Charlie", 30)); // 年龄与Alice相同,比较名字
personSet.insert(Person("Alice", 30)); // 与第一个Alice完全相同,不会被插入
cout << "Persons in set (sorted by age, then name):" << endl;
for (const auto& p : personSet) {
cout << p << endl;
}
// 输出:
// Bob(25)
// Alice(30)
// Charlie(30)
return 0;
}
方法二:提供自定义比较函数对象(仿函数) 这种方法更灵活,尤其是当你不想或不能修改原有类的定义时,或者你需要多种不同的排序方式。
#include <iostream>
#include <set>
#include <string>
using namespace std;
struct Person {
string name;
int age;
// 注意:这里没有重载 < 运算符
};
// 自定义比较器:按姓名升序排序
struct CompareByName {
bool operator()(const Person& a, const Person& b) const {
return a.name < b.name;
}
};
int main() {
// 在定义set时,将比较器类型作为第二个模板参数传入
set<Person, CompareByName> personSetByName;
personSetByName.insert({"Alice", 30});
personSetByName.insert({"Bob", 25});
personSetByName.insert({"Charlie", 35});
cout << "Persons sorted by name:" << endl;
for (const auto& p : personSetByName) {
cout << p.name << " - " << p.age << endl;
}
// 输出:
// Alice - 30
// Bob - 25
// Charlie - 35
// 你甚至可以定义另一个按年龄降序的set
struct CompareByAgeDesc {
bool operator()(const Person& a, const Person& b) const {
return a.age > b.age; // 注意这里是 >,实现降序
}
};
set<Person, CompareByAgeDesc> personSetByAgeDesc;
personSetByAgeDesc.insert({"Alice", 30});
personSetByAgeDesc.insert({"Bob", 25});
personSetByAgeDesc.insert({"Charlie", 35});
cout << "\nPersons sorted by age (descending):" << endl;
for (const auto& p : personSetByAgeDesc) {
cout << p.name << " - " << p.age << endl;
}
// 输出:
// Charlie - 35
// Alice - 30
// Bob - 25
return 0;
}
重要提醒 :自定义的比较规则必须满足 严格弱序 (Strict Weak Ordering)。简单来说,它需要满足:
- 非自反性:
comp(a, a)必须为false。- 非对称性:如果
comp(a, b)为true,则comp(b, a)必须为false。- 可传递性:如果
comp(a, b)为true且comp(b, c)为true,则comp(a, c)必须为true。- 等价的可传递性:如果
!comp(a, b) && !comp(b, a)(即a和b“等价”),并且!comp(b, c) && !comp(c, b),那么必须有!comp(a, c) && !comp(c, a)。不满足严格弱序的比较规则会导致
set行为未定义,通常会在运行时崩溃。最常见的错误是在比较浮点数时直接使用<(因为NaN不满足任何比较关系),或者在多字段比较时逻辑写错。务必小心。
4.2 性能考量与迭代器失效
虽然
set
的操作大多是O(log n),但在某些特定场景下,性能仍有差异,并且迭代器的稳定性也需要关注。
-
插入性能 :在已知位置附近插入(通过
hint迭代器)可以提升效率。insert方法有一个重载版本:iterator insert (iterator position, const value_type& val)。如果你能提供一个指向插入位置“附近”的正确迭代器(例如,通过lower_bound获得),插入操作可能接近常数时间。set<int> s = {10, 30, 50}; auto hint = s.lower_bound(25); // 指向30 s.insert(hint, 20); // 在30之前插入20,效率可能更高 -
查找性能 :
find是O(log n)。如果你的操作模式是“先查找,如果不存在则插入”,那么使用insert的返回值(pair<iterator, bool>)是更高效的做法,因为它将查找和插入合并为一次树操作。// 低效做法: if (mySet.find(value) == mySet.end()) { mySet.insert(value); } // 高效做法: mySet.insert(value); // insert本身会检查是否存在 -
迭代器失效 :
set的迭代器在插入和删除操作中表现出很强的稳定性。- 插入 :不会使任何现有迭代器失效。
-
删除
:只有指向被删除元素的迭代器会失效,其他迭代器仍然有效。
这与
vector(插入/删除可能导致所有迭代器失效)和deque(在中间插入/删除会使所有迭代器失效)形成鲜明对比。这使得在遍历set的同时安全地删除某些元素成为可能(但需要小心处理迭代器)。
set<int> s = {1, 2, 3, 4, 5, 6}; for (auto it = s.begin(); it != s.end(); /* 注意这里不递增 */) { if (*it % 2 == 0) { // 删除所有偶数 it = s.erase(it); // erase(it) 返回被删除元素的下一个元素的迭代器 } else { ++it; } } // s 现在包含 {1, 3, 5}关键技巧 :在循环中删除元素时,利用
erase的返回值来更新迭代器,这是安全且标准的做法。
5. 实战场景与避坑指南
了解了所有API之后,我们来看看
set
在真实项目中是如何大显身手的,以及有哪些“坑”需要提前避开。
5.1 典型应用场景剖析
场景一:维护唯一且有序的ID集合
这是
set
最经典的应用。比如在游戏服务器中,维护所有在线玩家的ID,需要快速检查某个玩家是否在线,并且有时需要按ID顺序进行批量操作(如广播消息给前N个玩家)。
set<uint64_t> onlinePlayerIds;
// 玩家登录
onlinePlayerIds.insert(playerId);
// 玩家退出
onlinePlayerIds.erase(playerId);
// 快速检查是否在线
bool isOnline = (onlinePlayerIds.find(playerId) != onlinePlayerIds.end());
// 按顺序处理前100名玩家
int count = 0;
for (auto id : onlinePlayerIds) {
doSomething(id);
if (++count >= 100) break;
}
场景二:求交集、并集、差集
利用
set
的有序特性,可以高效地实现集合运算。标准库提供了
std::set_intersection
、
std::set_union
、
std::set_difference
等算法,它们要求输入范围是有序的。
#include <iostream>
#include <set>
#include <algorithm>
#include <iterator>
using namespace std;
int main() {
set<int> A = {1, 2, 3, 4, 5};
set<int> B = {3, 4, 5, 6, 7};
set<int> C;
// 求交集 A ∩ B
set_intersection(A.begin(), A.end(),
B.begin(), B.end(),
inserter(C, C.begin())); // 使用inserter迭代器插入结果
cout << "Intersection: ";
for (int x : C) cout << x << " "; // 输出:3 4 5
cout << endl;
C.clear();
// 求并集 A ∪ B
set_union(A.begin(), A.end(),
B.begin(), B.end(),
inserter(C, C.begin()));
cout << "Union: ";
for (int x : C) cout << x << " "; // 输出:1 2 3 4 5 6 7
cout << endl;
return 0;
}
场景三:作为字典的“键”的集合
当你有一个
std::map<Key, Value>
,有时你需要获取所有键(key)的集合。虽然
map
本身提供了迭代器,但如果你需要一个独立、有序且唯一的键集合,可以将其键插入到一个
set
中。
map<string, int> studentScores = {{"Alice", 90}, {"Bob", 85}, {"Charlie", 92}};
set<string> studentNames;
for (const auto& pair : studentScores) {
studentNames.insert(pair.first);
}
// 现在 studentNames 是一个有序的学生姓名集合
5.2 常见“坑”与解决方案
坑一:误用
[]
运算符
std::map
和
std::unordered_map
支持通过
[]
运算符来访问或插入元素,但
set
没有这个运算符!因为
set
只有键,没有键值对,通过键来索引值没有意义。试图使用
mySet[5]
会导致编译错误。
正确做法 :插入用
insert(),查找用find()或count()。
坑二:试图修改
set
中的元素
这是新手常犯的错误。由于
set
元素的常量性(
const
),通过迭代器获取的是
const
引用,不能修改。
set<int> s = {1, 2, 3};
auto it = s.find(2);
// *it = 4; // 错误!编译不通过,不能修改set中的元素
正确做法 :如果需要修改,必须先删除旧元素,再插入新元素。但要注意,这可能会使指向旧元素的迭代器失效。
auto it = s.find(2); if (it != s.end()) { int newValue = 4; s.erase(it); // 删除旧元素 s.insert(newValue); // 插入新元素 }
坑三:自定义比较规则不满足严格弱序 这是最隐蔽也最危险的坑。比如,你想按字符串长度排序,但如果两个字符串长度相同,你希望它们“相等”从而只保留一个。错误的写法:
// 错误的比较器:不满足严格弱序
struct BadComparator {
bool operator()(const string& a, const string& b) const {
return a.length() < b.length(); // 只比较长度
}
};
set<string, BadComparator> badSet;
badSet.insert("apple");
badSet.insert("banana"); // 长度都是5,根据比较器,它们“等价”,所以不会被插入?
// 问题在于,对于set,“等价”(!comp(a,b) && !comp(b,a))意味着“相同”,但“apple”和“banana”内容不同。
// 这会导致未定义行为,程序可能崩溃或产生奇怪的结果。
正确做法 :当主要比较条件相等时,必须提供一个次要比较条件来建立全序,确保任意两个不同的元素都能比出大小。
struct GoodComparator { bool operator()(const string& a, const string& b) const { if (a.length() != b.length()) { return a.length() < b.length(); } return a < b; // 长度相同时,按字典序比较 } }; set<string, GoodComparator> goodSet; goodSet.insert("apple"); goodSet.insert("banana"); // 现在两者都会存在,且按字典序排列
坑四:忽略
insert
的返回值导致低效操作
在需要知道元素是否是新插入的场景下,忽略
insert
的返回值意味着你可能需要额外调用一次
find
。
// 低效
if (mySet.find(value) == mySet.end()) {
mySet.insert(value);
// 处理新插入的情况
}
// 高效
auto result = mySet.insert(value);
if (result.second) {
// 处理新插入的情况,result.first 是指向新元素的迭代器
}
坑五:在循环中删除元素时迭代器处理不当
这是很多容器的通病,但在
set
中,由于迭代器相对稳定,正确的处理模式是利用
erase
的返回值。
set<int> s = {1, 2, 3, 4, 5};
// 错误做法(未定义行为)
for (auto it = s.begin(); it != s.end(); ++it) {
if (*it % 2 == 0) {
s.erase(it); // it 失效了,下一次循环的 ++it 行为未定义!
}
}
// 正确做法
for (auto it = s.begin(); it != s.end(); ) {
if (*it % 2 == 0) {
it = s.erase(it); // erase 返回下一个有效迭代器
} else {
++it;
}
}
6. 性能测试与选型思考
理论上的复杂度是O(log n),但实际表现如何?我们通过一个简单的测试来感受一下
set
、
unordered_set
和
vector
在查找操作上的性能差异。这个测试虽然不严谨,但能给我们一个直观的印象。
#include <iostream>
#include <set>
#include <unordered_set>
#include <vector>
#include <algorithm>
#include <chrono>
#include <random>
using namespace std;
using namespace std::chrono;
int main() {
const int N = 100000; // 元素数量
const int M = 10000; // 查找次数
// 生成随机数
vector<int> data(N);
random_device rd;
mt19937 gen(rd());
uniform_int_distribution<> dis(1, N * 10);
for (int i = 0; i < N; ++i) {
data[i] = dis(gen);
}
// 准备容器
set<int> s;
unordered_set<int> us;
vector<int> v;
// 插入数据
for (int num : data) {
s.insert(num);
us.insert(num);
v.push_back(num);
}
// 对vector排序,以便使用binary_search
sort(v.begin(), v.end());
// 生成待查找的随机数(一半存在,一半不存在)
vector<int> searchKeys(M);
for (int i = 0; i < M; ++i) {
if (i % 2 == 0) {
searchKeys[i] = data[dis(gen) % N]; // 存在的数
} else {
searchKeys[i] = dis(gen) + N * 10; // 可能不存在的数
}
}
// 测试 set 查找
auto start = high_resolution_clock::now();
int foundCount = 0;
for (int key : searchKeys) {
if (s.find(key) != s.end()) {
foundCount++;
}
}
auto end = high_resolution_clock::now();
auto duration_set = duration_cast<microseconds>(end - start);
cout << "set find() time: " << duration_set.count() << " us, found: " << foundCount << endl;
// 测试 unordered_set 查找
start = high_resolution_clock::now();
foundCount = 0;
for (int key : searchKeys) {
if (us.find(key) != us.end()) {
foundCount++;
}
}
end = high_resolution_clock::now();
auto duration_uset = duration_cast<microseconds>(end - start);
cout << "unordered_set find() time: " << duration_uset.count() << " us, found: " << foundCount << endl;
// 测试 vector 的 binary_search
start = high_resolution_clock::now();
foundCount = 0;
for (int key : searchKeys) {
if (binary_search(v.begin(), v.end(), key)) {
foundCount++;
}
}
end = high_resolution_clock::now();
auto duration_vec = duration_cast<microseconds>(end - start);
cout << "vector binary_search() time: " << duration_vec.count() << " us, found: " << foundCount << endl;
return 0;
}
在我的测试环境(10万数据,1万次查找)下,结果大致如下(具体数值因机器而异,但关系稳定):
-
unordered_set:最快,通常在几百微秒级别。哈希表的O(1)查找名不虚传。 -
set:次之,通常在几千微秒级别。O(log n)的查找在数据量大时依然高效。 -
vector+binary_search:与set处于同一数量级,有时甚至略快,因为数组连续内存访问对CPU缓存更友好。 但是 ,vector的插入和删除成本远高于set。
这个测试告诉我们:
-
如果只需要极快的查找,且不关心顺序
,
unordered_set是王者。 -
如果需要有序性,或者需要频繁的插入删除
,
set是平衡的选择。 -
如果数据基本固定,只需要一次性排序后频繁查找
,排序后的
vector配合binary_search可能是性能最好、内存最紧凑的方案,但它失去了动态插入删除的高效性。
选择容器,永远是在 功能需求 (是否需要有序、唯一)、 操作类型 (查找、插入、删除的频率)和 性能表现 之间做权衡。没有最好的容器,只有最适合当前场景的容器。
7. 延伸阅读:与set相关的其他容器
掌握了
set
,理解它的“兄弟姐妹”就很容易了。它们共享相似的设计理念和接口。
-
std::multiset:允许存储重复元素的“集合”。它的insert操作总是成功。find会返回指向第一个匹配元素的迭代器,count会返回该元素出现的次数。equal_range方法在这里特别有用,可以获取所有重复元素的范围。 -
std::unordered_set(C++11):基于哈希表的集合,提供平均O(1)的查找、插入和删除。它不保证元素顺序。使用它需要你的元素类型有可用的哈希函数(标准类型如int、string已提供,自定义类型需要特化std::hash或提供自定义哈希函数)。 -
std::map/std::multimap:如果你需要存储键值对(key-value pairs),而不是单独的键,那么应该使用map(键唯一)或multimap(键可重复)。它们的底层也是红黑树,保持了键的有序性。 -
std::unordered_map(C++11):基于哈希表的键值对容器,提供平均O(1)的访问。
当你需要
set
的功能但发现自定义类型的哈希函数比比较函数更容易实现时,或者当你完全不需要顺序时,
unordered_set
是你的下一个学习目标。
回过头看,
set
就像是一个严谨的图书管理员。它不允许两本完全相同的书(唯一性),并且总是按照特定的编号规则(比较器)把书整理得井井有条(有序性)。当你需要找一本书时,它不会从第一本开始漫无目的地找,而是根据编号规则快速定位到大概区域(二分查找)。这个特性使得它在处理需要“唯一标识”和“快速检索”的场景时,成为了一个不可或缺的工具。理解它的原理和脾气,你就能在合适的场合放心地使用它,避免很多不必要的性能损耗和逻辑错误。
更多推荐
所有评论(0)