背景
繼上一篇《插入排序》之後的第四篇,筆者準備在本篇介紹歸併排序。
歸併排序 (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倍,就像第二個並行版的實現那樣,希望本文能幫助你更好的理解歸併排序。