背景
继上一篇《插入排序》之后的第四篇,笔者准备在本篇介绍归并排序。
归并排序 (Merge Sort)
本文要讲的归并排序是排序算法中最重要的算法之一。归并排序因其稳定的时间复杂度而被广为使用,如在JDK中的排序算法就是采用的归并排序而非不稳定的快速排序算法。
什么是归并排序?
归并排序,有时候笔者总是认为中文虽然博大精深,但是在命名舶来品的时候总是有种怪怪的感觉。通过命名”归并“我们其实并不能很好理解ta的思想,笔者一直不清楚到底”归并“是什么鬼意思。
所以笔者推荐用 Merge Sort 来理解。笔者粗浅地直译为合并排序,和我们日常日产一样,多人协作并行生产出的代码需要通过版本管理工具通过merge操作合并在一起,Merge Sort也一样[并行归并排序],其核心思想,就是把一个大的排序任务分为左右二等分,分别对这两个分区进行排序,最后再把两个有序分区合并在一起。
对于两个小分区的排序任务也可以进行再次分区。这意味着这个实现可递归的(Recursively)。
归并排序的空间时间复杂度
时间复杂度: O(n・㏒n),稳定的快速的排序算法。
空间复杂度: O(1.5n) - O(2n),通常需要一个辅助数组辅助结果集的生成,所以其内存开销是一般排序的1.5 - 2倍。虽然有一倍内存的实现方法(关键字:In-Place Merge Sort),但是笔者不推荐使用,因为这种复杂的实现是以时间复杂度暴涨至 O(n2・㏒n)为代价的。
归并排序的实现
归并排序的样例代码和测试代码在笔者github demo仓库里能找到。
/**
* 归并排序
* @author toranekojp
*/
public final class MergeAscendingSort extends AbstractAscendingSort {
@Override
protected void doSort(int[] nums) {
assert nums != null;
if (nums.length == 0) return;
int[] temp = nums.clone();
mergeSortRecursively(temp, nums, 0, nums.length - 1);
}
/**
* 对给定数组nums的指定范围[l, r]的元素进行排序,其排序结果同时反映在result和nums数组的[l, r]段。
*
* @param nums 排序对象数组。
* @param result 存放排序结果的辅助数组。
* @param l 指定排序范围的左边界,inclusive
* @param r 指定排序范围的右边界,inclusive
*/
private void mergeSortRecursively(int[] nums, int[] result , int l, int r) {
assert l <= r;
if (l == r) return; // 1个元素总是自然有序的。
final int mid = (l + r + 1) / 2;
// 分割 & 排序
mergeSortRecursively(nums, result, l, mid - 1); // 此时可以断言 result 的 [l, mid-1]是有序的。
mergeSortRecursively(nums, result, mid, r); //此时可以断言 result 的 [mid, r]是有序的。
// 合并两个有序区间[l, mid-1] & [mid, r]到[l, r]并保证结束后该区间保存有序。
final int leftBoundle = mid - 1;
final int rightBoundle = r;
// 一个指针记录整合区间的合并进度。针对result数组。
int cursor = l;
// 两个指针分别记录左右两个区间的合并进度。针对nums数组
int cursorL = l;
int cursorR = mid;
// 左右开工,直到某一方元素耗尽。
while (cursorL <= leftBoundle && cursorR <= rightBoundle)
result[cursor++] = nums[cursorL] < nums[cursorR] ? nums[cursorL++]: nums[cursorR++];
// 检查左区间是否有剩余未合并的元素,有就榨干。
while (cursorL <= leftBoundle) result[cursor++] = nums[cursorL++];
// 检查右区间是否有剩余未合并的元素,有就榨干。
while (cursorR <= rightBoundle) result[cursor++] = nums[cursorR++];
// 同步回nums
for (int i = l; i <= r; i++) nums[i] = result[i];
}
}
并行归并排序 (Parallel Merge Sort)
细心的读者可能能发现,我们的归并排序是单线程的。而我们之前举例子时,提到了多人并行协作开发最后merge合并代码的例子。所以试想一下我们的归并排序能否是并行的呢?答案很显然是肯定的。那么本节笔者将用Java实现并行归并排序。
通过并行化处理,可以另排序的速度大幅提高,不过需要小心在数据量比较小的时候如笔者设置的1000阈值,是不进行多线程并行分割的,因为创建线程是一个费时的操作。如果分割太多线程来处理,反而会导致性能下降。
其实现代码如下,仓库链接:
/**
* 并行归并排序
* @author toranekojp
*/
public final class ParallelMergeAscendingSort extends AbstractAscendingSort {
@Override
protected void doSort(int[] nums) {
assert nums != null;
if (nums.length == 0) return;
// DELEGATE: 委托排序任务到子组件SortTask
SortTask sortTask = new SortTask(nums, 0, nums.length);
sortTask.compute();
}
// 如果你觉得这段代码很眼熟?不要奇怪,这是RecursiveAction文档里就有的。
private class SortTask extends RecursiveAction {
/**
* 不进行并行分割排序的阈值。表示小于{@value}的时候不会并行执行。
*/
static final int THRESHOLD = 1000;
private static final long serialVersionUID = 2361239805661299619L;
/**
* 排序对象数组,non-null
*/
final int[] nums;
/**
* 排序对象区间左边界的下标,inclusive。
*/
final int l;
/**
* 排序对象区间有边界的下标,exclusive。
*/
final int r;
/**
* 构造一个排序任务。需要指定排序对象数组,并且指定排序对象区间[l, r)。
* 排序对象数组不能为空,并且区间必须合法(0 <= l < r <= nums.length)。
*
* @param nums 排序对象数组,non-null
* @param l 排序对象区间的左边界下标,inclusive。
* @param r 排序对象区间的右边界下标,exclusive
*/
SortTask(int[] nums, int l, int r) {
assert nums != null;
assert 0 <= l && l < r && r <= nums.length;
// System.out.printf("Sort[%d - %d)\n", l , r);
this.nums = nums;
this.l = l;
this.r = r;
}
@Override
protected void compute() {
final int elementCount = r - l;
if (elementCount < THRESHOLD) {
sortDirectly();
} else {
final int mid = (l + r + 1) / 2;
invokeAll(new SortTask(nums, l, mid),
new SortTask(nums, mid, r));
merge(l, mid, r);
}
}
/**
* 合并已经排序好的左右区间。[l, mid) 与 [mid, r)。<br/>
* 该方法需要额外的½区间大小的辅助数组来帮助合并。
*
* @param l 左区间起始下标。
* @param mid 右区间起始下标。(同时也是左区间的结尾下标。
* @param r 右区间结尾下标。
*/
private void merge(int l, int mid, int r) {
// 辅助数组用于,备份左区间
final int[] leftPartCopy = Arrays.copyOfRange(nums, l, mid);
for (int cursor = l, cursorL = 0, cursorR = mid;
cursorL < leftPartCopy.length ;) {
nums[cursor++] = (cursorR == r || leftPartCopy[cursorL] < nums[cursorR]) ?
leftPartCopy[cursorL++] : // 右区间用光 或 左区间的数较小
nums[cursorR++];
}
}
/**
* 这里笔者偷懒简化了,使用{@link MergeAscendingSort}的mergeSortRecursively方法是一样的。
*/
private void sortDirectly() {
Arrays.sort(nums, l, r);
}
}
}
结语
归并排序,是性能稳定的快速的重要排序算法,因此被广泛使用。在笔者的第一个实现里,可以看到辅助用的内存占到了一倍大小,但是其实可以优化到1.5倍,就像第二个并行版的实现那样,希望本文能帮助你更好的理解归并排序。