ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++ STL set容器详解:红黑树实现的有序唯一集合与高效操作

C++ STL set容器详解:红黑树实现的有序唯一集合与高效操作 1. 从“集合”到“有序唯一”为什么我们需要set在C的日常开发里尤其是处理数据去重、排序或者快速查找某个元素是否存在时你可能会本能地想到用数组或者vector然后自己写循环去遍历、比较、排序。但说实话这种“手动挡”操作不仅代码冗长效率上也常常是O(n)级别的线性查找数据量一大性能瓶颈就非常明显。这时候STLStandard Template Library里的set容器就该登场了。它本质上是一个关联式容器内部实现通常是红黑树一种自平衡的二叉搜索树这就决定了它最大的两个特性自动排序和元素唯一性。想象一下你正在开发一个游戏的后台服务需要维护一个当前在线的玩家ID列表。这个列表需要满足几个要求ID不能重复一个玩家不可能同时登录两次、能快速判断某个玩家是否在线查找操作、并且可能还需要按ID顺序进行一些批量处理比如发放区间奖励。如果你用一个vector来存每次玩家上线都要遍历整个列表检查ID是否已存在下线时又要遍历查找并删除这显然是不可接受的。而set完美契合了这个场景插入时自动去重和排序查找、删除的时间复杂度都是O(log n)对于动辄数万甚至数十万的在线玩家这个效率提升是数量级的。set的“有序唯一”特性让它成为了解决特定一类问题的利器。除了上述场景它还常用于词频统计后的去重排序、数据库查询结果集的临时存储与排序、需要维护一个动态有序且不重复的集合的任何算法如求解两个集合的交集、并集等。理解set不仅仅是学会几个成员函数更是掌握一种高效处理“集合”类数据的思想。接下来我们就深入它的内部看看如何驾驭这个强大的工具。2. 庖丁解牛set容器的核心接口与基础操作要熟练使用set首先得熟悉它的“工具箱”。set定义在头文件set中是一个类模板。我们从一个最简单的例子开始看看如何创建和使用一个set。2.1 容器的创建与初始化创建一个set非常简单。最基本的你可以创建一个空的set然后在后续插入元素。#include iostream #include set int main() { // 创建一个空的set用于存储int类型数据 std::setint mySet; // 也可以使用初始化列表进行初始化 std::setint initSet {5, 2, 8, 2, 1, 5}; // 注意重复的2和5会被自动去除 // 查看初始化后的set内容 for (int num : initSet) { std::cout num ; // 输出1 2 5 8 已排序且去重 } std::cout std::endl; return 0; }这里有一个关键点需要注意set中的元素是const的。这意味着一旦一个元素被插入到set中你就不能直接修改它的值。为什么因为set内部依靠元素的值来维持红黑树的排序结构。如果你直接修改了一个元素的值可能会破坏这棵树的排序性质导致后续的查找、插入等操作出现未定义行为。如果你真的需要“修改”一个元素正确的做法是先删除旧元素再插入新值。2.2 元素的插入与删除向set中添加元素主要使用insert成员函数。它非常智能会处理去重和排序。std::setstd::string nameSet; nameSet.insert(Alice); nameSet.insert(Bob); nameSet.insert(Alice); // 这次插入会被忽略因为Alice已存在 // 使用迭代器范围插入例如从另一个容器 std::vectorstd::string vecNames {Charlie, David, Bob}; nameSet.insert(vecNames.begin(), vecNames.end()); // Bob不会被重复插入 // 使用emplace进行原地构造插入C11及以上效率可能更高 nameSet.emplace(Eve);insert函数会返回一个pairiterator, bool。其中first是一个迭代器指向被插入的元素如果插入成功或指向set中已存在的那个等值元素如果插入失败。second是一个布尔值表示插入是否成功true表示成功插入新元素false表示元素已存在。这个返回值在需要知道插入结果时非常有用。删除元素则有几种方式erase(key): 删除与给定键值匹配的元素返回删除的元素个数对于set只能是0或1。erase(iterator): 删除迭代器指向的元素。erase(first, last): 删除迭代器区间[first, last)内的所有元素。clear(): 清空整个set。std::setint numSet {10, 20, 30, 40, 50}; // 通过值删除 size_t count numSet.erase(20); // count 1 count numSet.erase(99); // count 0 因为99不存在 // 通过迭代器删除通常先查找 auto it numSet.find(30); if (it ! numSet.end()) { numSet.erase(it); // 删除30 } // 删除一个区间比如删除所有大于等于40的元素 auto it_low numSet.lower_bound(40); numSet.erase(it_low, numSet.end()); // 删除40和50 // 清空 numSet.clear(); // 现在numSet为空2.3 元素的查找与访问由于set的元素是排序的它提供了高效的查找操作。find(key): 返回一个迭代器指向键值为key的元素。如果没找到则返回end()迭代器。这是最常用的查找方法。count(key): 返回set中键值为key的元素个数。对于set返回值只能是0或1。因此if (mySet.count(key))常用来判断元素是否存在。lower_bound(key)/upper_bound(key): 返回迭代器指向第一个不小于/大于key的元素。常用于范围查询。equal_range(key): 返回一个pairiterator, iterator表示所有键值等于key的元素范围。对于set这个范围最多包含一个元素。这里需要特别注意set没有[]运算符也不能用at()方法。因为你不能通过“键”来直接修改“值”在set中元素本身就是键。访问元素的唯一方式是通过迭代器。std::setint s {5, 1, 4, 2, 3}; // 查找元素4 auto it s.find(4); if (it ! s.end()) { std::cout Found: *it std::endl; // 输出Found: 4 // *it 10; // 错误不能修改set中的元素值 } // 判断元素是否存在 if (s.count(6)) { std::cout 6 exists. std::endl; } else { std::cout 6 does not exist. std::endl; // 输出这个 } // 范围查询找到所有在[2, 4]区间内的元素2 x 4 auto low s.lower_bound(2); // 指向2 auto up s.upper_bound(4); // 指向5第一个大于4的元素 for (auto itr low; itr ! up; itr) { std::cout *itr ; // 输出2 3 4 }2.4 容量与遍历set也提供了标准的容器查询接口empty(): 判断是否为空。size(): 返回元素个数。max_size(): 返回容器可能容纳的最大元素数量一个理论值。遍历set最常用的方法是使用基于范围的for循环C11或者使用迭代器。由于set是有序的遍历出来的元素也是按升序排列的。std::setchar charSet {d, a, c, b}; // 方法1基于范围的for循环推荐 for (const auto ch : charSet) { std::cout ch ; // 输出a b c d 自动排序 } std::cout std::endl; // 方法2使用迭代器 for (auto it charSet.begin(); it ! charSet.end(); it) { std::cout *it ; } std::cout std::endl; // 方法3反向遍历从大到小 for (auto rit charSet.rbegin(); rit ! charSet.rend(); rit) { std::cout *rit ; // 输出d c b a }3. 进阶玩法自定义排序与multiset基础的set默认使用std::less进行升序排序。但现实世界的数据并不总是简单的整数或字符串我们可能需要按照自定义的规则来组织集合。这时就需要为set指定一个比较函数或函数对象。3.1 使用自定义比较函数假设我们有一个Person结构体我们想按照年龄降序存储一个不重复的人员集合。#include iostream #include set #include string struct Person { std::string name; int age; // 为了方便初始化提供一个构造函数 Person(std::string n, int a) : name(std::move(n)), age(a) {} }; // 自定义比较仿函数函数对象 struct CompareByAgeDesc { bool operator()(const Person a, const Person b) const { // 按年龄降序排列 return a.age b.age; // 如果需要年龄相同则按姓名升序可以这样写 // return (a.age b.age) || (a.age b.age a.name b.name); } }; int main() { // 创建set并指定自定义的比较类型 std::setPerson, CompareByAgeDesc personSet; personSet.emplace(Alice, 25); personSet.emplace(Bob, 30); personSet.emplace(Charlie, 20); personSet.emplace(David, 30); // 年龄与Bob相同但比较器只比较年龄所以会被视为“重复”而插入失败 for (const auto p : personSet) { std::cout p.name : p.age std::endl; } // 输出 // Bob: 30 // Alice: 25 // Charlie: 20 // 注意David没有被插入因为对于set!(ab) !(ba) 即判定为相等。 return 0; }这里有一个非常重要的坑set判断两个元素是否“相等”并不是使用operator而是使用你提供的比较函数Comp。它判断a和b是否相等的逻辑是!comp(a, b) !comp(b, a)。在上面的例子中CompareByAgeDesc只比较了年龄所以年龄相同的Bob和David就被判定为“相等”导致David无法插入。如果你希望年龄相同但姓名不同的人可以同时存在就必须在比较函数中把姓名也作为排序条件的一部分确保每个对象都有唯一的排序键。3.2 使用Lambda表达式C11及以上对于简单的比较逻辑使用Lambda表达式定义set会更简洁尤其是在函数内部。auto cmp [](const Person a, const Person b) { return a.age b.age; // 按年龄升序 }; // 注意Lambda表达式不能直接作为模板参数类型需要decltype获取其类型并作为构造函数参数传入。 std::setPerson, decltype(cmp) personSet2(cmp); personSet2.emplace(Eve, 28); personSet2.emplace(Frank, 22); // ...3.3 允许重复元素的multiset如果你需要一个有序的集合但又允许元素重复那么std::multiset就是你的选择。它和set在同一个头文件set中接口几乎完全一样唯一的区别就是允许存储多个值相等的元素。#include set int main() { std::multisetint ms; ms.insert(5); ms.insert(2); ms.insert(5); ms.insert(5); ms.insert(1); for (int val : ms) { std::cout val ; // 输出1 2 5 5 5 } std::cout std::endl; // 查找元素5 auto range ms.equal_range(5); // 返回所有5的范围 for (auto it range.first; it ! range.second; it) { std::cout *it ; // 输出5 5 5 } std::cout std::endl; // 删除一个5只删除一个 ms.erase(ms.find(5)); // 通过迭代器删除找到的第一个5 // 删除所有的5 ms.erase(5); // 通过值删除会删除所有值为5的元素 return 0; }使用multiset时count()函数返回的可能大于1erase(key)会删除所有等于key的元素equal_range()函数会变得非常有用。它的底层实现同样通常是红黑树所以时间复杂度特性与set一致。4. 实战场景剖析set在算法与工程中的应用理解了基本操作和进阶特性后我们来看看set在解决实际问题时的威力。它不仅仅是存储数据的容器更是提升算法效率的利器。4.1 场景一维护动态有序不重复序列这是set最直接的应用。比如我们需要实时接收股票价格报价并始终保持当前最新的N个不重复的报价用于计算移动平均或其他指标。使用vector每次插入都要排序去重成本是O(n log n)。而使用set插入的代价是O(log n)并且始终保持有序。std::setdouble latestPrices; const size_t N 100; void onNewPrice(double price) { latestPrices.insert(price); // 如果超过容量删除最小的即第一个元素 if (latestPrices.size() N) { latestPrices.erase(latestPrices.begin()); } // 此时latestPrices中始终保持最新的100个不重复价格且已排序 }4.2 场景二快速去重与集合运算给定两个大的整数数组求它们的交集、并集、差集。使用set可以非常优雅地解决。#include vector #include set #include algorithm // for set_intersection, set_union等 #include iterator // for inserter std::vectorint vec1 {1, 2, 2, 3, 4, 5}; std::vectorint vec2 {3, 4, 4, 5, 6, 7}; // 方法1利用set自动去重排序的特性 std::setint set1(vec1.begin(), vec1.end()); std::setint set2(vec2.begin(), vec2.end()); std::setint intersection; std::set_intersection(set1.begin(), set1.end(), set2.begin(), set2.end(), std::inserter(intersection, intersection.begin())); // intersection 现在包含 {3, 4, 5} std::setint union_set; std::set_union(set1.begin(), set1.end(), set2.begin(), set2.end(), std::inserter(union_set, union_set.begin())); // union_set 现在包含 {1, 2, 3, 4, 5, 6, 7} // 方法2更“C”的写法直接操作set std::setint diff; std::set_difference(set1.begin(), set1.end(), set2.begin(), set2.end(), std::inserter(diff, diff.begin())); // diff 现在包含 set1有而set2没有的元素{1, 2}4.3 场景三作为算法的辅助数据结构在许多算法中set可以用来高效地维护一个“已访问”或“候选”集合。例如在图的最短路径算法Dijkstra算法中我们需要频繁地从所有未确定最短路径的顶点中选出距离起点最近的那个。如果使用数组每次选择都是O(n)。如果使用set或priority_queue但set可以方便地查找和修改元素可以将每次选择的复杂度降到O(log n)。下面是一个简化版的思路演示非完整Dijkstrastruct Vertex { int id; int dist; // 需要重载运算符让set能按dist排序 bool operator(const Vertex other) const { // 注意set需要严格弱序。如果dist相同必须用id区分否则会被视为相同元素 return std::tie(dist, id) std::tie(other.dist, other.id); } }; std::setVertex candidateSet; // 初始化将所有顶点加入候选集距离设为无穷大 for (int i 0; i numVertices; i) { candidateSet.insert({i, INT_MAX}); } // 将起点距离设为0需要先删除旧值再插入新值 candidateSet.erase({startId, INT_MAX}); candidateSet.insert({startId, 0}); while (!candidateSet.empty()) { // 取出距离最小的顶点set.begin()就是最小元素 Vertex current *candidateSet.begin(); candidateSet.erase(candidateSet.begin()); // 松弛其邻接顶点... for (auto neighbor : getNeighbors(current.id)) { int newDist current.dist neighbor.weight; // 在set中找到这个邻接顶点需要知道其旧的dist // 这里演示了查找和更新操作 auto it std::find_if(candidateSet.begin(), candidateSet.end(), [neighbor](const Vertex v) { return v.id neighbor.id; }); if (it ! candidateSet.end() newDist it-dist) { // 删除旧记录插入新记录 Vertex updated *it; candidateSet.erase(it); updated.dist newDist; candidateSet.insert(updated); } } }虽然在实际的Dijkstra实现中我们更常用priority_queue二叉堆因为它对于提取最小值的操作效率更高O(1)的堆顶访问。但set提供了一个可以同时支持高效查找、插入、删除和有序遍历的替代方案在某些需要频繁修改键值如距离并重新排序的场景下其代码逻辑可能更清晰。不过要注意set的每次插入和删除都是O(log n)而二叉堆的decrease-key操作如果实现得好均摊复杂度可以更低。这是一个在特定场景下的取舍。5. 性能、陷阱与最佳实践任何工具都有其适用边界set也不例外。了解它的性能特点和常见陷阱能让你在项目中用得更加得心应手。5.1 时间复杂度与空间开销set以及multiset,map,multimap基于红黑树实现这带来了以下性能特征插入 (insert,emplace): O(log n)。需要维持树的平衡。删除 (erase): O(log n)。查找 (find,count,lower_bound): O(log n)。遍历: 从begin()到end()是O(n)并且是顺序遍历有序的。空间开销: 除了存储元素本身每个节点还需要额外的指针左孩子、右孩子、父节点和颜色信息因此内存开销比vector、array等序列式容器要大。对比与选型建议如果需要频繁随机访问按索引用vector或array。set不支持[]运算符按值查找是O(log n)按“第k个”元素访问也需要O(n)的遍历。如果只需要检查元素是否存在且不关心顺序考虑unordered_set。它是基于哈希表的实现平均情况下的插入、删除、查找是O(1)但最坏情况是O(n)。它不保证元素顺序。如果元素经常变动且需要始终保持有序或者需要范围查询如“找出所有在A和B之间的值”set是很好的选择。如果允许重复元素且需要有序用multiset。5.2 迭代器失效问题与vector不同set和map的迭代器失效规则相对友好插入操作不会使任何迭代器失效除了被删除元素的迭代器当然会失效。删除操作只会使指向被删除元素的迭代器失效其他迭代器仍然有效。这意味着你可以在遍历set的过程中安全地插入新元素不会导致迭代器失效但在遍历过程中删除当前迭代器指向的元素需要特别注意。错误的写法会导致未定义行为。std::setint s {1, 2, 3, 4, 5}; // 错误示范在遍历时直接删除当前迭代器指向的元素 for (auto it s.begin(); it ! s.end(); it) { if (*it % 2 0) { s.erase(it); // 错误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返回下一个迭代器但更通用的写法是上面那种。 // 正确写法3先收集要删除的元素遍历完再统一删除适用于判断条件复杂或删除操作不影响后续判断的情况 std::vectorint toErase; for (const auto val : s) { if (val % 2 0) { toErase.push_back(val); } } for (int val : toErase) { s.erase(val); }5.3 自定义比较器的严格弱序要求这是使用自定义排序set时最容易出错的地方。比较器必须满足严格弱序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)。对于基本类型std::less自然满足。对于自定义类型常见的做法是使用std::tie将多个成员变量打包成元组进行比较这能帮你自动生成正确的严格弱序比较逻辑。struct MyKey { int id; std::string name; int value; // 方法1重载 运算符 bool operator(const MyKey other) const { // 按id升序如果id相同按name升序再相同按value升序 return std::tie(id, name, value) std::tie(other.id, other.name, other.value); } }; // 方法2自定义仿函数 struct CompareMyKey { bool operator()(const MyKey a, const MyKey b) const { return std::tie(a.id, a.name, a.value) std::tie(b.id, b.name, b.value); } }; std::setMyKey s1; // 使用重载的operator std::setMyKey, CompareMyKey s2; // 使用自定义仿函数一个常见的坑如果你只根据对象的部分字段进行比较比如只比较id那么对于set来说只要id相同的两个对象就被认为是“相等的”即使它们的name和value不同也无法同时存入set。这在设计比较逻辑时必须考虑清楚。5.4 查找与插入的优化技巧由于set的插入操作包含查找过程判断元素是否已存在当你明确知道要插入的元素可能不存在或者即使存在也要插入时对于multiset可以使用emplace_hint来提升性能。emplace_hint接受一个迭代器作为“提示”hint表示新元素插入位置的建议。如果提示位置正确新元素紧接在提示迭代器之后插入则插入操作可以达到分摊常数时间复杂度O(1)否则退化为普通的O(log n)。当你有一系列已知是连续或近似连续插入的值时这很有用。std::setint s; auto hint s.end(); // 初始提示位置在末尾 // 假设我们要按顺序插入1, 2, 3, 4, 5 for (int i 1; i 5; i) { // 每次插入后hint指向新插入的元素下一个插入很可能就在它后面 hint s.emplace_hint(hint, i); }另一个技巧是当你需要“如果不存在则插入如果存在则获取”这种原子性操作时可以利用insert的返回值。std::setstd::string userCache; // 尝试插入一个新用户 auto [it, inserted] userCache.insert(new_user); if (inserted) { std::cout 用户插入成功。\n; // it 指向新插入的元素 } else { std::cout 用户已存在。\n; // it 指向已存在的元素 } // 无论插入成功与否it都指向这个键对应的元素set是C STL中一个强大而精致的工具。它用红黑树的复杂性为开发者封装了有序、唯一集合的简单接口。理解其底层原理熟知其接口特性避开常见的陷阱你就能在合适的场景中发挥它的最大效能写出既高效又清晰的代码。从简单的去重排序到复杂的算法辅助数据结构set都是C程序员武器库中不可或缺的一员。
返回列表