C++ map与set容器:从红黑树原理到LeetCode高频算法实战
1. 项目概述从“会用”到“精通”的必经之路如果你正在学习C尤其是准备面试或者刷算法题那么map和set这两个容器绝对是你绕不开的坎。很多人觉得不就是个容器吗insert、find、erase几个函数用用就完了。但真到了实战尤其是面对LeetCode上那些中等偏上的题目比如“前K个高频元素”、“滑动窗口最大值”、“两个数组的交集”时你会发现仅仅“会用”是远远不够的。你可能会疑惑为什么我的解法逻辑都对但就是超时为什么别人的代码用map行云流水我的却显得笨拙不堪这背后差的就是对这两个容器底层原理、特性边界和适用场景的深刻理解。今天我们不谈枯燥的API手册就从我这些年写代码和面试别人的经验出发掰开揉碎了讲清楚map和set特别是set如何从基础使用一路杀到高频算法题实战让你真正掌握其精髓写出既高效又优雅的C代码。2. 核心思路为什么是红黑树关联容器的设计哲学在深入使用之前我们必须先搞懂一个根本问题C标准库里的std::map和std::set它们的底层到底是什么这直接决定了它们的所有行为特性。答案是红黑树。2.1 红黑树平衡与效率的优雅妥协你可能听说过二叉搜索树BST。理想情况下BST的查找、插入、删除时间复杂度是O(log n)。但如果插入的数据是有序的比如1,2,3,4,5BST就会退化成一条链表时间复杂度恶化到O(n)。为了解决这个问题计算机科学家们发明了各种自平衡二叉搜索树而红黑树就是其中在工程实践上最成功、最广泛应用的一种。红黑树通过一组复杂的规则节点有红黑颜色、从根到叶子的每条路径黑节点数相同等来保证树的大致平衡。它不像AVL树那样追求绝对平衡所有子树高度差不超过1而是允许一定程度的不平衡。这种设计带来了一个关键好处在维持了O(log n)的查找、插入、删除效率的同时插入和删除节点所需的旋转调整操作更少。对于需要频繁增删的关联容器来说这种“大致平衡”带来的综合性能往往优于“绝对平衡”。这就是为什么C选择红黑树作为map和set底层实现的原因——它在理论效率和工程实践之间找到了最佳平衡点。注意std::unordered_map和std::unordered_set的底层是哈希表它们提供了平均O(1)的查找效率但元素是无序的。选择有序的树结构还是无序的哈希表是你需要根据问题做出的第一个关键决策。2.2 Map vs Set键值对与纯键集合理解了底层都是红黑树我们再来看看map和set的表层区别std::mapKey, T存储的是std::pairconst Key, T即一个不可变的key和一个可变的value。它通过key来唯一标识和排序元素。你可以把它想象成一个字典或者电话簿通过名字key快速找到电话号码value。std::setKey存储的就是Key本身。它也可以看作一个特殊的map只不过这个map的value是空的或者与key相同。set的核心特性是元素唯一且自动排序。这个根本区别导致了它们的使用场景截然不同。map用于需要建立映射关系的场景而set则常用于去重、排序、快速判断存在性。3. Set容器深度解析从基础操作到高阶技巧很多人轻视set觉得它功能单一。但实际上在算法竞赛和工程中set因其“有序唯一”的特性常常是解决特定问题的“银弹”。3.1 基础操作不仅仅是插入和查找让我们先回顾并深化一下基础操作很多坑就藏在细节里。#include iostream #include set using namespace std; int main() { // 初始化 setint mySet {5, 2, 8, 2, 1}; // 初始化列表重复的2只会保留一个 // 最终mySet中的元素是 {1, 2, 5, 8}并且已经按升序排列 // 1. 插入操作 auto ret_pair mySet.insert(3); // 插入新元素 if (ret_pair.second) { cout 插入成功新元素位置已找到 endl; } // 尝试插入已存在的元素 auto ret_pair2 mySet.insert(2); // 2已存在 if (!ret_pair2.second) { cout 插入失败元素已存在。迭代器指向已存在的元素2 endl; } // 2. 查找与计数 auto it mySet.find(5); // 查找元素5 if (it ! mySet.end()) { cout 找到元素: *it endl; } // count对于set来说返回值只能是0或1因为元素唯一 if (mySet.count(10) 0) { // 等价于 mySet.find(10) ! mySet.end() cout 元素10存在 endl; } else { cout 元素10不存在 endl; } // 3. 删除操作 size_t num_erased mySet.erase(8); // 通过值删除返回删除的元素个数0或1 cout 删除了 num_erased 个元素 endl; auto it_to_erase mySet.find(2); if (it_to_erase ! mySet.end()) { mySet.erase(it_to_erase); // 通过迭代器删除更高效因为省去了二次查找 } // mySet.erase(mySet.begin(), next(mySet.begin(), 2)); // 删除一个区间 // 4. 遍历 cout 当前set内容: ; for (const auto num : mySet) { // 基于范围的for循环简洁安全 cout num ; } cout endl; // 反向遍历 cout 反向遍历: ; for (auto rit mySet.rbegin(); rit ! mySet.rend(); rit) { cout *rit ; } cout endl; return 0; }实操心得1insert的返回值是个宝insert返回一个std::pairiterator, bool。first是指向插入位置或已存在元素位置的迭代器second表示插入是否成功。这个返回值在需要“如果不存在则插入”的逻辑中非常有用可以避免先find再insert的冗余操作。实操心得2慎用erase(iterator)后的迭代器在C11之前set.erase(it)会使迭代器it失效后续再使用it会导致未定义行为。C11之后erase会返回被删除元素之后元素的迭代器。但为了代码的清晰和兼容性我建议在循环中删除元素时采用以下模式for (auto it s.begin(); it ! s.end(); /* 这里不写 it */) { if (condition(*it)) { it s.erase(it); // C11后erase返回下一个有效迭代器 } else { it; } }3.2 自定义排序与复杂类型存储set的默认排序是升序对于内置类型和定义了运算符的类型。但现实世界的数据并不总是那么简单。场景1存储自定义结构体假设我们有一个Person结构体想按年龄排序。struct Person { string name; int age; // 默认的 operator 未定义无法直接放入set }; // 方法1定义仿函数函数对象作为比较器 struct CompareByAge { bool operator()(const Person a, const Person b) const { return a.age b.age; // 按年龄升序 // 如果想年龄相同再按名字排序return tie(a.age, a.name) tie(b.age, b.name); } }; int main() { setPerson, CompareByAge personSet; personSet.insert({Alice, 25}); personSet.insert({Bob, 30}); personSet.insert({Alice, 25}); // 不会被插入因为25岁的“Alice”已经存在根据比较器判断 // 注意这里判断“相等”的依据是 !(ab) !(ba)。所以两个25岁的“Alice”被视为相同即使名字相同是巧合但比较器只比较了age。 }这里有个大坑上面的set认为两个age相同的人就是同一个人这显然不符合常理因为可能有重名。这引出了set和map的一个关键特性它的“唯一性”和“排序”依赖的是你提供的比较器而不是整个对象的所有字段。如果你需要根据多个字段共同决定唯一性和顺序必须在比较器中体现所有关键字段就像注释里用std::tie那样。场景2降序排列setint, greaterint descendingSet; // 使用 greaterint 实现降序 descendingSet.insert({3,1,4,1,5}); // descendingSet 的内容是 {5, 4, 3, 1}3.3 核心优势有序性带来的强大操作set作为有序容器提供了一些顺序相关的操作这些是unordered_set不具备的也是解决某些算法题的关键。lower_bound(key)/upper_bound(key)lower_bound(k)返回第一个不小于k的元素的迭代器。upper_bound(k)返回第一个大于k的元素的迭代器。它们共同用于在有序序列中定位一个区间。例如[lower_bound(5), upper_bound(5))这个左闭右开区间就包含了所有等于5的元素在set里最多一个。setint s {1, 2, 4, 5, 5, 6}; // 实际上只有一个5 auto low s.lower_bound(5); // 指向第一个5 auto up s.upper_bound(5); // 指向6 // 遍历 [low, up) 可以处理所有等于5的元素equal_range(key)一次性获取lower_bound和upper_bound返回一个pairiterator, iterator。对于set这个区间要么为空元素不存在要么只包含一个元素元素存在。但在允许重复元素的multiset中这个函数就非常有用。利用有序性进行范围查询和快速求最值因为元素有序s.begin()和s.rbegin()可以O(1)时间获得最小值和最大值。查找某个范围内的所有元素也很快。4. 高频算法题实战Set的威力展示理论说再多不如实战。下面我们看几个LeetCode上的经典题目看看set是如何大显身手的。4.1 实战一存在重复元素LeetCode 217题目给定一个整数数组判断是否存在重复元素。菜鸟思路双重循环O(n²)时间复杂度数据量大时直接超时。set思路遍历数组将每个元素插入set。如果插入失败insert返回的pair.second为false说明该元素已存在即存在重复。class Solution { public: bool containsDuplicate(vectorint nums) { unordered_setint seen; // 这里用unordered_set更合适因为我们不关心顺序只关心存在性哈希表平均O(1)更快。 for (int num : nums) { // 如果插入失败说明已存在 if (!seen.insert(num).second) { return true; } } return false; } };为什么这里用unordered_set因为题目只要求判断存在性不要求顺序。unordered_set基于哈希表平均插入和查找时间是O(1)比基于红黑树的set的O(log n)更快。这是一个典型的根据场景选择容器的例子。4.2 实战二两个数组的交集LeetCode 349题目给定两个数组计算它们的交集。输出结果中的每个元素必须是唯一的。set思路利用set的自动去重和高效查找特性。将第一个数组的所有元素放入一个set中自动去重。遍历第二个数组检查每个元素是否在第一个set中。如果在则加入结果集另一个set或直接vector但为了去重先用set接收。class Solution { public: vectorint intersection(vectorint nums1, vectorint nums2) { unordered_setint set1(nums1.begin(), nums1.end()); // 用nums1构造set去重 unordered_setint result_set; for (int num : nums2) { if (set1.find(num) ! set1.end()) { result_set.insert(num); // 找到交集元素插入结果set自动去重 } } // 将结果set转换为vector返回 return vectorint(result_set.begin(), result_set.end()); } };思考如果题目要求输出结果有序呢那么就应该使用setint而不是unordered_setint来存储结果或者最后对vector排序。4.3 实战三滑动窗口最大值LeetCode 239—— 进阶挑战题目给你一个整数数组nums和一个大小为k的滑动窗口。窗口从数组的最左边滑动到最右边每次向右滑动一位。返回每个滑动窗口中的最大值。这是一个困难题。暴力解法是对于每个窗口遍历其中k个元素找最大值时间复杂度O(n*k)。优化思路使用multiset我们可以维护一个当前窗口内所有元素的集合。由于窗口在滑动我们需要支持动态地添加新元素和移除旧元素并且要能快速获得最大值。set只能存唯一的元素而窗口内可能有重复值所以我们需要multiset允许重复元素的有序集合。multiset的最后一个元素*rbegin()就是当前最大值。当窗口右移时将新元素加入multiset并将离开窗口的旧元素从multiset中删除一个实例用erase找到该值的一个迭代器删除避免删除所有相同值。class Solution { public: vectorint maxSlidingWindow(vectorint nums, int k) { vectorint result; if (nums.empty() || k 0) return result; multisetint window; // 初始化第一个窗口 for (int i 0; i k; i) { window.insert(nums[i]); } result.push_back(*window.rbegin()); // 第一个窗口的最大值 // 开始滑动窗口 for (int i k; i nums.size(); i) { // 移除离开窗口的元素 (nums[i-k]) // 关键点不能直接用 window.erase(nums[i-k])那会删除所有等于该值的元素 window.erase(window.find(nums[i - k])); // 只删除一个实例 // 加入新进入窗口的元素 window.insert(nums[i]); // 当前窗口的最大值 result.push_back(*window.rbegin()); } return result; } };避坑指南multiset.erase(value)会删除所有等于value的元素。multiset.erase(iterator)只删除该迭代器指向的单个元素。在滑动窗口场景中我们只希望移除离开窗口的那个特定实例所以必须先用find或lower_bound找到要删除元素的一个迭代器然后用迭代器版本的erase。这是本题使用multiset的核心技巧。性能分析每个插入和删除操作是O(log k)总共进行n次所以总时间复杂度是O(n log k)。虽然比暴力法好但还不是最优最优解是使用单调队列可以达到O(n)。不过multiset的解法思路清晰容易理解和实现在面试中是一个不错的起点。4.4 实战四日程安排表 ILeetCode 729题目实现一个MyCalendar类来存放你的日程安排。如果要添加的日程安排不会导致重复预订则可以存储这个新的日程安排。这本质是一个区间判交问题。我们可以将每个已预订的日程看作一个区间[start, end)。set思路将所有已预订的区间按起始点排序存储。set的自动排序特性非常适合。当要插入一个新区间[new_start, new_end)时我们需要检查它是否和任何已有区间重叠。重叠的条件是new_start existing_end new_end existing_start。利用set的有序性我们可以快速定位到可能与新区间重叠的候选区间即第一个起始点小于new_end的区间以及它的前一个区间因为区间按起始点排序重叠只可能发生在这两个相邻区间附近。更常用的技巧是找到第一个起始点大于等于new_end的区间lower_bound那么这个区间本身肯定不会和新区间重叠因为它的起始点已经在新区间结束之后了。我们只需要检查这个区间的前一个区间是否与新区间重叠即可。class MyCalendar { private: // 使用 set 存储 pairstart, end并按 start 排序 setpairint, int booked; public: MyCalendar() {} bool book(int start, int end) { // 将要插入的区间 auto new_interval make_pair(start, end); // 找到第一个起始点 end 的区间它本身不会与新区间重叠 auto it booked.lower_bound({end, 0}); // 只需要比较start所以end传0即可 // 检查前一个区间 (如果存在) 是否重叠 if (it ! booked.begin()) { auto prev_it prev(it); // 如果前一个区间的结束时间 新区间的开始时间则重叠 if (prev_it-second start) { return false; } } // 检查找到的区间本身虽然理论上不重叠但标准做法也检查一下其实这里可以省略 // if (it ! booked.end() it-first end) { ... } // 插入新区间 booked.insert(new_interval); return true; } };这个解法充分利用了set的排序和二分查找lower_bound能力将每次预订的检查时间复杂度降到了O(log n)其中n是已预订的日程数量。5. Map容器精讲键值映射的艺术如果说set是独行侠那map就是最佳搭档它存储的键值对能表达丰富的关系。5.1 基础操作与迭代器map的使用与set类似但访问元素的方式更多样。#include iostream #include map #include string using namespace std; int main() { mapstring, int scoreMap; // 插入元素 scoreMap[Alice] 95; // 使用下标运算符如果key不存在则创建 scoreMap.insert({Bob, 88}); scoreMap.insert(make_pair(Charlie, 92)); // 访问元素 - 务必注意“不存在”的情况 // 方法1使用下标运算符危险 cout Alice的分数: scoreMap[Alice] endl; // 输出95 // 问题如果key不存在operator[]会创建一个默认初始化的value对于int是0 cout 不存在的Dave的分数: scoreMap[Dave] endl; // 输出0但Dave被加入了map cout Map大小: scoreMap.size() endl; // 大小变成了4 // 方法2使用find安全 auto it scoreMap.find(Eve); if (it ! scoreMap.end()) { cout Eve的分数: it-second endl; } else { cout Eve不在map中 endl; // 不会意外创建元素 } // 方法3使用atC11安全但会抛异常 try { int score scoreMap.at(Frank); // key不存在抛出 std::out_of_range 异常 } catch (const out_of_range e) { cout 捕获异常: e.what() endl; } // 遍历map for (const auto kv_pair : scoreMap) { cout kv_pair.first : kv_pair.second endl; } // 也可以用迭代器kv_pair-first, kv_pair-second return 0; }核心注意事项operator[]的副作用map的operator[]是一个“非纯”的访问器。它的行为是如果key存在返回其对应value的引用如果key不存在则插入一个以该key为键、以value类型的默认值初始化的新元素然后返回这个新value的引用。这个特性在你想初始化或更新值时很方便但当你只想“只读”访问时它可能意外地改变map的内容引入难以察觉的bug。因此当你不确定key是否存在且不想插入新元素时务必使用find方法。5.2 Map在算法题中的典型应用场景一统计频率/计数这是map最经典的应用。键是元素值是该元素出现的次数。// 统计一个字符串中每个字符出现的次数 string s hello world; mapchar, int charCount; for (char c : s) { if (c ! ) { // 忽略空格 charCount[c]; // 如果c不存在operator[]会将其值初始化为0然后 } } for (const auto p : charCount) { cout p.first 出现了 p.second 次 endl; }场景二充当字典或缓存记忆化搜索在动态规划或递归中我们经常用map来存储已经计算过的子问题的结果避免重复计算。// 斐波那契数列的记忆化搜索 unordered_mapint, long long memo; // 用unordered_map可能更快 long long fib(int n) { if (n 1) return n; if (memo.find(n) ! memo.end()) { return memo[n]; // 直接返回已缓存的结果 } long long res fib(n-1) fib(n-2); memo[n] res; // 缓存结果 return res; }场景三两数之和LeetCode 1题目给定一个整数数组和一个目标值找出数组中和为目标值的两个数。map思路在遍历数组时我们想知道“当前遍历到的数nums[i]是否和之前遍历过的某个数x相加等于target”。即我们需要快速查找target - nums[i]这个数是否出现过。map的O(log n)查找速度完美契合。class Solution { public: vectorint twoSum(vectorint nums, int target) { unordered_mapint, int num_index_map; // key: 数值, value: 该数值的索引 for (int i 0; i nums.size(); i) { int complement target - nums[i]; auto it num_index_map.find(complement); if (it ! num_index_map.end()) { // 找到了返回之前那个数的索引和当前索引 return {it-second, i}; } // 没找到把当前数和它的索引存入map供后续查找 num_index_map[nums[i]] i; } return {}; // 题目保证有解这里不会执行到 } };这里使用unordered_map是因为我们只需要O(1)的平均查找时间不需要顺序。6. 性能对比、选择与常见陷阱6.1 Map/Set vs Unordered_Map/Unordered_Set特性std::map/std::setstd::unordered_map/std::unordered_set底层结构红黑树平衡二叉搜索树哈希表元素顺序有序按key排序无序查找/插入/删除平均时间复杂度O(log n)O(1)查找/插入/删除最坏时间复杂度O(log n)O(n) 哈希冲突严重时需要自定义Key类型时需要提供比较函数如operator或自定义仿函数需要提供哈希函数和相等比较函数内存开销相对较小树节点相对较大哈希桶数组迭代器稳定性插入/删除元素通常不会使其他迭代器失效除非删除当前元素插入操作可能导致重哈希使所有迭代器失效适用场景需要元素有序遍历、范围查询、顺序相关操作只需要快速查找、插入、删除不关心顺序选择指南需要元素有序吗是- 选map/set。否- 优先考虑unordered_map/unordered_set通常更快。Key是自定义类型吗对于map/set你只需要定义运算符或提供比较器。对于unordered_map/unordered_set你需要提供哈希函数告诉容器如何把Key转换成一个哈希值和相等比较函数判断两个Key是否相等。C11后对于标准类型组合可以使用std::hash特化但自定义类型需要自己实现。这比提供比较器稍麻烦。对最坏情况下的性能有严格要求吗map/set的O(log n)非常稳定。unordered_map/unordered_set在极端情况下所有key哈希冲突会退化到O(n)。如果这是不可接受的就用map/set。需要频繁遍历吗map/set的遍历是顺序的中序遍历缓存友好性一般。unordered_map/unordered_set的遍历是“随机”的按桶遍历缓存不友好可能较慢。6.2 迭代器失效陷阱这是使用STL容器时必须时刻警惕的问题。对于map和set插入操作通常不会使任何现有迭代器失效除了指向被删除元素的迭代器。删除操作指向被删除元素的迭代器会失效。其他迭代器通常保持有效。但是对于unordered_map和unordered_set情况更复杂插入操作可能导致重哈希当元素数量超过负载因子与桶数量的乘积时。重哈希会使所有迭代器失效即使没有重哈希插入也可能使指向某个桶内元素的迭代器失效如果该桶的链表需要重新连接。删除操作指向被删除元素的迭代器失效。其他迭代器通常保持有效。安全法则在遍历容器并可能修改它插入/删除时务必小心处理迭代器。尽量使用上面提到的“it container.erase(it)”模式或者先收集要删除的键遍历完再统一删除。6.3 自定义Key类型的坑对于map/set你的比较器必须定义严格弱序。简单说它需要满足对于任何kcomp(k, k)必须是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等价即容器认为它们“相等”。最常见的错误是比较器实现不正确导致元素排序混乱或容器行为异常。对于unordered_map/unordered_set哈希函数需要尽可能均匀地将Key映射到size_t减少冲突。好的哈希函数能保证性能。相等函数当两个Key的哈希值相同时哈希冲突容器会使用相等函数来判断它们是否真的是同一个Key。必须保证如果eq(a, b)为true则hash(a) hash(b)也必须为true。反之则不一定成立哈希冲突。通常相等函数就是operator。一个常见的错误是只重载了operator用于unordered_map但没有提供哈希函数导致编译错误。7. 总结与进阶思考map和set及其无序版本是C STL中最强大、最常用的关联容器。从简单的去重计数到复杂的区间调度、滑动窗口、缓存优化它们的身影无处不在。掌握它们不仅仅是记住几个API更要理解其红黑树或哈希表的底层原理明白有序和无序带来的根本差异清楚迭代器失效的边界条件并能在具体问题中做出最合适的选择。我个人在项目中最深的体会是选择容器就是选择数据结构选择数据结构就在很大程度上决定了算法的效率和代码的复杂度。在动手写代码之前花几分钟思考一下数据的访问模式频繁查找需要顺序频繁增删往往能事半功倍。下次当你遇到需要快速查找、去重或维护映射关系的问题时不妨先想想“这里用map或set会不会更优雅” 很多时候答案都是肯定的。