std::set 是 C++ STL 中的有序关联容器 ,其核心特性是存储唯一元素并自动按指定规则排序 。底层通常基于红黑树(自平衡二叉搜索树)实现,这使得它在查找、插入、删除操作上具有稳定的高效性。
1、底层数据结构与核心概念
1.1 底层数据结构
- 底层结构 :通常实现为红黑树(Red-Black Tree),这是一种自平衡的二叉搜索树(BST)。
- 核心特性 :
- 关联性 :元素是键(Key)本身 。在
set
中,值就是键。 - 有序性 :容器中的元素会根据特定的比较准则 (默认为
std::less<Key>
)自动进行排序。你遍历集合时,元素总是按升序排列的。 - 唯一性 :集合中每个元素的键都是唯一的。不允许存在两个键相等的元素。
- 关联性 :元素是键(Key)本身 。在
- 节点结构:红黑树的每个节点存储一个元素(即键),以及必要的父指针、左孩子指针、右孩子指针和颜色标记(用于平衡)。
1.2 核心特性与原理
-
自动排序与唯一性
- 每当插入一个元素时,
set
会根据其内部的红黑树结构,将该元素放到正确的位置以维持二叉搜索树的性质(左子树所有键 < 根节点键 < 右子树所有键)。 - 在插入过程中,它会检查新元素的键是否已存在。如果存在,则插入操作失败,容器保持不变。这保证了元素的唯一性。
- 每当插入一个元素时,
-
操作效率 (O(log n))
- 由于红黑树是自平衡的,它能保证在最坏情况下,插入、删除和查找 操作的时间复杂度都是对数级 的,即 O(log n) ,其中
n
是树中元素的数量。 - 这种效率远高于在
vector
或list
中进行线性查找 (O(n)),但低于std::unordered_set
的平均常数时间复杂度 (O(1))。
- 由于红黑树是自平衡的,它能保证在最坏情况下,插入、删除和查找 操作的时间复杂度都是对数级 的,即 O(log n) ,其中
-
迭代器稳定性
- 插入和删除操作不会使任何现有迭代器失效(除了指向被删除元素的迭代器)。
- 这是因为树的结构调整是通过旋转和重新着色完成的,节点在内存中的地址通常不会改变。这与
vector
的动态内存重新分配形成鲜明对比。
-
不可修改元素
set
元素的键是const
的。一旦一个元素被插入到set
中,你就不能直接修改它,因为这会破坏树内部的有序性。- 如果需要修改一个元素,必须先将其删除,然后插入修改后的新值。
2、操作指导与代码示例
2.1 初始化与构造函数
cpp
#include <set>
#include <iostream>
// 1. 空set
std::set<int> set1;
// 2. 使用初始化列表 (C++11)
std::set<int> set2 = {5, 2, 8, 1, 2, 4}; // 重复的2会被忽略 -> {1, 2, 4, 5, 8}
// 3. 通过迭代器范围(另一个容器的)
std::vector<int> vec = {6, 7, 7, 8, 1};
std::set<int> set3(vec.begin(), vec.end()); // {1, 6, 7, 8}
// 4. 自定义比较准则(例如:降序)
struct Greater {
bool operator()(const int& a, const int& b) const {
return a > b;
}
};
std::set<int, Greater> set4 = {5, 2, 8, 1}; // {8, 5, 2, 1}
// 5. 拷贝构造函数
std::set<int> set5(set2); // {1, 2, 4, 5, 8}
2.2 元素访问与查找
cpp
std::set<int> mySet = {50, 20, 60, 10, 30};
// 查找元素 count & find
// 1. count: 由于唯一性,返回值只能是0或1
if (mySet.count(30)) {
std::cout << "30 is in the set\n";
}
// 2. find: 更高效和常用的方法
auto it = mySet.find(20);
if (it != mySet.end()) { // 一定要检查是否找到!
std::cout << "Found: " << *it << "\n"; // Output: Found: 20
} else {
std::cout << "Not found\n";
}
// 3. 获取范围边界:lower_bound, upper_bound, equal_range
// 这些在需要范围查询时非常有用
// lower_bound(30): 返回第一个 >= 30 的元素的迭代器
// upper_bound(30): 返回第一个 > 30 的元素的迭代器
auto low = mySet.lower_bound(25); // 指向30
auto high = mySet.upper_bound(55); // 指向60
2.3 增加元素
cpp
std::set<int> s;
// 1. insert: 插入一个值
std::pair<std::set<int>::iterator, bool> result = s.insert(100);
if (result.second) {
std::cout << "Insertion successful\n";
} else {
std::cout << "Element already exists\n";
}
// 2. emplace: 原地构造,避免临时对象,更高效(对于非内置类型)
// 直接传递构造参数给emplace
s.emplace(200);
// 对于自定义类型,优势明显:
// std::set<MyClass> s;
// s.emplace(arg1, arg2); // 直接在set内部构造MyClass对象
// 3. 插入一个迭代器范围(提示位置),提示位置是为了优化插入效率
// 如果提示位置正确,插入可能从O(log n)加速到接近O(1)
auto hint = s.end();
s.insert(hint, 150); // 提供一个插入位置的"提示"
2.4 删除元素
cpp
std::set<int> s = {10, 20, 30, 40, 50};
// 1. erase by key: 删除键为30的元素,返回删除的元素个数(0或1)
size_t numErased = s.erase(30); // numErased = 1, s: {10, 20, 40, 50}
// 2. erase by iterator: 删除指定位置的元素,更高效
auto it = s.find(20);
if (it != s.end()) {
s.erase(it); // s: {10, 40, 50}
}
// 3. erase by range: 删除一个区间的元素 [first, last)
auto first = s.find(10);
auto last = s.find(50); // 注意:50不会被删除,因为是[first, last)
s.erase(first, last); // 删除10和40,s: {50}
2.5 遍历元素
cpp
std::set<std::string> fruits = {"apple", "banana", "orange", "mango"};
// 1. 使用迭代器(总是有序的)
std::cout << "Using iterators: ";
for (auto it = fruits.begin(); it != fruits.end(); ++it) {
std::cout << *it << " "; // Output: apple banana mango orange
}
std::cout << "\n";
// 2. 使用范围for循环 (C++11)
std::cout << "Using range-for: ";
for (const auto& fruit : fruits) { // 使用const引用,避免拷贝
std::cout << fruit << " ";
}
std::cout << "\n";
// 3. 使用反向迭代器(逆序遍历)
std::cout << "Reverse order: ";
for (auto rit = fruits.rbegin(); rit != fruits.rend(); ++rit) {
std::cout << *rit << " "; // Output: orange mango banana apple
}
2.6 自定义类型作为键
要让自定义类型(如MyClass)作为 set 的键,你需要为其定义排序准则。
2.6.1 在自定义类型内重载 operator<(推荐,简单直观)
cpp
struct Person {
std::string name;
int age;
// 必须定义为const成员函数
bool operator<(const Person& other) const {
// 定义你的比较逻辑:先按name比,再按age比
if (name == other.name) {
return age < other.age;
}
return name < other.name;
}
};
std::set<Person> personSet; // 可以直接使用
personSet.insert({"Alice", 25});
personSet.insert({"Bob", 30});
2.6.2 提供自定义的函数对象(仿函数)作为模板参数
cpp
struct Person {
std::string name;
int age;
};
// 自定义比较器
struct PersonCompare {
bool operator()(const Person& a, const Person& b) const {
return a.name < b.name; // 只按name比较
}
};
// 使用时必须将比较器作为模板参数传入
std::set<Person, PersonCompare> personSet;
2.7 修改元素
std::set 的元素是常量(value_type 为 const T),无法通过迭代器修改,否则会破坏红黑树的有序性:
cpp
set<int> s = {1, 2, 3};
auto it = s.find(2);
// *it = 5; // 编译报错!不允许修改元素
修改元素的正确方式:先删除旧元素,再插入新元素:
cpp
s.erase(it); // 删除 2
s.insert(5); // 插入 5,s 变为 {1, 3, 5}
3、常见问题
-
std::set
的底层数据结构是什么?为什么选择红黑树?底层是红黑树。选择红黑树的原因:
- 红黑树是自平衡二叉搜索树,保证插入、删除、查找的时间复杂度均为 O(log n)(稳定高效)。
- 中序遍历可得到有序序列,满足
std::set
的自动排序需求。 - 相比其他平衡树(如 AVL 树),红黑树的旋转操作更少,实际性能更优。
-
std::set
的元素为什么不能修改?因为
std::set
基于红黑树实现,元素的排序依赖其值。若允许修改元素,可能导致红黑树的有序性被破坏(如将一个小值改为大值,可能使其应位于右子树却仍在左子树),进而导致后续操作(查找、插入等)出错。因此标准规定std::set
的元素为const
。 -
std::set
的insert
函数返回值有什么意义?返回
pair<iterator, bool>
:- 第一个元素是迭代器,指向插入位置(若元素已存在,则指向已有的那个元素)。
- 第二个元素是布尔值,标识插入是否成功(
true
表示新元素插入,false
表示元素已存在)。
-
如何在
std::set
中高效查找大于等于 x 的第一个元素?使用
lower_bound(x)
方法,其时间复杂度为 O(log n),直接返回符合条件的迭代器。这是std::set
有序特性带来的高效操作,优于unordered_set
(需遍历所有元素)。 -
什么时候该用
set
,什么时候该用unordered_set
?如果需要元素有序 ,或者需要进行范围查询 ,或者非常关心最坏情况的性能,就选择 set。如果只需要快速查找、插入和删除 ,并且不关心元素的顺序,那么 unordered_set 的平均性能会更好。