排序算法入门(三):归并排序 —— 稳定、分治与外排序

选排序算法时,O(n log n) 只是及格线。优先考虑归并排序的理由很朴素:它稳定,相等元素次序不变;比较次数也不看输入脸色,最好、平均、最坏都是 O(n log n)。快排平均更快但最坏要额外兜底,堆排空间是 O(1) 但跳跃访存对缓存不友好。归并的代价很明确:一份 O(n) 额外空间。
分治的两个动作
骨架只有两个动作:把区间一分为二,左右各自有序,再把两段并成一个有序段。前者是递归,后者是合并。
正确性因此是归纳的:长度 1 的区间天然有序是基线,左右两半有序时合并拼出整体有序。递归深度严格是 log₂n,不随输入变化,这就是最坏情况可预测的来源。
自顶向下:递归实现
java
import java.util.Arrays;
public class MergeSort {
public static void sort(int[] a) {
if (a == null || a.length < 2) {
return;
}
// 整个排序过程共用这一块 O(n) 的临时空间。
// 如果下沉到 merge 里 new,每层递归都会各申请一块长度接近区间的数组,
// 总分配量从 O(n) 变成 O(n log n),内存峰值和 GC 压力同时翻倍。
int[] temp = new int[a.length];
sort(a, temp, 0, a.length - 1);
}
private static void sort(int[] a, int[] temp, int lo, int hi) {
if (lo >= hi) {
return; // 长度 <= 1 的区间天然有序,这是递归的基线
}
int mid = lo + (hi - lo) / 2; // 不用 (lo + hi) / 2:(lo + hi) 会整数溢出成负数
sort(a, temp, lo, mid);
sort(a, temp, mid + 1, hi);
merge(a, temp, lo, mid, hi);
}
private static void merge(int[] a, int[] temp, int lo, int mid, int hi) {
// 左段最大值不大于右段最小值时整个区间已经有序,跳过这次搬运。
// 真实数据里相邻两段已排好的情况很多,这一句省下的是实打实的 O(n) 拷贝。
// (这个 <= 只是判断有序,与稳定性无关。)
if (a[mid] <= a[mid + 1]) {
return;
}
// 把待合并区间原样拷进 temp,然后从 temp 读、往 a 写。
// 读写落在两块不重叠的内存上,结果能直接写回原数组,不用二次拷回。
System.arraycopy(a, lo, temp, lo, hi - lo + 1);
int i = lo; // 左段读取游标
int j = mid + 1; // 右段读取游标
int k = lo; // 写回 a 的落点
while (i <= mid && j <= hi) {
// 相等时取左边。左段元素在原始数组里就排在右段之前,先取它,
// 相对次序才保得住 —— 这就是稳定性的全部来源。
// 改成 < 会先取右段元素,相等元素次序被翻转,排序不再稳定。
if (temp[i] <= temp[j]) {
a[k++] = temp[i++];
} else {
a[k++] = temp[j++];
}
}
// 右段先耗尽时,把左段剩下的逐个搬过去。
// 左段先耗尽时不需要任何动作:此时 k 恰好等于 j,
// a[j..hi] 里放的就是右段剩余元素,位置已经正确。
while (i <= mid) {
a[k++] = temp[i++];
}
}
public static void main(String[] args) {
int[] a = {5, 2, 9, 2, 7, 1, 8, 3, 2};
sort(a);
System.out.println(Arrays.toString(a)); // [1, 2, 2, 2, 3, 5, 7, 8, 9]
}
}
temp 只在最外层 new 一次。写进 merge 的话,每层递归都会申请一块接近区间大小的数组,总分配量从 O(n) 涨到 O(n log n),元素一多 GC 先顶不住;归并的空间需求本质是“任意时刻只用一块”。mid = lo + (hi - lo) / 2 是同样的防御写法。
合并那一步才是关键
merge 的模型像一张桌子、两支队伍:把区间原样拷进 temp,用 i、j 分别读左右两段,挑出较小的写回 a 的 k 位。先拷再原地写回,是因为直接往 a 里写会覆盖还没读完的左段元素;若先写进 temp 再整段拷回 a,又多一次全量拷贝。
收尾不用背:左段先耗尽时什么都不用做,此时 k 恰好等于 j,a[j..hi] 里已经是右段剩余元素。
这个动作单独拎出来,就是一个能独立使用的小方法:
java
/**
* 独立的小方法:合并两个各自有序的数组,返回新数组。
* 归并排序里的 merge 是它的“就地版”——复用原数组和一块临时区,
* 内核完全一样:两个游标各扫一段,谁小先取谁。
*/
public static int[] mergeTwoSorted(int[] left, int[] right) {
int[] result = new int[left.length + right.length];
int i = 0, j = 0, k = 0;
while (i < left.length && j < right.length) {
// 仍然是 <=:两边出现相同值时先取左边的,结果才稳定
result[k++] = (left[i] <= right[j]) ? left[i++] : right[j++];
}
while (i < left.length) {
result[k++] = left[i++]; // 右边先走完,左边剩余直接接上
}
while (j < right.length) {
result[k++] = right[j++]; // 左边先走完,右边剩余直接接上
}
return result;
}
自底向上(迭代)实现
java
/**
* 自底向上(迭代)版本,放在同一个类里。
* 不递归,按段长 1、2、4、8…… 逐层合并相邻两段,直到段长覆盖整个数组。
*/
public static void sortBottomUp(int[] a) {
if (a == null || a.length < 2) {
return;
}
int n = a.length;
int[] temp = new int[n]; // 同样只在最外层分配一次,每一轮合并复用
for (int len = 1; len < n; len <<= 1) { // 段长每轮翻倍,共 log n 轮
for (int lo = 0; lo < n - len; lo += len << 1) { // lo < n - len 保证右段至少有一个元素
int mid = lo + len - 1;
int hi = Math.min(lo + (len << 1) - 1, n - 1); // 右段不满 len 时必须夹住,否则越界
merge(a, temp, lo, mid, hi);
}
}
}
它把“往深处钻”换成“按段长铺开”:第一轮合并长度 1 的相邻两段,第二轮长度 2,然后 4、8……每轮翻倍共 log n 轮,复杂度与递归版一致,共用同一个 merge。好处是没有递归栈,代价是边界得自己算:右段不满时夹到 n-1,外层条件 lo < n - len 保证右段至少有一个元素。
稳定性从哪来
秘密就在合并时那个 <=:相等时取左边,而左段元素原本就排在右段之前,先取它,相对次序就保住了。改成 <,相等时先取右段元素,次序被翻转,排序立刻不稳定。
稳定性只在多关键字排序里才有意义:先按次要关键字排,再用稳定排序按主关键字排,结果就是主键有序、同主键内按次键有序。排金额相同的订单要保持原有下单顺序,不稳定就出事故。
空间代价与“就地归并”为什么很难
归并不是原地排序,合并总得有一块地方同时装下两份数据。就地归并确实存在,但要靠块交换加三次反转之类的手法,元素移动次数从 O(n) 涨到 O(n log n) 甚至更差,指针操作密集,缓存表现也变差。多花一点空间通常比多花一堆移动便宜。另外,排对象数组时 temp 里存的是引用,开销比排 int[] 更小。
内存放不下时:外部排序
这是归并不可替代的地方:几十 GB 的日志要按时间排,堆只有 2 GB,一次性读进来不可能,而分治结构天生适合分批。
做法两步。先把文件按内存容量切块,每块读进内存用 Arrays.sort 排好写回磁盘,得到 k 个有序小文件;再对这 k 个块做多路归并:每块读一段进缓冲区,用小顶堆维护各块当前的最小值,弹出堆顶写进输出文件,再从该块补读一个入堆,堆里永远只有 k 个候选,选全局最小只要 O(log k) 次比较。
k 的取舍在两头:k 越大,归并趟数从以 2 为底的对数降到以 k 为底的对数,磁盘读写越省;但堆内比较变多、每块缓冲区变小、随机读增多,还会撞上文件句柄上限。工程上常取几十到几百,缓冲区对齐底层 I/O 块。
JDK 里的 TimSort
Arrays.sort(Object[]) 和 Collections.sort 用的不是教科书归并,而是 TimSort —— 针对真实数据优化过的归并变体,机制有三条。
先找 run:线性扫描出已经有序的连续片段,递增的留下,严格递减的反转成递增,把数据里现成的有序结构白捡进来。
短 run 用二分插入排序补齐:run 短于 minRun(按长度算出的 16 到 32 之间的值)时用二分插入排序扩到 minRun。小数组上插入排序常数因子小得多,二分查找又把定位插入点压到 O(log n)。
按规则归并:run 压在栈上,长度必须满足不变量(大意是越深的 run 要比它上面相邻两个之和更长),不满足就合并相邻 run,既保证栈深 O(log n),也避免把长度悬殊的 run 拿去归并。
它对真实数据更快靠的是前两条:数据很少是随机排列,常带部分有序和局部趋势,教科书归并却无论输入什么样都要把每个元素搬 log n 次。另外 Arrays.sort(int[]) 走的是双轴快排,不是 TimSort:基本类型没有“身份”,稳定性没有意义。
两种写法怎么选
| 对比项 | 自顶向下(递归) | 自底向上(迭代) |
|---|---|---|
| 写法 | 先拆后合,直接对应分治定义 | 按段长逐层合并,边界要自己算 |
| 空间 | O(n) 临时数组 + O(log n) 递归栈 | O(n) 临时数组,无递归栈 |
| 稳定性 | 稳定,相等时取左边 | 稳定,复用同一个 merge |
| 适用场景 | 教学、链表排序、利用“已有序”跳过合并 | 栈空间紧张、纯循环、按批处理数据 |
| 性能 | 复杂度相同,差异只来自调用与缓存 | 略省调用开销,不改变数量级 |
小结
归并排序的定位很清晰:要稳定性、要最坏情况可预测,或者数据大到装不进内存,它就是首选。三个关键点是分治结构带来的稳定 O(n log n)、决定稳定性的那个 <=,以及把空间压在一个 n 的共享临时数组。业务代码里通常不必自己写,Arrays.sort 背后的 TimSort 已经把归并对真实数据的优化做在了这些点上。
评论