C++ STL集合算法全解析:并集、交集、差集使用指南
如果让我选STL算法库里最容易被低估的一组算法我大概率会把票投给集合算法。它们平时用得确实不多可一旦遇到批量数据对比、交集差集提取、标签合并这类需求写起来是真的顺手。今天这篇就来聊一聊C STL里的集合算法家族帮你搞明白它们分别干什么、什么时候用、以及怎么用才不踩坑。这个系列我按“每天一个STL知识点”的方式来拆适合刚接触STL的初学者也适合用过但还没系统性梳理过的朋友。集合算法是几个老牌算法头文件algorithm里的一组函数包括set_union、set_intersection、set_difference、set_symmetric_difference和includes。它们解决的问题听起来很简单两个有序序列之间做并集、交集、差集、对称差集和子集判断。但STL的高明之处就在于这几个操作全部是线性复杂度归功于有序序列上的双指针扫描策略。这一点在实际业务中尤其有价值当你有两个几十万量级的ID列表要做交集比较手写双循环的时间复杂度是O(n*m)而用STL集合算法只要O(nm)效率差出好几个数量级。网上很多资料把集合算法当成“排序后的容器专用操作”一带而过其实不够。这些算法的真正价值是它们不修改输入序列、只输出结果到目标迭代器的设计哲学以及复杂多样的灵活用法。这篇文章我把原理、代码、坑点一次讲透。1. 集合算法到底解决什么问题1.1 从真实需求场景理解集合操作做后台开发的朋友应该深有体会日常需求里有大量“比较两个数据集”的场景。比如你有一份线上用户的ID列表又有一份活动报名用户的ID列表想知道哪些人报名了但没上线这就是差集想知道哪些人既在线上又是活动参与者这就是交集想合并两份名单并且去掉重复这就是并集。还有一个很典型的场景是权限系统的角色合并。用户本身在一个用户组里又通过部门角色继承了另一批权限最终可访问资源列表需要做多集合合并这时候set_union一次调用就把去重合并做完了。如果用循环遍历加std::find代码又长又容易出错性能还拉胯。集合算法的适用范围远不止数值类型。STL里的集合算法本质上是“对有序序列做离散数学集合运算的通用工具”只要你的元素类型支持比较操作或者你提供了自定义比较器这些算法就能工作。所以字符串列表、自定义结构体、指针列表都可以用。唯一的硬性前提是两个输入序列必须按同一规则有序排列。1.2 前置条件有序序列是硬性要求集合算法有个很容易被忽略的前提两个输入区间必须已经有序而且排序规则必须与算法使用的比较规则一致。默认情况下算法用运算符也就是升序。区间[first1, last1)和[first2, last2)都必须按升序排好否则结果完全不可预期。为什么要求有序因为算法实现依赖有序性才能做双指针线性扫描每次比较两个指针指向的元素相等就推进两个指针不等就推进较小的一侧。这样每个元素最多被访问一次才能保证线性复杂度。如果输入乱序这个策略就失效了结果自然也是错的。实际项目中我见过不少“结果偶尔不对”的bug最后排查下来都是因为输入序列没排序就调了集合算法。这不是算法本身的问题而是使用前提没满足。还有个细节值得注意两个输入区间可以使用不同的容器类型比如一个std::vector一个std::list没关系只要有随机访问或迭代器能正常移动就行std::list的迭代器是双向的也能用。但前提都是有序。注意判断有序时不能只看表面。如果你用了自定义比较器comp那么输入序列必须按这个comp排序而不是按排序。默认参数和自定义参数不能混用。2. 六大集合算法逐个拆解2.1set_union并集数据合并去重的利器set_union把两个有序序列的并集写入结果迭代器重复元素只保留一份。函数签名长这样templateclass InputIt1, class InputIt2, class OutputIt OutputIt set_union(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2, OutputIt d_first);默认版本使用operator比较还有一个重载版本可以传入Compare comp自定义比较规则。这个函数的处理策略是两个序列各自是排好序的所以重复的元素只会连续出现。算法用双指针边比较边输出如果*first1 *first2输出*first1并推进第一个指针如果反过来输出*first2并推进第二个指针如果两个相等输出一个并同时推进两个指针。这样就自动去重了。需要注意set_union返回的是输出区间的尾后迭代器所以调用时最好提前算好结果长度或者用插入迭代器让它动态扩展。下面给一个实际例子#include algorithm #include iostream #include iterator #include vector int main() { std::vectorint a {1, 2, 3, 5, 7}; std::vectorint b {2, 4, 5, 6, 7}; std::vectorint result; std::set_union(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(result)); for (int x : result) { std::cout x ; } // 输出: 1 2 3 4 5 6 7 }这里result的大小没提前分配直接用了back_inserter动态扩展。如果数据量很大提前reserve可以避免多次扩容带来的性能损耗比如result.reserve(a.size() b.size())。虽然并集结果不一定等于两者之和但作为上限是安全的。2.2set_intersection交集快速筛选公共数据交集算法找出同时存在于两个序列中的元素。实现策略同样优雅如果两个指针指向的元素相等就输出该值并同时推进两个指针如果不相等哪个小就推进哪个。因此每个元素最多被比较一次线性复杂度。签名和用法跟set_union很像std::set_intersection(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(result));这里有个细节值得展开set_intersection在输出重复元素时的策略。如果第一个序列里有多个相同的值比如{1, 1, 2}第二个序列里也有多个相同值那么交集会输出min(count1, count2)个重复元素。什么意思呢比如第一个序列是{1, 1, 2, 2}第二个是{1, 1, 1, 2}输出结果是{1, 1, 2}。这个行为是标准规定的交集输出两个序列中出现次数的最小值。这个细节对“去重后的集合”场景没影响因为集合本身不允许重复。但如果你把算法用在“处理有序数组中的重复元素”场景这反而是有用的小技巧。比如统计两个用户的购买记录中共同出现的商品数量用这个特性可以顺便把重复次数也算出来。2.3set_difference差集找出独有数据差集的语义是“存在于第一个序列但不存在于第二个序列的元素”。实现策略是比较两个指针如果*first1 *first2说明第一个序列的当前元素在第二个序列中不存在因为第二个序列的有序性保证后面不会再出现更小的值于是输出并推进第一个指针如果*first1 *first2说明该值在两个序列里都有推进两个指针如果*first1 *first2推进第二个指针继续找。这个算法在处理“新增数据”或“流失数据”统计时特别有用。比如你有一份昨天的用户ID列表和今天的用户ID列表差集就能快速找出今天新增的用户。做一个简单示例std::vectorint yesterday {1001, 1003, 1005, 1008}; std::vectorint today {1001, 1005, 1008, 1010, 1012}; std::vectorint new_users; std::set_difference(today.begin(), today.end(), yesterday.begin(), yesterday.end(), std::back_inserter(new_users)); // new_users {1010, 1012}注意参数顺序很关键第一个区间是“被减数”第二个是“减数”。方向反了就得到相反的结果。接口虽然简单但业务上“谁减谁”需要想清楚我身边的人自己都犯过这个错误。2.4set_symmetric_difference对称差集找两边的差异对称差集返回“只出现在一个序列中的元素”通俗说就是并集减去交集。实现上可以视为先做一次差集再做一次反向差集然后把两个结果合并输出但STL的实现是线性单趟完成的效率更好。还是用刚才那组数据std::vectorint a {1, 2, 3, 4}; std::vectorint b {3, 4, 5, 6}; std::vectorint result; std::set_symmetric_difference(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(result)); // result {1, 2, 5, 6}对称差集常用在两个数据集做变更检测的场景。比如比较两份配置文件的key列表对称差集可以快速找出哪些key只在一侧存在再配合交集判断哪些key两侧都有但值不同就构成了配置对比的基础逻辑。2.5includes子集判断检查包含关系includes做的事情是判断第一个有序序列是否完整包含第二个有序序列中的所有元素。它返回bool不产生输出。std::vectorint bigger {1, 2, 3, 4, 5, 6}; std::vectorint subset {2, 4, 6}; bool ok std::includes(bigger.begin(), bigger.end(), subset.begin(), subset.end()); // ok true这个算法在权限校验、依赖关系检查等场景非常实用。比如你要判断某用户的权限集合是否覆盖了某个接口所需的最小权限集合一行调用就搞定了。注意这个算法同样要求两个序列有序如果subset里的某个元素在bigger里连续出现多次但数量不够includes也会返回false。标准规定它检查的实际上也是多重集合的包含关系bigger中每个值的数量必须不低于subset中对应值的数量。这个行为和日常直觉里的“集合包含”略有差异但作为多重集合语义是自洽的。2.6 与std::merge的区别镜像操作但语义不同很多人会把std::merge和std::set_union搞混。这两个函数都是把两个有序序列合并成一个有序序列快排归并的阶段也就长这样。但关键差异在于merge不保证去重两个输入序列中重复出现的元素都会出现在结果里set_union会去重重复元素只保留一份。举个例子第一个序列为{1, 2, 2}第二个序列为{2, 3}。merge的结果是{1, 2, 2, 2, 3}set_union的结果是{1, 2, 3}。所以如果你的目标是合并两个列表并保留所有重复项用merge如果想得到数学意义上的并集去重用set_union。这两个函数虽然“长得像”但语义完全不同。顺带一提std::inplace_merge。它把一个区间内部的两个连续有序段原地合并比如把{1, 3, 5, 2, 4, 6}中{1,3,5}和{2,4,6}合并成{1,2,3,4,5,6}。它和集合算法没那么相关但归并排序的经典应用就是它。如果之后写归并排序或外部排序系列再展开讨论。3. 从入门到进阶的实操要领3.1 自定义比较器与自定义类型的正确姿势集合算法支持第五个参数Compare comp这让它们可以处理自定义结构体。但有个非常容易踩的坑两个输入序列必须按同一个比较器排序。如果第一个序列按comp1排序第二个序列按comp2排序哪怕两个比较器语义相近也不能保证结果正确。因为算法在比较时只调用你传入的那一个comp它要求整个序列关系是偏序一致的。举例说明。假如我们有一个订单结构体要按照订单金额做差集struct Order { int id; double amount; }; struct ByAmount { bool operator()(const Order a, const Order b) const { if (a.amount ! b.amount) return a.amount b.amount; return a.id b.id; } }; std::vectorOrder today_orders {{1, 99.5}, {2, 200.0}, {3, 300.0}}; std::vectorOrder ignore_orders {{2, 200.0}, {4, 50.0}}; std::sort(today_orders.begin(), today_orders.end(), ByAmount()); std::sort(ignore_orders.begin(), ignore_orders.end(), ByAmount()); std::vectorOrder need_handle; std::set_difference(today_orders.begin(), today_orders.end(), ignore_orders.begin(), ignore_orders.end(), std::back_inserter(need_handle), ByAmount()); // need_handle {{1, 99.5}, {3, 300.0}}注意这里比较器处理了“金额相同但 id 不同”的情况。如果只按amount比较两个金额相等但 id 不同的订单会被算法认为“相等”导致结果里丢失一个订单。这是自定义类型用集合算法时最常见的逻辑bug。给出一个很重要的经验自定义比较器必须定义严格的弱排序并且要能区分绝大多数的实际元素如果比较器把多个不同元素视为等价集合算法的结果就会按“多重集合中取 min 计数”的规则输出容易和直觉不符。3.2 输出迭代器的选择与容器适配细节集合算法的最后一个参数是输出迭代器选择什么样的输出迭代器直接影响代码的安全性和性能。三种常见选择第一种是std::back_inserter(container)最省心自动调用push_back动态扩容适合结果数量不确定的场景。缺点是每次插入都可能触发内存分配和拷贝数据量大时性能不理想。第二种是提前reserve后传普通迭代器适合已知结果上限的情况。比如并集结果不会超过两个序列长度之和可以先reserve再传result.begin()。注意这里必须保证result有足够的空间否则会越界写。另一种做法是用std::vector的resize或者“传一个足够大的目标容器”对输出边界要格外小心。标准库默认不检查输出边界写越界是未定义行为。第三种是std::ostream_iteratorT,直接输出到流。适合调试、打印结果。比如std::set_union(a.begin(), a.end(), b.begin(), b.end(), std::ostream_iteratorint(std::cout, ));这段代码直接把并集打印出来省去了临时容器的开销调试时很好用。不过要记得包含iterator头文件。关于输出容器的一个隐藏问题是输出迭代器指向的容器不能是输入区间之一。比如想把交集结果写回astd::set_intersection(a.begin(), a.end(), b.begin(), b.end(), a.begin())这种写法是未定义行为因为算法在写入a的同时还依赖a的内容做后续比较写入会破坏后续读取。如果确实想要原地更新可以先放到临时容器再赋值或者使用std::erase加条件移除等替代方案。3.3 性能分析为什么能做到线性复杂度集合算法最有价值的地方是复杂度。标准规定这组算法的复杂度都是N1 N2量级N1是第一个序列长度N2是第二个序列长度也就是线性。相比之下如果你用嵌套循环做差集复杂度是O(N1 * N2)哪怕用哈希集合先构建查找表也要额外开销构建哈希表和哈希查找的常数成本。为什么线性可行根本原因是有序性带来的“单调推进”特性。以set_difference为例算法维护两个下标每次比较后至少推进一个下标所以最坏情况下的总比较次数不超过N1 N2 - 1。这个特性跟归并排序的归并阶段本质相同。在数据量极大的场景这个差距是决定性的。比如你有100万个元素和80万个元素做交集O(N1*N2)最坏是80亿次比较而线性算法只需要180万次比较差距接近三个数量级。这也是为什么在处理大规模有序数组时集合算法总是首选。不过要注意线性复杂度的前提是输入有序。如果你每次都临时调用std::sort复杂度就会变成O(N log N)。所以正确用法是数据本身已经有序比如从数据库按索引顺序取出来、或者内部维护着有序容器直接调集合算法。如果数据乱序是否值得先排序再用集合算法需要对比排序成本。这时候也可以考虑用std::unordered_set做交集构建哈希表同样是线性复杂度但常数项更大而且无法处理有序输出的需求。4. 实战演练实现一个用户标签聚合系统4.1 业务背景与方案设计前面讲了一堆原理这里用一个相对完整的例子串联起来。假设我们在做一个内容推荐系统需要给用户打标签。每个用户可能有一个“历史兴趣标签”集合又有一个“实时行为标签”集合。我们需要做的是将两个标签集合合并成一个最终标签集合去重也就是并集。找出“历史有但实时没再现”的标签差集用于分析兴趣衰减。找出“历史没有但实时新增”的标签对称差集的一部分用于分析兴趣扩展。判断某个基础标签集合是否是当前用户的子集用于是否推送某些内容。设计上使用std::vectorstd::string存储标签并保持有序。这里选择vector而不是std::set是有意的标签数量通常不太多vector的缓存局部性更好排序成本也比红黑树构建成本低。这是STL容器选择中一个很重要的权衡集合容器适用于频繁插入删除元素但顺序容器加排序在只读查询和批量计算场景下性能更优。4.2 核心代码与分析来看一个可运行的最小实现#include algorithm #include iostream #include iterator #include string #include vector void print_tags(const std::vectorstd::string tags, const char* title) { std::cout title : ; std::copy(tags.begin(), tags.end(), std::ostream_iteratorstd::string(std::cout, )); std::cout \n; } int main() { std::vectorstd::string history {c, database, distributed, algorithm, network}; std::vectorstd::string realtime {algorithm, cpp, ml, network, system}; std::sort(history.begin(), history.end()); std::sort(realtime.begin(), realtime.end()); std::vectorstd::string merged; std::set_union(history.begin(), history.end(), realtime.begin(), realtime.end(), std::back_inserter(merged)); print_tags(merged, merged (union)); std::vectorstd::string decayed; std::set_difference(history.begin(), history.end(), realtime.begin(), realtime.end(), std::back_inserter(decayed)); print_tags(decayed, decayed (history-only)); std::vectorstd::string new_interests; std::set_difference(realtime.begin(), realtime.end(), history.begin(), history.end(), std::back_inserter(new_interests)); print_tags(new_interests, new (realtime-only)); std::vectorstd::string common; std::set_intersection(history.begin(), history.end(), realtime.begin(), realtime.end(), std::back_inserter(common)); print_tags(common, common (intersection)); std::vectorstd::string base_tags {algorithm, network}; bool can_push std::includes(merged.begin(), merged.end(), base_tags.begin(), base_tags.end()); std::cout can push content: std::boolalpha can_push \n; return 0; }在我的机器上跑出来的输出是merged (union): algorithm cpp database distributed ml network system decayed (history-only): c database distributed new (realtime-only): cpp ml system common (intersection): algorithm network can push content: true注意这里有个容易疏忽的地方c标签在history里而realtime里的cpp是另一个字符串按字典序排列后cASCII 中的码值是43排在cppp的码值是112前面所以它们不会被当成同一个东西。实际项目中标签体系一定要统一否则同样含义的标签出现在不同集合里会导致差集和交集结果都错。代码里的print_tags用std::ostream_iterator输出也是集合算法输出迭代器的一个实战用法。整个程序只包含algorithm、vector、string、iterator和iostream没有引入额外的容器依赖逻辑清晰跑起来轻快。4.3 实测数据与优化经验为了验证性能我用随机生成的整数列表做了个简单实验。两个各有10万个随机整数取值在0到200万之间的vector先排序再做交集实测耗时在几毫秒级别。如果用嵌套循环暴力做交集数据量在1万时就慢得肉眼可见了。这个差距在真实业务里非常致命尤其是接口需要在几十毫秒内返回结果时。另外分享一个优化细节如果连续对同一组数据做多个集合操作一次排序后重复使用比每次操作前都重新排序划算得多。比如上面的例子中history和realtime只排序了一次后面四个集合算法全部复用有序性。这种“一次排序、多次计算”的模式在批量处理场景中很常见值得有意识地利用。5. 常见问题与排查技巧实录5.1 高频坑点速查表结合我自己的经验这里把集合算法的高频坑点整理成一张速查表方便你在排查问题时快速定位现象可能原因解决办法结果中元素少于预期输入序列未排序调用前先std::sort结果中元素多于预期重复元素处理规则不熟悉明确数学集合与多重集合的区别程序崩溃或写入异常输出迭代器容量不足用back_inserter或确保resize足够自定义类型结果缺失比较器没处理好等价关系比较器必须形成严格弱序两个序列比较结果错乱两个序列排序规则不一致统一排序和算法的比较器结果和预期范围相反set_difference参数顺序搞反第一个是被减数第二个是减数输出到流时格式不对ostream_iterator的定界符没设置第二个参数传 或\n上面的表格里最容易被忽视的是“多重集合 vs 集合”的语义差异。标准库集合算法并不假设输入是严格的数学集合它只是按有序序列处理重复元素。所以当输入中有重复元素时行为是有明确规则的但结果可能和“数学集合”的预期不同。我在项目里就遇到过两个列表都含重复ID用了set_union后发现有重复ID遗留检查后才发现输入列表本来就有重复且是“全集”而不是“集合”应该先去重或者调整算法选择。5.2 实战中的排查思路与测试技巧排查集合算法问题有个很有效的三步法。第一步检查输入。写一个断言或调试代码确认两个输入区间确实有序“std::is_sorted(a.begin(), a.end())”。这是最常用的排查手段。很多隐藏的“灵异现象”都是这一步暴露的。第二步缩小范围。用一个极小的数据集比如5个元素以内手动跑一遍把中间结果打印出来看算法在哪一步开始和预期不符。集合算法的逻辑不复杂5个元素基本一眼能看出来问题在哪。第三步检查比较器语义。如果用了自定义比较器用几个精心构造的数据验证严格弱序。常见问题是比较器只比较了部分字段导致两个不同的对象被判定为“等价”在差集或并集算法中相互抵消。一个推荐的小技巧是在项目里写一个assert_sorted的辅助函数内部调用std::is_sorted在 debug 模式下启用。一旦有人改了数据源的排序逻辑忘记同步调整集合算法的调用测试阶段就能立刻发现问题而不是等到线上数据出错才追查。5.3 一个容易忽略的坑输出内容与输入区间重叠把集合算法的结果写回输入区间是常见需求但有不少人顺手就写了std::set_union(a.begin(), a.end(), b.begin(), b.end(), a.begin())。之前也提过这是未定义行为因为目标区间和输入区间重叠写入会破坏算法后续要读取的数据。正确做法有两种。第一种是用std::erase和条件判断业务上等价实现但这会让代码复杂度上升。第二种更简单把结果先写到临时容器再swapstd::vectorint temp; temp.reserve(a.size() b.size()); std::set_union(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(temp)); a.swap(temp);这个写法的好处是既安全又高效swap操作是常数时间复杂度。其他几个集合算法同理需要原地更新时先写临时区再交换。这个坑在真实项目中很常见。有次我在做标签合并时偷懒直接把结果写回了其中一个输入vector上线后偶发崩溃排查了好几个小时才发现是输出迭代器覆盖了输入数据。从那之后我养成了习惯凡是集合算法的输出目标一律独立分配或者至少先写临时区再交换。5.4 集合算法在实际工作中的一些使用习惯最后聊点个人使用习惯。我没有把所有集合算法的参数都背下来平时写代码时也常查 reference但有几个习惯是长期稳定带来的收益写任何调用前先想清楚三个问题两个序列是否有序输出目标是否独立比较器是否和排序规则一致小规模数据测试时我会故意构造重复元素和目标边界场景。比如测试交集时构造一个元素在第一个序列出现两次、第二个序列出现三次的情况验证输出是否符合“取最小重复次数”的规则。这种边界测试能提前暴露比较器和去重逻辑的问题。在实际业务中如果数据来自数据库我会优先让SQL把结果排序后再拉到内存直接喂给集合算法省掉一次std::sort。如果来自接口则尽量在数据进入容器时就保持有序状态。这个习惯让很多集合操作在业务代码里跑得又快又稳。集合算法的学习曲线不算陡峭。只要抓住“有序线性扫描”这个核心思想所有算法的行为都能推导出来。下一篇每日STL我计划聊一聊查找相关的算法家族包括lower_bound、upper_bound、equal_range以及它们和二分搜索的关系感兴趣的话可以持续关注。今天这组集合算法建议你打开编译器亲手跑一遍把每个算法的输出都验证一遍。亲手敲过一遍代码之后记忆会牢固得多。