图解「归并排序」算法(修订版)

考察归并排序的题目可以形态各异,但总会有万变不离其宗,希望看完今日之章,你能掌握归并排序及其思想大成。

归并排序

归并排序和之前的冒泡排序、插入排序和选择排序不同,其蕴含了一种分治的思想,关于分治和递归的思想之前有分享一篇文章 数据结构与算法之递归 + 分治 ,感兴趣的可以作为参考,不过在今天的图文中,同样会看到这两个思想。

话不都说,我们还是以数组 arr = [5,1,4,2,8,4] 为例进行一趟归并排序。

第一步:计算数组的中间元素 ,然后将数组分成 两个区间,即  与 两个子数组,这一步属于「」的过程。

第二步:计算分出来的子数组   的中间元素, 1 ,将数组分成了 以及 两个子数组,这一同样是「」的过程,但同时也应该注意到,这一步和第一步的操作过程是一致的,也就说第一步和第二步是同一个功能,所以最终你会在代码中看到递归实现,后面会专门讲归并排序所蕴含的递归思想。

第三步:将上一步分出的子数组 [0,1] 再进行拆分, , 将数组分成了 [0] 和 [1],只包含一个元素。

第四步:将 arr[0] 和 arr[1] 进行合并,因为上一步得到的两个数组不可再分了,已经有序,所以进行合并。这一步事实上也是 「」的过程,所谓治就是真正进行排序的过程,因为通过这一步元素 5 和 1 的顺序将被改变。

之后的每一步都是如此,我们在下图中将每一步用红色数字标注了出来:

分治和递归就像一对好基友,永远不分离,为了看到归并排序的递归过程,我们先看一下归并排序的实现。

实现代码

归并排序包含两个过程,分和治,所以递归实现代码也相当简单。

void mergeSort(int arr[], int left, int right) 
{ 
    if (left < right) { 
        // 计算 l 和 r 的中点
        int mid = (left + right) / 2; 

        // 对 分 出的两个子数组分别进行归并排序 
        mergeSort(arr, left, mid); 
        mergeSort(arr, mid + 1, right); 

        // 合并有序的两部分
        merge(arr, left, mid, right); 
    } 
} 

和之前讲插入排序的递归实现一样,我们将数组 arr = [5,1,4,2,8,4] 带入代码中:

心中一惊,为何这里的递归过程如此曲折,事实上没有什么可担心的,你将代码中的 mergeSort(arr,l,r) 理解为「」和「」,而将 merge(arr,l,m,r)  理解为 「」和「」,心中就会豁然开朗,递归与分治就是孪生兄弟。

那么, 「」和「」的过程到底又是如何实现的呢?

不急,我们以最后一次 merge(arr,0,2,5) 操作为栗子说明一下。

最后一次合并前的数组如下所示:

此时原始数组已被分成了两个有序的子数组 [1,4,5][2,4,8] .

紧接着将两个有序的子数组分别保存到一个数组 L[]=[1,4,5]R[]=[2,4,8] 当中:

然后再将这个子数组 L[]R[] 合并到原始数组当中:

治(也就是合并)的代码实现:

void merge(int arr[], int l, int m, int r) 
{ 
    // 计算合并的两个子数组的大小
    int n1 = m - l + 1; 
    int n2 = r - m; 

    /*创建两个临时的子数组,存储要合并的两个子数组 */
    int L[] = new int[n1]; 
    int R[] = new int[n2]; 
    
    /*拷贝数据*/
    for (int i = 0; i < n1; ++i) 
        L[i] = arr[l + i]; 
    for (int j = 0; j < n2; ++j) 
        R[j] = arr[m + 1 + j]; 

    /* 合并临时数组 */
    // 初始化两个临时子数组的下标
    int i = 0, j = 0; 

    // 初始化合并后数组的下标k为 l(不是1,是left)
    int k = l; 
    while (i < n1 && j < n2) { 
        if (L[i] <= R[j]) { 
            arr[k] = L[i]; 
            i++; 
        } 
        else { 
            arr[k] = R[j]; 
            j++; 
        } 
        k++; 
    } 
    /* 如果 L[] 有剩余,拷贝 */
    while (i < n1) { 
        arr[k] = L[i]; 
        i++; 
        k++; 
    } 
    /* 拷贝 R[] 中剩余的部分 */
    while (j < n2) { 
        arr[k] = R[j]; 
        j++; 
        k++; 
    } 
} 

复杂度分析

时间复杂度

归并排序(二路归并)方法就是把一个包含 n 个数的数组,折半拆分为两个子数组(二路),然后再将这两个子数组再分,一直分下去,直到分为n个长度为1的元素。然后两两按大小归并。如此反复,直到最后形成包含 n 个数的一个有序数组。

归并排序总时间 = 拆分时间 + 子数组排好序时间 + 合并时间

无论每个数组有多少个数都是折半拆分,也就是代码中的 int m = (left + right) / 2;,所以拆分时间就常数 级别,可以忽略不计。

则:归并排序总时间 = 子数组排序时间 + 合并时间

假设对于一个包含 n 个元素的数组而言,排序时间为 .

则可以很轻松的得到  之间的关系式。

将包含 n 个元素的数组拆分成 2 个分别包含 的子数组,则归并排序的时间 ,其中的 表示合并时间,也就是 merge()  函数中合并两个子数组的时间,时间复杂度为 .

接下来,可以有两种方式得到 之间的关系式,也就是归并排序的时间复杂度。

一、递归式

已知   ,注意 前面的系数  

又很容易得到  和 之间的关系式,即  .

之间的关系式带入 当中,就是将所有的 替换为

则, ,注意

就得到了 之间的关系式,

同理,  与 之间的关系为: ,

带入 ,得到 之间的关系式;

,注意

以此类推...

可以推知 之间的关系:

∴ 归并排序的时间复杂度为 量级。

二、主定理

是常数, 是一个函数, 是定义在非负整数上的递归式:

其中我们将 解释为 ,那么 有如下渐近界:

  1. 若对某个常数 ,则 .

  2. ,则   .

  3. 若对某个常数 有   ,且对某个常数 和所有足够大的 ,则   .

主定理对递归式 所提供的一种 “菜谱式” 的求解方法,关于主定理的证明就不在这里解释了,感兴趣可以看一下 《算法导论》4.6 节的主定理的证明。

我们这里直接 “下菜“ 即可。

已知  

首先和 对应起来

∴ .

即归并排序的时间复杂度为 .

有没有很喜欢主定理呢?想吃菜了,查菜谱就可以了!

空间复杂度

在合并的时候,我们使用了存储待合并的两个数组元素的空间,这个数组大小依次开辟的就是 1,2,4,8,...,n/2,但是开辟了两个,所以可能开辟的空间大小为 2,4,8,......,n,归并排序的空间复杂度为 量级。与插入排序,冒泡排序,还有选择排序相比,你也可以将归并排序理解为空间换时间的有效例证。归并排序也就不是一个原地排序算法了,原地排序算法的空间复杂度为 .

稳定性分析

这幅图中,可以看到归并排序是稳定的排序算法,排序前后,数组中两个4的相对位置没有发生变化。归并排序稳定的根本原因在合并的时候对值相同的两个关键字不存在交换的可能性。

归并排序相关问题摘要

关于归并排序的问题还有很多,下面给大家列举一些,之后的文章中都会一一向大家介绍。

  1. 与数组相比,归并排序在单链表上进行排序的优势何在?

  2. 如何实现一个空间复杂度为 ,时间复杂度为 的归并排序?

  3. 三路归并排序如何实现和操作?

  4. 如何让今天讲的归并排序变成一个原地排序算法(In-place Algorithm)?

  5. 迭代形式的归并排序又该如何实现?

  6. 如何利用统计一个数组中次序颠倒的元素对的个数,比如 arr[] = [3,1,2] ,就应该返回 2,因为 (3,1) ,(3,2) 都是次序相反的数对。

还有更多关于排序算法的题目和讲解景禹都会努力在排序算法专题分享给大家,最近工作还有接了一个私活,更新文章有些晚了,还望各位见谅,谢谢你们一直以来的关注和鼓励~

推荐阅读:

面试官问你排序算法最基础的这几问,把这篇文章扔给他

这才是选择排序正确的打开方式

原来「插入排序」面试官爱考是因为这样~~

特么,冒泡排序有这么难?

作者:景禹,一个追求极致的共享主义者,想带你一起拥有更美好的生活,化作你的一把伞。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值