不同算法中往往蕴含着通用的思想,分治法就是最常用的一种。
分治法使用递归的方式,将原问题分解为几个规模较小但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解从而得到原问题的解。
仍然考虑排序问题,本文将要介绍的归并排序就是使用分治法的典型案例。
思路
把待排序数组从中间分割为两个子数组,这两个子数组分别排序,排完序后再把它们合并起来。该思路的关键在于子数组如何排序,以及如何把排完序的两个子数组合并起来。
第一个问题,分治法的精髓就在于把大问题分解为小问题,因此子数组仍然要使用归并排序,这是一个递归的过程。递归结束的条件是问题不能进一步分解,即数组只有一个元素,处于自然有序状态。
第二个问题,排完序的两个子数组只需要同时遍历一遍,每次都取两者中较小的数放入最终数组,直到取完所有元素。
整个算法从细分到合并的详细过程如下图所示。
![43885a8edfd1b11016a13631bfac40a0.png](https://i-blog.csdnimg.cn/blog_migrate/d6f240a054e3e27a4eef9b94c9c7d29d.jpeg)
每次分割,取
下图形象地描述了归并排序的完整流程。
![de7c0e15b2879afa0b44beb5407171d6.gif](https://i-blog.csdnimg.cn/blog_migrate/2c5adef98d455c4e47c6ee91188bcc14.gif)
实现代码
代码主要包括三个函数。第一个sort
是封装好的调用接口。 第二个mergeSort
是归并排序的递归实现,为了复用数据,我们每次都把完整的数组引用传进去,同时传入当前处理的子数组的范围。mergeSort
内部实现很简单,只需要将子数组一分为二,再次分别调用mergeSort
,最后调用merge
将其合并。但需要注意的是,if (p < r)
作为终止条件必不可少,当分割到最后一层时,子数组的长度为1,此时p
和r
具有相同的值,条件判断不再成立,从而结束递归。
/**
* Merge sort.
* @param arr Integer array to be sorted.
*/
public void sort(int[] arr) {
mergeSort(arr, 0, arr.length - 1);
}
/**
* Merge sort a subarray recursively.
* @param arr The integer array where the subarray resides.
* @param p The start index of the subarray to be sorted.
* @param r Thn end index(included) of the subarray to be sorted.
*/
public void mergeSort(int[] arr, int p, int r) {
if (p < r) {
int q = (p + r) / 2;
mergeSort(arr, p, q);
mergeSort(arr, q + 1, r);
merge(arr, p, q, r);
}
}
/**
* Merge the two sorted subarray.
* @param arr The integer array where the two sorted subarrays reside.
* @param p The start index of the first subarray.
* @param q The end index(included) of the first subarray.
* @param r The end index(included) of the second subarray. Note that the start index of the second subarray is
* always the next position of the end of the first subarray.
*/
public void merge(int[] arr, int p, int q, int r) {
int n1 = q - p + 1;
int n2 = r - q;
int[] arrL = new int[n1 + 1];
int[] arrR = new int[n2 + 1];
for (int i = 0; i < n1; i++) {
arrL[i] = arr[p + i];
}
for (int i = 0; i < n2; i++) {
arrR[i] = arr[q + 1 + i];
}
arrL[n1] = Integer.MAX_VALUE;
arrR[n2] = Integer.MAX_VALUE;
int i = 0;
int j = 0;
for (int k = p; k <= r; k++) {
if (arrL[i] <= arrR[j]) {
arr[k] = arrL[i];
i++;
}
else {
arr[k] = arrR[j];
j++;
}
}
}
merge
的实现稍微麻烦一些, 需要额外创建两个数组,把排好序的两个子数组拷贝进去。这是为了把原来的位置腾出来,方便放置最终结果。接下来的for
循环循环
arr
中。这里用了一个小技巧,往两个子数组的最后额外添加了一个超级大的数,这样在比较大小的时候,如果一个子数组已经空了,不至于影响整个比较流程进行到最后。
完整代码见MergeSort.java。
分析
现在,我们来分析一下该算法的时间复杂度。归并排序采用了二等分的分治策略,因此每一级分治使问题规模减小一半,所以直到问题规模减小到1时应该经历了
改进空间
但是,归并排序也有一些缺点。与插入排序不同的是,在任何情况下,归并排序的时间复杂度都是
/**
* Hybrid merge sort implemented by merge sort and insertion sort.
* @param arr Integer array to be sorted.
*/
public void hybridSort(int[] arr) {
mergeInsertionSort(arr, 0, arr.length - 1);
}
/**
* Merge sort a subarray with the help of insertion sort.
* @param arr The integer array where the subarray resides.
* @param p The start index of the subarray to be sorted.
* @param r The end index(included) of the subarray to be sorted.
*/
private void mergeInsertionSort(int[] arr, int p, int r) {
if (p < r) {
if (r - p < k) {
// To simplify our implementation, we just copy arr to another array, insertion sort it and copy it
// back.
int[] arrCopy = new int[r - p + 1];
System.arraycopy(arr, p, arrCopy, 0, arrCopy.length);
insertionSort.sort(arrCopy);
System.arraycopy(arrCopy, 0, arr, p, arrCopy.length);
} else {
int q = (p + r) / 2;
mergeInsertionSort(arr, p, q);
mergeInsertionSort(arr, q + 1, r);
merge(arr, p, q, r);
}
}
}
// Switch merge sort to insertion sort when the length of subarray is below k.
private final int k = 200;
// Inner insertion sort object.
private final Sort insertionSort = new InsertionSort();
在mergeInsertionSort
中,我们根据子数组的长度来决定使用哪种策略。当子数组长度小于200时,切换到插入排序,一次性将这个子数组排序完成;否则继续递归调用归并排序。
我在自己电脑上测试了原始版本的归并排序和改进版的归并排序 ,各自耗时如下(数组长度为20000000,Core i5 7th Gen,单位ms)。
k | Merge | Merge+Insertion |
---|---|---|
2 | 2189 | 2021 |
20 | 2239 | 1617 |
200 | 2186 | 1482 |
2000 | 2182 | 2429 |
20000 | 2202 | 23712 |
可以发现,取不同的k,结果大不相同,但存在一个最优值。当k过小时,算法接近于原版归并排序,当k过大时,算法接近于插入排序,在该测试中,k在200左右效果最好,用时低于原版归并排序。
当然,这只是一个非常简单的改进,实现非常粗暴(从代码中的两次System.arraycopy
可见一斑)。在实际应用中,还有更多改进手段,比如预先分配内存,搜索局部有序片段,将单调下降片段反转等等。把所有可行的优化手段都用上,就得到了目前业内应用最普遍的排序算法,称为TimSort,也是Python和Java等语言的内置排序算法。虽然我们的实现距离TimSort还有些距离,但基本思想是一致的,都是归并排序结合插入排序。
改进版本的完整代码见HybridSort.java。
参考资料
十大经典排序算法(动图演示) 一像素
除了冒泡排序,你知道Python内建的排序算法吗? Brandon Skerritt