算法学习in js:归并排序

1,841 阅读5分钟

本文是本人学习归并排序的记录,阅读的材料主要是《算法(第4版)》

前言

何为归并?归并即是把两个有序的数组合并成一个更大的有序数组。应用于排序算法之中,欲将一个数组排序,可以把原数组(递归地)拆成两半,再将结果合并起来,如此就可达到排序的效果。

归并排序最大的特点即是它能保证任意长度为N的数组所需的时间与NlogN成正比。但主要缺点也是所需要的额外空间和N成正比。

下面将以此序列展开此文

  • 归并排序思想
  • 自顶向下的归并排序
  • 自底向上的归并排序
  • 归并排序复杂度
  • 归并排序局限性

归并及归并排序思想

归并

此处我们进一步了解归并的操作及代码实现,如图是对两个数组[2, 3, 6, 7]和[1, 4, 5, 8]的归并

这只是归并的简单示意,并不是归并排序

在归并实际代码中,我们需要一个额外的空间存储原数组内容,并通过下标控制,把数组内容放入原数组进行排序。这里把额外的空间数组称之为aux,到归并结果的操作也对应如下图:

《算法(第4版)》中给出了原地归并的代码,这里我用js给出

  //原地归并的抽象方法,将涉及的所有元素复制到一个辅助函数中,再把归并的结果放回原数组中。
  merge(array, lo, mid, hi){
    let i = lo;
    let j = mid + 1;
    for(let k = lo; k <= hi; k++){  //将array[lo..hi]复制到aux[lo..hi]
      this.aux[k] = array[k];
    }
    
    //归并回到a[lo..hi]
    for(let k = lo; k <= hi; k++){
      if(i > mid){//左半边用尽
        array[k] = this.aux[j++];
      }else if(j > hi){//右半边用尽
        array[k] = this.aux[i++];
      }else if(this.less(this.aux[j], this.aux[i])){//右半边当前元素小于左半边的当前元素
        array[k] = this.aux[j++];
      }else{//右半边的当前元素大于左半边的当前元素
        array[k] = this.aux[i++];
      }
    }
  }

在后文归并排序实现中,我们将用到此函数。

归并排序思想

分治思想是归并排序的核心思想。即将一个大问题分割成小问题解决,然后用所有小问题的答案来解决整个大问题。

在归并排序中,我们会将数组以最小单元(比如数组中只有两个元素)开始归并排序,不断地把单元变大(排序后的数组规模变大),最后完成排序。(自顶向下则递归至最小单元开始排序,自底向上则从小单元开始排序)

我们拿上图的原数组以图示例。

此图例是自顶向下的归并排序,在递归至底层后,[6, 3]排序为[3, 6]。接着[7, 2]归并为[2, 7]。接着对[3, 6]与[2, 7]归并为[2, 3, 6, 7]。右半部分也是如此。最后将[2, 3, 6, 7]与[1, 4, 5, 8]归并,结束递归。

接下来分别看看自顶向下的归并排序与自底向上的归并排序。

自顶向下的归并排序

自顶向下的归并排序即不断地递归至最小规模数组,再往上层层归并,如此完成排序,同时也是分治思想核心的体现。

  sortBegin(array){
    this.aux = new Array(array.length);
    this.sort(array, 0, array.length - 1);
  }
  
  sort(array, lo, hi) {
    if(hi <= lo){
      return;
    }
    let mid = Math.floor(lo + (hi - lo)/2);
    this.sort(array, lo, mid);//左侧递归
    this.sort(array, mid + 1, hi);//右侧递归
    this.merge(array, lo, mid, hi);//归并
  }

CodePen打开

在了解上图的示例之后,这串代码的思路也非常简单易懂。

自底向上的归并排序

自底向上的归并排序更加容易理解,图示:

自底向上的归并排序会多次遍历数组,将子数组两两进行归并排序。子数组的大小sz初始值为1,后不断加倍,以此扩大子数组。

核心代码如下:

  sort(array) {
    const N = array.length;
    this.aux = new Array(N);
    for(let sz = 1; sz < N; sz = sz+sz){//sz表示子数组大小
      for(let lo = 0; lo < N - sz; lo += sz + sz){//lo表示子数组索引
        this.merge(array, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1))
      }
    }
  }

CodePen打开

归并排序复杂度

传统归并排序的最好时间复杂度、最坏时间复杂度、平均时间复杂度均为NlogN,至于具体证明在这里难以给出,因为想要表达清楚又会是一篇长篇大论。

我们学习归并排序的一个重要价值是它是证明计算复杂性领域的一个重要结论的基础

没有任何基于比较的算法能够保证使用少于lg(N! )~NlogN次比较将长度为N的数组排序 ——《算法(第4版)》

这里简单点出结论,关于证明可以去网上搜索更全面的资源,否则又是一篇长篇大论。知乎上也有类似的问题和回答:很多高效排序算法的代价是 nlogn,难道这是排序算法的极限了吗? - 舒自均的回答 - 知乎

归并排序局限性

归并排序在时间复杂度上有优点,但其也存在局限性:

  • 空间复杂度不是最优的,需要额外的存储空间。辅助数组所使用的额外空间和N的大小成正比
  • 实践中不一定会遇到最坏的情况

总结

归并排序是分治思想的典型例子,将大问题化成小问题,最后用小问题的答案解决大问题。其稳定的时间复杂度着实是优势,但也有相应的额外空间上的付出。

参考资料

《算法(第4版)》