1. 归并排序的定位:为什么排序算法里绕不开它
我第一次认真琢磨归并排序,是被一个面试问题逼的:"哪种排序最坏情况也能做到 O(n log n),而且还稳定?"当时我脑子里能想到的排序算法不少,但第一反应全是快速排序。快排平均是 O(n log n),最坏却会退化到 O(n²),稳定性也要看具体实现,根本不算合格答案。后来我把归并排序从头到尾重新学了一遍,才意识到它在排序算法族里的特殊地位:最坏 O(n log n)、稳定、实现思路清晰,这三点同时满足的经典算法,基本就它一个。
我会从归并排序的分治原理讲起,给出可以直接复制运行的 Java 代码,把每一行的含义拆开讲清楚,然后推导复杂度、分析稳定性,再聊聊我实际写代码时踩过的边界问题,最后介绍几个工程上常用的优化手法和衍生应用。无论你是刚学数据结构的学生,还是想系统补一遍排序基础的工作党,都可以照着步骤慢慢复现。
1.1 和常见排序算法的横向对比
先把归并排序放进整个排序算法谱系里看,它的位置就很清晰了:
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 额外空间 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 插入排序 | 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(1) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
这张表能读出几个关键信息。第一,归并排序是最坏情况有硬保证的:无论输入怎么排列,它都稳定在 O(n log n),这一点在实时性要求高的场景非常重要。第二,它是稳定的,而且稳定性不依赖任何复杂的实现技巧,只要合并时把相等元素的处理写对,稳定性天然成立。第三,代价是 O(n) 的辅助空间,这也是很多人嫌弃它的理由,但这个代价换来的是规律的内存访问模式,后面讲外部排序时你会发现,这个"规律"反而是它最大的资产。
1.2 什么场景会让你想到归并排序
归并排序的出场机会通常不是"给一个随机数组排序"这种简单任务,而是下面这几类:
- 数据规模大到内存装不下。归并排序只依赖顺序读取和顺序写入,非常适合磁盘、磁带这类顺序访问介质,外部排序的底层就是多路归并。
- 需要稳定排序。比如按某个关键字排序时希望保持原有的相对次序,归并排序是教科书里唯一能稳定做到 O(n log n) 的通用比较排序。
- 数据结构不是数组而是链表。链表无法随机访问,快速排序的性能优势完全发挥不出来,而归并排序只要沿着 next 指针走就行,配合快慢指针找中点,可以轻松实现链表排序。
- 需要统计逆序对数量。归并排序在合并过程中天然可以顺带统计逆序对,这是很多算法场景的标准解法。
我遇到的大多数人学归并排序,都是先从数组版开始,我也是。所以下面我先把数组版讲透,最后再回头说链表和逆序对的应用。
2. 分治思想:归并排序背后到底在做什么
2.1 把大问题拆成小问题
归并排序的核心思想是"分治",这个词听起来抽象,其实概括起来只有三步:
- 分解:把数组从中间一分为二。
- 解决:递归地对左半和右半分别排序。
- 合并:把两个已经有序的子数组合并成一个有序的大数组。
这个过程很像组织一场淘汰赛:先把队伍分成小组,小组内部比出名次,再让各组的第一名、第二名依次对决,最终排出整个赛区的名次。拆到什么时候为止?拆到每个子数组只剩一个元素——一个元素天然有序,不需要再排。
拿 8 个元素的数组举例:第一层切成 4+4,第二层切成 2+2+2+2,第三层切成 1 乘 8。整个过程可以画成一棵二叉树,每个节点代表一个子数组,节点的两个孩子就是它切出来的两半。叶子节点全是单元素数组,已经有序,然后从叶子开始逐层向上合并,每一层把所有节点都合并完,整个数组就完全有序了。
这棵递归树有多少层?从 n 个元素一路切到 1,每层减半,所以层数是 log₂n。树有 log₂n 层,每层要处理的总数据量都是 n,所以总工作量是 n 乘 log₂n。这个直观理解比死记 O(n log n) 有用得多,后面推导复杂度时还会用到。
还有一点值得对比:快速排序是先处理、再递归;归并排序是先递归到底、再在返回的路上处理。快排在每次递归时都要做分区操作,把基准元素放到最终位置;归并排序真正干活的是"合并"这一步,所有排序动作都发生在递归返回的途中。
2.2 合并操作为什么高效
合并两个有序数组,本质上是一个"拉链"过程。想象两条已经排好的队列,每次比较队头元素,谁小谁出队进入结果队列,直到某条队列空了,就把另一条队列剩下的元素全部搬过来。
这里有个核心结论:合并两个长度分别为 m 和 n 的有序数组,最坏只需要 m + n - 1 次比较。因为每产生一个输出元素至多需要一次比较,最后一个元素不用比也知道位置。整个过程每个元素最多被比较一次、被移动一次,加上最后把剩余整段搬过去,总代价就是两个数组长度之和。这个近乎线性的开销,是归并排序整体高效的基石。
很多人会问:合并时为什么不直接就地交换,非要开辅助数组?因为要把两个有序片段合到一起,同时还要保持 O(n log n) 的复杂度,原地做非常困难。经典的原地归并需要复杂的旋转操作,代价是常数因子变大,甚至在某些实现里会退化成 O(n²)。教材里的归并排序普遍采用辅助数组,用空间换时间,换来的是简单、可靠、可预测的性能。这个取舍在我多年的工程实践里反复出现:代码容易写对,往往比省那一点内存更重要。
3. Java实现:从递归框架到合并核心逐行拆解
3.1 接口设计与辅助数组的一次性分配
直接上代码。我习惯把对外方法和内部递归方法分开,对外方法只负责参数校验和资源准备,内部方法专注递归逻辑:
public class MergeSort { public static void sort(int[] arr) { if (arr == null || arr.length < 2) { return; } int[] temp = new int[arr.length]; sort(arr, temp, 0, arr.length - 1); } private static void sort(int[] arr, int[] temp, int left, int right) { if (left >= right) { return; } int mid = left + ((right - left) >> 1); sort(arr, temp, left, mid); sort(arr, temp, mid + 1, right); merge(arr, temp, left, mid, right); } }这里有一个很重要的设计:辅助数组 temp 只在 sort 入口分配一次,然后一路传下去。刚开始很多学习者会把 temp 写在 merge 方法里,每次合并都 new 一个新数组。这样做在小数组上没感觉,但数组一大,频繁分配和垃圾回收的开销会明显拖慢性能,而且这个开销和排序本身是同一数量级的。等做完基准测试,你会对"分配数组也是成本"有切身体会。
3.2 递归边界与中点计算
递归终止条件是left >= right。为什么不是left == right?因为写成>=是防御性写法,即使某次调用传入的参数异常,也不会出现死递归。排序的两个子问题分别是[left, mid]和[mid + 1, right],中间不会重叠,所以正常的递归序列里其实只会出现left == right,但防御性写法能让代码更稳。
中点计算用left + ((right - left) >> 1)而不是(left + right) / 2。很多人知道这是为了避免整数溢出,但没细想什么时候会溢出。Java 里 int 最大值是 2147483647,当数组长度超过这个数的一半时,left + right就可能超过 int 范围变成负数,导致 mid 算错,甚至出现死循环。虽然日常很少遇到这么大的数组,但这是个习惯问题,写成位运算版本既防溢出,又顺带做了除以 2 的运算,一举两得。
3.3 merge方法:三个指针的"拉链"
接下来是归并排序的心脏——merge 方法:
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++]; } System.arraycopy(temp, left, arr, left, right - left + 1); } }三个指针的含义要记牢:i 指向左半部分当前要比较的元素,j 指向右半部分当前要比较的元素,k 指向辅助数组 temp 中下一个要写入的位置。初值分别是 left、mid + 1、left。
第一个 while 是主循环,每一轮比较 arr[i] 和 arr[j],把较小的放到 temp[k],然后对应指针前进。这里有个细节:当两个元素相等时,我们取左边那个(<=而不是<),这是归并排序稳定性的关键,后面我会专门展开。
主循环结束后,一定有一半还有剩余元素。比如左半全部处理完了,右半剩下的一截本身就是有序片段,直接整体搬进 temp 即可。两个 while 分别处理"左边剩"和"右边剩"两种情况,互斥且至少有一个会执行。
最后一步是把 temp 中 [left, right] 这段已排好的数据拷回 arr。很多初学者会忘记这一步,导致排序结果丢失。记住这条链路:排好的数据永远在 temp 里,必须 copy 回原数组,上一层递归才能继续用。
我用一个例子手动跟踪一遍。假设数组是 {38, 27, 43, 3},第一次进入 sort 时 left=0, right=3, mid=1。先排左半 [0,1],也就是 {38, 27}:继续切,左右各一个元素,直接返回,然后合并两个单元素,得到 {27, 38} 写回原数组。再排右半 [2,3],也就是 {43, 3},同理变成 {3, 43}。最后合并 [0,3]:i=0 指向 27,j=2 指向 3,k=0。3 比 27 小,temp[0]=3,j 移到 3;27 比 43 小,temp[1]=27,i 移到 1;38 比 43 小,temp[2]=38,i 移到 2;此时 i>mid,退出主循环,右边剩一个 43,搬进 temp[3]。最后整体拷回,得到 {3, 27, 38, 43}。
3.4 完整运行示例
补一个 main 方法,方便直接跑:
import java.util.Arrays; public class MergeSort { // sort、merge 方法见上方 public static void main(String[] args) { int[] arr = {38, 27, 43, 3, 9, 82, 10}; MergeSort.sort(arr); System.out.println(Arrays.toString(arr)); } }输出:
[3, 9, 10, 27, 38, 43, 82]实际工程里如果要排序对象数组,记得加泛型并传入 Comparator,原理完全一样,只是比较逻辑从arr[i] <= arr[j]变成comparator.compare(arr[i], arr[j]) <= 0。
4. 复杂度推导与稳定性分析:结果背后的逻辑
4.1 递推式推导 O(n log n)
归并排序的时间复杂度可以用递推式严格推导。设 T(n) 表示对 n 个元素排序的时间,那么:
- 递归处理两个 n/2 规模的子数组:2 × T(n/2)
- 合并两个子数组:遍历一遍所有元素,代价 O(n)
所以有 T(n) = 2T(n/2) + O(n)。把这个式子一层层展开:
T(n) = 2T(n/2) + cn
= 2(2T(n/4) + c(n/2)) + cn = 4T(n/4) + 2cn
= 4(2T(n/8) + c(n/4)) + 2cn = 8T(n/8) + 3cn
...
推到第 k 层时,T(n) = 2^k T(n/2^k) + k·cn。当 n/2^k = 1,也就是 k = log₂n 时,T(n) = n·T(1) + n·log₂n·c = O(n log n)。
这个推导揭示了 n log n 的真正来源:递归树有 log₂n 层,每一层的总工作量都是 O(n)。无论输入怎么排列,递归结构都一样、合并代价都一样,所以最坏、平均、最好三种情况全是 O(n log n)。相比快速排序依赖"基准选得好",归并排序的时间复杂度是硬保证,不需要任何运气。如果熟悉主定理,也可以直接套:T(n) = 2T(n/2) + Θ(n),a=2、b=2,log_b a = 1,f(n) = Θ(n),落在主定理第二种情形,结论同样是 Θ(n log n),两种推导殊途同归。
4.2 空间复杂度的真实开销
归并排序的空间复杂度经常被简单说成 O(n),严谨一点要拆成两部分:
- 显式辅助数组:长度 n,这就是 O(n)。
- 递归调用栈:深度 log₂n,每层压栈的空间是 O(1),总计 O(log n)。
所以严格写是 O(n + log n),但 n 主导,通常就记作 O(n)。递归栈这部分在大 O 记号里被吞掉了,实际中也要注意方向:因为递归深度只有 log₂n,一般不会栈溢出;真正吃内存的是那个和原数组等长的辅助数组。
这个 O(n) 的辅助空间,在内存紧张或嵌入式环境里是实打实的负担。比如手里只有几十 MB 堆内存,要排 10MB 的 int 数组,辅助数组又占 10MB,加上其他对象,内存可能就吃紧了。所以归并排序并不是所有场景的最优解,它是一个用空间换时间、用空间换稳定性的典型。真到内存极度受限的场景,你会优先考虑堆排序;但如果内存够用又要求稳定,归并排序就是很省心的选择。
4.3 稳定性:一个 <= 决定生死
稳定性指排序后相等元素的相对顺序保持不变。归并排序的稳定性只取决于合并时的一个判断:两个元素相等时,先取哪边的。
if (arr[i] <= arr[j]) { // 相等时也取左半,保证左半元素在前 temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; }如果把<=改成<,当 arr[i] 和 arr[j] 相等时,代码会走 else 分支,把右半的元素先放进 temp。原本在左半、位置更靠前的那个元素反而被排到后面去了,稳定性就被破坏了。
这个细节很容易被忽略,因为对纯数值排序来说,结果一模一样,根本看不出差别。但当你排序的是对象数组,比如按成绩排序学生列表,两个学生分数相同,你希望保持他们在原列表里的先后顺序,这时候稳定性就变得非常重要。这种需求在多字段排序时尤其常见:先按主字段排,再用次字段做稳定排序,层层叠加,最后得到的数据就符合业务预期。所以我的习惯是:只要写比较排序,一律用<=或compareTo <= 0,不给自己留隐患。
5. 我踩过的坑:归并排序的边界问题与调试经验
5.1 中点计算溢出的隐蔽问题
前面说了位运算防溢出,但还有个更容易踩的坑:如果你用的是(left + right) / 2,在小数组上完全正常,代码跑得好好的。等你某天处理超大数据,或者 left 和 right 都很大时,mid 可能突然变成负数,递归瞬间全乱。这个问题的特点是"平时正常、极端才炸",很难复现,也很难排查。所以我建议从一开始就养成写left + ((right - left) >> 1)的习惯,这类防御性写法成本极低,能省下未来几小时的排查时间。
5.2 临时数组分配的两种策略对比
临时数组分配有两种常见策略,适用场景完全不同:
| 策略 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 一次性分配 | 入口 new 一次,递归传递 | 零重复分配,性能好 | 代码多一个参数 |
| 每次合并分配 | 在 merge 里 new | 实现最简单 | 产生大量临时对象,大数组明显变慢 |
初学者用第二种方便理解,但一旦数据量上万,性能差距立刻显现。我之前做了一组对比测试,n = 10 万随机数,第二种比第一种慢了三倍不止,主要时间都耗在数组分配和垃圾回收上。所以进入工程阶段,务必改成第一种。如果你所在团队要求严格限定堆内存,辅助数组还可以考虑复用同一个数组并在合并时用偏移量管理,不过这会牺牲代码可读性,一般没必要。
5.3 忘记拷回原数组的经典失误
还有一个特别经典的错误:merge 里把数据都归并到 temp 了,但忘了最后的 System.arraycopy。当天的调试画面通常是:某一层递归返回值看起来没问题,但排完整个数组后,结果只有部分有序,或者根本没变化。因为每一层递归都在等前一层把结果写回 arr,你漏了这一步,上一层读到的还是旧数据。
我的调试建议是,在中间状态打印关键的 left、mid、right 和数组片段,别只盯着最终结果。归并排序是递归结构,只看最终结果很难定位是哪一层出了问题。我通常会在 merge 前后各加一行日志:
System.out.println("merge: [" + left + ", " + mid + "] + [" + (mid + 1) + ", " + right + "]"); System.out.println("before: " + Arrays.toString(Arrays.copyOfRange(arr, left, right + 1)));跑一个小数组,一眼就能看到每一层合并的输入和输出,问题出在哪立马暴露。调试完记得把日志删掉,或者用日志框架的 debug 级别控制输出。
5.4 递归深度与栈溢出的关系
归并排序的递归深度是 log₂n,比如一百万个元素只需要 20 层递归,通常不用担心栈溢出。但如果递归边界写错,比如sort(arr, temp, mid + 1, right)误写成sort(arr, temp, mid, right),区间没有缩小,就会无限递归,最后抛 StackOverflowError。
遇到栈溢出别慌,先在 sort 方法开头打印 left 和 right。如果发现区间反复出现相同的值,边界条件肯定写错了。另外也要区分两种情况:栈溢出是因为递归太深(这里正常不会发生),还是因为递归停不下来(这里最可能)。定位思路完全不同,前者考虑转迭代版,后者优先检查终止条件和区间划分。
6. 进阶优化:从教材代码到工程可用的归并排序
6.1 小规模子数组改用插入排序
归并排序在小数组上并不划算,因为递归到叶子时,每个单元素都要做一次函数调用,而这些调用本身有开销。工程上常见的优化是:当子数组长度小于某个阈值时,直接用插入排序,不再继续递归。
阈值选多少?我测试下来 7 到 15 之间差别不大,经典实现里常见的是 7。原因是插入排序在近有序和小规模数组上常数极小,切换后整体可以省下大约 10%~20% 的时间。代码只需在递归方法开头加一个判断:
if (right - left + 1 <= 7) { insertionSort(arr, left, right); return; }插入排序子方法写起来很简单,就是从 left+1 到 right 逐个往前插,注意这是作用在子数组片段上的,起始位置要用 left 而非 0。
6.2 跳过已经有序的合并
另一个很实用的优化是:如果左半部分的最后一个元素已经小于等于右半部分的第一个元素,说明整个子数组已经有序,不需要做合并。判断条件就一行:
if (arr[mid] <= arr[mid + 1]) { return; }这个优化对"本来就有序"或"近乎有序"的输入特别有效。最极端的例子是输入就是一个排好序的数组,每一层递归都能跳过合并,归并排序会退化成接近 O(n) 的检查过程。虽然这个优化不能改变最坏复杂度,但对实际数据的加速非常可观。加上插入排序阈值和小数组优化,归并排序在大部分真实数据上都能跑得相当不错。
6.3 自底向上的迭代版:避免递归
递归版是自顶向下,先拆到底再合并。迭代版反过来,先合并相邻的单个元素,再合并长度为 2 的块,再合并长度为 4 的块,依次翻倍,直到整个数组合并完。它不需要递归,没有栈空间,理论上更省内存:
public static void sortBottomUp(int[] arr) { if (arr == null || arr.length < 2) { return; } int[] temp = new int[arr.length]; int n = arr.length; for (int width = 1; width < n; width <<= 1) { for (int i = 0; i < n; i += width << 1) { int mid = i + width - 1; if (mid >= n - 1) { continue; } int right = Math.min(i + (width << 1) - 1, n - 1); merge(arr, temp, i, mid, right); } } }这里的 merge 方法和递归版共用,逻辑不变。外层循环 width 从 1 开始,每次翻倍,表示当前待合并子块的大小;内层循环每次跳 2 × width,把相邻的两个 width 大小的块合并。注意最后一个块可能不完整,所以用 Math.min 截取 right,同时用mid >= n - 1跳过那些只有一个完整块、没有配对块的情况。
我在实际测试中,迭代版和递归版性能基本相当,迭代版略快一点点,省下了递归调用的开销,而且完全没有栈溢出的担忧。如果你在做底层工具库,迭代版会更稳。
7. 归并排序的衍生应用:从数组排序到更广的战场
7.1 外部排序:归并思想在大数据场景的核心地位
很多经典排序算法假设数据都在内存里,一旦数据量大到只能存在磁盘上,快排、堆排序就都不好使了,因为它们需要频繁随机访问。磁盘随机访问的代价比顺序访问高出几个数量级,这时候归并排序的"顺序读写"特性就成了决定性优势。
外部排序的典型做法是:先把大文件切分成若干能装进内存的小块,分别排序后写回磁盘,再用多路归并把这些有序块合并成一个全局有序的大文件。这里的"多路归并"是归并排序思想的延伸:一次合并 k 个有序流,用堆来选出当前最小值,一趟就能把 k 个文件并成一个。我当年处理接近内存容量的日志数据时,就是用这个思路设计分批排序流程,效果非常直观。想深挖的话,可以从"败者树""置换选择排序"这些关键词入手,它们都是归并思想在大规模数据上的具体落地。
7.2 链表归并排序:不需要额外数组的稳定排序
链表不能随机访问,快排在链表上会退化成没有意义的做法。归并排序只需要沿着 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); }这里快指针要从 head.next 开始,而不是 head,否则在链表只有两个节点时会死循环。细节虽小,写错一次就得调试半天。mergeTwoLists 就是标准的两个有序链表两指针合并,沿着 next 把较小的节点串起来即可。
7.3 逆序对计数:合并过程顺带统计
逆序对的定义是:如果 i < j 且 arr[i] > arr[j],那么 (i, j) 就是一个逆序对。暴力解法是双重循环 O(n²),而归并排序可以在合并时把统计顺手做了,整体复杂度降到 O(n log n)。
原理很简单:合并过程中,当右边元素 arr[j] 小于左边当前元素 arr[i] 时,说明左半从 i 到 mid 的所有剩余元素都大于 arr[j],这些元素全部和 arr[j] 构成逆序对。于是一次性累加mid - i + 1即可:
} else { temp[k++] = arr[j++]; inversionCount += mid - i + 1; }把这段逻辑嵌进标准 merge,就能在排序的同时统计逆序对数量。我第一次写的时候,对这个"顺手"的统计印象非常深:单看代码就一行,但背后是整个分治思想的红利——一次合并恰好覆盖了所有跨左右两半的元素比较,不重不漏。
我在一些离线数据处理任务里,就用这个方式分析过数据序列的"混乱程度",逆序对数量可以当作序列有序性的一个量化指标。归并排序的适用面,远不止数组排序这一件事。
最后分享一个实操小技巧:如果你在写归并排序的递归版本,调试时可以在 sort 方法里维护一个 depth 参数,打印时按深度缩进对齐,递归树的结构就能一目了然。我后面所有分治算法的代码都保留了这个习惯,排查起来比满屏打日志高效得多。