本篇文章参考借鉴了以下文章和极客时间的数据结构与算法一文,若涉及侵权,请联系我删除。
前言
之前的博客里面我们一块学习了,冒泡排序,插入排序,选择排序,这三种排序算法,它们的时间复杂度都是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]
相同。我们用两个游标 i
和j
,分别指向A[p...q]
和A[q+1...r]
的第一个元素,比较这两个元素,A[i]
和A[j]
,如果A[i] <= A[j]
,我们就把A[i]
放入到临时数组tmp
,并且i
后移一位,否则就将A[j]
放入到数组tmp
,j
后移一位。
直到其中一个子数组中所有的数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候临时数组存储的值就是两个子数组合并之后的结果。最后再把临时数组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) + kn 当 T(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)