归并排序
归并排序,即把一个数组分成两个子数组,然后对这两个子数组分别排序,再将它们归并为一个有序数组。这几乎就是归并排序的全部内容:拆分数组-子数组排序-合并数组。
同样的,我们先来看一个实例。
对于数组 a = [5 9 0 7 1 10]
,取 i = 0, j = a.length-1 = 5, mid = (i+j) / 2 = 2
。
1. 先将该数组拆分成两个子数组(根据 mid),分别为 s1 = a[5 9 0], s2 = a[7 1 10]
2. 分别对两个数组进行排序,为 s1 = [0 5 9], s2 = [1 7 10]
3. 合并两个数组。合并过程是归并排序中比较重要一步。操作如下
- 新建一个缓存数组 b[n]。取 i = j = 0
- 比较 s1[i], s2[j],取较小值添加到数组 b 中。如当前 s1[0] = 0 < s2[0] = 1,我们将 s1[0] 添加到数组b中,然后 i++。
- 重复步骤二直到 i >= s1.length 或 j >= s2.length;
- 如果 i < s1.length 则将s1剩余的元素添加到数组b中。
- 如果 j < s2.length 则将 s2 剩余的元素添加到数组b中。
4. 合并后的数组为 a = [0 1 5 7 9 10]
排序完成。
理解了上述过程也就理解了基本的归并排序,但是这里还有一个问题,我们怎么对子数组进行排序?
这个问题有两个解决方案。
第一个是被普通利用的。我们利用不断递归来保证最终子数组都是有序的。而递归退出条件为当前数组元素 个数 小于等于1,易得当一个数组只有一个元素时,它本身就是有序的。如上述 a,它会经历如下拆分过程
s1 = [5 9 0], s2 = [7 1 10]
s11=[5 9], s12=[0] s21=[7 1], s22=[10]
s111=[5], s112=[9], s12=[0], s211=[7], s212=[1,] s22=[10]
当递归到达最后一层即数组都仅有一个元素时,递归退出。最终所有子数组都应该是有序的。接下来我们再分别对相邻数组进行合并,最终将得到一个有序数组。
合并代码如下:
/**
* 合并两个数组。这里我们使用 lo,min,hi 三个索引指针
* 将一个数组标记为两部分来表示两个数组
* @param tmp 合并时使用的缓存数组
* @param a 待操作数组
* @param lo 第一个子数组的第一个元素
* @param mid 第一个子数组的最后一个元素
* @param hi 第二个子数组的最后一个元素
*/
ublic static void merge(Comparable[] tmp, Comparable[] a, int lo, int mid, int hi) {
int i = lo;
int j = mid + 1;
int index = lo;
while (i <= mid && j <= hi) {
if (less(a[i], a[j])) {
tmp[index++] = a[i++];
} else {
tmp[index++] = a[j++];
}
}
while (i <= mid) tmp[index++] = a[i++];
while (j <= hi) tmp[index++] = a[j++];
// 部分排序需要重新写回到a数组中,因为这部分后续还会被用到
index = lo;
while (lo <= hi) {
a[lo++] = tmp[index++];
}
}
归并排序核心代码如下
public static void sort(Comparable[] tmp, Comparable[] a, int lo, int hi) {
if (lo >= hi) return;
int mid = (lo+hi) / 2;
// 这个步骤相当于数组拆分
sort(tmp, a, lo, mid);
sort(tmp, a, mid+1, hi);
merge(tmp, a, lo, mid, hi);
}
上述过程需要注意几点:
1. 我们并不是在递归过程中去生成缓存数组,而是在排序算法调用前先生成一个数组然后通过参数的方式来传递这个数组,这样我们只需要一个固定的额外的空间。
2. 在合并代码时我们在合并尾部将当前操作的合并数组写回到数组 a 中(一开始我犯的错误是将其留在tmp中)
3. 在 sort 中,我们知道程序在到达merge前,两个子数组已经是有序的,所以我们只要判断 s1[mid] 是否小于 s2[mid+1] 即可,如果小于则数组 s[lo..hi] 已经有序,则 merge 过程可省略。
第二种解决方案,我们可以归并到达方案一的临界条件(即数组元素个数小于等于 1 )前的某个时刻,对子数组进行插入排序并跳出递归。
核心代码如下
public static void sort(Comparable[] tmp, Comparable[] a, int lo, int hi) {
if (hi - lo < min) { // 其中min为数组需要进行插入排序时数组元素个数的最小值
Insertion.sort(a);
return;
}
int mid = (lo+hi) / 2;
// 这个步骤相当于数组拆分
sort(tmp, a, lo, mid);
sort(tmp, a, mid+1, hi);
merge(tmp, a, lo, mid, hi);
}
使用这种方式的好处是显而易见的
- 当数组很小时,如果还需要进行递归那将是消耗资源的
- 插入排序对小数组排序十分高效
算法分析
1. 和之前讨论的三个排序算法不同,插入排序需要使用而外的空间,空间复杂度为 O(N) ,与数组大小成正比
2. 由于使用了递归,且在每一次递归中,数组都被一分为二,利用二叉树的相关知识,我们可以知道其时间复杂度大致为 O(N log N)
3. 使用插入排序后,一般而言,算法更加高效。
4. 由于插入排序对数组进行了拆分,也就是说数组的规模在递归中变小了,并且是数组局部排序,这样的特性也让它适用于对进行外部排序(它的应用一般也是如此)
Java 实现完整代码:Merge.java
C 实现完整代码: merge.c