[排序算法] 歸併排序的原理及其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倍,就像第二個並行版的實現那樣,希望本文能幫助你更好的理解歸併排序。

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