C++ set 全面解析与实战指南
在C++标准模板库(STL)中,set是一种基于红黑树(Red-Black Tree)实现的有序关联容器,其核心特性是自动对元素进行排序且不允许重复元素。相比于vector的动态数组特性,set更擅长有序数据的存储、查找与去重场景。本文将从set的核心特性、常用操作、实现原理、性能分析及实战案例等方面,带你系统掌握set的使用逻辑与底层机制。
一、set 核心特性
set作为有序关联容器,其特性与底层红黑树的实现紧密相关,核心亮点如下,也是它区别于vector、list等序列容器的关键:
-
自动有序性:set会根据元素的默认比较规则(通常是<运算符)自动对元素进行升序排序,插入元素时无需手动维护顺序。
-
无重复元素:set不允许存储重复的元素,若尝试插入已存在的元素,插入操作会被忽略(返回的pair对象中second值为false)。
-
基于红黑树实现:红黑树是一种平衡二叉搜索树,保证了插入、删除、查找等操作的时间复杂度均为O(log n),效率稳定。
-
不支持随机访问:set的元素存储不连续,无法通过下标直接访问元素,只能通过迭代器进行顺序遍历。
-
迭代器稳定性:与vector不同,set的迭代器在插入、删除元素时(除被删除元素的迭代器外)不会失效,因为红黑树的节点删除/插入不会导致内存块整体移动。
-
模板化设计:支持存储任意可比较的类型(基本数据类型、自定义结构体/类,需重载比较运算符),通用性强。
补充:STL中还有multiset容器,其特性与set基本一致,唯一区别是multiset允许存储重复元素,适合需要保留重复有序数据的场景。
二、set 常用操作(C++实现)
使用set需先包含头文件 <set>,并使用std命名空间(或显式指定std::set)。以下是set最常用的操作及对应的代码实现,涵盖初始化、插入、删除、查找等核心场景。
2.1 set 初始化
set提供了多种初始化方式,可适配不同的数据导入场景,具体如下:
cpp
#include <set>
#include <iostream>
#include <vector>
using namespace std;
int main() {
// 1. 空set,默认升序排序(基于less<int>比较器)
set<int> s1;
// 2. 用初始化列表初始化(C++11及以上)
set<int> s2 = {5, 2, 8, 2, 1}; // 自动去重并排序,最终为{1,2,5,8}
// 3. 用迭代器范围初始化(从其他容器导入数据)
vector<int> vec = {3, 7, 1, 7, 9};
set<int> s3(vec.begin(), vec.end()); // 去重排序后为{1,3,7,9}
// 4. 自定义排序规则(降序),使用greater比较器
set<int, greater<int>> s4 = {5, 2, 8, 1}; // 最终为{8,5,2,1}
// 5. 拷贝构造(用另一个set初始化)
set<int> s5(s2); // s5是s2的副本,为{1,2,5,8}
// 打印验证
cout << "s2(默认升序):";
for (auto it = s2.begin(); it != s2.end(); ++it) {
cout << *it << " "; // 输出:1 2 5 8
}
cout << endl;
cout << "s4(自定义降序):";
for (auto x : s4) {
cout << x << " "; // 输出:8 5 2 1
}
cout << endl;
return 0;
}
2.2 元素插入
set的插入操作会自动完成排序与去重,主要通过insert()方法实现,支持多种插入形式,返回值为pair<iterator, bool>:其中iterator指向插入位置(或已存在元素的位置),bool表示插入是否成功(成功为true,重复插入为false)。
cpp
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s;
pair<set<int>::iterator, bool> ret;
// 1. 插入单个元素
ret = s.insert(5);
cout << "插入5:" << (ret.second ? "成功" : "失败") << endl; // 输出:成功
ret = s.insert(5); // 尝试插入重复元素
cout << "再次插入5:" << (ret.second ? "成功" : "失败") << endl; // 输出:失败
// 2. 插入多个元素(初始化列表,C++11及以上)
s.insert({2, 8, 1}); // 插入后s为{1,2,5,8}
// 3. 插入迭代器范围的元素
set<int> s2 = {3, 7};
s.insert(s2.begin(), s2.end()); // 插入后s为{1,2,3,5,7,8}
// 4. 插入到指定位置(hint,提示位置,若提示正确可提高插入效率)
// 注意:hint仅为提示,最终插入位置仍由排序规则决定
s.insert(s.begin(), 4); // 提示在开头插入,但实际插入到3和5之间
cout << "插入4后:";
for (auto x : s) {
cout << x << " "; // 输出:1 2 3 4 5 7 8
}
cout << endl;
return 0;
}
2.3 元素查找与统计
set的查找操作基于红黑树的二分查找特性,效率较高,常用方法有find()、count()、lower_bound()、upper_bound()等:
cpp
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s = {1, 2, 3, 4, 5, 7, 8};
// 1. find():查找指定元素,返回指向该元素的迭代器;若不存在,返回end()
auto it = s.find(5);
if (it != s.end()) {
cout << "找到元素5:" << *it << endl; // 输出:找到元素5:5
} else {
cout << "未找到元素5" << endl;
}
it = s.find(6);
if (it == s.end()) {
cout << "未找到元素6" << endl; // 输出:未找到元素6
}
// 2. count():统计元素出现的次数(set中仅为0或1,multiset中可大于1)
cout << "元素3出现次数:" << s.count(3) << endl; // 输出:1
cout << "元素6出现次数:" << s.count(6) << endl; // 输出:0
// 3. lower_bound(x):返回第一个大于等于x的元素的迭代器
auto it_low = s.lower_bound(4);
cout << "第一个大于等于4的元素:" << *it_low << endl; // 输出:4
// 4. upper_bound(x):返回第一个大于x的元素的迭代器
auto it_up = s.upper_bound(4);
cout << "第一个大于4的元素:" << *it_up << endl; // 输出:5
// 5. equal_range(x):返回pair<lower_bound(x), upper_bound(x)>,表示x的合法插入范围
auto range = s.equal_range(4);
cout << "equal_range(4) 左边界:" << *(range.first) << endl; // 输出:4
cout << "equal_range(4) 右边界:" << *(range.second) << endl; // 输出:5
return 0;
}
2.4 元素删除
set的删除操作支持通过迭代器、元素值、迭代器范围三种方式,删除后红黑树会自动重新平衡,保证结构稳定:
cpp
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s = {1, 2, 3, 4, 5, 7, 8};
// 1. 通过迭代器删除单个元素
auto it = s.find(4);
if (it != s.end()) {
s.erase(it); // 删除元素4
}
cout << "删除4后:";
for (auto x : s) cout << x << " "; // 输出:1 2 3 5 7 8
cout << endl;
// 2. 通过元素值删除(返回删除的元素个数,set中为0或1)
size_t del_cnt = s.erase(5);
cout << "删除5的个数:" << del_cnt << endl; // 输出:1
del_cnt = s.erase(6);
cout << "删除6的个数:" << del_cnt << endl; // 输出:0
// 3. 通过迭代器范围删除(左闭右开)
s.erase(s.begin(), s.find(3)); // 删除从开头到第一个小于3的元素(即删除1、2)
cout << "删除1、2后:";
for (auto x : s) cout << x << " "; // 输出:3 7 8
cout << endl;
// 4. 清空所有元素(clear())
s.clear();
cout << "清空后元素个数:" << s.size() << endl; // 输出:0
return 0;
}
2.5 迭代器遍历
由于set不支持随机访问,遍历只能通过迭代器进行,支持正向、反向、常量三种遍历方式,遍历结果均遵循set的排序规则:
cpp
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s = {1, 2, 3, 4, 5};
// 1. 正向迭代器(begin()/end()):升序遍历
cout << "正向升序遍历:";
for (set<int>::iterator it = s.begin(); it != s.end(); ++it) {
cout << *it << " "; // 输出:1 2 3 4 5
}
cout << endl;
// 2. 常量正向迭代器(cbegin()/cend()):不可修改元素
cout << "常量正向遍历:";
for (set<int>::const_iterator it = s.cbegin(); it != s.cend(); ++it) {
// *it = 10; // 错误:常量迭代器不可修改元素
cout << *it << " "; // 输出:1 2 3 4 5
}
cout << endl;
// 3. 反向迭代器(rbegin()/rend()):降序遍历
cout << "反向降序遍历:";
for (set<int>::reverse_iterator it = s.rbegin(); it != s.rend(); ++it) {
cout << *it << " "; // 输出:5 4 3 2 1
}
cout << endl;
// 4. 范围for循环(C++11及以上):简化正向遍历
cout << "范围for遍历:";
for (auto x : s) {
cout << x << " "; // 输出:1 2 3 4 5
}
cout << endl;
return 0;
}
2.6 其他常用操作
除上述核心操作外,set还提供了获取大小、判断空、交换容器等辅助操作:
cpp
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s = {1, 2, 3};
set<int> s2 = {4, 5, 6};
// 1. size():获取元素个数
cout << "s的元素个数:" << s.size() << endl; // 输出:3
// 2. empty():判断容器是否为空(空返回true,否则返回false)
cout << "s是否为空:" << (s.empty() ? "是" : "否") << endl; // 输出:否
// 3. max_size():获取容器可容纳的最大元素个数(受系统内存限制)
cout << "s的最大容量:" << s.max_size() << endl;
// 4. swap():交换两个set的内容(同类型、同排序规则)
s.swap(s2);
cout << "交换后s的元素:";
for (auto x : s) cout << x << " "; // 输出:4 5 6
cout << endl;
cout << "交换后s2的元素:";
for (auto x : s2) cout << x << " "; // 输出:1 2 3
cout << endl;
return 0;
}
三、set 实现原理简析
C++ STL中的set(以及multiset、map、multimap)均基于红黑树实现,红黑树是一种自平衡的二叉搜索树(Self-Balanced Binary Search Tree),其核心优势是通过特定的旋转和着色规则,保证树的高度始终维持在O(log n)级别,从而确保插入、删除、查找等操作的时间复杂度稳定为O(log n)。
3.1 红黑树的核心特性
红黑树的每个节点都有一个"颜色"属性(红色或黑色),且满足以下5条规则,正是这些规则保证了树的平衡性:
-
每个节点要么是红色,要么是黑色;
-
根节点必须是黑色;
-
所有叶子节点(NIL节点,空节点)都是黑色;
-
如果一个节点是红色,那么它的两个子节点必须是黑色(即不允许两个红色节点相邻);
-
从任意节点到其所有叶子节点的路径上,黑色节点的数量都相同(称为"黑高"相等)。
3.2 set 与红黑树的映射关系
set的每个元素对应红黑树的一个节点,元素值作为节点的键值(key),set的核心操作本质是红黑树的节点操作:
-
插入操作:先按二叉搜索树的规则插入新节点(默认着色为红色),然后检查红黑树规则是否被破坏,若破坏则通过"旋转"(左旋转、右旋转)和"重新着色"修复平衡;
-
删除操作:找到目标节点后,按二叉搜索树的规则删除,同样检查并修复红黑树的平衡规则;
-
查找操作:利用二叉搜索树的特性,从根节点开始,若目标值小于当前节点键值则向左子树查找,大于则向右子树查找,直到找到目标节点或遍历到叶子节点;
-
排序特性:二叉搜索树的中序遍历(左子树→根节点→右子树)结果就是有序的,set的正向迭代器本质就是红黑树的中序遍历迭代器。
3.3 set 不支持修改元素的原因
set的迭代器是"常量迭代器"(const_iterator),不允许直接修改元素值,这是因为元素值是红黑树节点的键值,修改键值会破坏二叉搜索树的有序性,导致红黑树的平衡规则失效,进而使set的后续操作(查找、插入等)出现错误。若必须修改set中的元素,需先删除旧元素,再插入新元素。
四、set 性能分析
set的性能表现由其底层红黑树的特性决定,优势和劣势都非常明确,选择时需结合具体场景判断:
4.1 优势场景
-
有序数据存储与遍历:自动排序特性无需手动维护顺序,遍历效率稳定(O(n),本质是红黑树的中序遍历);
-
高效查找与去重:查找、去重操作时间复杂度为O(log n),适合需要频繁判断元素是否存在的场景(如数据去重、字典查询);
-
稳定的插入/删除效率:无论数据量大小,插入、删除操作的时间复杂度均为O(log n),优于vector(扩容时为O(n))和list(查找目标元素时为O(n));
-
迭代器稳定:插入、删除元素时(除被删除元素的迭代器外),其他迭代器均有效,无需像vector那样重新获取迭代器。
4.2 劣势场景
-
不支持随机访问:无法通过下标快速访问元素,只能顺序遍历,随机访问场景(如频繁按索引取元素)效率低下;
-
内存开销较大:红黑树节点除了存储元素值,还需要存储颜色、左右子节点指针、父节点指针等额外信息,内存利用率低于vector;
-
插入/删除效率低于vector尾部操作:vector尾部插入/删除(不扩容时)时间复杂度为O(1),而set始终为O(log n),小数据量高频尾部操作场景下vector更优;
-
自定义类型需重载比较运算符:存储自定义结构体/类时,必须手动重载<运算符(或自定义比较器),否则无法编译通过,使用成本高于vector。
五、set 实战案例
以下通过两个典型实战案例,展示set在实际编程中的应用场景,涵盖数据去重、有序数据查询等核心需求。
案例1:利用set实现数据去重与排序
需求:读取一组无序且可能包含重复元素的整数,使用set自动去重并按升序输出,再按降序输出。
cpp
#include <set>
#include <iostream>
using namespace std;
int main() {
set<int> s_asc; // 升序set,用于去重+升序
set<int, greater<int>> s_desc; // 降序set,用于去重+降序
int num;
cout << "请输入一组整数(以非整数结束):";
while (cin >> num) {
s_asc.insert(num);
s_desc.insert(num);
}
// 输出升序结果(去重后)
cout << "去重后升序:";
for (auto x : s_asc) {
cout << x << " ";
}
cout << endl;
// 输出降序结果(去重后)
cout << "去重后降序:";
for (auto x : s_desc) {
cout << x << " ";
}
cout << endl;
return 0;
}
运行结果:
请输入一组整数(以非整数结束):5 2 8 2 1 5 9 a
去重后升序:1 2 5 8 9
去重后降序:9 8 5 2 1
案例2:set 存储自定义结构体(需重载比较运算符)
需求:定义一个"商品"结构体,包含商品名称和价格,使用set存储商品列表,按价格升序排序;若价格相同,按名称字典序排序。
cpp
#include <set>
#include <iostream>
#include <string>
using namespace std;
// 定义商品结构体
struct Goods {
string name; // 商品名称
double price; // 商品价格
// 重载<运算符,定义排序规则:先按价格升序,价格相同按名称字典序升序
bool operator<(const Goods& other) const {
if (price != other.price) {
return price < other.price;
}
return name < other.name;
}
};
int main() {
// 初始化商品列表(包含重复价格的商品)
set<Goods> goods_set = {
{"Apple", 5.99},
{"Banana", 3.99},
{"Orange", 5.99},
{"Grape", 4.99},
{"Banana", 3.99} // 重复元素,会被去重
};
// 输出排序后的商品信息
cout << "按规则排序后的商品列表:" << endl;
for (const auto& g : goods_set) {
cout << "商品:" << g.name << ",价格:" << g.price << endl;
}
// 查找价格为5.99的商品
auto it = goods_set.find({"", 5.99}); // 利用重载的<运算符,仅需匹配价格
if (it != goods_set.end()) {
cout << "\n找到价格为5.99的商品:" << it->name << endl; // 输出:Apple
}
return 0;
}
运行结果:
按规则排序后的商品列表:
商品:Banana,价格:3.99
商品:Grape,价格:4.99
商品:Apple,价格:5.99
商品:Orange,价格:5.99
找到价格为5.99的商品:Apple
六、常见问题与注意事项
6.1 自定义类型必须重载比较运算符
当set存储自定义结构体/类时,必须为该类型重载<运算符(或在定义set时指定自定义比较器),否则编译器无法判断元素的排序规则,会直接报错。重载<运算符时,需确保逻辑是"严格弱序"(即满足自反性、反对称性、传递性),否则可能导致set的操作异常。
自定义比较器的替代方案(无需重载运算符):
cpp
#include <set>
#include <iostream>
#include <string>
using namespace std;
struct Goods {
string name;
double price;
};
// 自定义比较器结构体
struct GoodsCompare {
bool operator()(const Goods& g1, const Goods& g2) const {
if (g1.price != g2.price) {
return g1.price < g2.price;
}
return g1.name < g2.name;
}
};
// 定义set时指定比较器
set<Goods, GoodsCompare> goods_set;
6.2 无法直接修改set中的元素
如前文所述,set的元素是红黑树的键值,修改元素会破坏树的有序性和平衡性,因此set的迭代器是常量迭代器,不允许直接修改元素。若需修改,需先删除旧元素,再插入新元素:
cpp
set<int> s = {1, 2, 3};
// 错误:无法修改常量迭代器指向的元素
// auto it = s.find(2);
// *it = 4;
// 正确做法:删除旧元素,插入新元素
s.erase(2);
s.insert(4); // 插入后s为{1,3,4}
6.3 set 与 unordered_set 的选择
C++11及以上提供了unordered_set容器,其底层基于哈希表实现,与set的核心区别如下,选择时需结合场景:
| 特性 | set | unordered_set |
|---|---|---|
| 底层实现 | 红黑树 | 哈希表 |
| 排序性 | 自动有序 | 无序 |
| 时间复杂度 | 插入/删除/查找均为O(log n)(稳定) | 平均O(1),最坏O(n)(受哈希函数影响) |
| 适用场景 | 需要有序数据、频繁范围查询 | 无需排序、追求高效插入查找(无哈希冲突时) |
6.4 避免使用低效的遍历查找
部分开发者会习惯用范围for循环遍历set查找元素,这种方式的时间复杂度为O(n),远低于set自带的find()方法(O(log n))。因此,查找元素时务必使用find()方法,而非手动遍历。
七、总结
set是C++ STL中极具价值的有序关联容器,其基于红黑树的实现保证了稳定高效的插入、删除、查找操作,自动排序和去重特性使其在有序数据管理场景中大放异彩。掌握set的核心操作、实现原理及适用场景,能帮助我们在数据去重、有序存储、高效查询等需求中选择最优容器,编写高效、简洁的代码。