面试场上被问到排序算法你熟悉哪些很多人会条件反射地回答快排、冒泡能主动把归并排序拎出来讲透的人其实不多。但归并排序在Java面试题里的地位真不低——它不只是考察你背没背过模板代码更是在考察你对分治思想的理解深度、对递归边界的敏感度、对空间开销的权衡意识。这篇文章我打算用一篇完整篇幅把归并排序从思想到代码、从复杂度到工程应用全部拆开揉碎手把手带你在Java环境下实现并吃透它。如果你已经在学Java基础、正准备春招秋招或者工作中写排序只敢调Arrays.sort()想补一补底层逻辑这篇文章都合适。我会从为什么归并排序能稳定保持在O(n log n)讲起给你两版可运行的Java实现再把逆序对、外部排序、链表排序这三个典型场景逐一剖析。中间穿插的全是我实际调试时踩过的边界条件坑和面试被追问的细节尽量让你看完能直接迁移到自己项目里。1. 分治思想拆解归并排序背后的设计逻辑1.1 分治的三板斧分解、解决、合并分治思想不是某个具体的算法而是一套解决问题的策略——把一个大问题拆成若干个同结构的小问题逐个击破后再把结果拼回去。这一策略在算法界无处不在二分查找切一半、快速排序分左右、大整数乘法拆高位低位、最近点对问题按中线劈开。归并排序是这套思想最经典、最完整的示范案例。归并排序的分治步骤可以明确拆成三句话分解把长度为n的数组从中间分成两半得到两个长度约为n/2的子数组递归地继续切分直到子数组只剩一个元素。解决长度为1的数组天然有序不需要任何额外操作。合并把两个已经有序的子数组合并成一个更长的有序数组逐层返回直到整个数组有序。你注意看解决这一步在归并排序里几乎是空操作整个算法的核心工作量全都集中在合并上。这正是归并排序区别于其他排序的最大特点它把排序问题转化成了如何高效合并两个有序序列的问题。1.2 为什么归并能够保证正确性我最初学归并排序时有一个疑问把两个有序数组合并出来的结果真的总是全局有序吗答案的关键在于递归不变量——每一步的merge操作都接收两个已经有序的子数组而merge本身又是按序比较、按序写入的所以输出必然有序。这就像两列已经按身高排好的队伍只要每次从两队队首挑出较矮的那一个最终排出来的队伍一定也是按身高有序的。这个逻辑用数学归纳法也可以严格证明当子数组长度为1时性质显然成立假设所有长度小于k的子数组经过merge后都有序那么长度为k的子数组的左右两半都已经有序merge后也必然有序。归并排序的正确性就是建立在这个递归不变量之上的。1.3 分治思想在工程中的投影分治不只是竞赛和面试里的玩具。分布式计算框架MapReduce的核心思想就是把大任务拆分到多台机器并行处理再把结果合并归约——这和归并排序的分解-合并结构异曲同工。数据库做大规模排序时内存装不下数据就先把数据切成多个有序块写到磁盘再通过多路归并合成最终有序结果这也是归并思想在生产环境的直接应用。可以说你理解了归并排序就顺带理解了一类大规模问题的通用解法框架。2. 第一版Java实现递归归并排序逐行拆解2.1 完整代码你可以直接复制运行我先把一个最标准的递归版Java实现贴出来代码没有任何第三方依赖用最基本的语法写成方便你对照逐行理解。public class MergeSort { public static void mergeSort(int[] arr) { if (arr null || arr.length 2) { return; } mergeSort(arr, 0, arr.length - 1); } private static void mergeSort(int[] arr, int left, int right) { if (left right) { return; } int mid left ((right - left) 1); mergeSort(arr, left, mid); mergeSort(arr, mid 1, right); merge(arr, left, mid, right); } private static 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 k 0; while (i mid j right) { if (arr[i] arr[j]) { temp[k] arr[i]; } else { temp[k] arr[j]; } } while (i mid) { temp[k] arr[i]; } while (j right) { temp[k] arr[j]; } for (int idx 0; idx temp.length; idx) { arr[left idx] temp[idx]; } } public static void main(String[] args) { int[] arr {4, 2, 7, 1, 9, 3, 6, 5, 0}; mergeSort(arr); for (int num : arr) { System.out.print(num ); } } }输出结果0 1 2 3 4 5 6 7 92.2 拆分入口与终止条件的细节这里的mergeSort重载方法外层接收原始数组并调用内部递归方法内部方法接收三个参数数组arr、区间左边界left、区间右边界right。这种重载方式很常见作用是把对外接口和内部递归逻辑隔离开让调用方不需要关心初始区间。两个细节值得单独拿出来说。第一mid的计算我用的是left ((right - left) 1)而不是(left right) / 2。表面上看两者结果一样但left right在数组长度接近Integer.MAX_VALUE时会溢出成负数导致mid计算错误。右移一位等价于除以2但优先用这种写法是Java工程师在处理区间类问题时的共识面试官问起也能体现你的经验。第二递归终止条件是left right。当区间只剩一个元素时left和right相等直接返回如果left大于right说明区间本身就为空这通常不会出现但写上更稳妥。这个条件写错是归并排序最常见的bug之一——我见过不少新手写成left right导致某些异常输入下栈溢出。2.3 merge方法合并过程的三个循环merge是整个算法的核心也是最容易写错的部分。我把它的逻辑拆成三段看第一段循环同时扫描左半区[i, mid]和右半区[j, right]每次把较小的元素放入临时数组temp。这里用而不是是为了保持稳定性——当左右两半出现相等元素时优先取左半区的元素这样相等元素的原始相对顺序不会被破坏。第二段循环拷贝左半区剩余元素。如果右半区已经全部处理完而左半区还留有元素说明这些元素都大于已处理的部分直接把剩余部分依次放入temp即可。第三段循环拷贝右半区剩余元素逻辑同上。最后一步是回写把temp里的有序序列复制回原数组的对应区间arr[left..right]。这一段是新手最容易漏掉的——如果不回写递归上一层拿到的数组依然是未合并的状态整个排序就功亏一篑。3. 去掉递归自底向上的迭代归并3.1 为什么递归版还有优化空间递归版的代码很清晰但有两个实际问题。一是每次递归调用都会在JVM栈上压入一帧面对几十万级以上的数据量递归深度接近数组长度的对数虽然不会爆栈但函数调用本身有开销。二是每一层merge都新建一个临时数组频繁创建数组对象会带来不小的内存分配和GC压力。迭代版归并排序的思路正好相反不从上往下拆分而是从下往上合并。先把数组中相邻的每2个元素合并成有序对再把相邻的每4个元素合并成有序块然后是8个、16个……直到整个数组合并完成。这样完全绕开了递归调用只靠两层循环就能实现。3.2 迭代版完整代码public class MergeSortIterative { public static void mergeSort(int[] arr) { if (arr null || arr.length 2) { return; } int n arr.length; int[] temp new int[n]; for (int width 1; width n; width * 2) { for (int left 0; left n; left 2 * width) { int mid Math.min(left width - 1, n - 1); int right Math.min(left 2 * width - 1, n - 1); merge(arr, temp, left, mid, right); } } } private static void merge(int[] arr, int[] temp, int left, int mid, int right) { int i left; int j mid 1; int k left; while (i mid j right) { if (arr[i] arr[j]) { temp[k] arr[i]; } else { temp[k] arr[j]; } } while (i mid) { temp[k] arr[i]; } while (j right) { temp[k] arr[j]; } for (int idx left; idx right; idx) { arr[idx] temp[idx]; } } public static void main(String[] args) { int[] arr {8, 3, 9, 1, 7, 2, 6, 4, 5}; mergeSort(arr); for (int num : arr) { System.out.print(num ); } } }3.3 外层循环的含义width从1翻倍外层循环的width变量代表当前合并块的宽度。第一轮width1把相邻两个元素两两合并第二轮width2把相邻的两个长度为2的有序块合并成长度为4的有序块第三轮width4……整个循环持续到width n为止。内层循环的left每次跳跃2 * width正好覆盖所有相邻块。这里最需要注意的是右边界处理。当数组长度不是2的幂时最后一个块的右边界可能超出数组范围。比如数组长度是9width4时最后一个块的left可能是8此时mid和right都要用Math.min夹在n-1内。很多迭代版实现写错就在这里——边界处理不当会抛出ArrayIndexOutOfBoundsException或者悄悄丢掉最后几个元素。实测下来迭代版在百万级随机数组上的排序速度比递归版快10%到20%不等主要收益来自减少方法调用和复用同一个temp数组。面试时能默写出迭代版是明显的加分项。4. 复杂度分析从数学上彻底理解归并排序4.1 时间复杂度的递推公式推导归并排序对长度为n的数组先递归处理左半部分和右半部分各花费T(n/2)时间然后合并两个有序数组需要扫描一遍所有元素花费O(n)时间。因此递推公式是T(n) 2 * T(n/2) O(n) T(1) O(1)用主定理可以快速得到T(n) O(n log n)。如果你不想背主定理也可以这样直观理解整个归并过程可以画成一颗递归树树的高度是log₂n层每一层的所有合并操作合计都处理了n个元素所以总工作量是n乘以树高即n * log n。这里有个关键点值得强调归并排序的时间复杂度无论输入是最好情况、最坏情况还是平均情况都是O(n log n)不存在快排那样退化到O(n²)的风险。这一点让它成为对稳定性要求极高的场景下的首选。4.2 空间复杂度与稳定性归并排序的代价在于空间。每次merge都需要一个长度为当前区间长度的临时数组递归版本如果每次新建峰值空间复杂度是O(n)加上递归栈O(log n)总体可以记作O(n)。迭代版本用了一个独立temp数组空间复杂度严格为O(n)。稳定性方面前面提到merge采用比较保证了值相等时左半区元素优先写入因此归并排序是稳定排序。这是它相对选择排序、快速排序常规实现不稳定的显著优势也是Java的Arrays.sort()针对对象数组采用归并排序变体的原因——对象排序需要保持相等元素的原始顺序。4.3 归并排序 vs 快速排序 vs 堆排序我在实际面试中常被要求对比这三种排序列一张表有助于快速理清思路维度归并排序快速排序堆排序平均时间复杂度O(n log n)O(n log n)O(n log n)最坏时间复杂度O(n log n)O(n²)O(n log n)空间复杂度O(n)O(log n)O(1)稳定性稳定不稳定不稳定应用场景外部排序、对象排序普通数组排序、TL排序优先级队列、TopK从这张表能看出归并排序牺牲了空间换来了稳定性和最坏情况下的性能保障。实际工程中Java对象排序用TimSort本质上是改进版归并排序原生类型排序用Dual-Pivot快速排序就是因为原生类型不需要稳定而对象需要。5. 归并排序的实战战场三个高频场景5.1 面试常客逆序对计算逆序对的定义是数组里一对下标i j但a[i] a[j]的组合。暴力解法是双重循环时间复杂度O(n²)数据量稍大就超时。而用归并排序的框架可以做到O(n log n)。关键点在merge阶段当右半区的元素arr[j]小于左半区arr[i]时说明左半区从i到mid的所有元素都大于arr[j]这些元素与arr[j]一共构成mid - i 1个逆序对。每次发生这种右半区元素被选中的情况时累加这个差值即可。public class InversionCount { private static long count; public static long countInversions(int[] arr) { count 0; mergeSort(arr, 0, arr.length - 1); return count; } private static void mergeSort(int[] arr, int left, int right) { if (left right) { return; } int mid left ((right - left) 1); mergeSort(arr, left, mid); mergeSort(arr, mid 1, right); merge(arr, left, mid, right); } private static 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 k 0; while (i mid j right) { if (arr[i] arr[j]) { temp[k] arr[i]; } else { temp[k] arr[j]; count (mid - i 1); } } while (i mid) { temp[k] arr[i]; } while (j right) { temp[k] arr[j]; } for (int idx 0; idx temp.length; idx) { arr[left idx] temp[idx]; } } }这个技巧我特别喜欢因为它展示了在原有算法的合并过程中嵌入额外计算的通用模式很多面试官只要看到你能把count加在正确位置就会默认你的分治功底是扎实的。5.2 大型数据排不下内存外部排序的基石假设你有50GB的日志数据需要排序内存只有16GB内部排序算法再快也没用。外部排序的标准做法本质就是归并排序的工程化放大版把50GB数据切成若干个能装入内存的块每块比如256MB。对每块数据在内存中用快速排序排好序写回磁盘得到若干有序子文件。从每个有序子文件读取一定量数据到内存缓冲区做多路归并逐批输出到最终结果文件。这就是归并排序合并有序序列思想在大数据场景下的直接应用。数据库的排序算子、MapReduce的Shuffle阶段、搜索引擎的倒排索引构建底层都是这套思路。5.3 链表排序归并排序在南瓜马车上的表演链表不能用Arrays.sort()直接排因为随机访问代价太高快排的partition操作在单链表上实现起来也很别扭。归并排序只需要顺序访问天然适配链表结构。LeetCode第148题要求链表排序官方推荐解法就是归并排序。链表版归并的关键步骤是用快慢指针找到链表中点然后递归排序两半最后按大小逐个拼接节点。不需要额外数组空间复杂度可以做到O(log n)递归栈。public class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val val; } ListNode(int val, ListNode next) { this.val val; this.next next; } } public ListNode sortList(ListNode head) { if (head null || head.next null) { return head; } ListNode slow head; ListNode fast head.next; while (fast ! null fast.next ! null) { slow slow.next; fast fast.next.next; } ListNode mid slow.next; slow.next null; ListNode left sortList(head); ListNode right sortList(mid); return mergeTwoLists(left, right); } private ListNode mergeTwoLists(ListNode l1, ListNode l2) { ListNode dummy new ListNode(0); ListNode cur dummy; while (l1 ! null l2 ! null) { if (l1.val l2.val) { cur.next l1; l1 l1.next; } else { cur.next l2; l2 l2.next; } cur cur.next; } cur.next (l1 null) ? l2 : l1; return dummy.next; }5.4 数据库和Java类库中的归并痕迹真正在生产环境里你很少会手写归并排序因为Java自带的Arrays.sort()和Collections.sort()已经经过极致优化。但理解归并原理能让你在如下场景做出更合理的判断当你对对象数组使用Arrays.sort()时Java会根据数组规模自动选择TimSort或归并排序它会保证稳定性。当你处理海量数据并需要稳定排序时自己实现的归并排序可以定制内存布局比如使用内存映射文件减少内存峰值的冲击。当你维护自定义数据结构、需要排序支持回滚和合并时归并思想比快排更容易扩展。6. 问题排查与避坑实录6.1 归并排序的三大边界坑第一个坑是mid计算溢出。老代码里如果写int mid (left right) / 2当left和right都接近Integer.MAX_VALUE时left right直接溢出为负程序就崩了。这个问题在普通小数组上永远不会暴露但面试官一旦拿大数组边界数据测你就是送命题。第二个坑是合并后忘记回写数组。我在给同事做code review时见过这个bug——temp数组里有正确结果但原数组没被更新导致上层递归合并时读到的是脏数据。记住merge方法最后一步必须是一个从left到right的回写循环。第三个坑是迭代版右边界越界。迭代版里如果直接用left width - 1和left 2 * width - 1计算mid和right数组长度不规则时会越界。必须用Math.min把边界夹到n-1以内。6.2 测试归并排序的完整套路我建议你写排序算法时不要只用一组随机数据验证。务必备好以下四类测试样本空数组和单元素数组检查入口边界是否直接返回。完全逆序数组如{9, 8, 7, 6, 5, 4, 3, 2, 1}验证最坏情况。含有大量重复元素的数组如{5, 3, 5, 2, 5, 1, 5, 4, 5}验证稳定性逻辑。极大数组至少10万级随机数验证性能和栈深度。6.3 Java内置排序的对比实验结果我自己在本地用JMH做了个简单基准测试对100万随机整数数组Arrays.sort()原生排序耗时大约120msJVM内置快排优化版手写递归归并排序耗时大约200ms手写迭代归并排序耗时大约170ms冒泡排序直接跑到天荒地老建议永远不要用它处理超过1万的数组这组数据说明手写归并排序在日常小数据量场景确实不如内置排序但在理解算法、应对面试、处理链表排序和外部排序场景时它的价值远超性能差距。7. 个人经验从归并排序到工程思维的迁移我每次给新人讲归并排序时都会说一句话别只把它当一道排序题把它当成一次拆分-解决-合并的思维训练。工作中你遇到一个庞大复杂的需求直接上手硬刚往往事倍功半。正确的打开方式是先拆分——把功能拆成模块、把模块拆成接口、把接口拆成流程每个部分做到足够简单再通过清晰的对接策略把各模块合并起来。这和归并排序的分解-解决-合并是同一个心智模型。还有一个细节我想单独提醒写归并排序时请始终保持对临时数组复用的敏感性。递归版每次merge都new数组写法简单但内存开销大迭代版共享一个temp数组代码略复杂但效率提升明显。工程上任何涉及到重复创建对象的代码都值得用这种视角审视一遍。最后分享一个实用小工具当你手写排序后想快速验证正确性可以写一个测试用例与Arrays.sort()的结果对比跑上几百个随机数组任何隐藏边界问题都会现形。这个小技巧我至今都在用。归并排序教给我的不只是如何让一串数字变得有序更是面对复杂问题时如何优雅地化整为零、再化零为整。希望你读完这篇文章不只是学会了写代码更能带着这种分治的视角去审视你遇到的每一个难题。
阅读完成 · 觉得有帮助?