前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >图解:「归并排序」

图解:「归并排序」

作者头像
程序员小浩
发布2020-10-26 15:02:43
8350
发布2020-10-26 15:02:43
举报
文章被收录于专栏:小浩算法

今天小浩给大家分享一篇关于归并排序的文章。考察归并排序的题目可以形态各异,但是万变不离其宗,希望看完今日之章,你能掌握归并排序及其思想大成。

归并排序

归并排序和之前的冒泡排序、插入排序和选择排序不同,其蕴含了一种分治的思想,在本文中我们会详细说明。

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

第一步:计算数组的中间元素

m = (l+r)/2 = 2

,然后将数组分成

[l,m]

[m+1, r]

两个区间,即

[0,2]

[3,5]

两个子数组,这一步属于「」的过程。

第二步:计算分出来的子数组

[0,2]

的中间元素,

m = (l+r)/2 =

1 ,将数组分成了

[0,1]

以及

[2]

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

第三步:将上一步分出的子数组 [0,1] 再进行拆分,

m=0

, 将数组分成了 [0] 和 [1],只包含一个元素。

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

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

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

实现代码

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

代码语言:javascript
复制
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[] 合并到原始数组当中:

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

代码语言:javascript
复制
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;,所以拆分时间就常数

O(1)

级别,可以忽略不计。

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

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

T(n)

.

则可以很轻松的得到

T(n)

T(n/2)

之间的关系式。

将包含 n 个元素的数组拆分成 2 个分别包含

n/2

的子数组,则归并排序的时间

T(n) = 2T(n/2) + n

,其中的

n

表示合并时间,也就是 merge() 函数中合并两个子数组的时间,时间复杂度为

O(n)

.

接下来,可以有两种方式得到

T(n)

T(1)

之间的关系式,也就是归并排序的时间复杂度。

一、递归式

已知

T(n) = 2T(n/2) + n

,注意

n

前面的系数

1 = log_2{2}

又很容易得到

T(n/2)

T(n/4)

之间的关系式,即

T(n/2) = 2T(n/4) + n/2

.

T(n/2)

T(n/4)

之间的关系式带入

T(n) = 2T(n/2) + n

当中,就是将所有的

T(n/2)

替换为

2T(n/4) + n/2

则,

T(n) = 2(2T(n/4) + n/2)+ n = 4T(n/4) + 2n

,注意

2 = log_2{4}

就得到了

T(n)

T(n/4)

之间的关系式,

同理,

T(n/4)

T(n/8)

之间的关系为:

T(n/4) = 2T(n/8) + n/4

,

带入

T(n) = 4T(n/4) + 2n

,得到

T(n)

T(n/8)

之间的关系式;

T(n) = 8T(n/8) + 3n

,注意

3 = log_2{8}

以此类推...

可以推知

T(n)

T(1)

之间的关系:

T(n) = nT(1) + (log_2{n})\times n

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

O(n\log{n})

量级。

二、主定理

a\ge 1

b > 1

是常数,

f(n)

是一个函数,

T(n)

是定义在非负整数上的递归式:

T(n) = aT(n/b) + f(n)

其中我们将

n/b

解释为

⌈n/b⌉

⌊n/b⌋

,那么

T(n)

有如下渐近界:

  1. 若对某个常数
\epsilon >0

f(n)=O(n^{\log_b{a-\epsilon}})

,则

T(n) = O(n^{\log_b{a}})

.

f(n) = O(n^{\log_b{a}})

,则

T(n) = O(n^{\log_b{a}}\log n)

.

  1. 若对某个常数
\epsilon >0

f(n)=O(n^{\log_b{a+\epsilon}})

,且对某个常数

c < 1

和所有足够大的

n

af(n/b) \le cf(n)

,则

T(n) = O(f(n))

.

主定理对递归式

T(n) = aT(n/b) + f(n)

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

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

已知

T(n) = 2T(n/2) + n

首先和

T(n) = aT(n/b) + f(n)

对应起来

a = 2

b = 2

f(n) = n

f(n) = n = O(n^{\log_b{a}}) = O(n)

T(n) = O(n^{\log_b{a}}\log n) = O(n\log n)

.

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

O(n\log n)

.

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

空间复杂度

在合并的时候,我们使用了存储待合并的两个数组元素的空间,这个数组大小依次开辟的就是 1,2,4,8,...,n/2,但是开辟了两个,所以可能开辟的空间大小为 2,4,8,......,n,归并排序的空间复杂度为

O(n)

量级。与插入排序,冒泡排序,还有选择排序相比,你也可以将归并排序理解为空间换时间的有效例证。归并排序也就不是一个原地排序算法了,原地排序算法的空间复杂度为

O(1)

.

稳定性分析

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

归并排序相关问题摘要

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

  1. 与数组相比,归并排序在单链表上进行排序的优势何在?
  2. 如何实现一个空间复杂度为
O(1)

,时间复杂度为

O(n\log n)

的归并排序?

  1. 三路归并排序如何实现和操作?
  2. 如何让今天讲的归并排序变成一个原地排序算法(In-place Algorithm)?
  3. 迭代形式的归并排序又该如何实现?
  4. 如何利用统计一个数组中次序颠倒的元素对的个数,比如 arr[] = [3,1,2] ,就应该返回 2,因为 (3,1) ,(3,2) 都是次序相反的数对。

我把一千本开源电子书,以及我写的 140 多篇算法图解,进行了汇总。下方扫码回复【资源】就可以全部下载 !!!

内容展示:

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-10-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 小浩算法 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 归并排序
    • 实现代码
      • 复杂度分析
        • 时间复杂度
        • 空间复杂度
      • 稳定性分析
      • 归并排序相关问题摘要
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档