为什么复杂度是O(nlogn)?深入讲解归并排序

2,374 阅读7分钟

系列文章链接

1. 原理

归并排序(Merge Sort)是利用分治法(Divide and Conquer)的一种非常经典的排序算法。通过几句话地描述这个算法就是:

  • 先不断拆分数组成为多个只有一个元素最小数组,然后最小数组两两比较进行排序,合并成排好序的较大数组
  • 较大数组继续两两比较,合并成多个排好序的更大数组
  • 以此类推,直至完成整个数组的排序。

image.png

以这张图为例,将[5,3,8,6,2,7,1,4]通过归并排序转为[1,2,3,4,5,6,7,8]

排序过程主要分为拆分(Divide)和治理(Conquer)两个步骤,笔者认为治理并不是一个容易理解的词,实际上它的主要工作还是合并,因此后文统一用合并表示治理这一步骤

拆分:把数组对半拆解为小数组

(1) 先把[5,3,8,6,2,7,1,4]分成[5,3,8,6][2,7,1,4];
(2) 再把[5,3,8,6]分成[5,3][8,6];
(3) [2,7,1,4]分成[2,7][1,4];
(4) 以此类推,直到每个数组只有一个元素。

合并:按从小到大排序,合并小数组

(1) 对[5][3],比较大小后,合并成数组[3,5];
(2) 对[8][6],比较大小后,合并成数组[6,8];
(3) 对[3,5][6,8],比较大小后,合并成数组[3,5,6,8];
(4) 以此类推,最后对[3,5,6,8][1,2,4,7],比较大小排序后,合并成最终结果[1,2,3,4,5,6,7,8],完成排序

2. 详细分析

对于拆分这一步比较容易实现,比如,我们可以使用递归的方式

function mergeSort(arr, l, r) { // arr为完整数组,l为左索引,r为右索引
  var mid = l + ((r - 1) / 2); // 获取中点
  if (r - l > 1) {  // 递归直到左索引大于右索引
    mergeSort(arr, l, mid);  // 递归处理数组左半边
    mergeSort(arr, mid + 1, r); // 递归处理数组右半边
  }
  merge(arr, l, mid, r); // 做排序和合并的处理,后面会讲
  return arr; // 返回排序合并好结果
}

通过减少索引lr的距离,拆解大数组,直到得到仅有一个元素的小数组。

在拆分之后需要将数组排序并合并起来,上面代码中的merge方法实现了这些功能。以合并[2,7][1,4]为例,见下图

image.png

我们一步步分析合并这一过程

image.png

  1. 对于最小数组[2][7][1][4]
  • [2][7]比较,27小,本身已经排好序,合并成[2,7];
  • [1][4]比较,也是已经排好序,合并成[1,4]

image.png

  1. 接下来合并[2,7][1,4],在[2,7][1,4]合并前要先排好序。

image.png

通过左指针left指向[2,7]的元素2,通过右指针right指向[1,4]的最元素1image.png

新建一个辅助数组Help,比较Array[4]Array[6]21大,将1放到Helpimage.png

右指针right右移到位置7,继续比较Array[4]Array[7]24小,将2放到Help

image.png

左指针left右移到位置5,再比较Array[5]Array[7]74大,将4放到Help中,此时发现右指针right没办法再右移

image.png

直接将左指针left右边剩余部分,全部放到Help里面,Help完成[2,7][1,4]的排序和合并。

image.png

最后将Help刷回到原数组,完成原数组[2,7][1,4]的合并,其它小数组的合并以此类推。

image.png

3. 代码实现

function main(arr) {
  var l = 0;
  var r = arr.length - 1;
  return mergeSort(arr, l, r);
}

function mergeSort(arr, l, r) {
  var mid = l + ((r - l) >> 1); // 等价于l + ((r - 1) / 2)
  if (r - l > 1) {
    mergeSort(arr, l, mid);
    mergeSort(arr, mid + 1, r);
  }
  merge(arr, l, mid, r);
  return arr;
}

function merge(arr, l, mid, r) {
  var left = l;
  var right = mid + 1;
  var helpInd = 0;
  var help = [];
  while (left <= mid && right <= r) {
    help[helpInd++] = arr[left] < arr[right] ? arr[left++] : arr[right++];
  }
  while (left <= mid && right > r) {
    help[helpInd++] = arr[left++];
  }
  while (left > mid && right <= r) {
    help[helpInd++] = arr[right++];
  }
  var i = 0;
  while (help[i] !== undefined) {
    arr[l + i] = help[i];
    i++;
  }
}

console.log(main([5, 1, 4, 3, 0])); // [0,1,2,3,4,5]

4 时间复杂度

我们可以通过两种方法计算归并排序的时间复杂度,分别是直观理解递归复杂度公式

4.1 直观理解

假设一颗二叉树的节点数量为N,那么它的高度就是logN + 1。 在拆分阶段,一共做了logN次(log8 = 3)的操作,该阶段时间复杂度是logN

image.png

合并阶段,我们要遍历二叉树的每一层节点,每层节点数都是N,需要遍历的层数有logN层(log8 = 3),所以这个阶段的时间复杂度就是NlogN

image.png 两个阶段的时间复杂度相加logN + NlogN,可以得到时间复杂度O(NlogN)

4.2 递归复杂度公式

aT(Nb)+O(Nd)a * T(\frac{N}{b}) + O(N^d)

如果 logba>dlog_ba > d ,递归函数时间复杂度为 O(Nlogba)O(N^{log_ba})
如果 logba<dlog_ba < d ,递归函数时间复杂度为 O(Nd)O(N^d)
如果 logba=dlog_ba = d ,递归函数时间复杂度为 O(NdlogN)O(N^d * logN)

在介绍公式之前,我们先了解递归函数规模这个概念,以上面提到的递归函数为例

function mergeSort(arr, l, r) { // arr为完整数组,l为左索引,r为右索引
  var mid = l + ((r - 1) / 2); // 获取中点
  if (r - l > 1) {  // 递归直到左索引大于右索引
    mergeSort(arr, l, mid);  // 递归处理数组左半边
    mergeSort(arr, mid + 1, r); // 递归处理数组右半边
  }
  merge(arr, l, mid, r); // 做排序和合并的处理,后面会讲
  return arr; // 返回排序合并好结果
}

mergeSort里面有两个子递归mergeSort,它们分别处理了父函数中数组arr左半边和右边,那么它们的递归函数规模就是12\frac{1}{2}

公式 aT(Nb)+O(Nd)a * T(\frac{N}{b}) + O(N^d) 在中的1b\frac{1}{b}就是递归函数规模a是子递归函数的数量(刚才提到有2个),所以公式前半部分就是2T(N2)2 * T(\frac{N}{2})

公式aT(Nb)+O(Nd)a * T(\frac{N}{b}) + O(N^d)的后半部分O(Nd)O(N^d),是子递归以外代码的时间复杂度。比如上面例子中,递归以外的代码有

function mergeSort(arr, l, r) { // arr为完整数组,l为左索引,r为右索引
  var mid = l + ((r - 1) / 2); // 获取中点
  // if (r - l > 1) {  // 递归直到左索引大于右索引
  //   mergeSort(arr, l, mid);  // 递归处理数组左半边
  //   mergeSort(arr, mid + 1, r); // 递归处理数组右半边
  // }
  merge(arr, l, mid, r); // 做排序和合并的处理,后面会讲
  return arr; // 返回排序合并好结果
}

主要还是看merge函数,大家可以看下章节的代码实现,不难看出时间复杂度是O(N)
好啦,终于可以套入公式可以得到

2T(N2)+O(N)2 * T(\frac{N}{2}) + O(N)

其中 a = 2, b = 2, d = 1

如果 logba>dlog_ba > d ,递归函数时间复杂度为 O(Nlogba)O(N^{log_ba})
如果 logba<dlog_ba < d ,递归函数时间复杂度为 O(Nd)O(N^d)
如果 logba=dlog_ba = d ,递归函数时间复杂度为 O(NdlogN)O(N^d * logN)

套入log22=1log_22 = 1,所以时间复杂度是O(NlogN)

递归复杂度公式适应于所有递归规模相同的递归函数,比如上面例子中每个子递归函数的规模都是12\frac{1}{2}。假如是下面这种就不适用了

function mergeSort(arr, l, r) {
    // ...
   mergeSort(arr, l, (r - l) / 3);  // 递归处理数组的1/3
   mergeSort(arr, (r - l) / 3, r); // 递归处理数组的2/3
   // ...
}

5. 额外空间复杂度

合并过程中,我们使用辅助数组Help用于数组合并前的排序。

从总体上来看,因为二叉树中每层的节点数都恒定为N,每一层所有小数组的节点总数都是N,多个辅助数组Help的占用总空间也是N

因此,我们需要的额外空间为N,额外空间复杂度为O(N)

6. 稳定性

稳定性取决于值相同的两个元素在排序时有没有交换位置。

算法主要是在合并阶段利用辅助数组Help进行排序,在比较大小过程中,如果值相同的元素不需要交换元素,而在合并过程中也不会出现顺序上的变化。

因此归并排序算法具有稳定性

7. 本章小结

  1. 归并排序是利用分治法的一种的排序,先不断拆分数组称为最小数组,然后最小数组两两比较,合并成多个排好序的较大数组,较大两两比较,合并成多个排好序的更大数组,直至完成整个数组的排序
  2. 归并排序的时间复杂度是O(NlogN)
  3. 归并排序的空间复杂度是O(N)
  4. 归并排序具有稳定性