1. 为什么需要深入理解set容器?
作为C++标准模板库(STL)中最常用的关联容器之一,set在算法竞赛和工程实践中扮演着不可替代的角色。我第一次真正体会到set的威力是在解决LeetCode第349题"两个数组的交集"时——当其他选手还在手写二分查找时,用set实现的解法只需5行代码就能高效运行。
set底层基于红黑树实现,这种自平衡二叉搜索树保证了元素的有序性和操作的高效性。与vector等序列容器不同,set具有以下核心特性:
- 自动去重:相同元素只能存在一个
- 自动排序:元素默认按升序排列
- 快速查找:O(log n)时间复杂度的查找操作
- 稳定的插入删除:平均O(log n)的时间复杂度
提示:虽然unordered_set的查找速度更快(O(1)),但在需要元素有序或范围查询的场景下,set仍然是更好的选择。
2. set容器的基本操作全解析
2.1 创建与初始化
set的声明和初始化有多种方式,每种都有其适用场景:
cpp复制#include <set>
using namespace std;
// 基本声明方式
set<int> s1; // 空set
set<int> s2 = {1, 3, 5, 2, 4}; // 初始化列表
set<int> s3(s2.begin(), s2.end()); // 通过迭代器范围构造
// 自定义比较函数
struct cmp {
bool operator()(const int& a, const int& b) const {
return a > b; // 实现降序排列
}
};
set<int, cmp> s4; // 使用自定义比较函数的set
2.2 元素插入与删除
set的插入操作需要注意返回值类型,这在处理去重逻辑时非常有用:
cpp复制set<int> s;
auto ret = s.insert(5); // 返回pair<iterator, bool>
if (ret.second) {
cout << "插入成功" << endl;
} else {
cout << "元素已存在" << endl;
}
// 批量插入
vector<int> v = {2, 4, 6, 8};
s.insert(v.begin(), v.end());
// 删除操作
s.erase(4); // 通过值删除
auto it = s.find(6);
if (it != s.end()) {
s.erase(it); // 通过迭代器删除
}
s.erase(s.begin(), s.find(5)); // 删除范围[begin, 5)
2.3 查找与统计
set提供了多种查找方式,各有适用场景:
cpp复制set<int> s = {1, 3, 5, 7, 9};
// 基本查找
if (s.count(3)) { // 返回0或1
cout << "元素存在" << endl;
}
// 使用find获取迭代器
auto it = s.find(5);
if (it != s.end()) {
cout << "找到元素:" << *it << endl;
}
// 边界查找
auto lower = s.lower_bound(4); // 第一个>=4的元素
auto upper = s.upper_bound(6); // 第一个>6的元素
cout << "范围[4,6]内的元素:";
for (auto i = lower; i != upper; ++i) {
cout << *i << " "; // 输出5
}
3. set在算法题中的高频应用
3.1 去重与排序
这是set最直接的应用场景。考虑LeetCode第26题"删除排序数组中的重复项",使用set可以轻松解决:
cpp复制int removeDuplicates(vector<int>& nums) {
set<int> s(nums.begin(), nums.end());
nums.assign(s.begin(), s.end());
return nums.size();
}
虽然题目要求原地修改,但这种解法在需要快速解决问题时非常有效。
3.2 维护动态有序集合
当需要频繁插入、删除并保持元素有序时,set是理想选择。例如LeetCode第480题"滑动窗口中位数":
cpp复制vector<double> medianSlidingWindow(vector<int>& nums, int k) {
multiset<int> window(nums.begin(), nums.begin() + k);
auto mid = next(window.begin(), k / 2);
vector<double> res;
for (int i = k; ; ++i) {
res.push_back((double(*mid) + *prev(mid, 1 - k % 2)) / 2);
if (i == nums.size()) break;
window.insert(nums[i]);
if (nums[i] < *mid) mid--;
if (nums[i - k] <= *mid) mid++;
window.erase(window.lower_bound(nums[i - k]));
}
return res;
}
这里使用multiset允许重复元素,并利用迭代器高效计算中位数。
3.3 范围查询与集合运算
set的高效范围查询使其非常适合处理区间问题。例如实现一个日程安排系统(LeetCode第729题):
cpp复制class MyCalendar {
set<pair<int, int>> bookings;
public:
bool book(int start, int end) {
auto it = bookings.lower_bound({start, end});
if (it != bookings.end() && it->first < end) return false;
if (it != bookings.begin() && (--it)->second > start) return false;
bookings.insert({start, end});
return true;
}
};
这个解法利用set的有序性,通过检查相邻区间来判断是否有重叠。
4. set的高级用法与性能优化
4.1 自定义比较函数
对于复杂数据类型,自定义比较函数是必须掌握的技巧:
cpp复制struct Point {
int x, y;
};
struct PointCmp {
bool operator()(const Point& a, const Point& b) const {
return a.x < b.x || (a.x == b.x && a.y < b.y);
}
};
set<Point, PointCmp> points;
points.insert({1, 2});
points.insert({3, 4});
points.insert({1, 3});
4.2 与unordered_set的对比选择
虽然set功能强大,但并非所有场景都适用。与unordered_set的主要区别:
| 特性 | set | unordered_set |
|---|---|---|
| 底层实现 | 红黑树 | 哈希表 |
| 元素顺序 | 有序 | 无序 |
| 查找时间复杂度 | O(log n) | O(1)平均,O(n)最坏 |
| 内存占用 | 较低 | 较高 |
| 适用场景 | 需要有序或范围查询 | 只需快速查找 |
4.3 迭代器失效问题
set的迭代器在元素删除时需要特别注意:
cpp复制set<int> s = {1, 2, 3, 4, 5};
for (auto it = s.begin(); it != s.end(); ) {
if (*it % 2 == 0) {
it = s.erase(it); // erase返回下一个有效迭代器
} else {
++it;
}
}
错误的做法是直接调用erase(it++),这可能导致未定义行为。
5. 实战案例分析:set在竞赛中的应用
5.1 维护动态Top K元素
考虑这样一个问题:实时接收数字流,需要随时能够返回当前最大的K个数字。使用set可以高效实现:
cpp复制class TopK {
set<int> elements;
int k;
public:
TopK(int k) : k(k) {}
void add(int num) {
elements.insert(num);
if (elements.size() > k) {
elements.erase(elements.begin());
}
}
vector<int> getTopK() {
return vector<int>(elements.rbegin(), elements.rend());
}
};
这个实现保证了插入和删除操作都是O(log k)复杂度。
5.2 最近邻查找
在二维平面上有一组点,需要快速找到距离给定点最近的点。使用set可以优雅解决:
cpp复制set<pair<int, int>> points;
void addPoint(int x, int y) {
points.insert({x, y});
}
pair<int, int> findNearest(int x, int y) {
auto it = points.lower_bound({x, y});
if (it == points.end()) --it;
double minDist = distance(x, y, it->first, it->second);
pair<int, int> result = *it;
// 检查前一个点
if (it != points.begin()) {
--it;
double dist = distance(x, y, it->first, it->second);
if (dist < minDist) {
minDist = dist;
result = *it;
}
}
return result;
}
5.3 时间序列数据处理
在处理带时间戳的事件时,set可以高效维护时间顺序:
cpp复制struct Event {
time_t timestamp;
string data;
bool operator<(const Event& other) const {
return timestamp < other.timestamp;
}
};
set<Event> eventLog;
void processEvent(time_t ts, const string& data) {
eventLog.insert({ts, data});
// 删除1小时前的旧事件
time_t threshold = time(nullptr) - 3600;
auto it = eventLog.lower_bound({threshold, ""});
eventLog.erase(eventLog.begin(), it);
}
6. 常见陷阱与最佳实践
6.1 性能陷阱
- 不必要的拷贝:在插入复杂对象时,使用emplace代替insert可以避免临时对象构造:
cpp复制set<vector<int>> s;
s.emplace(3, 1); // 直接构造vector<int>(3, 1)
- 频繁的插入删除:虽然单次操作是O(log n),但批量操作时应该尽量使用范围操作:
cpp复制// 不好:O(n log n)
for (int num : nums) {
s.insert(num);
}
// 更好:O(n)平均
s.insert(nums.begin(), nums.end());
6.2 正确性陷阱
- 自定义比较函数的严格弱序:比较函数必须满足严格弱序关系,否则会导致未定义行为:
cpp复制// 错误的比较函数
struct BadCmp {
bool operator()(int a, int b) const {
return a <= b; // 应该使用 < 而不是 <=
}
};
- 迭代器失效:除了erase外,多线程环境下也需要特别注意迭代器安全。
6.3 调试技巧
当set行为不符合预期时,可以:
- 打印容器内容:
cpp复制template<typename T>
void printSet(const set<T>& s) {
for (const auto& x : s) cout << x << " ";
cout << endl;
}
-
检查比较函数是否满足严格弱序。
-
使用gdb等调试器检查红黑树结构。
7. 扩展思考:从set到其他关联容器
掌握了set后,可以轻松扩展到其他关联容器:
- multiset:允许重复元素的set,接口几乎相同
- map/multimap:存储键值对,底层同样使用红黑树
- unordered_set/unordered_map:哈希表实现,提供更快的查找速度
选择容器时的决策树:
- 需要键值对?→ 选择map系列
- 允许重复?→ 选择multi系列
- 需要元素有序?→ 选择set/map
- 只需快速查找?→ 选择unordered系列
在实际工程中,我通常会先使用unordered_set,当发现需要有序特性时再切换到set。这种渐进式的选择策略往往能获得最佳的性能与功能平衡。
