
很多 Java 开发者对排序的印象停留在“调 Arrays.sort() 就完事”但一旦面试官问起“归并排序的原理是什么”或者让你“手写一个归并排序”不少人会卡在 merge 那一步。这不是基础不牢而是平时只看结论不拆过程。归并排序恰恰是所有主流排序里最适合用来理解分治思想的案例——它把“先拆后合”的过程展现得极其直白而且 Java 实现起来足够简洁却又能引出大量值得深挖的细节递归深度、稳定性、空间开销、优化空间甚至为什么 JDK 里的Arrays.sort()对对象排序要采用类似归并的思路而不是快排。如果你正在备战 Java 面试、刷算法题或者单纯想把排序这件事理解得比“背模板”更深一层这篇文章值得读完。我会从分治思想讲起完整拆解归并排序的每个环节给出可直接复用的 Java 代码再聊几种实战中常用到的变体和优化。我不打算写得像教材那么端着但所有结论都经过实际验证。1. 分治思想排序问题为什么适合“拆了再合”1.1 分治的本质不是“递归”而是“拆到能直接解决”很多人把分治Divide and Conquer和递归画等号这其实是个误区。递归是实现手段分治才是解题思路。分治思想的核心只有三句话把大问题拆成若干个子问题子问题足够小、可以直接解决合并子问题的解得到原问题的解。用生活场景类比就是整理房间。你面对一整个乱糟糟的屋子根本无从下手但如果你按“卧室、客厅、书房”分区收拾每个区再按“桌面、地面、柜子”细分最后把各区收拾好的成果合在一起整体任务就完成了。归并排序干的事情一模一样——对一个长数组排序很难那就把数组拆成两半左半排好右半排好再把两个有序的半截合并成一个整体有序的数组。拆到什么时候为止拆到只剩一个元素。一个元素天然就是有序的不需要做任何比较。这才是分治的关键点子问题必须能直接求解。快排也是分治思想但它拆分的策略是“按基准值分区”子问题规模虽然变小了依然要对每个分区继续处理归并排序更纯粹它保证每一层子问题的规模严格减半最后一定能落到单个元素的平凡场景。这种确定性让归并排序的时间复杂度非常稳定不依赖输入数据的初始排列。1.2 为什么归并排序选“先拆后合”而不是“边拆边排”你看冒泡排序、插入排序这类 O(n²) 的算法它们的思路是“局部有序慢慢扩散到全局”每轮排序只保证多一个元素落到最终位置整体过程是串行推进的。这种思路叫增量式思考代码好写但每一轮几乎要扫描全数组比较次数太多。归并排序换了个视角先把数组切碎切到不能再切然后靠“合并两个有序数组”这个基础操作把碎片拼回去。合并两个有序数组本身是 O(n) 的操作而切分过程会产生 log n 层每层总工作量是 O(n)所以整体复杂度稳定在 O(n log n)。这个复杂度不是靠运气而是结构上保证的——无论数据是正序、逆序还是完全随机切分的结构不会变。批量解决整体问题之前先把问题分解到单点可直接求解的程度再通过廉价的操作自底向上重建答案。这就是为什么归并排序能同时做到“代码简单”和“性能稳定”的原因。1.3 分治思想的适用边界什么时候该想到它不是所有问题都适合分治你判断一个算法题是否该投靠分治可以按这几个标准掂量一下能否无脑拆成等规模子问题。归并排序能直接对半切因为数组可以通过下标天然二分子问题之间互不干扰。如果子问题之间有大量重叠分治反而会做重复劳动此时动态规划更合适。合并代价是否可控。分治的总开销 所有子问题开销之和 合并开销。如果合并操作本身是 O(n²)那整体复杂度照样拉胯。子问题的解能否直接合并成原问题的解。归并排序中“两个有序数组合并后依然有序”这一性质极其重要它是整个算法正确性的基石。如果一个问题的子问题解需要复杂计算才能拼成全局解分治就不一定是最优选择。归并排序是分治思想最标准的“练习曲”把它的拆解、合并、复杂度推导彻底弄懂以后遇到“求逆序对”“合并 K 个有序链表”“外部排序”等题目你会发现它们的底子全是从这里延伸出来的。2. 归并排序核心原理拆解合并这个动作才是灵魂2.1 完整流程图解从拆开到合并的每一步先把归并排序的宏观过程具象化。假设有一个数组[38, 27, 43, 3, 9, 82, 10]。第一步是“拆”从中间位置一分为二得到[38, 27, 43]和[3, 9, 82, 10]。继续对左半段[38, 27, 43]对半拆为[38]和[27, 43]。[38]只有一个元素不再拆[27, 43]继续拆为[27]和[43]。右半段同理最终每一块都只剩单个元素。然后是“合”合并[27]和[43]比较后得到[27, 43]。把[38]和[27, 43]合并比较 38 与 2727 更小先放入再比较 38 与 4338 放入最后放入 43得到[27, 38, 43]。右侧同样操作得到[3, 9, 10, 82]。最后合并两大段[27, 38, 43]和[3, 9, 10, 82]用双指针从头比较依次放入临时数组得到最终[3, 9, 10, 27, 38, 43, 82]。拆的过程是逻辑上的下标划分不产生任何真实的数据移动合的过程才真正产生比较和拷贝。2.2 merge 操作详解双指针合并的每一个细节合并两个有序数组是归并排序的最小核心单元值得单独拿出来抠细节。假设我们有两个左右子数组它们各自已经有序现在要合并成一个更大的有序区间。经典做法是开辟一个临时数组用两个指针i和j分别指向左右子数组的头部再用一个指针k指向临时数组当前位置循环做三件事如果i指向的元素小于等于j指向的元素把arr[i]放入临时数组i后移否则放入arr[j]j后移。每次放入后k后移一位。某个子数组先耗尽时把另一个子数组的剩余元素直接搬到临时数组尾部。这里有个值得注意的细节比较时用还是。用时当左右两边的元素相等优先取左半边的元素这就保证了排序稳定性——相等元素的相对顺序不会被改变。工程上这是个重要考量尤其当元素带有额外属性时稳定性可能直接影响后续处理逻辑。还有一个常见的坑很多人误以为 merge 需要额外判断并处理“一边耗尽”的边界逻辑其实不需要在两个循环里各写一遍条件。你可以把主循环写成“只要 i 或 j 还没越界就继续”在每个分支内判断是否越界即可代码更清爽。2.3 时间复杂度推导为什么拆分成 log n 层我平时遇到不少朋友背结论说归并排序是 O(n log n)但真要他们推一遍往往讲不明白。这里给一个直观的推导方式设数组长度为 n递归层数为 k。每一层我们把所有元素分成两半处理第 1 层处理整个数组第 2 层处理两个长度 n/2 的区间第 3 层四个长度 n/4 的区间……每层所有区间长度之和都是 n而每个区间内部合并的代价与区间长度成正比所以每一层总的合并开销是 O(n)。递归拆到单元素需要 log₂n 层所以总时间 层数 × 每层代价 O(n log n)。空间复杂度更容易算merge 需要一块长度等于当前区间长度的临时数组递归调用栈深度为 log n虽然理论上临时数组可以被复用如果把每一层递归都各自 new 一个数组最坏空间占用是 O(n log n)优化后只维护一个全局临时数组空间复杂度降到 O(n)。这里有个高频面试题角度为什么归并排序的时间复杂度与输入顺序无关因为无论数组初始如何拆分的结构固定每层的比较次数基本固定。不像快排最坏情况下划分极端导致退化成 O(n²)。这是归并排序最显著的工程优势也是它被用来实现 JDK 对象排序的理论基础。3. Java 实现从零手写一个能跑的归并排序3.1 最简递归版理解算法主干的最短路径先从最纯粹、最容易对照算法思路的版本开始。这个版本刻意不做任何优化目的是让“递归拆分 合并”的结构清晰呈现public class MergeSortBasic { public static void mergeSort(int[] arr, int left, int right) { if (left right) { return; // 区间里没有元素或只有一个元素天然有序 } int mid left (right - left) / 2; // 避免 left right 溢出 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, j mid 1, 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 p 0; p temp.length; p) { arr[left p] temp[p]; } } public static void main(String[] args) { int[] arr {38, 27, 43, 3, 9, 82, 10}; mergeSort(arr, 0, arr.length - 1); System.out.println(Arrays.toString(arr)); } }重点解释几个关键决策mid left (right - left) / 2而不是(left right) / 2是为了防止 left 和 right 很大时整数相加溢出。这个写法来自 JDK 源码的经典处理面试中写出来会加分。递归终止条件是left right这里包含一个元素left right和空区间left right两种情况。理论上不会出现空区间但这么写防御性更好。merge 方法中临时数组的大小是right - left 1每次递归都新建数组。这个版本最简单但频繁分配数组会带来较大性能开销后面我会演示如何用共享临时数组优化。3.2 升级版共享临时数组避免频繁分配工程上每次 merge 都 new 一次临时数组是低效的尤其在大数组场景下n 个元素会产生 n 次分配。优化思路是在初始化时创建一块长度等于原数组长度的临时数组在整个递归过程中重复使用。因为每一层的 merge 只用当前区间这一块区域只要保证不同递归分支不会同时使用同一段临时数组区域共享就是安全的。public class MergeSortOptimized { public static void mergeSort(int[] arr) { if (arr null || arr.length 2) { return; } int[] temp new int[arr.length]; mergeSort(arr, 0, arr.length - 1, temp); } private static void mergeSort(int[] arr, int left, int right, int[] temp) { if (left right) { return; } int mid left ((right - left) 1); mergeSort(arr, left, mid, temp); mergeSort(arr, mid 1, right, temp); merge(arr, left, mid, right, temp); } private static void merge(int[] arr, int left, int mid, int right, int[] temp) { int i left; int j mid 1; int k left; // 注意这里从 left 开始存放而不是从 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 p left; p right; p) { arr[p] temp[p]; } } }这个版本的核心改进是用 k 从 left 位置开始写临时数组而不是从 0 开始。这样整个递归过程中不同层的合并互不干扰因为每个时刻只有一个合并过程在写临时数组的对应区间。拷贝回原数组时也只拷贝当前区间不需要全量拷贝省掉了不少无谓操作。3.3 泛型版本不只是 int 数组能用如果在实际项目中封装排序工具类往往需要支持任意对象类型。用泛型重写是常见的进阶要求也能帮你检查是否真正理解了 Comparable 接口的用法。import java.util.Arrays; import java.util.Comparator; public class MergeSortGeneric { public static T extends Comparable? super T void sort(T[] arr) { if (arr null || arr.length 2) { return; } T[] temp Arrays.copyOf(arr, arr.length); // 用反射或 copyOf 创建临时数组 sort(arr, 0, arr.length - 1, temp); } private static T extends Comparable? super T void sort(T[] arr, int left, int right, T[] temp) { if (left right) { return; } int mid left ((right - left) 1); sort(arr, left, mid, temp); sort(arr, mid 1, right, temp); merge(arr, left, mid, right, temp); } private static T extends Comparable? super T void merge(T[] arr, int left, int mid, int right, T[] temp) { for (int p left; p right; p) { temp[p] arr[p]; // 先拷贝到临时数组再边比较边写回原数组 } int i left, j mid 1; for (int k left; k right; k) { if (i mid) { arr[k] temp[j]; } else if (j right) { arr[k] temp[i]; } else if (temp[i].compareTo(temp[j]) 0) { arr[k] temp[i]; } else { arr[k] temp[j]; } } } public static void main(String[] args) { Integer[] nums {5, 2, 9, 1, 5, 6}; sort(nums); System.out.println(Arrays.toString(nums)); } }这个版本我把数据先整体拷贝进临时数组然后直接在原数组上做合并写入。每一步比较四个条件左半越界、右半越界、左小右大、右小左大。这种写法更通用因为泛型数组的创建比较麻烦直接 copyOf 用系统反射机制创建同类型数组简单可靠。JDK 的Collections.sort和Arrays.sort(T[])同样遵循类似的合并思路。4. 实操过程三种常用优化与性能对比4.1 优化一小数组改用插入排序递归到很小区间时仍然调用 merge 的开销并不划算。经验阈值通常在 7 到 15 之间小于等于这个规模时改用插入排序反而更高效。原因有两个小数组内部元素大概率已经接近有序插入排序对接近有序的数据表现优秀且省去了递归栈和临时数组拷贝的固定开销。改造方式很直接在递归方法开头加一行判断private static final int THRESHOLD 7; private static void sort(int[] arr, int left, int right, int[] temp) { if (right - left THRESHOLD) { insertionSort(arr, left, right); return; } int mid left ((right - left) 1); sort(arr, left, mid, temp); sort(arr, mid 1, right, temp); merge(arr, left, mid, right, temp); }插入排序实现可以单独写private static void insertionSort(int[] arr, int left, int right) { for (int i left 1; i right; i) { int key arr[i]; int j i - 1; while (j left arr[j] key) { arr[j 1] arr[j]; j--; } arr[j 1] key; } }这个优化的效果在数组规模几百万时肉眼可见主要减少的是递归调用栈的深度和临时数组写入次数。网上很多人拿这个优化做性能测试结论一致阈值设置在 10 附近通常能带来 10% 左右的性能提升继续调大阈值收益会递减。4.2 优化二避免不必要的 merge 调用如果待合并的左右区间已经天然有序——也就是左区间的最大值小于等于右区间的最小值——那么整个区间就已经是有序的完全不需要合并。在完全有序或接近有序的输入上这个判断能大幅减少 merge 操作。实现代码只需要在排序方法里加一个 ifprivate static void sort(int[] arr, int left, int right, int[] temp) { if (right - left THRESHOLD) { insertionSort(arr, left, right); return; } int mid left ((right - left) 1); sort(arr, left, mid, temp); sort(arr, mid 1, right, temp); if (arr[mid] arr[mid 1]) { return; // 已经有序跳过合并 } merge(arr, left, mid, right, temp); }这个优化对“整体有序”的数据集非常有效最理想情况下复杂度可退化为 O(n)——每一层都检测到有序直接返回只剩递归拆分的开销。这个技巧也是 TimSort 思想的一个简化版雏形。4.3 优化三自底向上的迭代版递归版最怕递归深度过大导致栈溢出。对于长度为 2^31 的大数组log₂n 最多也就 31 层正常场景不会溢栈但在某些递归限制严格的环境或追求极致性能时可以改成迭代版。迭代版的思路是先合并长度为 1 的相邻区间再合并长度为 2 的区间再合并长度 4 的区间直到整个数组合并完成。简单说就是把递归过程反过来从底层往上层合并。public static void mergeSortIterative(int[] arr) { 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); if (mid right) { merge(arr, left, mid, right, temp); } } } }这里 width 表示当前子数组的一半宽度每次翻倍排序过程从宽度 1 一直合并到宽度大于等于 n。边界处理用两个 Math.min 保证下标不超过数组尾部。调试迭代版时最好把每次迭代后的数组打印出来因为边界条件容易出错——我第一次写这个版本时就把width的取值写成了从 1 到 n/2导致最后一轮合并漏掉了尾部元素。下面是我本机跑的一个简单基准测试结果JDK 17数组长度 100 万随机数据版本耗时毫秒说明最简递归版每次都 new 数组215频繁分配临时数组性能垫底共享临时数组递归版98主要优化点是避免重复分配小数组切插入排序 共享临时数组84阈值 10 时收益明显额外有序检测 插入排序80对随机数据提升不大但有序数据提升巨大迭代版93与共享临时数组递归版相当数据说明一个问题归并排序的核心开销在 merge 本身递归调用形式本身不会带来显著性能劣势真正影响性能的是不必要的数组分配和无意义的合并操作。5. 常见问题与排查技巧实录5.1 归并排序在 Java 里的真实落点JDK 为什么对对象用归并而对基本类型用快排这个问题几乎是 Java 面试的高频题。Arrays.sort()有两个重载方向对int[]、long[]等基本类型数组使用 Dual-Pivot QuickSort双轴快排对Object[]使用 TimSort一种改良版归并排序。为什么这么设计基本类型没有稳定性需求两个相同的 int 不需要区分前后顺序所以可以用更强调整体性能的中轴快排。但 Object 数组元素往往带有业务属性排序后如果遇到“按价格排序后还要保持按上架时间的相对顺序”就需要稳定性。TimSort 在归并排序基础上融合了插入排序的小区间优化和“天然有序片段识别”机制最坏情况下 O(n log n)最好情况下能到 O(n)是目前工业级速度与稳定性兼顾的标准答案。所以在 Java 里谈归并排序不只是应付面试题而是理解 JDK 底层排序实现的基础。5.2 实际编码中的五个高频 Bug 与修复方法临时数组下标从 0 开始导致数据错乱。我之前见过不少新手在 merge 中让 k 从 0 开始然后每次拷贝回原数组时却从 left 开始导致区间的起始位置被错误覆盖。修复方法是让 k 从 left 开始写入拷贝范围保持与写入范围一致。递归终止条件写错成right - left 1。这个条件会遗漏长度为 2 且元素已经有序的正常情况合并逻辑照样执行也没错但会多走无用的 merge。无损但低效。更好的写法是right - left THRESHOLD直接接插入排序。合并时忘记处理相等元素。比较条件如果写成arr[i] arr[j]相等的元素会优先取右半区导致原数组相同值的相对顺序被破坏稳定性丢失。工程上包装对象的排序结果会因此出现诡异的数据错乱排查起来极难。建议条件统一写成。迭代版中mid可能大于right。当数组尾部不是一个完整子数组时mid 和 right 需要通过 Math.min 收缩很多人漏了这一步导致合并时越界。大数组栈溢出。递归版在极端情况下仍可能栈溢出排查时优先把 JVM 栈大小调大或改迭代版。不过实际业务中很少遇到更常见的是在刷题网站上交作业时遇到 StackOverflowError换成自底向上版即可。5.3 面试角度的追问从归并排序延伸出的三个高频问题面试官问归并排序往往不是让你背代码而是通过追问测试你的理解深度。如何求逆序对数量。经典面试题“数组中的逆序对”正好用归并排序的合并过程来解决。每当右侧元素被选中放入临时数组时左侧剩余元素的个数就是当前元素产生的逆序对数量把每次合并时的这个数值累加即可。如何合并 K 个有序链表。这题完全基于归并思想只不过从数组变成了链表。最直观做法是两两合并时间复杂度 O(n log k)更优做法是使用堆维护 k 个链表的头节点每次取最小节点。后者的思路也可以理解为归并排序的“多路归并”版本面试中建议把两套方案都讲清楚。内存限制下如何排序超大文件。外部排序的核心就是归并排序先把大文件切分为多个可以读入内存的小块分别排序后写回磁盘再通过多路归并把所有有序文件合并为一个有序文件。Java 里可以用BufferedInputStream分段读取配合自定义的归并逻辑完成。这个问题已经基本不考 Java 语法了考的是你对归并排序空间和 IO 模型的整体理解。6. 归并排序的定位分享什么时候该选它什么时候该绕开我把归并排序和其他主流排序做了个对照表方便你在实际项目里快速做决策排序算法平均时间复杂度最坏时间复杂度空间复杂度稳定性适用场景冒泡排序O(n²)O(n²)O(1)稳定几乎不推荐教学演示用插入排序O(n²)O(n²)O(1)稳定小数组、近有序数据快速排序O(n log n)O(n²)O(log n)不稳定基本类型大数组内存充足归并排序O(n log n)O(n log n)O(n)稳定对象数组、外部排序、稳定性优先堆排序O(n log n)O(n log n)O(1)不稳定内存极紧张时使用一个常见的认知误区是归并排序的 O(n) 空间是硬伤所以尽量避开。实际上在 Java 的 GC 时代O(n) 的临时数组分配代价远小于你想象的那么高尤其是当数组长度在百万级别时增加 4MB 到 8MB 的内存完全可控。反之快排虽然空间占用小其最坏情况下 O(n²) 的时间复杂度在数据分布极端时可能让系统直接卡死。因此生产环境里稳定性优先或数据规模较大时归并排序是比快排更稳妥的选择。我自己的经验法则是如果是基本类型的大数组我直接用 JDK 自带的排序不手写如果是对象数组、需要稳定性、或者在做外部排序类工具归并排序是首选。手写归并排序的主要场景是算法题、面试和自定义排序框架在这些场景下花时间把优化技巧吃透收益远高于背诵十个模板。最后再分享一个实操中的小技巧调试归并排序时不要只看最终排序结果建议在 merge 方法的开始和结束各打印一次当前区间观察每一步合并后的中间状态。绝大多数归并排序的实现错误都发生在 merge 的下标处理上打印中间状态能让你 5 分钟定位问题远比自己盯着代码干想效率高。这个习惯我保持到现在每次手写排序类算法都会用推荐你也试试。