C++ STL merge与reverse算法详解:原理、应用与避坑
平时写代码最常听到的一句话就是“STL里啥都有别整天造轮子”。但真到用的时候不少人面对一长串算法头文件却只认识 sort 和 find。今天借一个学习笔记类的话题把两个非常实用、但经常被低估的算法 ——merge合并和reverse反转彻底讲透。它们一个负责把有序数据快速融合一个负责把序列原地调头看似简单背后牵扯的迭代器类别、稳定性语义和泛型设计思路才是真正值得花时间琢磨的东西。这个话题适合谁看我建议这几类人重点留意一是刚把 C 语法过完一遍、准备系统啃算法库的初学者二是写过一些业务代码但一遇到“两个有序表合成一个”“列表要倒序展示”之类的需求还在用循环硬怼的开发者三是在校生准备面试时被问到“归并排序的核心子过程”或者“std::reverse 为什么不能在单链表上直接用”这类问题。文章不会只贴代码就完事我会把接口背后的原理、我和同事在项目中踩过的坑以及完整的可编译示例都放在后面尽量让每个看完的人都能直接照着用。1. 整体思路两个算法在STL里的定位1.1 先澄清一个概念merge严格来说不算排序算法项目标题用的是“常用排序算法”这个说法这点我得先较个真在 C 标准库里严格意义上的排序算法是 sort、stable_sort、partial_sort、nth_element 这些。merge 干的事情叫做“归并”它默认输入区间必须已经有序输出结果才有序。换句话说merge 是排序工作流里“最后一公里”的粘合剂而不是从无序到有序的那个起点。这个区分很重要因为很多人学 merge 的时候会犯迷糊既然 merge 也能让结果有序那它能不能替代 sort不行。sort 的核心是基于快速排序的内省排序平均复杂度 O(n log n)面对的是无序输入merge 是基于线性扫描复杂度 O(n m)但要求输入有序。这就像做菜sort 是“从食材零基础开始炒出一盘菜”merge 是“把两锅已经做好的菜拼盘摆好”职责完全不同。也正因为 merge 能在线性时间内完成合并它成了归并排序算法的灵魂子过程。手写归并排序的时候你把一个数组拆成两半、各自排序最后合并的那一步本质上就是在调 merge 的逻辑。理解这一点再回头看你以前背过的归并排序模板很多细节会一下子通掉。1.2 什么业务场景会用到这两个算法merge 的典型场景我平时接触得最多的有这么几类多路有序数据合并。比如多个分片日志文件每个文件内部按时间戳有序要汇总成一份全局有序的日志直接两两 merge 就能搞定没必要全部读进内存重新 sort。我们以前做一个离线日志分析工具一天几十个文件就是靠 merge 一层层合成的内存占用很小。增量更新场景。程序启动时加载一份全量有序配置运行过程中又来了一批增量配置这批增量也保持有序你希望合并成一份新配置merge 就是最稳的做法。数据库/搜索引擎里归并倒排链。这类后端场景普通人接触不到但原理完全一样——多个有序 id 列表求并集merge 的变体是核心操作。reverse 的场景就更直白了展示层需要倒序列表、倒序遍历数据。字符串反转、回文判断等经典面试题。某些算法里需要逆序处理步骤比如从结果反推来源。和 sort 搭配做降序输出可以先按升序排序再 reverse 一下虽然效率不如直接给 sort 传 greater 比较器但有些场景下逻辑会更清晰。这两个算法一个处理“有序结构的融合”一个处理“序列方向的翻转”用途都很基础但当你真正高频地使用标准库而不是手写循环时代码的稳定性和可读性都会上一个台阶。2. 核心细节接口、原理与选型参考2.1 merge 的接口形态与底层推进逻辑merge 在标准库里的声明一般是下面两组重载定义在algorithm头文件中templateclass InputIt1, class InputIt2, class OutputIt OutputIt merge(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2, OutputIt d_first); templateclass InputIt1, class InputIt2, class OutputIt, class Compare OutputIt merge(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2, OutputIt d_first, Compare comp);两个输入区间用两对迭代器指定分别叫做 first1/last1 和 first2/last2输出端给一个目标区间的起始迭代器 d_first。没有 comp 参数的版本用 operator 做比较有 comp 的版本允许你自定义排序规则比如合并两段按降序排列的数据、合并结构体数组等。底层逻辑其实不复杂两个指针或者迭代器分别指向两个区间的头部每次比较当前指向的元素谁小就把谁写到输出位置然后对应指针前进一位。某一区间走完之后直接把另一个区间的剩余元素全部拷贝到输出位置。这个流程决定了三件事第一merge 需要输出区间有足够的容量。它不会自己扩容如果你把目标放在一个空的 vector 里而不预先 reserve代码会直接崩或者使用 back_insert_iterator 这类插入迭代器自动扩展。第二merge 是稳定算法。这里的“稳定”指的是如果两个区间里存在值相等的元素最终输出里第一个区间的元素会排在第二个区间的前面。这个语义在归并排序里尤其重要重点要先记下来merge 天然是稳定归并。第三merge 的两个输入区间都必须是前向迭代器ForwardIterator或更强。它要求能反复读取、递增比较所以像单向链表 std::forward_list 的迭代器也能用相比之下reverse 的要求反而更高后面再说。2.2 inplace_merge 与函数式组合的延伸除了把结果写到新位置的 merge标准库还提供了一个就地版本inplace_merge。它的典型用法是一个区间内部恰好由两段有序数据拼接而成比如 [first, middle) 有序、[middle, last) 也有序你想让整个 [first, last) 变成完全有序不额外开一块大空间。templateclass BidirIt void inplace_merge(BidirIt first, BidirIt middle, BidirIt last);inplace_merge 的名字起的很直白——“在原始位置合并”。它有两种常见命运如果内部剩余空间足够它会尝试借助缓冲区比如调用方预分配的临时内存完成合并空间不够的时候算法会退化成一种更慢的原地归并。换言之它的时间复杂度和空间占用都比较灵活使用时要心里有数没有额外缓冲时性能可能明显下降。在实战里我把 merge 和 sort、unique、copy_if 这些算法组合起来用效果出奇地好。比如要先合并再去重再筛选符合条件的元素一行链式调用就能写清楚。标准库算法的组合能力恰恰是手写循环最大的劣势——手写代码一长逻辑噪音一堆维护起来很痛苦。2.3 reverse 的接口限制与 reverse_copy 的补偿reverse 的接口比 merge 简单很多templateclass BidirIt void reverse(BidirectionalIterator first, BidirectionalIterator last);它的语义是把 [first, last) 区间里的元素顺序原地翻转第一个变最后一个最后一个变第一个。这里要注意核心限制reverse 要求输入迭代器至少是双向迭代器BidirectionalIterator。这就是我在开头提到的关键点——像 std::forward_list单链表只有前向迭代器不能直接对整个链表调用 std::reverse但 std::list 有双向迭代器可以用。很多人会想单链表反转不是很容易吗把指针指来指去不就行了。但标准库这里的设计哲学是算法只通过迭代器接口操作元素不直接触碰容器的内部指针结构。对于前向链表要反转节点关系就得动容器内部结构所以 std::forward_list 自己提供了一个成员函数 reverse()专门干这件事。这种“算法和容器解耦、特殊情况容器自备成员函数”的设计是 STL 非常重要的一种风格理解它你才不容易写出“明明迭代器类型不对还硬调”的编译错误。reverse 的实现复杂度是 O(n)元素被交换的次数大约是区间长度的一半。它不会返回值直接操作原区间。如果你的原始数据还要留着可以考虑reverse_copytemplateclass BidirIt, class OutputIt OutputIt reverse_copy(BidirIt first, BidirIt last, OutputIt d_first);reverse_copy 是“边反转边拷贝”的实用工具从源区间尾部开始向头部遍历把每个元素顺序写入目标区间。这样你可以保留原容器不变同时得到一个逆序副本。反过来如果你有几份数据要同时反转也可以各调各的 reverse然后直接继续用。2.4 C20 ranges 版本带来的新写法如果你是 C20 及以上环境标准库还提供了 ranges 命名空间下的重载std::ranges::merge(first1, last1, first2, last2, out); std::ranges::reverse(vec.begin(), vec.end());有的编译器还支持直接传容器名配合视图和管道操作符比如把一个算法串联起来。ranges 版本最大的优势是接口更安全、组合性更强、代码阅读起来更接近自然语言。如果你的项目编译器比较新我建议优先用 ranges 风格写新代码如果是老项目、老标准那还是 std:: 名字空间下的经典接口稳妥。3. 实操完整示例与运行解析3.1 环境准备与基础框架在展开代码之前先说下环境我测试用的是一台 Linux 机器GCC 版本支持 C17编译命令是g -stdc17 -O2 -Wall main.cpp -o demo。如果是 Windows 上用 Visual Studio选 x64 的 Debug 或 Release 都行C 标准设为 C17 即可。下面的示例不依赖第三方库用标准头文件就能跑。正式开始前先列一下会用到的头文件#include iostream #include vector #include list #include string #include algorithm #include iterator说明一下各头文件的用途algorithm是今天的主角所在地std::merge、std::reverse、std::reverse_copy、std::inplace_merge、std::sort。vector、list、string都是我们要操作的容器类型。iterator主要为了用 std::back_inserter它能让输出容器在写入时自动扩展空间。iostream用来打印结果。3.2 merge 合并有序序列的完整示例我先写一个最经典的 merge 示例合并两个有序 vector。int main() { // 两个已经升序排列的 vector std::vectorint left {1, 3, 5, 5, 7, 9}; std::vectorint right {2, 4, 5, 6, 8}; // 输出容器先空着用 back_inserter 自动扩容 std::vectorint result; // 调 merge把 left 和 right 合并到 result std::merge(left.begin(), left.end(), right.begin(), right.end(), std::back_inserter(result)); // 打印结果 std::cout merge result: ; for (int x : result) { std::cout x ; } std::cout \n; return 0; }运行结果是merge result: 1 2 3 4 5 5 5 6 7 8 9我自己调试这段代码的时候第一次把输出位置写成了result.begin()结果程序直接崩了——因为 result 是空的begin() 根本没有可写空间。后来换成std::back_inserter(result)才算正常。初学者建议优先背下这个写法当目标容器是空的或者不确定容量时用 back_inserter 包裹容器作为输出迭代器。3.3 reverse 反转容器的完整示例reverse 的用法没有 merge 那么多弯弯绕绕直接操作容器区间int main() { std::vectorint vec {10, 20, 30, 40, 50}; // 原地反转 std::reverse(vec.begin(), vec.end()); std::cout reversed vector: ; for (int x : vec) std::cout x ; std::cout \n; // 字符串反转 std::string s hello good sir; std::reverse(s.begin(), s.end()); std::cout reversed string: s \n; return 0; }输出reversed vector: 50 40 30 20 10 reversed string: ris doog olleh这里顺便提醒一句字符串内容如果是普通 ASCII反转后结果正常如果里面夹了中文等多字节字符直接用 reverse 按字节翻转会得到乱码。这个问题我单独放在第四部分详细说因为真的很常见也很坑。如果你想保留原数组又想要一个逆序版本可以直接用 reverse_copystd::vectorint origin {5, 4, 3, 2, 1}; std::vectorint copy; std::reverse_copy(origin.begin(), origin.end(), std::back_inserter(copy)); // 输出 origin 还是 {5,4,3,2,1} // copy 为 {1,2,3,4,5}有一说一这个函数在一些“既要正向又要反向数据”的场景里非常省事比如做双向列表展示时一份数据拉两份倒序副本完事。3.4 进阶组合排序、合并与反转的综合实验很多实际需求不是单独调一个算法就能解决的而是几个算法连起来用。我平时做过不少这类调研所以这里安排一个综合性更强的 demo把 sort、merge、reverse 串起来演示一遍方便你理解它们之间的关系。场景现在有两组用户评分数据每组内部都是乱序的。我希望把它们合成一组整体按“分数从高到低”排列的数据。int main() { // 第一组评分数据乱序 std::vectorint groupA {85, 90, 78, 95, 88}; // 第二组评分数据乱序 std::vectorint groupB {99, 72, 93, 81, 76}; // 第一步各自排序升序 std::sort(groupA.begin(), groupA.end()); std::sort(groupB.begin(), groupB.end()); // 第二步升序合并 std::vectorint merged; std::merge(groupA.begin(), groupA.end(), groupB.begin(), groupB.end(), std::back_inserter(merged)); // 第三步整体反转变成降序 std::reverse(merged.begin(), merged.end()); // 打印 std::cout high - low scores: ; for (int s : merged) { std::cout s ; } std::cout \n; return 0; }输出high - low scores: 99 95 93 90 88 85 81 78 76 72这个例子说明一个组合套路先 sort 保证有序再用 merge 完成线性合并最后用 reverse 调整方向。这样的组合拳在一些数据处理 pipeline 里非常常见。当然如果一开始就想按降序合并你也可以在 sort 时传 std::greater ()或者给 merge 传自定义 comp记得把升序逻辑反过来就行。两种做法最终效果一样看哪个更贴合你的场景。4. 常见问题与排查技巧实录4.1 头文件、命名空间、容器选择等基础坑不是开玩笑我见过不少人写 merge 报错结果原因就是algorithm头文件没包含。标准库算法大多在algorithm里但有些被分到了别的头文件比如numeric里才有 iota、accumulate。如果出现 “merge is not a member of std” 这种错误第一反应检查头文件algorithmmerge、sort、reverse、swap、find 等numericaccumulate、inner_product 等memoryunique_ptr、make_unique 等另外如果你们项目里用了using namespace std;偶尔会和自定义的 merge 或 reverse 函数产生歧义建议避免这种写法显式加std::前缀。容器选择方面注意两点vector、deque、list、string 的迭代器都支持 reverse可以直接反转forward_list单链表不支持 std::reverse必须用它的成员函数fl.reverse()数组也可以通过std::begin(arr)/std::end(arr)拿到迭代器同样可以反转。4.2 迭代器要求不满足引发的编译失败这个错误很典型如果你尝试对一个std::forward_listint实例调用std::reverse编译器会直接报错因为 reverse 需要双向迭代器而 forward_list 的迭代器只满足前向迭代器要求。报错信息在不同编译器下会非常抽象甚至可能甩出一长串模板实例化过程。我的建议是遇到这类报错先回头确认容器的迭代器类别。快速判断方法支持快速随机访问的容器vector、deque、array、string迭代器是随机访问迭代器双向链表list、forward_listlist 是双向迭代器forward_list 只有前向迭代器输入流、输出流这类特殊迭代器只有输入或输出能力几乎不能用于这种可变区间算法。merge 对输入迭代器要求相对宽松只要前向迭代器即可所以 forward_list 可以传给 merge 当输入但不能传给 reverse 当整个区间处理。4.3 输出区间容量不足与迭代器失效问题继续讲一个我在 merge 上遇到的经典问题。当你说“我要把合并结果放到 result”时如果 result 是个空 vector然后你直接写std::merge(..., result.begin())程序运行时会崩出 “segmentation fault” 或类似错误。原因很简单result.begin() 指向一个空区间merge 尝试往这个位置写元素越界访问。三种推荐的解决方式使用std::back_inserter(result)它每次写入都会调用 push_back自动扩容预估好大小先result.resize(left.size() right.size())再传result.begin()如果不想用 resizing也可以用result.reserve(...)但重点提醒reserve 只改变容量不改变 size。如果只调用 reserve 而不 resize然后用 result.begin() 当输出迭代器同样会出问题。我用表格整理一下常见输出方式对比方便你快速判断输出迭代器方式是否会扩容适用场景注意事项result.begin()不会目标容器已提前 resize空间确定必须先保证 size 足够大std::back_inserter(result)自动目标容器为空或不明确大小性能略低于预留空间的方式原始数组 手算下标不会数组空间足够底层性能要求高容易越界谨慎使用迭代器失效问题同样值得提一句。在调用 merge 等算法期间如果往里写的是 vector而 vector 因扩容导致迭代器失效可能会引发未定义行为。使用 back_inserter 时每次写入都通过 push_back如果越过容量边界就动态扩容但算法内部感知不到旧迭代器失效——实际上 back_inserter 每次访问容器对象而不是长期持有旧迭代器所以相对安全。但如果你提前取了auto it result.begin()再在算法里使用这个 it 而非 back_inserter一旦扩容发生it 就是失效状态后患无穷。4.4 中文反转乱码问题reverse 对 string 是逐字节操作的。拿 “你好” 举例它在 UTF-8 编码下占 6 个字节直接 reverse 会使字节顺序整个颠倒输出一堆乱码。这个坑很多人第一次处理中文字符串反转时都会踩。如果确实需要反转一个可能包含多字节字符的字符串我建议不要直接用 std::reverse而是先把字符串按字符拆开。最简单的方式是用std::wstring_convert或手动按 UTF-8 字节头解析也可以借助第三方字符编码库。这里给一个简化的示例思路// 不推荐直接反转 UTF-8 字符串 // 应该先拆成字符序列再反转字符序列这种场景实际开发中不算高频但遇到了很头疼。我一直的做法是先确认字符串编码是 UTF-8 就必须走字符边界解析不能拿字节数组瞎 reverse。业务篇也建议要么先转成宽字符再处理。4.5 常见误区速查表误区正确理解merge 能做完整排序错merge 只合并不输入两个有序区间无序数据请先用 sortmerge 输出目标可以随意放错必须保证输出区间容量或使用插入迭代器reverse 所有容器都能用错forward_list 需要成员函数reverse_copy 会修改原数据不会它只读原区间拷贝到新目标merge 会去重不会等价元素会被全部保留去重要配 unique 使用inplace_merge 速度永远很快不一定无缓冲时可能变慢这张表算是我个人从项目里总结出来的“排雷手册”内容不多但基本覆盖了平时最容易出错的地方。你用的时候可以把它当 check list写完代码后对一遍能省不少调试时间。结尾最后按惯例聊点个人经验。我在实际项目里用 merge 和 reverse 最大的感触是标准库算法不是背完接口就完事关键要理解它“为什么这样设计”。reverse 坚持双向迭代器而不是为每个容器单独提供版本是为了保持算法和容器解耦merge 要求输入有序是为了换取 O(n) 的合并效率。这些设计逻辑你吃透了写出来的代码自然会往“用标准库解决问题”的方向靠而不是又回去写一堆手糊的循环。另一个小技巧是调试算法时多用小数据模拟。我之前写 merge 相关逻辑测试数据从来不用特别大但一定会包含重复元素、空区间、一个区间只有一个元素这种边界情况。拿这种小测例跑一遍错了肉眼就能看出来比直接上生产数据省心得多。这个习惯如果你也能养成对排查标准库算法的行为很有帮助。如果你看完这篇还是觉得 merge 和 reverse 的使用场景不够清晰建议你先从最简单的需求入手给自己写一个小工具输入两个有序数组输出一个合并结果再写一个函数把输入的字符串反转并返回。这样亲自动手跑一遍远比盯着接口文档来得深刻。希望这篇内容对你有用也欢迎你在实际使用中遇到其他坑时回来一起交流。