[排序算法] 归并排序的原理及其Java实现

背景


继上一篇《插入排序》之后的第四篇,笔者准备在本篇介绍归并排序。

归并排序 (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倍,就像第二个并行版的实现那样,希望本文能帮助你更好的理解归并排序。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章