
1. 项目概述为什么我们需要set和multiset在C的日常开发中尤其是在处理需要快速查找、去重或排序的数据集合时我们常常会面临一个选择用数组用链表还是自己手搓一个平衡二叉树对于有经验的C开发者来说答案往往是直接转向标准模板库STL中的关联容器。今天我们就来深入聊聊其中一对功能相似但细节决定成败的“兄弟”std::set和std::multiset。简单来说set是一个有序的、元素唯一的集合。想象一下你有一个通讯录你肯定不希望同一个人的名字出现两次并且希望名字能按字母顺序排列方便查找——set干的就是这个活。而multiset顾名思义是“多重集合”它允许元素重复出现但同样保持有序。比如统计一篇文章中每个单词出现的次数单词本身需要排序但“the”、“and”这类词肯定会重复出现这时multiset就派上用场了。这对容器之所以重要是因为它们底层通常基于红黑树一种自平衡的二叉搜索树实现。这意味着插入、删除和查找操作的平均时间复杂度都是 O(log n)对于需要频繁进行这些操作的中等规模数据集来说效率远高于线性查找的数组或链表。理解它们的异同不仅能帮助你在面试中应对“STL容器八股文”更能让你在实际项目中做出最合适、最高效的选择避免因为用错容器而引入隐藏的性能瓶颈或逻辑错误。2. 核心特性与底层原理深度对比要真正用好set和multiset不能只停留在“一个能重复一个不能”的表面认知。我们需要深入到它们的接口行为、迭代器特性和底层实现逻辑。2.1 元素唯一性与插入行为这是两者最根本的区别也直接影响了它们的核心接口行为。std::set唯一性的守护者当你向一个set中插入一个已经存在的元素时insert操作会失败。更准确地说set::insert会返回一个pairiterator, bool。其中bool值表示插入是否成功true为新插入false为已存在iterator指向容器中已存在的那个元素。这个特性使得set天然适合用于“确保唯一性”的场景。#include iostream #include set int main() { std::setint mySet; auto [it1, success1] mySet.insert(10); // 插入成功success1 true auto [it2, success2] mySet.insert(10); // 插入失败success2 false, it2 指向已存在的10 std::cout Set size: mySet.size() std::endl; // 输出: 1 return 0; }std::multiset允许重复的收集器multiset的insert操作总是“成功”的因为它允许重复。它的insert方法只返回一个迭代器指向新插入的元素无论是否已存在。这意味着每次调用insert容器的大小都会增加。#include iostream #include set // multiset 也在 set 头文件中 int main() { std::multisetint myMultiSet; auto it1 myMultiSet.insert(10); // 插入第一个10 auto it2 myMultiSet.insert(10); // 插入第二个10 std::cout Multiset size: myMultiSet.size() std::endl; // 输出: 2 return 0; }注意这里的“重复”是指值相等。对于自定义类型你需要正确定义运算符或提供自定义的比较函数Compare模板参数容器根据这个比较结果来判断两个元素是否“等价”而非使用运算符。2.2 迭代器与元素访问由于底层都是有序结构两者的迭代器都是“常迭代器”const_iterator这意味着你不能通过迭代器来修改元素的值。为什么因为修改元素的值可能会破坏容器内部的有序性这是底层红黑树数据结构所不允许的。你必须先删除旧元素再插入新值。std::setint s {1, 5, 3}; auto it s.find(3); if (it ! s.end()) { // *it 10; // 错误不能修改 set 中的元素 s.erase(it); // 先删除 s.insert(10); // 再插入新值 }对于multiset当你拥有多个相同值的元素时find、lower_bound、upper_bound和equal_range这几个方法的行为就变得尤为重要。find(value)返回指向第一个等于value的元素的迭代器。lower_bound(value)返回指向第一个不小于value的元素的迭代器。upper_bound(value)返回指向第一个大于value的元素的迭代器。equal_range(value)返回一个pairiterator, iterator表示所有等于value的元素的范围[first, last)。std::multisetint ms {1, 2, 2, 2, 3, 4}; auto range ms.equal_range(2); for (auto it range.first; it ! range.second; it) { std::cout *it ; // 输出: 2 2 2 } std::cout std::endl; // 删除所有值为2的元素 ms.erase(2); // 注意直接 erase(2) 会删除所有值为2的元素 // 如果只想删除一个需要传入迭代器 ms.insert(2); auto single_it ms.find(2); if (single_it ! ms.end()) { ms.erase(single_it); // 只删除一个2 }2.3 底层数据结构与性能分析两者通常都基于红黑树实现。红黑树是一种近似平衡的二叉搜索树它通过对节点着色和旋转规则来确保没有一条路径会比其他路径长出两倍从而保证了基本的动态集合操作查找、插入、删除在最坏情况下的时间复杂度也是 O(log n)。性能细节对比查找 (find,count,lower_bound): 两者都是 O(log n)。对于setcount只能是 0 或 1对于multisetcount需要遍历所有相等元素时间复杂度为 O(log n k)其中 k 是重复元素的数量。插入 (insert): 平均 O(log n)。set需要先查找位置并检查唯一性multiset只需查找插入位置。删除 (erase): 删除指定迭代器指向的元素是 O(log n)分摊后。但set::erase(value)和multiset::erase(value)行为不同set的版本返回 0 或 1multiset的版本返回被删除的元素个数并且因为它会删除所有匹配值时间复杂度是 O(log n k)。内存占用两者每个节点都需要存储左右子节点指针、父节点指针、颜色标志以及元素本身。multiset在存储大量重复元素时会产生更多节点内存开销更大。3. 典型应用场景与实战选择理解了原理关键是要知道在什么情况下该用谁。选择错误可能会导致代码低效、逻辑复杂甚至隐含bug。3.1std::set的适用场景去重且有序的集合这是最经典的场景。例如从日志文件中提取所有唯一的用户ID并排序。std::setstd::string uniqueUserIds; for (const auto logEntry : logEntries) { uniqueUserIds.insert(logEntry.userId); } // 现在 uniqueUserIds 包含了所有不重复且已排序的ID存在性检查成员测试频繁检查某个元素是否存在于一个大型集合中。由于查找是 O(log n)比线性容器快得多。std::setint allowedPorts {80, 443, 8080, 8443}; if (allowedPorts.find(incomingPort) ! allowedPorts.end()) { // 端口被允许 }作为其他数据结构的键当你需要一个有序且唯一的键集合时例如std::map的键本身就是一种set。有时你需要单独操作这个键集合。3.2std::multiset的适用场景排序的“袋子”Multibag需要维护一个有序序列但允许重复。例如实时显示当前服务器连接数的排行榜同一连接数的服务器可能有多个。std::multisetint, std::greaterint connectionCounts; // 从大到小排序 // 服务器连接数变化时 connectionCounts.insert(newCount); // 可以快速获取连接数最多/最少的服务器数量区间实现优先队列的替代方案std::priority_queue默认基于vector实现只提供堆顶访问。multiset同样有序且允许你遍历所有元素虽然插入删除稍慢O(log n) vs O(log n)但功能更灵活。std::multisetint taskPriorityQueue; taskPriorityQueue.insert(3); taskPriorityQueue.insert(1); taskPriorityQueue.insert(3); // 允许相同优先级 // 获取最高优先级的任务最小的数字如果是最小堆逻辑 if (!taskPriorityQueue.empty()) { int highestPriorityTask *taskPriorityQueue.begin(); taskPriorityQueue.erase(taskPriorityQueue.begin()); }计数与统计虽然std::mapKey, int更适合做计数器但如果你只需要知道有哪些值及其出现次数并且希望它们有序multiset插入后遍历即可省去了手动递增计数的步骤。std::vectorstd::string words {apple, banana, apple, orange, banana, apple}; std::multisetstd::string wordBag(words.begin(), words.end()); for (auto it wordBag.begin(); it ! wordBag.end(); ) { auto range wordBag.equal_range(*it); int count std::distance(range.first, range.second); std::cout *it : count std::endl; it range.second; // 跳到下一个不同的单词 } // 输出: // apple: 3 // banana: 2 // orange: 13.3 选择决策树与经验法则面对一个具体问题你可以通过以下流程来做选择需求是否要求元素唯一是- 选择std::set。否- 进入第2步。是否需要元素始终保持有序状态是- 选择std::multiset。否- 考虑std::unordered_multiset哈希表实现平均O(1)操作但无序或std::vector 必要时排序。经验心得如果“去重”是你的首要需求毫不犹豫选set。它的唯一性保证可以简化很多逻辑。如果你发现自己在用mapKey, int做计数器并且后续需要按Key顺序遍历可以考虑是否multiset更直接。当数据量非常大例如百万级以上且对插入、查找性能极度敏感且不需要顺序遍历时应优先考虑unordered_set或unordered_multiset哈希表它们的平均时间复杂度是 O(1)。set/multiset的排序是自动维护的这带来了便利也带来了成本插入和删除比vector慢。如果你的操作模式是“批量插入然后多次查询”那么先插入vector再排序最后用binary_search可能更高效。4. 高级用法、自定义比较与性能陷阱掌握了基础我们来看看一些进阶技巧和需要避开的“坑”。4.1 自定义比较函数与自定义类型容器默认使用std::lessKey进行排序这意味着你的类型必须支持操作。对于自定义类型你有两种方式1. 重载运算符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); } }; std::setPerson peopleSet;2. 提供自定义比较函子Functor这种方式更灵活尤其是当你无法修改类型本身或者需要多种不同排序方式时。struct Person { std::string name; int age; }; // 比较函子 struct CompareByAgeDesc { bool operator()(const Person a, const Person b) const { return a.age b.age; // 降序排列 } }; std::setPerson, CompareByAgeDesc peopleSetByAgeDesc;重要提示自定义比较函数必须实现严格弱序。简单说它需要满足非自反性comp(a, a)必须为false。非对称性如果comp(a, b)为true则comp(b, a)必须为false。传递性如果comp(a, b)和comp(b, c)都为true则comp(a, c)必须为true。 如果比较逻辑写错了会导致容器行为未定义通常表现为崩溃或死循环。4.2 与unordered_set的对比与选择set/multiset有序和unordered_set/unordered_multiset无序是两大家族。它们的核心区别在于底层数据结构红黑树 vs 哈希表。特性std::set/std::multisetstd::unordered_set/std::unordered_multiset底层结构红黑树平衡BST哈希表元素顺序按键排序默认升序无序取决于哈希函数和桶平均时间复杂度O(log n)O(1)最坏时间复杂度O(log n)O(n) 哈希冲突极端情况自定义类型要求需定义或比较函子需定义std::hash特化和运算符内存开销较高每个节点多指针较高维护桶数组迭代器稳定性插入删除除被删元素不使迭代器失效插入可能导致重哈希使所有迭代器失效适用场景需要元素有序、范围查询、前缀搜索需要极快查找、插入、删除不关心顺序选择建议在大多数需要“集合”功能的场景下如果顺序不重要优先使用unordered_set因为它更快。只有当你需要以下功能时才选择set按顺序遍历元素。进行范围查询如lower_bound,upper_bound。需要找到最接近某个值的元素。元素的哈希函数难以设计或质量不佳导致unordered_set性能退化。4.3 性能陷阱与优化建议避免在循环中查找-插入对于set如果你不确定元素是否存在应该直接使用insert并检查返回值而不是先find再insert。// 低效做法 if (mySet.find(value) mySet.end()) { mySet.insert(value); } // 高效做法 mySet.insert(value); // 对于set重复插入代价很低查找后发现存在即返回 // 或者如果你需要知道是否为新插入 auto [it, inserted] mySet.insert(value); if (inserted) { /* 是新元素 */ }小心multiset::erase(value)它会删除所有匹配的元素。如果只想删除一个务必使用接受迭代器参数的版本。std::multisetint ms {1, 2, 2, 3}; ms.erase(2); // 大小从4变为2 // 正确删除一个2 auto it ms.find(2); if (it ! ms.end()) { ms.erase(it); // 只删除迭代器指向的那个2 }迭代器失效问题对于set和multiset删除元素只会使指向被删除元素的迭代器失效其他迭代器仍然有效。这是基于节点的容器的优势。但是插入操作不会使任何迭代器失效。对于自定义类型确保比较/哈希函数高效比较函数或哈希函数如果很复杂会成为容器操作的性能瓶颈。尽量使用轻量级的计算或者缓存关键值用于比较。考虑预留空间仅对unordered_*有意义set/multiset是树结构无法预留空间。但unordered_set/multiset可以调用reserve来预分配桶的数量避免插入过程中的多次重哈希提升性能。5. 实战案例解析一个简单的单词频率统计器让我们通过一个综合案例看看如何在实际中运用set和multiset。我们将实现一个程序读取一段文本然后输出所有出现过的唯一单词按字母顺序。输出每个单词的出现频率按频率从高到低排序。#include iostream #include set #include map #include string #include cctype #include sstream #include vector #include algorithm // 辅助函数将字符串转为小写并移除标点 std::string normalizeWord(const std::string word) { std::string result; for (char ch : word) { if (std::isalpha(static_castunsigned char(ch))) { result.push_back(std::tolower(static_castunsigned char(ch))); } } return result; } int main() { std::string text Hello world! Hello C. C is powerful. The world is big.; std::istringstream iss(text); std::string rawWord; // 使用 multiset 来记录所有单词包括重复 std::multisetstd::string wordBag; while (iss rawWord) { std::string cleanWord normalizeWord(rawWord); if (!cleanWord.empty()) { wordBag.insert(cleanWord); } } // 任务1: 输出所有唯一单词按字母顺序 // 巧妙利用 set 的唯一性和有序性 std::setstd::string uniqueWords(wordBag.begin(), wordBag.end()); std::cout Unique words in alphabetical order:\n; for (const auto word : uniqueWords) { std::cout word ; } std::cout \n\n; // 任务2: 输出单词频率按频率降序 // 先用 map 统计频率 std::mapstd::string, int wordFreq; for (const auto word : wordBag) { wordFreq[word]; } // 为了按频率排序我们需要将 pair 放入一个 vector 中排序 // 但这里我们展示另一种思路使用 multiset 和自定义比较器来排序 struct WordFreqItem { std::string word; int freq; // 按频率降序排序 bool operator(const WordFreqItem other) const { // 频率高的在前如果频率相同按单词字母顺序 return std::tie(other.freq, word) std::tie(freq, other.word); } }; std::multisetWordFreqItem sortedByFreq; for (const auto [word, freq] : wordFreq) { sortedByFreq.insert({word, freq}); } std::cout Word frequency (descending):\n; for (const auto item : sortedByFreq) { std::cout item.word : item.freq std::endl; } return 0; }输出结果Unique words in alphabetical order: big c hello is powerful the world Word frequency (descending): hello: 2 world: 2 c: 2 is: 2 big: 1 powerful: 1 the: 1案例点评与技巧multiset用于原始收集我们首先用multisetstring存储所有归一化后的单词。它自动帮我们完成了“排序”并且允许重复。这里其实用vectorstring然后排序也可以但multiset在数据流式输入时能保持实时有序。set用于轻松去重通过用multiset的迭代器范围构造setstring我们一行代码就得到了有序的唯一单词列表。这是set构造函数的一个巧妙用法。频率统计的容器选择统计频率最自然的是mapstring, int。当然你也可以遍历multiset并用equal_range来数个数但map更直观高效。按值排序的技巧map是按键单词排序的。要按值频率排序我们需要将数据转移到另一个容器。这里我们定义了一个新结构WordFreqItem并利用multiset的自定义排序能力实现了按频率降序排列。这比将pair放入vector再调用std::sort在某些场景下更清晰尤其是当我们需要频繁插入新数据并保持顺序时。这个案例展示了如何根据任务的不同阶段灵活选用set和multiset并结合其他容器如map、vector来高效解决问题。理解每种容器的特性并像挑选工具一样组合使用它们是写出高效、清晰C代码的关键。