一、算法概述
归并排序是一种稳定的非原地排序算法,核心依托"分而治之"(Divide and Conquer)的思想,将复杂的排序问题拆解为若干个简单的子问题,通过解决子问题并合并结果,最终实现整体有序。
其核心流程分为"分""治"两个阶段:
-
分(Divide):将待排序序列递归地拆分为两个长度大致相等的子序列,直到每个子序列仅包含一个元素(单个元素天然有序);
-
治(Merge):将两个有序的子序列按照大小规则合并为一个有序的序列,逐层回溯合并,最终得到完整的有序序列。
二、核心原理与执行流程
归并排序的核心逻辑是"拆分无差别,合并有规则",其中合并操作是算法的灵魂。以下以升序排序为例,通过具体示例拆解完整执行流程。
2.1 核心思想拆解
-
拆分阶段:采用二分法思想,将长度为n的序列从中间位置(mid = (left + right) / 2)拆分为左子序列[left, mid]和右子序列[mid+1, right],对两个子序列递归执行拆分操作,直到子序列长度为1;
-
合并阶段:初始化一个临时数组,用于存储合并后的有序序列。同时设置两个指针分别指向两个有序子序列的起始位置,依次比较指针指向的元素,将较小的元素放入临时数组并移动对应指针;当其中一个子序列遍历完毕后,将另一个子序列的剩余元素直接追加到临时数组末尾,最后将临时数组的有序数据复制回原序列的对应区间。
2.2 可视化执行流程(示例:$$[8, 4, 5, 7, 1, 3, 6, 2]$$)
-
初始序列:[8, 4, 5, 7, 1, 3, 6, 2],拆分区间[0,7];
-
第一次拆分:拆分为左[8,4,5,7]([0,3])、右[1,3,6,2]([4,7]);
-
第二次拆分:左子序列拆分为[8,4]([0,1])、[5,7]([2,3]);右子序列拆分为[1,3]([4,5])、[6,2]([6,7]);
-
最终拆分:所有子序列拆分为单个元素:[8]、[4]、[5]、[7]、[1]、[3]、[6]、[2];
-
逐层合并:
合并 [8] 与 [4] → [4,8] ;合并 [5] 与 [7] → [5,7] ;合并 [4,8] 与 [5,7] → [4,5,7,8] ;合并 [1] 与 [3] → [1,3] ;合并 [6] 与 [2] → [2,6] ;合并 [1,3] 与 [2,6] → [1,2,3,6] ;最终合并 [4,5,7,8] 与 [1,2,3,6] → [1,2,3,4,5,6,7,8] 。
三、算法实现(以Java为例)
归并排序有两种经典实现方式:递归实现 (逻辑直观,符合分治思想)和非递归实现(避免递归栈溢出,适合大规模数据)。同时,针对递归实现的空间开销,可通过"原地合并"进行优化。
3.1 递归实现
java
/**
* 归并排序递归实现(升序)
* @param arr 待排序数组
*/
public static void mergeSortRecursive(int[] arr) {
// 边界校验:空数组或长度小于2,直接返回
if (arr == null || arr.length < 2) {
return;
}
// 初始化临时数组,避免递归中重复创建,减少性能损耗
int[] temp = new int[arr.length];
// 调用核心递归方法
mergeSort(arr, 0, arr.length - 1, temp);
}
/**
* 递归拆分与合并核心方法
* @param arr 待排序数组
* @param left 左边界索引
* @param right 右边界索引
* @param temp 临时合并数组
*/
private static void mergeSort(int[] arr, int left, int right, int[] temp) {
// 递归终止条件:左边界 >= 右边界(子序列长度为1)
if (left >= right) {
return;
}
// 计算中间索引,避免溢出
int mid = left + (right - left) / 2;
// 递归拆分左子序列 [left, mid]
mergeSort(arr, left, mid, temp);
// 递归拆分右子序列 [mid+1, right]
mergeSort(arr, mid + 1, right, temp);
// 合并两个有序子序列
merge(arr, left, mid, right, temp);
}
/**
* 合并两个有序子序列的核心方法
* @param arr 原数组
* @param left 左子序列起始索引
* @param mid 左子序列结束索引(右子序列起始索引为mid+1)
* @param right 右子序列结束索引
* @param 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; // 临时数组指针
// 1. 依次比较两个子序列元素,放入临时数组
while (i <= mid && j <= right) {
// 此处用 <= 保证排序的稳定性
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
// 2. 处理左子序列剩余元素
while (i <= mid) {
temp[k++] = arr[i++];
}
// 3. 处理右子序列剩余元素
while (j <= right) {
temp[k++] = arr[j++];
}
// 4. 将临时数组的有序数据复制回原数组
System.arraycopy(temp, left, arr, left, right - left + 1);
}
3.2 非递归实现(迭代版,避免栈溢出)
递归实现在处理超大规模数据时,可能因递归深度过大导致栈溢出。非递归实现通过手动控制拆分步长,逐层合并,彻底解决该问题。
java
/**
* 归并排序非递归实现(升序)
* @param arr 待排序数组
*/
public static void mergeSortIterative(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int n = arr.length;
int[] temp = new int[n];
// 步长从1开始,每次翻倍(1→2→4→8...),直到步长 >= 数组长度
for (int step = 1; step < n; step *= 2) {
// 按步长遍历数组,合并相邻的两个子序列
for (int left = 0; left < n; left += 2 * step) {
// 计算中间索引和右边界索引,避免越界
int mid = Math.min(left + step - 1, n - 1);
int right = Math.min(left + 2 * step - 1, n - 1);
// 合并当前两个有序子序列
merge(arr, left, mid, right, temp);
}
}
}
3.3 优化实现(原地合并,减少空间开销)
java
/**
* 原地合并两个有序子序列(替代临时数组,优化空间)
* @param arr 原数组
* @param left 左边界
* @param mid 中间边界
* @param right 右边界
*/
private static void mergeInPlace(int[] arr, int left, int mid, int right) {
int i = left;
int j = mid + 1;
// 当左子序列的末尾 <= 右子序列的开头,直接有序,无需合并
if (arr[mid] <= arr[j]) {
return;
}
// 原地合并核心逻辑:通过移位实现元素插入
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
i++;
} else {
// 保存右子序列当前元素
int val = arr[j];
int k = j;
// 将[i, k-1]区间的元素右移一位
while (k > i) {
arr[k] = arr[k - 1];
k--;
}
// 将val插入到i位置
arr[i] = val;
// 指针集体后移
i++;
mid++;
j++;
}
}
}
// 调用时,将merge方法替换为mergeInPlace即可
四、关键细节
归并排序的实现看似规整,但在工程落地中,以下细节直接决定算法的正确性和性能:
4.1 稳定性的保障
归并排序是稳定排序,核心在于合并阶段的比较逻辑 。在 merge 方法中,必须使用 arr[i] <= arr[j] 而非 arr[i] < arr[j]。若使用 <,当两个元素相等时,会优先选择右子序列的元素,导致相等元素的相对位置颠倒,破坏稳定性。
4.2 避免数组越界
在非递归实现中,步长翻倍时可能超出数组长度,因此必须通过 Math.min 计算 mid 和 right,确保索引不越界。例如,当数组长度为7,步长为4时,右边界应取6而非7。
4.3 减少临时数组的创建
递归实现中,临时数组应在入口方法初始化 ,而非在 merge 方法中每次创建。频繁创建数组会带来大量的内存分配与回收开销,严重降低性能。
4.4 小规模数据的优化
当子序列长度较小时(如$$n \leq 15$$),归并排序的递归开销会大于排序本身的开销。此时可在递归终止条件中,改用插入排序处理子序列,结合插入排序在小规模数据下的高效性,提升整体算法性能。