上一篇博文我初步讲解了插入排序,冒泡排序和选择排序。这几种排序算法的时间复杂度都是O()。时间复杂度比较高,比较适合小规模的数据排序。介绍一种时间复杂度是O(nlogn) 的算法。归并排序。
其实归并排序采用的是分治思想,对于数据量比较大时,可以先把问题分为小问题,最终再把小问题的解合并为大问题的解。而且归并排序在解决大数据量时比较使用,读者可参考博文大数据中的归并排序。
归并排序的核心思想还是蛮简单的,如果要排序一个数组,可以把数组分为两部分,分别对两部分依次排序,然后再把两部分的结果合并起来,最终得到一个合并的结果如下图所示
其实从上面的图中我们可以看出归并排序是很简单的
我们可以大概写出如下的算法框架
/**
* 归并排序
*
* @param nums 待排序的数组
*/
public static void mergeSort(int[] nums) {
if (nums == null || nums.length == 0) return;
//排序nums数组 0 -> nums.length-1范围内的元素
mergeSort(nums, 0, nums.length - 1);
}
private static void mergeSort(int[] nums, int p, int r) {
//递归终止条件
if (p >= r) return;
//分割位置,一般是取p 与 r的中间位置
int q = (p + r) / 2;
//排序nums数组的p -> q 的值
mergeSort(nums, p, q);
//排序nums数组的 q+1 -> r的值
mergeSort(nums, q + 1, r);
//对两办排序的值进行合并
merge(nums, p, q, r);
}
private static void merge(int[] nums, int p, int q, int r) {
}
从上述的算法框架我们可以看出,其实归并排序的 分 阶段是比较简单的,把两个排序的子数组合并为一个大的数组时逻辑稍微复杂一点,即merge函数的实现逻辑,但其实理解了就没有那么难了。这个地方我们是没办法原地合并,需要借助额外的空间。我们用如下两个排序的子数组的合并逻辑进行说明。
我们可以申请一个大小为4的临时数组空间。定义指针p指向第一个排序数组的第一个元素,指针q指向第二个排序数组的第一个元素,k指向临时数组的第一个元素。
我们读取两个排序数组的第一个元素,把小的一个放入临时数组中,同时改变指针的值。
再继续执行直到如下情况。
我们可以看到第二个数组还有元素没有处理完毕,直接追加到临时数组后面就可以。如下图
最终我们再把临时数组的元素依次插入到原始数组中的相同位置。如下图
好啦,到这里合并逻辑就差不多讲完了,需要关注下边界情况,一遍写出来都不怎么难,merge函数的代码如下
private static void merge(int[] nums, int p, int q, int r) {
//分别定义两个指针指向两个有序数组的第一个数组,k指向临时数组的第一个元素
int i = p, j = q + 1, k = 0;
//定义临时数组
int[] tmp = new int[r - p + 1];
while (i <= q && j <= r) {
if (nums[i] <= nums[j]) {
tmp[k++] = nums[i++];
} else {
tmp[k++] = nums[j++];
}
}
//判断哪个字数组还有剩余的元素
for (; i <= q; i++) {
tmp[k++] = nums[i];
}
for (; j <= r; j++) {
tmp[k++] = nums[j];
}
//把临时数组中的元素放置回原始数组中
for (i = 0; i <= r - p; i++) {
nums[p + i] = tmp[i];
}
}
归并排序的稳定性
其实从merge函数的实现可知,归并排序也是稳定的排序算法。算法的稳定性,参见博文
归并排序的时间复杂度分析,来源与文章:
归并排序涉及递归,时间复杂度的分析稍微有点复杂。我们正好借此机会来学习一下,如何分析递归代码的时间复杂度。在递归那一节我们讲过,递归的适用场景是,一个问题 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) 呢?还不够直观?那我们再进一步分解一下计算过程。
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) = 2^kT(n/2^k)+kn。当 T(n/2^k)=T(1) 时,也就是 n/2^k=1,我们得到 k=log2n 。我们将 k 值代入上面的公式,得到 T(n)=Cn+nlog2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以归并排序的时间复杂度是 O(nlogn)。从我们的原理分析和伪代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。
归并排序的空间复杂度:
归并排序的时间复杂度任何情况下都是 O(nlogn),看起来非常优秀。(待会儿你会发现,即便是快速排序,最坏情况下,时间复杂度也是 O(n2)。)但是,归并排序并没有像快排那样,应用广泛这是为什么呢?因为它有一个致命的“弱点”,那就是归并排序不是原地排序算法。
这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。这一点你应该很容易理解。那我现在问你,归并排序的空间复杂度到底是多少呢?是 O(n),还是 O(nlogn),应该如何分析呢?
如果我们继续按照分析递归时间复杂度的方法,通过递推公式来求解,那整个归并过程需要的空间复杂度就是 O(nlogn)。不过,类似分析时间复杂度那样来分析空间复杂度,这个思路对吗?
实际上,递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚我们忘记了最重要的一点,那就是,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
参考:https://time.geekbang.org/column/article/41913