系列文章链接
1. 原理
归并排序(Merge Sort)是利用分治法(Divide and Conquer)的一种非常经典的排序算法。通过几句话地描述这个算法就是:
- 先不断
拆分数组成为多个只有一个元素最小数组,然后最小数组两两比较进行排序,合并成排好序的较大数组较大数组继续两两比较,合并成多个排好序的更大数组- 以此类推,直至完成整个数组的排序。
以这张图为例,将[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; // 返回排序合并好结果
}
通过减少索引l和r的距离,拆解大数组,直到得到仅有一个元素的小数组。
在拆分之后需要将数组排序并合并起来,上面代码中的merge方法实现了这些功能。以合并[2,7]和[1,4]为例,见下图
我们一步步分析合并这一过程
- 对于最小数组
[2]、[7]、[1]、[4]
[2]和[7]比较,2比7小,本身已经排好序,合并成[2,7];[1]和[4]比较,也是已经排好序,合并成[1,4]。
- 接下来合并
[2,7]和[1,4],在[2,7]和[1,4]合并前要先排好序。
通过左指针left指向[2,7]的元素2,通过右指针right指向[1,4]的最元素1。
新建一个辅助数组Help,比较Array[4]和Array[6],2比1大,将1放到Help中
右指针right右移到位置7,继续比较Array[4]和Array[7],2比4小,将2放到Help中
左指针left右移到位置5,再比较Array[5]和Array[7],7比4大,将4放到Help中,此时发现右指针right没办法再右移
直接将左指针left右边剩余部分,全部放到Help里面,Help完成[2,7]和[1,4]的排序和合并。
最后将Help刷回到原数组,完成原数组[2,7]和[1,4]的合并,其它小数组的合并以此类推。
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
在合并阶段,我们要遍历二叉树的每一层节点,每层节点数都是N,需要遍历的层数有logN层(log8 = 3),所以这个阶段的时间复杂度就是NlogN
两个阶段的时间复杂度相加
logN + NlogN,可以得到时间复杂度O(NlogN)
4.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; // 返回排序合并好结果
}
mergeSort里面有两个子递归mergeSort,它们分别处理了父函数中数组arr左半边和右边,那么它们的递归函数规模就是。
公式 在中的就是递归函数规模,a是子递归函数的数量(刚才提到有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; // 返回排序合并好结果
}
主要还是看merge函数,大家可以看下章节的代码实现,不难看出时间复杂度是O(N)。
好啦,终于可以套入公式可以得到
其中 a = 2, b = 2, d = 1
如果 ,递归函数时间复杂度为
如果 ,递归函数时间复杂度为
如果 ,递归函数时间复杂度为
套入,所以时间复杂度是O(NlogN)
递归复杂度公式适应于所有递归规模相同的递归函数,比如上面例子中每个子递归函数的规模都是。假如是下面这种就不适用了
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. 本章小结
- 归并排序是利用分治法的一种的排序,先不断拆分数组称为最小数组,然后最小数组两两比较,合并成多个排好序的较大数组,较大两两比较,合并成多个排好序的更大数组,直至完成整个数组的排序
- 归并排序的时间复杂度是
O(NlogN) - 归并排序的空间复杂度是
O(N) - 归并排序具有稳定性