前端不懂算法(三)--归并排序

本篇文章参考借鉴了以下文章和极客时间的数据结构与算法一文,若涉及侵权,请联系我删除。

前言

  之前的博客里面我们一块学习了,冒泡排序,插入排序,选择排序,这三种排序算法,它们的时间复杂度都是O(n2),适用于小规模数据的排序,本篇文章,我们一块来学习下归并排序。适用于大规模数据。

没看过之前文章的小伙伴可以先了解一下

核心思想

  归并排序的核心思想是 分治思想 ,非常巧妙。我们可以借鉴这个思想,来解决非排序问题,比如:如何在O(n)的时间复杂度内查找一个无需数组的第 K 大元素?,这就用到了归并排序。

归并排序原理

  归并排序的实现原理还是比较简单的,如果要排序一个数组,我们先把数组从中间分成前后两个部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就有序了。
在这里插入图片描述
  分治,顾名思义就是分而治之,将一个大问题分解成小的子问题来解决。小问题解决了,大问题也就解决了。

  从刚才的描述中可以看出,分治思想跟递归很像。是的,分治算法一般都是用递归实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。分治算法的思想我们放到后面的章节来梳理,现在不展开讨论,本篇文章重点还是排序算法。

如何实现归并排序

  如何用递归来实现归并排序?

  首先,递归代码的技巧是分析得出递推公式,找到终止条件,最后将递推公式翻译成递推代码。所以一起看看如何写出递推公式。

递推公式:
margin_sort(p...r) = merge(merge_sort(p...q), merge_sort(q+1...r))

终止条件:
p >= r不用再继续分了。

  该递推公式仔细观察就会发现:margin_sort(p...r)表示给下标从 p 到 r 之间的数组排序。现在将这个排序问题转化成两个子问题,margin_sort(p...q)margin_sort(q+1...r),其中 q 是 p 和 r 的中间位置,也就是(p + r)/2

  当下标从 p 到 q 和从 q + 1 到 r 这两个子数组都排序好之后,我们再将两个有序的子数组合并在一起,这样下标从 p 到 r 之间的数据也就好排序了。

  有了递推公式,下一步就是转换成代码了,各位请看:

//归并排序算法
function mergeSort(arr) {
  mergeSortFn(arr, 0, arr.length - 1)
}

//递归调用函数
function mergeSortFn(arr, p, r) {
  //递归终止条件
  if (p >= r) return;

  //取 p 到 r 之间的中位数 q
  let q = parseInt((p + r) / 2);

  //分治递归
  mergeSortFn(arr, p, q)
  mergeSortFn(arr, q + 1, r)

  //将A[p...q] 和 arr[q...r]合并为 arr[p...r]
  merge(arr, p, q, r);
}

  上面代码中merge()函数的作用,就是将两个有序数组,合并成一个新的有序数组,实现原理如图
在这里插入图片描述
  如图所示,申请一个临时数组tmp,大小与A[p...r]相同。我们用两个游标 ij,分别指向A[p...q]A[q+1...r]的第一个元素,比较这两个元素,A[i]A[j],如果A[i] <= A[j],我们就把A[i]放入到临时数组tmp,并且i后移一位,否则就将A[j]放入到数组tmpj后移一位。

  直到其中一个子数组中所有的数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候临时数组存储的值就是两个子数组合并之后的结果。最后再把临时数组tmp中的数据拷贝到原始数组A[p...r]中。

  加上merge()部分的代码如下:

//归并排序算法
function mergeSort(arr) {
    mergeSortFn(arr, 0, arr.length - 1);
}

//递归调用函数
function mergeSortFn(arr, p, r) {
    //递归终止条件
    if (p >= r) return;

    //取 p 到 r 之间的中位数 q
    let q = parseInt((p + r) / 2);

    //分治递归
    mergeSortFn(arr, p, q);
    mergeSortFn(arr, q + 1, r);

    //将A[p...q] 和 arr[q...r]合并为 arr[p...r]
    merge(arr, p, q, r);
}


//合并分开的数据
function merge(arr, p, q, r) {
    let i = p,     //第一段序列下标
        j = q + 1, //第二段序列下标
        k = 0,     //临时存放数组下标
        tmp = [];  //临时存放数组

    while (i <= q && j <= r) {
        if (arr[i] <= arr[j]) {
            tmp[k++] = arr[i++];
        } else {
            tmp[k++] = arr[j++];
        }
    }

    //判断哪个子数组中有剩余数据
    var star = i, end = q;
    if (j <= r) {
        star = j;
        end = r;
    }

    //将剩余数据拷贝到临时数组
    while (star <= end) {
        tmp[k++] = arr[star++];
    }

    //将tmp中的数据拷贝回arr (*)
    for (k = 0, i = p; i <= r; i++, k++) {
        arr[i] = tmp[k];
    }
}

var arr = [10, 8, 6, 4, 9, 7, 5];
mergeSort(arr);
console.log('排序后的数组:', arr); // 排序后的数组:[4,5,6,7,8,9]

上面代码 (*)的部分,注意,这里把 tmp 替换到 arr 中不是单纯的完全替换,而是对应位置的替换,比如上文中的例子

  • 第一次替换,arr 是 [10, 8, 6, 4, 9, 7, 5] ,tmp 是 [8, 10]。        替换后 arr 变成 [8, 10, 6, 4, 9, 7, 5]
  • 第二次替换,arr 是 [8, 10, 6, 4, 9, 7, 5] ,tmp 是 [4, 6]。        替换后 arr 变成 [8, 10, 4, 6, 9, 7, 5]
  • 第三次替换,arr 是 [8, 10, 4, 6, 9, 7, 5] ,tmp 是 [4, 6, 8,10]。    替换后 arr 变成 [4, 6, 8, 10, 9, 7, 5]
  • 第四次替换,arr 是 [4, 6, 8, 10, 9, 7, 5] ,tmp 是 [7, 9]。        替换后 arr 变成 [4, 6, 8, 10, 7, 9, 5]
  • 第五次替换,arr 是 [4, 6, 8, 10, 7, 9, 5] ,tmp 是 [5, 7, 9]。       替换后 arr 变成 [4, 6, 8, 10, 5, 7, 9]
  • 第六次替换,arr 是 [4, 6, 8, 10, 5, 7, 9] ,tmp 是 [4, 5, 6, 7, 8, 9, 10]。替换后 arr 变成 [4, 5, 6, 7, 8, 9, 10]

归并排序性能分析

分析排序算法的三个方面,不记得的小伙伴点下方文章复习一下

第一、归并排序是稳定的排序算法吗?

  判断归并排序稳不稳定,关键在于往 tmp 中放数据的时候,先放谁?在合并过程中,如果有相同的元素,我们需要先把 A[p...q] 中的元素放入 tmp 数组,这样就保证了值相同的元素,在合并前后顺序不变。所以归并排序是一种稳定的排序算法

第二、归并排序的时间复杂度是多少?

  归并排序涉及递归,分析起来稍微有点复杂。我们知道递归是把 a 问题分解成 b ,c 问题,求解 b ,c 之后,合并起来,就能解决 a 问题。那么就会有这样一个公式, a 的时间是 b 的时间加上 c 的时间,再加上合并的时间。

  T(a) = T(b) + T(c) + k

  这样我们就有了 递归代码的时间复杂度的递推公式

  设对 n 个元素进行归并排序的时间需要 T(n) ,那分解成两个子数组的排序时间都是 T(n/2), 我们知道 merge() 函数合并两个有序子数组的时间复杂度是 O(n) ,所以归并排序的时间复杂度就是:

  • T(1) = C; n = 1 时,只需要常量级的执行时间。
  • T(n) = 2*T(n) + 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
   …
   = 2k *T(n/2k) + k*n
   …

  通过上面的分析推导,我们可以得到 T(n) = 2k T(n/2k) + knT(n/2k) = T(1) 时,也就是 n/2k = 1,我们得到 k = log2n

k 代入上面的公式,得到 T(n) = Cn + nlog2n。 如果用大 O 标记法表示, T(n) = O(nlogn)。 所以归并排序的时间复杂度为 O(nlogn)

  上面为什么 nlogn 没有写出 底数? ,这是因为我们在研究时间复杂度的时候,都是把 n 想象成无穷大,举个例子,O(logx(n))O(logy(n))x != y,当 n 无穷大的时候,logx(n)/logy(n) 的极限可以发现,极限等于 lny/lnx ,是一个常数。

  也就是说,在n趋于无穷大的时候,这两个东西仅差一个常数。所以从研究算法的角度log的底数不重要。

(提示:只要是时间复杂度中有log级别的,都是由于使用了分治思想,这个底数直接由分治的复杂度决定)

第三、归并排序的空间复杂度是多少?

  归并排序的时间复杂度在任何情况下都是 O(nlogn) ,看起来非常优秀,但是归并排序却并没有像快排那样应用广泛,这是因为归并排序有一个致命“弱点”,它并不是原地排序算法。

  这是因为归并排序的合并函数,在合并两个有序数组的时候,需要借助额外的存储空间。这一点很好理解,需要 tmp 的参与,而且 tmp 是根据所排序数组的大小决定的。那么归并排序的时间复杂度是多少呢?

  由于 tmp 是我们在定义在 merge() 函数中的变量,当函数执行完之后,变量就会被释放,所以归并排序的空间复杂度最大也不会超过 n ,空间复杂度就是 O(n)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值