资讯动态

归并排序详解:从递归到迭代、复杂度到外部排序的工程实践指南

发布时间:2026/10/15 5:07:14 来源:尧图企业网站定制
1. 归并排序核心思想先拆后合把无序变有序的笨功夫1.1 分治思路“拆到不能再拆”就是全部答案归并排序最底层的逻辑其实只有一句话如果一个数组只有一个元素那它天然是有序的。剩下的事情就是把两个已经有序的数组按顺序合并成一个更大的有序数组然后不断重复。我最早被归并排序吸引不是因为它跑得快而是因为它把“排序”这件事拆解成了两个极其简单的动作拆分和合并。拆分不用管数组长什么样只要找到中点一分为二合并也不用做复杂判断只用维护两个指针谁小就先放谁。整套过程没有任何花哨的“跳跃式交换”或者随机化基准点每一步都是确定性的所以它特别适合用来建立分治算法的直觉。很多只背过结论的人以为归并排序就是把数组一分为二、再合起来这没有错但容易忽略一个关键点归并排序的“有序”是在合并那一刻才真正产生的。递归拆分阶段并没有修改元素的位置所有元素的移动都发生在合并阶段。也就是说如果你把递归树的叶子节点想象成一个个单元素数组那么从最底层开始每一层都会把相邻的“小有序区”拼成“大有序区”直到最顶层把所有元素合成一个整体。这个思路决定了它是一个自底向上的过程哪怕你写的是递归代码执行顺序其实是先跑到最深处再一层层往回合并。1.2 稳定性与可预期性为什么它值得被记住很多人在排序任务上天然会优先想到快速排序因为快排的平均常数更小堆排序的最坏复杂度同样优秀而且空间占用少。但归并排序有一个它们都比不了的特质稳定。所谓稳定是指当两个元素的值相同时排序后它们的相对顺序不会改变。这个性质在业务排序里很值钱。比如一个名单先按姓名排再按部门排如果排序算法不稳定第二次排序就会打乱第一次的结果如果算法稳定部门相同的记录内部还能保留姓名的顺序。归并排序天然自带这个属性因为在合并时我只需要约定“左边相等时左边的元素先出”稳定性就能成立不需要像快排那样额外处理。除此之外归并排序最坏情况、平均情况、最好情况的时间复杂度都是 O(n log n)不存在“输入逆序就退化”的问题。这一点在真实项目里比“平均快但最坏慢”更让人安心。你不需要担心某次数据形状特别差导致线上接口从1秒变成10秒归并排序给的是可以预估的性能上限。2. 递归版归并排序手写实战三步走不背代码也能写对2.1 第一步拆分的递归出口与边界移动写递归排序最怕的不是合并逻辑复杂而是边界条件写错。先看最核心的递归结构public void mergeSort(int[] arr, int left, int right) { if (left right) { return; } int mid left (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid 1, right); merge(arr, left, mid, right); }这里有一个我踩过很多次的坑中点的计算必须写成 left (right - left) / 2而不是 (left right) / 2。虽然两者在大多数时候结果一样但 left 和 right 都是 int 时如果数组很长left right 可能溢出变成负数mid 就会跑到错误的位置递归陷入死循环或者数组越界。养成写中点的好习惯不仅归并排序要用二分查找、线段树这些同样适用。递归出口是 left right也就是区间里只有零个或一个元素时停止。零个的情况对应空区间在一个元素时自然有序不需要再拆。这里其实隐藏了“单元素天然有序”这个分治思想的起点新手往往只记得写出口却说不清为什么出口是 left right。2.2 第二步合并时的双指针谁小谁走合并是归并排序的灵魂也是最容易写出问题的地方。合并的目标是把已经有序的 [left, mid] 和 [mid 1, right] 两个区间合并成一个有序的大区间。实现思路是用一个临时数组开两个指针分别指向两个区间的开头然后比较它们指向的元素把更小的放进临时数组同时移动对应指针。private void merge(int[] arr, int left, int mid, int right) { int[] temp new int[right - left 1]; int i left; int j mid 1; int index 0; while (i mid j right) { if (arr[i] arr[j]) { temp[index] arr[i]; } else { temp[index] arr[j]; } } while (i mid) { temp[index] arr[i]; } while (j right) { temp[index] arr[j]; } System.arraycopy(temp, 0, arr, left, temp.length); }这里我建议记一个口诀两个指针、一个临时数组、三截填装顺序。两个 while 处理左边或右边还有剩余的情况最后把临时数组拷回原数组覆盖对应位置。特别要注意拷回时目标起始位置不是 0而是 left因为合并的只是原数组中间的一段区间。2.3 第三步在纸上画递归树比看十遍代码都管用如果你第一次写递归版归并排序强烈建议拿一个长度为 8 的数组在纸上画递归树例如[6, 2, 8, 1, 7, 5, 3, 4]。第一轮拆分left0, right7mid3分成 [0,3] 和 [4,7]第二轮继续拆[0,3] 再拆成 [0,1] 和 [2,3][4,7] 拆成 [4,5] 和 [6,7]第三轮拆到单元素[0,0]、[1,1]、[2,2]……然后从底层开始合并先把两个单元素合成两个元素的有序区再往上合成四个元素的有序区最后合并成八个元素的有序区。我见过太多人拿着代码反复看就是没搞懂执行顺序问题就出在没画递归树。画完之后你会发现递归代码虽然写成自上而下的样子真正跑起来是自下而上合并。这个领悟一旦建立后面看迭代版归并排序会格外顺畅。注意递归版实现简单但数组很大时递归深度达到 log2 n普通情况下几百层而已Java 默认栈空间足够但如果你在某个递归深度上做了大量局部变量分配或者递归栈里压入过多帧比如对 10 亿规模的数据排序就要考虑迭代版了。3. 迭代版归并排序自底向上规避递归栈风险3.1 从“步长”视角理解自底向上递归版既然本质是自底向上那我们就可以不写递归直接用循环控制合并粒度。这个方法也叫“自底向上的归并排序”核心思想是先用宽度为 1 的子数组两两合并再使用宽度为 2 的合并然后宽度为 4依次翻倍直到整个数组排好序。用循环来写最自然的写法是按合并区间的长度 step 逐步扩大public void mergeSortIterative(int[] arr) { int n arr.length; int[] temp new int[n]; for (int step 1; step n; step * 2) { for (int left 0; left n - step; left step * 2) { int mid left step - 1; int right Math.min(left step * 2 - 1, n - 1); merge(arr, temp, left, mid, right); } } }这里要特别注意循环条件的设置。外层循环用 step 表示每个有序子块的当前长度初始为 1每轮翻倍内层循环每次从 left 开始合并 [left, mid] 和 [mid1, right]。right 必须取Math.min(left step * 2 - 1, n - 1)因为数组长度不一定是 2 的幂最后一块可能满不了 step*2 那么大。3.2 复用临时数组与合并接口调整迭代版相比递归版的一个重要改动是可以只申请一次临时数组然后在合并函数里显式传入。好处是减少反复创建数组带来的内存抖动和 GC 压力。我能给出的建议是合并函数不要再内部 new 数组而是把大临时数组传进来用待排序片段的起始偏移和长度控制写入位置。private void merge(int[] arr, int[] temp, int left, int mid, int right) { int i left; int j mid 1; int index left; while (i mid j right) { if (arr[i] arr[j]) { temp[index] arr[i]; } else { temp[index] arr[j]; } } while (i mid) { temp[index] arr[i]; } while (j right) { temp[index] arr[j]; } System.arraycopy(temp, left, arr, left, right - left 1); }我在实际项目中常用迭代版因为它逼我把“合并区间”当成一个独立函数来设计逻辑边界更明确。而且迭代版的 step 从 1 开始倍增等价于递归树每一层从左到右完整扫一遍数组每一步都发生在同一个循环层级里没有栈深度问题甚至可以在多线程场景下把同层不同区块的合并任务丢给线程池并行执行。提示如果你是非科班出身刚开始接触迭代版可能觉得 step 和 left 的边界难懂。我的建议是先跑一个 n5 的数组把 step1、step2、step4 时每轮的 left、mid、right 打印出来观察区间划分跑一次就能明白。4. 复杂度定量分析O(n log n) 到底是怎么算出来的4.1 递推方程T(n) 2T(n/2) O(n)时间复杂度方面我经常用“一棵树”来理解归并排序。每次递归都会把一个长度为 n 的问题分成两个长度为 n/2 的子问题而合并两个子序列需要线性时间。设总时间为 T(n)则有递推式T(n) 2T(n/2) O(n)用主定理求解结果是 O(n log n)。更具体地说整棵递归树有 log2 n 层每一层所有合并操作处理的总数据量都是 n所以总工作量是 n * log2 n。这个结论不仅适用于归并排序也可以推广到很多分治算法。空间复杂度方面递归版每次 merge 都可能 new 一个新数组峰值额外空间是 O(n)。这不是指所有临时数组加起来是 O(n)而是指递归调用栈上同时存活的最大临时数组长度是 n。因为你从根节点往下递归时左右子问题大小大约都是 n/2它们各自需要 n/2 的临时空间但同一时刻两个子问题不会同时分配 n/2 的额外空间实际上需要仔细看如果每次合并都 new 数组递归树的最左侧路径会先分配 n/2 的空间返回后再分配另一个 n/2峰值时可能是一个 n/2 数组正在用到另一个 n/2 数组随后也出现但整体上不会超过 n。所以空间复杂度是 O(n)。4.2 比较次数上界约为 n log2 n - n 1有一个很有意思的细节归并排序在最好情况下的比较次数可能更少。比如数组本身已经有序每一步合并时左边区间的所有元素都小于右边理论上比较次数最优可以到约 n/2 次每层总比较次数接近 n log2 n。而最坏情况下每次合并都需要比较到某个区间先耗尽比较次数接近上界。很多人以为归并排序“无论输入什么样都是完全一样的比较次数”这并不准确因为合并时如果左边一直先耗尽右边剩余部分不需要再比较直接拷贝过来即可。不过这个常数差异不会影响 O(n log n) 的整体增长趋势。4.3 常数因子对比归并比快排慢但差距有限站在工程实践的角度我要说句实话纯归并排序的性能通常比快速排序慢 20% 到 40%原因主要有两个。第一归并排序需要额外的数组拷贝每次合并结束都要把数据从临时数组拷回原数组这会增加大量内存读写开销。第二快排的局部性更好访问的数据集中在当前分区附近而归并排序的读写区域随着区间变化跳来跳去缓存命中率略差。但慢不等于没价值。归并排序强在稳定、可预期、适合链表、适合外部排序这些优势在某些场景下远重要于那 20% 的常数差距。比如 Java 的官方排序库在排序对象数组时用的就是一种改造过的归并排序TimSort 的前身因为对象排序对稳定性更敏感。注意如果你处理的是 int 等基本类型多数语言默认用双轴快排因为它不需要稳定性内存读写的常数更小。但如果你排序的是数据对象、业务记录稳定性就有实实在在的价值。5. 归并排序的真实应用场景从内存到磁盘从数组到链表5.1 外部排序内存放不下时归并几乎是唯一选项这是我个人认为归并排序最“扛大梁”的场景。假设你在做数据仓库的 ETL需要按某个字段给 100G 的日志文件排序内存只有 8G任何要一次性把所有数据读进内存的算法都没法用。快排做不了这种外部排序因为它的分治依赖随机访问整个数组的数据。归并排序可以很自然地切分工作负载把大文件切成若干个能在内存中排序的小块比如每块 1G先把每块读进来用普通排序排好写成有序临时文件然后用归并的思路每次从每个有序临时文件里读取一部分数据做多路归并把整体顺序一点点写出来。这就是典型的外部排序流程最早用于磁带排序今天依然是大数据离线计算引擎里的基础操作。多路归并的实现比两路归并复杂一些你需要维护 K 个指针每次从 K 个候选里找出最小的那个输出。工程上常用败者树或者堆来优化“找最小”这个操作把单次找最小从 O(K) 降到 O(log K)。但如果 K 不大直接线性扫描也够用。5.2 链表排序不能随机访问归并排序如鱼得水数组排序里快排跑得快是因为能用下标 O(1) 地找到中点、交换元素但链表做不到随机访问。如果对链表做快排找中点就得从头遍历复杂度飙升如果用数组下标思维强行适配代码又难看又慢。归并排序天然适合链表。因为链表无需额外空间就能做归并只需要调整节点的 next 指针。合并两个有序链表是一个很经典的问题也是归并排序在链表结构上的核心操作。用归并排序对链表排序空间复杂度可以做到 O(log n) 的递归栈如果用递归甚至可以用迭代把额外空间降到 O(1)。实现时通常先用快慢指针找链表的中点然后递归处理前后两半再双指针合并。快慢指针找中点是一个很容易写错的点慢指针每次走一步快指针每次走两步循环条件是 fast ! null fast.next ! null循环结束后慢指针正好指向中间节点的前驱或者中间节点具体要看你怎么拿捏两个指针的初始位置。5.3 归并思想在“逆序对计数”中的妙用逆序对的定义很好理解如果数组中 i j 且 arr[i] arr[j]那 (i, j) 就是一个逆序对。暴力解法是双层循环O(n²) 显然太慢。利用归并排序的合并过程可以优雅地把复杂度降到 O(n log n)。思路是在合并左右两个有序区间时如果发现左边某个数 arr[i] 大于右边某个数 arr[j]那么左边从 i 到 mid 的所有数都大于 arr[j]因为左边区间是有序的。这意味着一次可以直接加 mid - i 1 个逆序对然后移动 j。代码只需在标准的归并合并代码里加一个计数累加几乎不需要额外引入复杂逻辑。这个技巧我强烈建议每个准备算法面试的人都要会因为它同时考察了三件事分治思想的灵活运用、对合并过程内部循环的理解、以及对复杂度优化的敏感度。很多面试者能写出归并排序但想不到在合并时多写一行计数就能解决逆序对就是缺了“把算法结论迁移到数据统计问题”的能力。6. 归并排序的性能调优与高频坑点从能跑到跑得好6.1 小数组插入排序优化如果待排序的数组非常小比如小于 7 个元素归并排序的递归分裂和临时数组合并开销都会显得很不划算。一种经典优化是当递归区间长度小于某个阈值时改用插入排序来处理。Java 老版本里快速排序对长度小于 7 的数组也这样做过JDK 的排序实现里大量出现这种“小规模用暴力、大规模用分治”的组合策略。我把这个优化写进代码之后实测排序 10 万随机数时性能提升大概在 5% 到 10%在小数组上提升更明显。阈值选多少看你具体语言和平台一般 7 到 20 之间都合理。你要是懒得试直接用 16 基本不会出大错。6.2 合并前检查是否已经有序归并排序即便面对已经有序的数组也会老实执行每次合并。但有一种优化可以省去很多无谓的工作在合并 [left, mid] 和 [mid1, right] 之前先检查 arr[mid] arr[mid1]如果成立说明两个区间拼接处已经有序那整个大区间就已经有序不需要再进入合并流程。这个检查的时间是 O(1)但能省下整个合并的 O(right - left 1) 时间。当输入数据接近有序时效果非常显著。比如日志按时间生成后排序我实际测试过优化后运行时间能缩短 60% 以上。6.3 四个高频坑边界写错、临时数组位置错、递归栈爆、稳定性写丢第一个坑是 mid1 写成了 mid。在递归区间截断处只要右子区间的左边界错一个位置数组越界还是其次更严重的是会有元素被漏掉或重复合并。第二个坑是合并完成后的拷贝起点写错。很多人把System.arraycopy(temp, 0, arr, left, temp.length)里的目标起点写成 0这在只处理整个数组时看不出来一旦对子区间排序就会把临时数组的内容复制到错误位置导致合并前后的数据错乱。相信我这类 bug 跑起来很像“全乱套”排查起来很费劲。第三个坑是递归深度。某次我为某公司处理超大数组时用递归版约两千层栈深度仍没事但如果在 Android 的 UI 线程或者递归深度嵌套其他场景下调用过贪深容易栈溢出。解决办法就是切换到迭代版或者提高 JVM 栈大小。第四个坑是稳定性在合并时被不小心破坏。如果两个元素相等你把右区间的元素先放进临时数组稳定性就没了。合并时遇到相等情况必须优先取左边元素也就是判断条件要写成arr[i] arr[j]而不是简单的小于号。6.4 小数组打印调试法如果你写归并排序后输出结果不对我建议不要盯着代码硬看而是用一个前 5 个元素的小数组把每次 merge 调用前后的状态打印出来。比如 [3, 1, 2] 长度为 3第一步一定是对左边两个元素合并如果你发现合并结果还是 [3, 1]那就说明比较方向或者拷贝索引出问题了。用长度 5 到 8 的测试用例能覆盖大多数字边界错位比用随机大数组排查效率高得多。提示排查算法题时日志不是越详细越好。我通常只在 merge 入口打印 left、mid、right在 merge 出口打印 array 的对应片段这样既能定位问题又不至于被海量日志淹没。6.5 在这道题上我还想再加一句最重要的个人心得前阵子帮一个刚转行的朋友过算法题他对着归并排序背了一个星期代码也能默写但遇到逆序对问题就彻底懵了。原因很简单他只是在“背代码”没有理解合并过程里“两边都有序”这件事。归并排序里最值钱的其实不是代码本身而是那个合并循环中蕴含着的大量条件判断左边剩余元素全部大于右边某个元素时可以一次性计入一个区间数量。这个模式换一层皮就是用归并做逆序对、做区间合并、做某些多路归并甚至可以用来做自然归并排序的优化。我自己在实际工程里用得最多的反而是迭代版归并排序配合多路归并处理超大文件排序。因为当数据量大到内存装不下的时候常规的“快排”根本不会出现在选择列表里你真正需要的是一个能分块、能处理磁盘 IO、能稳定输出的算法框架。归并排序恰恰是这样一套可以无限分层扩展的思路。最后分享一个判断排序算法该不该用归并的快速标尺如果资源充足、追求极致速度且不关心稳定性选快排或者语言库自带的排序如果需要稳定排序、需要排序链表、需要处理超过内存规模的数据、需要在一个合并过程中顺便统计额外信息那归并排序就是最顺手的选择。把这道题吃透不只是掌握了一个排序算法而是掌握了分治思想在真实数据处理里最常用的一把钥匙。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑