C++ STL std::set 容器详解:红黑树实现、核心接口与性能实战

发布时间:2026/7/24 5:45:05
C++ STL std::set 容器详解:红黑树实现、核心接口与性能实战 1. 容器新贵为什么是std::set在C的STL标准模板库里容器家族可谓人丁兴旺。我们最熟悉的莫过于std::vector它像是一个可以动态扩容的数组数据按插入顺序紧密排列访问速度飞快。还有std::list一个双向链表插入删除灵活自如。但当你需要频繁地检查一个元素是否存在或者需要维护一个自动排序且元素唯一的集合时前面两位就显得有些力不从心了。这时std::set就该登场了。你可以把std::set想象成一个高度自律、且有强迫症的集合管理员。它有两个核心特质唯一性和有序性。你往里面扔元素它会自动帮你排好序默认是升序并且保证每个元素只出现一次重复的会被无情拒绝。这种特性让它天生适合解决一些特定场景的问题比如维护一个在线用户ID列表保证唯一、统计一篇文章中出现的不同单词去重并可能按字母序输出、或者作为某些算法的辅助数据结构来快速判断元素归属。它的底层通常由红黑树Red-Black Tree实现。这是一种自平衡的二叉搜索树。我刚开始学的时候也觉得“树”这个概念有点抽象后来我把它类比成公司的组织架构图最顶上是CEO根节点下面分管不同部门左右子树每个部门经理节点下面又有自己的团队。红黑树通过一套复杂的着色和旋转规则确保这棵“公司树”不会长得一边倒即避免退化成链表从而保证了插入、删除、查找操作的时间复杂度都能稳定在O(log n)。这意味着即使你的数据量从1万增加到100万set的操作耗时也只会增加一个很小的常数倍性能非常可预测。相比之下在vector里查找一个元素平均需要 O(n) 的时间数据量大时差距就非常明显了。2. 庖丁解牛std::set的核心接口与使用精要了解了set的“内在美”我们来看看怎么跟它打交道。它的接口设计得很清晰但有些细节不注意就容易踩坑。2.1 创建与初始化不止一种方式创建一个set很简单但根据不同的需求我们有多种初始化姿势。#include iostream #include set #include vector int main() { // 1. 默认构造创建一个空的set按默认的 lessint 排序升序 std::setint s1; // 2. 范围构造用另一个容器的迭代器范围来初始化 std::vectorint vec {5, 2, 5, 8, 2, 1}; // 注意有重复元素 std::setint s2(vec.begin(), vec.end()); // s2 内容为 {1, 2, 5, 8}已去重排序 // 3. 初始化列表构造C11及以上最直观的方式 std::setint s3 {10, 30, 20, 10}; // s3 内容为 {10, 20, 30} // 4. 指定排序规则创建一个降序排列的set std::setint, std::greaterint s4 {5, 1, 4}; // s4 内容为 {5, 4, 1} // 5. 拷贝构造和赋值 std::setint s5(s3); // 拷贝s3 std::setint s6 s3; // 赋值操作 return 0; }注意初始化列表{10, 30, 20, 10}中的第二个10在构造s3时会被忽略因为set的唯一性在构造阶段就生效了。这是很多新手容易困惑的地方他们以为set会在插入时才去重其实在初始化时就已经完成了去重和排序。2.2 元素的增删查改“改”的陷阱set的“增删查”都很直观唯独这个“改”需要特别小心。插入Insert插入是set最常用的操作之一它有多个重载版本返回值也很有讲究。std::setint mySet {1, 5, 9}; // 方式1插入单个值。返回一个pairiterator, bool auto result mySet.insert(3); // result.first 是指向新插入元素或已存在元素的迭代器 // result.second 是一个bool表示插入是否成功true表示成功插入false表示元素已存在 if (result.second) { std::cout 插入成功 std::endl; } else { std::cout 元素已存在插入失败。 std::endl; } // 方式2使用提示迭代器插入hint insert效率可能更高 auto hint mySet.find(5); // 先找到一个位置 if (hint ! mySet.end()) { mySet.insert(hint, 4); // 提示在5之前插入4如果提示正确能提升性能 } // 方式3插入一个范围 std::vectorint moreNumbers {2, 6, 6, 10}; mySet.insert(moreNumbers.begin(), moreNumbers.end()); // 插入2,6,106被去重删除Erase删除操作同样灵活可以按值、按迭代器或按范围删除。std::setint mySet {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 方式1按值删除。返回删除的元素个数对于set只能是0或1 size_t count mySet.erase(5); // count 1 // 方式2按迭代器删除。返回被删除元素之后元素的迭代器C11起 auto it mySet.find(3); if (it ! mySet.end()) { it mySet.erase(it); // 删除3it现在指向4 } // 方式3按范围删除 auto first mySet.find(7); auto last mySet.find(10); if (first ! mySet.end() last ! mySet.end()) { mySet.erase(first, last); // 删除 [7, 10) 区间的元素即7,8,9 }查找Find与计数Count查找是set的强项得益于红黑树结构速度很快。std::setstd::string nameSet {Alice, Bob, Charlie}; // 使用 find()返回迭代器 auto it nameSet.find(Bob); if (it ! nameSet.end()) { std::cout 找到了: *it std::endl; } else { std::cout 没找到 std::endl; } // 使用 count()对于set返回值只能是0或1 if (nameSet.count(David) 0) { std::cout David在集合中 std::endl; } else { std::cout David不在集合中 std::endl; }实操心得判断一个元素是否存在count()和find() ! end()在功能上等价。但如果你找到元素后还需要用它做点什么比如修改等等set的元素不能直接修改那么find()是更好的选择因为它一次性拿到了迭代器。而count()对于set来说除了返回0或1没有更多信息。“修改”的陷阱与正确姿势这是set最关键的坑点之一set中的元素是const的。为什么因为元素的值决定了它在红黑树中的位置。如果你直接通过迭代器修改了元素的值就破坏了树的排序不变性导致整个数据结构处于非法状态后续行为未定义。std::setint s {1, 2, 3}; auto it s.find(2); // *it 4; // 错误编译不通过因为 *it 是 const int那么如何“修改”一个元素呢正确的做法是先删除旧元素再插入新元素。std::setstd::string s {apple, banana, cherry}; // 想把 “banana” 改成 “berry” auto it s.find(banana); if (it ! s.end()) { s.erase(it); // 1. 删除旧元素 s.insert(berry); // 2. 插入新元素 } // 注意这里“berry”会按照排序规则插入到新的位置不一定是原来“banana”的位置。这个过程涉及一次查找用于定位、一次删除和一次插入时间复杂度是 O(log n)。虽然看起来步骤多但这是保证set有序性和完整性的唯一安全方式。2.3 遍历与范围操作迭代器的正确打开方式遍历set非常简单因为它的迭代器提供了对元素的只读访问并且遍历顺序就是排序顺序。std::setint s {50, 20, 80, 30, 60}; // 方法1使用迭代器 (C98风格) for (std::setint::iterator it s.begin(); it ! s.end(); it) { std::cout *it ; } std::cout std::endl; // 输出: 20 30 50 60 80 // 方法2使用基于范围的for循环 (C11风格推荐) for (const auto num : s) { std::cout num ; } std::cout std::endl; // 输出同样是有序的 // 反向遍历 for (auto rit s.rbegin(); rit ! s.rend(); rit) { std::cout *rit ; } std::cout std::endl; // 输出: 80 60 50 30 20set的迭代器属于双向迭代器可以前进 (it)、后退 (--it)但不能像vector的随机访问迭代器那样进行it 5这样的跳跃。范围操作lower_bound和upper_bound这两个函数是set以及其他有序关联容器的利器用于进行范围查询。lower_bound(key)返回第一个不小于key的元素的迭代器。upper_bound(key)返回第一个大于key的元素的迭代器。它们通常配合使用来获取一个左闭右开区间[lower_bound, upper_bound)。std::setint s {10, 20, 30, 40, 50, 60}; // 找出所有大于等于25且小于45的元素 auto low s.lower_bound(25); // 指向30第一个25的 auto up s.upper_bound(45); // 指向50第一个45的 for (auto it low; it ! up; it) { std::cout *it ; } std::cout std::endl; // 输出: 30 40 // 还有一个 equal_range(key)它返回一个pair分别是lower_bound和upper_bound的结果 auto range s.equal_range(30); // range.first 等价于 s.lower_bound(30) // range.second 等价于 s.upper_bound(30) // 对于set如果key存在这个区间就只包含key本身如果不存在则区间为空。3. 进阶探索自定义类型与性能考量当你不再满足于存储int、string这些内置类型想要把自定义的类或结构体放进set时事情就变得有趣了。3.1 让自定义类型住进setset要为你自定义的类型排序就必须知道如何比较两个对象的大小。有两种主流方式方式一重载运算符这是最简洁的方式。只需要在你的类里定义operator。struct Person { std::string name; int age; // 重载小于运算符按年龄排序 bool operator(const Person other) const { return age other.age; // 如果需要多级排序比如年龄相同按姓名排 // return std::tie(age, name) std::tie(other.age, other.name); } }; int main() { std::setPerson people; people.insert({Alice, 30}); people.insert({Bob, 25}); people.insert({Charlie, 30}); // 年龄与Alice相同根据我们的operator它被视为“相等”插入失败 for (const auto p : people) { std::cout p.name : p.age std::endl; } // 输出: // Bob: 25 // Alice: 30 // 注意Charlie没有被插入因为30岁的“键”已经存在Alice。 }关键点set判断两个元素是否“相等”不是用operator而是用!a b !b a。也就是说如果a不小于b且b也不小于a那么它们就被认为是相等的。所以你的operator必须定义出一个严格弱序。简单理解就是不能出现a b和b a同时为真的情况并且如果a不小于b且b不小于a那么a和b就是等价的对于set就是重复的。方式二提供自定义比较函数对象仿函数这种方式更灵活尤其是当你无法修改类定义或者需要多种不同排序方式时。struct Person { std::string name; int age; }; // 自定义比较器按姓名排序 struct CompareByName { bool operator()(const Person a, const Person b) const { return a.name b.name; } }; int main() { // 在模板参数中传入比较器类型 std::setPerson, CompareByName peopleByName; peopleByName.insert({Zack, 40}); peopleByName.insert({Alice, 30}); peopleByName.insert({Bob, 25}); for (const auto p : peopleByName) { std::cout p.name std::endl; } // 输出: Alice, Bob, Zack (按姓名字母序) // 你也可以用lambda表达式C14起需要指定比较器类型 auto cmpByAgeDesc [](const Person a, const Person b) { return a.age b.age; }; std::setPerson, decltype(cmpByAgeDesc) peopleByAgeDesc(cmpByAgeDesc); // 注意用lambda作为比较器时构造set对象时需要传入这个lambda的一个实例。 }3.2std::set的性能分析与实战选型我们总说set的操作是 O(log n)但这个“log n”具体意味着什么我们来算一笔账。 假设n是元素数量log 通常指以2为底的对数。n1024时log₂(1024) 10。set最多需要10次比较就能找到元素。n1,000,000时log₂(1,000,000) ≈ 20。最多20次比较。n1,000,000,000时log₂(1,000,000,000) ≈ 30。最多30次比较。可以看到即使数据量达到十亿级别set的查找次数也只在30次左右效率非常高且稳定。相比之下在无序的vector中查找平均需要5亿次比较这是天壤之别。但是O(log n) 就一定比 O(1) 慢吗这里有个常见的误区。std::unordered_set哈希集合的查找平均是 O(1)但它是不稳定的最坏情况可能退化到 O(n)。而set的 O(log n) 是最坏情况保证。在实际中对于数据量不是特别巨大比如百万级以下且需要有序遍历的场景set的综合性能往往更优因为它缓存友好红黑树节点通常在内存中连续分配而哈希表可能因为冲突和重哈希导致缓存命中率低。选型指南什么时候用set什么时候用别的用std::set需要元素自动排序。需要按顺序进行范围查询如找某个区间内的所有元素。需要稳定的对数级性能保证厌恶哈希表最坏情况的不可预测性。数据量适中且插入删除查找操作混合进行。用std::unordered_set只需要快速判断存在性不关心顺序。数据量非常大且哈希函数设计良好能保证较低的冲突率。元素的哈希计算很快而比较操作用于set相对较慢。用std::vectorstd::sortstd::unique数据一次性导入之后主要是查询很少增删。对内存连续性要求高需要极致的遍历速度。你可以接受在数据变更后手动重新排序去重的开销。4. 避坑指南与高阶技巧在实际项目中用set我踩过不少坑也总结出一些让代码更高效、更安全的心得。4.1 迭代器失效看不见的陷阱这是所有STL容器都需要注意的问题set也不例外。set的迭代器在元素被删除时会失效但仅限于指向被删除元素的迭代器。其他迭代器通常保持有效。std::setint s {1, 2, 3, 4, 5}; auto it1 s.find(3); auto it2 s.find(4); s.erase(3); // 删除元素3 // it1 现在已失效不能再解引用或使用它。 // std::cout *it1 std::endl; // 错误未定义行为。 // it2 仍然有效因为它指向的元素4没有被删除。 if (it2 ! s.end()) { // 虽然有效但最好重新获取除非你能百分百确定 std::cout *it2 std::endl; // 安全输出4 } // 安全的做法利用 erase 的返回值 it2 s.find(4); if (it2 ! s.end()) { it2 s.erase(it2); // erase 返回被删元素下一个位置的迭代器 // 现在 it2 指向5如果存在 }重要经验在循环中删除元素是经典陷阱。错误的写法会导致迭代器失效和崩溃。std::setint s {1, 2, 3, 4, 5}; // 错误写法删除所有偶数 for (auto it s.begin(); it ! s.end(); it) { if (*it % 2 0) { s.erase(it); // 删除后it 失效后续的 it 行为未定义 } } // 正确写法1利用 erase 返回值更新迭代器 for (auto it s.begin(); it ! s.end(); /* 这里不写 it */) { if (*it % 2 0) { it s.erase(it); // erase 返回下一个有效迭代器 } else { it; } } // 正确写法2C11起使用 erase_if 算法更清晰 std::erase_if(s, [](int n) { return n % 2 0; });4.2 自定义比较器的严格弱序要求前面提到自定义比较器必须满足“严格弱序”。违反这个规则会导致set行为异常甚至程序崩溃。一个常见的错误是在比较器中忘记处理相等的情况或者写出了非传递性的比较逻辑。// 错误示例一个“无效”的比较器 struct BadComparator { bool operator()(int a, int b) const { return abs(a) abs(b); // 按绝对值排序 } }; // 对于 setint, BadComparator: // 比较 2 和 -2abs(2) abs(-2) 为 falseabs(-2) abs(2) 也为 false。 // 根据 set 的规则!(2 -2) !(-2 2) 为真所以 2 和 -2 被认为是“相等”的。 // 这会导致你无法同时将 2 和 -2 插入到同一个 set 中即使它们的值不同。一个正确的、能区分正负数的绝对值比较器应该像这样struct GoodComparator { bool operator()(int a, int b) const { if (abs(a) ! abs(b)) { return abs(a) abs(b); } else { // 当绝对值相等时用原值的大小来区分例如让负数小于正数 return a b; } } }; // 这样2 和 -2 就能同时存在于 setint, GoodComparator 中了。4.3 内存与效率优化emplace与extract使用emplace进行原地构造如果你要向set中插入一个自定义类型的对象使用insert可能需要先构造一个临时对象再拷贝或移动到容器中。emplace允许你直接传入构造参数在容器内部原地构造对象避免了临时对象的创建和拷贝/移动开销。struct MyData { int id; std::string name; MyData(int i, const std::string n) : id(i), name(n) { std::cout 构造 MyData( id , name ) std::endl; } // 定义比较规则 bool operator(const MyData other) const { return id other.id; } }; int main() { std::setMyData s; std::cout 使用 insert: std::endl; s.insert(MyData(1, Alice)); // 先构造临时对象再移动或拷贝进set std::cout \n使用 emplace: std::endl; s.emplace(2, Bob); // 直接在set内部构造更高效 return 0; }使用extract进行低开销的“修改” (C17)C17 引入了extract成员函数它可以从set中“拔出”一个节点包含元素返回一个node_type。你可以修改这个节点中的元素然后再将其“插回”同一个或另一个set。这个过程避免了元素的拷贝或移动对于移动成本高的对象非常有用。std::setstd::string s {apple, banana, cherry}; // 传统方式删除再插入可能涉及字符串的分配和拷贝 auto it s.find(banana); if (it ! s.end()) { std::string value *it; // 拷贝字符串 s.erase(it); value[0] B; // 修改 s.insert(value); // 插入可能再次拷贝/移动 } // C17 extract 方式 if (auto node s.extract(banana); !node.empty()) { // node.value() 获取的是非常量引用 std::string value node.value(); value[0] B; // 直接修改 s.insert(std::move(node)); // 插回所有权转移无拷贝 } // 注意extract 后节点不再属于任何容器你可以任意修改其值 // 但只要你想把它插回某个 set修改后的值必须满足该 set 的排序规则。4.4 与std::multiset和std::unordered_set的对比std::set还有两个亲兄弟了解它们的区别能帮你做出更好的选择。std::multiset它允许重复元素其他特性与set相同有序红黑树实现。#include set std::multisetint ms {1, 3, 3, 2, 1}; for (int n : ms) std::cout n ; // 输出: 1 1 2 3 3 // count(3) 会返回 2 // find(3) 返回指向第一个3的迭代器 // equal_range(3) 返回包含所有3的迭代器范围当你需要有序但又需要记录元素出现次数时就用multiset。std::unordered_set基于哈希表实现元素无序但查找、插入、删除的平均时间复杂度是 O(1)。它需要元素提供哈希函数 (std::hash) 和相等比较 (operator)。#include unordered_set std::unordered_setint us {5, 2, 8, 2, 1}; // 顺序不确定 for (int n : us) std::cout n ; // 可能输出: 1 5 8 2 去重了当你对顺序没要求只追求极快的查找速度并且能提供好的哈希函数时就用unordered_set。选择哪一个最终取决于你的具体需求要顺序选set要允许重复选multiset要最快速度且不管顺序选unordered_set。没有绝对的好坏只有最适合场景的工具。

相关新闻

最新新闻

日新闻

周新闻

月新闻