1. 原理解析
归并排序是**“分治法(Divide and Conquer)”的经典体现。核心思想就三个字:“先分,后合”**。
- 分(Divide):将数组不断从中间对半劈开,一直劈到每个子数组只剩下一个元素(此时每个子数组天然有序)。
- 合(Merge):将两个有序的子数组合并成一个更大的有序数组。不断向上合并,直到最终合并成一个完整的有序数组。
生活中的例子:整理两组已经按学号排好的试卷。我们只需要比较两组试卷最上面的一张,谁小谁就先放到新桌子上,直到全部放完。
2. 使用场景
- 需要极度稳定排序时间的场景:归并排序的最好、最坏、平均时间复杂度都是严格的 ,不会像快速排序那样在极端情况下退化。
- 外部排序(处理海量数据):当数据量大到内存装不下时(比如几百GB的日志),可以把文件切成小块排好序,再用归并的思想把多个有序小文件合并成大文件。
- Java中的对象排序:Java 的
Arrays.sort()在对对象(Object)进行排序时,底层通常采用归并排序(或 TimSort,归并的变种),因为它是一种稳定排序(相等的元素排序后相对位置不变)。
3. 代码实战
Java 实现(工业级:传递下标,复用 temp 数组)
public class MergeSort {
public static void mergeSort(int[] arr) {
if (arr == null || arr.length <= 1) return;
// 准备一张和原数组一样大的“草稿纸”,避免频繁开辟空间
int[] temp = new int[arr.length];
sort(arr, 0, arr.length - 1, temp);
}
private static void sort(int[] arr, int left, int right, int[] temp) {
if (left >= right) return; // 只剩一个元素,递归终止
int mid = left + (right - left) / 2; // 防溢出的写法
// 1. 分:递归排好左半边和右半边
sort(arr, left, mid, temp);
sort(arr, mid + 1, right, temp);
// 2. 合:将两个有序部分合并
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 t = left; // temp 数组的写入下标(这里优化为从 left 开始,方便直接抄回)
// 比较两边,谁小谁上桌
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[t++] = arr[i++];
} else {
temp[t++] = arr[j++];
}
}
// 兜底:如果左半边有剩余,直接扫进 temp
while (i <= mid) {
temp[t++] = arr[i++];
}
// 兜底:如果右半边有剩余,直接扫进 temp
while (j <= right) {
temp[t++] = arr[j++];
}
// 将排好序的 temp 数组内容抄回原数组 arr 的对应位置
int k = left;
while (k <= right) {
arr[k] = temp[k];
k++;
}
}
}
Python 实现(教学切片版,便于理解思想)
def merge_sort(arr):
# 数组为空或只有一个元素时,本来就是有序的,直接返回
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left_half = arr[:mid]
right_half = arr[mid:]
# 递归分
sorted_left = merge_sort(left_half)
sorted_right = merge_sort(right_half)
# 合并
return merge(sorted_left, sorted_right)
def merge(left, right):
result = []
i, j = 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
# 兜底操作
result.extend(left[i:])
result.extend(right[j:])
return result
4. 核心难点/易错点详解
- 合并时的剩余元素处理(兜底):在比较合并时,必然有一边会先出完牌(指针越界)。此时必须立刻用循环(或
extend)把另一边剩下的元素按原顺序全部追加到临时数组的末尾。因为剩下的元素本身就是有序的,不需要再比较。 - 数组越界与拷贝范围:在原数组上操作时(如 Java 版),
temp数组拷贝回arr时,下标必须严格对应当前的left到right区间,不能一股脑从0抄到尾。 - 递归的执行顺序(压栈与弹栈):大老板(主函数)会一直等待两个小弟(递归调用)都执行完毕并返回有序结果后,才会执行最后的合并。计算机是深度优先执行的,即“先一路劈到底,再一层层缝合上来”。
5. 复杂度分析
- 时间复杂度:。
- 分(层数):将长度为 的数组不断对半劈开,劈到长度为 1 需要多少次?答案是 次(相当于一棵二叉树的深度)。
- 合(每层的工作量):在每一层的合并过程中,不管分成多少块,所有元素的总数还是 。合并操作本质上就是把 个元素分别看一眼并放进新数组,需要遍历 次,也就是 。
- 总时间:层数 每层操作 = 。且无论原数组长什么样,都要老老实实劈开再合并,所以最好、最坏、平均时间复杂度全都是 。
- 空间复杂度:。
- 归并排序最大的痛点在于它不是原地排序。为了把两个有序数组合并,必须借用一张大小为 的“新桌子”(即代码中的
temp数组)。所以空间复杂度是 。
- 归并排序最大的痛点在于它不是原地排序。为了把两个有序数组合并,必须借用一张大小为 的“新桌子”(即代码中的