【基础算法】排序算法 —— 归并排序
排序算法 | 时间复杂度 | 空间复杂度 | 稳定性 | 算法核心 |
归并排序 | O(nlog2n) | O(n) | 稳定 | 比较合并 |
一、算法原理
归并排序的核心思想是:
- 将待排序数组从中间分成两个子数组,对两个子数组分别排序,然后将排好序的两个子数组合并,这样整个数组就排序完成了。每次合并从两个子数组中逐个取最小值放入合并后数组,这样可以保证合并后的数组有序。
- 分治思想。将待排序数组从中间分成两个子数组,再将两个子数组分别从中间分开,一直分到最底层子数组只有1个元素为止。
示例:使用归并排序对数组 arr = [8,5,2,7,6,1,3,4] 从小到大排序。
归并排序使用的是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成几个小的子问题,小的子问题解决了,大问题也就解决了。
分治思想一般都是用递归来实现的,分治是一种算法思想,递归是一种编程技巧。所以,实现归并排序就需要找到递归的递推公式和终止条件。
递推公式:
mergeSort(l...r) = merge(mergeSort(l...mid), mergeSort(mid + 1...r))
终止条件:
l >= r 代表分解到子数组只有1个数字,不用再分解
mergeSort(l...r) 表示,给下标从 l 到 r 之间的数组排序。我们将这个排序问题转化为了两个子问题,mergeSort(l…mid) 和 mergeSort(mid+1…r),其中下标 mid 是 l 和 r 的中间位置,也就是 (l+r)/2。当下标从 l 到 mid 和从 mid+1 到 r 这两个子数组都排好序之后,再将两个有序的子数组合并,这样下标从 l 到 r 之间的数据也就排好序了。
上述递推公式转换成伪代码如下:
mergeSort(arr, l, r) {
if (l >= r) { // 递归终止条件
return
}
mid = (l + r) / 2
// 分治递归
mergeSort(arr, l, mid)
mergeSort(arr, mid + 1, r)
// 合并 [l...mid][mid + 1...r] 为 [l...r],保证 [l...r] 有序
merge(arr[l...mid], arr[mid + 1...r])
}
其中,merge(arr[l...mid], arr[mid + 1...r]) 的作用是将已经有序的 arr[l...mid] 和 arr[mid + 1...r] 合并成一个有序数组。
合并时,需要申请一个临时数组 tmp,大小与 arr[l...r] 相同。用两个索引 i 和 j,分别指向 arr[l...mid] 和 arr[mid + 1...r] 的第一个元素,比较这两个元素 arr[i] 和 arr[j],如果 arr[i] <= arr[j],就把 arr[i] 放入临时数组 tmp,并且 i 后移一位,否则将 arr[j] 放入数组 tmp,j 后移一位。重复上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次放入临时数组,这时候,临时数组中存储的就是两个子数组合并之后的结果。最后再把临时数组 tmp 中的数据拷贝到原数组 arr[l...r] 中。图示如下:
merge 函数转换成伪代码如下:
merge(arr, l, mid, r) {
i = l // 左边子数组索引
j = mid + 1 // 右边子数组索引
k = 0 // tmp 数组索引
tmp = new Array[r - l + 1] // tmp 数组
// 从左右子数组取 较小的数字 放入 tmp 数组,直到左边子数组取完或者右边子数组取完
while(i <= mid && j <= r) {
if(arr[i] <= arr[j]) {
tmp[k] = arr[i]
i++
} else {
tmp[k] = arr[j]
j++
}
k++
}
// 如果左边子数组还有剩余元素,依次放入 tmp 数组
while(i <= mid) {
tmp[k] = arr[i]
i++
k++
}
// 如果右边子数组还有剩余元素,依次放入 tmp 数组
while(j <= r) {
tmp[k] = arr[j]
j++
k++
}
// 将 tmp 数组的元素复制到原数组的 l...r 位置
for(i = 0, len = tmp.length; i < len; i++) {
arr[l + i] = tmp[i]
}
}
二、代码实现
public static void main(String[] args) {
int[] arr = {4, 5, 6, 3, 2, 1};
mergeSort(arr, 0, arr.length - 1);
}
/**
* 归并排序,时间复杂度 O(nlogn),空间复杂度 O(n),稳定
*
* @param arr 待排序数组
* @param left 待排序数组左边索引
* @param right 待排序数组右边索引
*/
public static void mergeSort(int[] arr, int left, int right) {
if (left >= right) { // 递归终止条件
return;
}
// 分治递归
int mid = (left + right) >> 1;
mergeSort(arr, left, mid);
mergeSort(arr, mid + 1, right);
// 合并 [left...mid][mid + 1...right] 为 [left...right],保证 [left...right] 有序
merge(arr, left, mid, right);
}
private static void merge(int[] arr, int left, int mid, int right) {
int l = left; // 左边子数组索引
int r = mid + 1; // 右边子数组索引
int i = 0; // tmp 数组索引
int[] tmp = new int[right - left + 1]; // tmp 数组
// 从左右子数组取 较小的数字 放入 tmp 数组,直到左边子数组取完或者右边子数组取完
while (l <= mid && r <= right) {
tmp[i++] = arr[l] <= arr[r] ? arr[l++] : arr[r++];
}
// 如果左边子数组还有剩余元素,依次放入 tmp 数组
while (l <= mid) {
tmp[i++] = arr[l++];
}
// 如果右边子数组还有剩余元素,依次放入 tmp 数组
while (r <= right) {
tmp[i++] = arr[r++];
}
// 将 tmp 数组的元素复制到原数组的 left...right 位置
for (int j = 0, len = tmp.length; j < len; j++) {
arr[left + j] = tmp[j];
}
}
三、算法评价
3.1 时间复杂度
归并排序使用递归实现,时间复杂度的分析与递归代码一样。递归的适用场景是,一个问题 a 可以分解为子问题 b、c,那求解问题 a 就可以分解为求解问题 b、c。问题 b、c 解决之后,再把 b、c 的结果合并成 a 的结果。
如果定义求解问题 a 的时间是 T(a),求解问题 b、c 的时间分别是 T(b) 和 T( c),那么就可以得到这样的递推关系式:
T(a) = T(b) + T(c) + K
其中,K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。
从刚刚的分析,我们可以得到一个重要的结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。套用这个公式,我们来分析一下归并排序的时间复杂度。
假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
T(1) = C; // n = 1时,只需要常量级的执行时间,所以表示为C。
T(n) = 2 * T(n/2) + n; // n > 1
进一步分解一下计算过程如下:
T(n) = 2 * T(n/2) + n
= 2 * (2 * T(n/4) + n/2) + n = 4 * T(n/4) + 2 * n
= 4 * (2 * T(n/8) + n/4) + 2 * n = 8 * T(n/8) + 3 * n
= 8 * (2 * T(n/16) + n/8) + 3 * n = 16 * T(n/16) + 4 * n
......
= 2^k * T(n/2^k) + k * n
通过这样一步步分解推导,可以得到 T(n) = 2k * T(n/2k) + k * n
。当 T(n/2k) = T(1) 时,也就是 n/2k = 1,代表子数组分到只有1个元素,不需要再分了。这时候,可以得到 k = log2n,将 k 值代入前面的公式,得到 T(n) = C * n + n * log2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlog2n)。所以归并排序的时间复杂度是 O(nlog2n)。
从以上分析可以看出,归并排序的执行效率与原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlog2n)。
3.2 空间复杂度
归并排序的 merge() 函数,在将两个有序子数组合并成一个有序数组的过程中,需要借助额外的存储空间(tmp数组)。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
3.3 稳定性
归并排序稳不稳定关键要看 merge() 函数,也就是两个有序子数组合并成一个有序数组的那部分代码。在合并的过程中,如果左右子数组元素相同,可以先把左边子数组的元素放入 tmp 数组,这样就保证了值相同的元素,在排序前后顺序不变。所以,归并排序是一个稳定的排序算法。
本文来自博客园,作者:有点成长,转载请注明原文链接:https://www.cnblogs.com/luwei0424/p/17742940.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具