归并排序

上一篇博文我初步讲解了插入排序,冒泡排序和选择排序。这几种排序算法的时间复杂度都是O(n^{2})。时间复杂度比较高,比较适合小规模的数据排序。介绍一种时间复杂度是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

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值