今天來學習歸併排序算法。
歸併算法的核心思想是分而治之,就是將大問題轉化爲小問題,在解決小問題的基礎上,再去解決大問題。將這句話套用到排序中,就是將一個大的待排序區間分爲小的待排序區間,對小的排序區間進行排序,然後再去解決大區間的排序,而對小區間進行排序的時候可以繼續使用該方法,將小的待排序區間分爲小小的待排序區間… …依次往復。最終將排序區間分到只有一個元素,這個時候,因爲一個元素已經就是排好序的,無需繼續切分了,然後我們再依照此結果去解決大區間的排序。
假設我們現在有[53, 89, 32, 45, 67, 2, 32, 89, 65, 54]
這麼一個數組,我們要對它進行歸併排序(從小到大排序),整體的過程如下圖所示:
整個算法分爲兩大階段,分割階段和歸併階段。
分割階段
[53, 89, 32, 45, 67, 2, 32, 89, 65, 54]
分爲[53, 89, 32, 45, 67]
和[2, 32, 89, 65, 54]
。[53, 89, 32, 45, 67]
分爲[53, 89]
和[32, 45, 67]
,[2, 32, 89, 65, 54]
分爲[2, 32]
和[89, 65, 54]
。- … …
- 數組分割完畢,所有小數組依次爲
[53]
,[89]
,[32]
,[45]
,[67]
,[2]
,[32]
,[89]
,[65]
,[54]
。
歸併階段
[53]
,[89]
歸併爲[53, 89]
,[32]
,[45]
歸併爲[32, 45]
,[2]
和[32]
歸併爲[2, 32]
,[65]
和[54]
歸併爲[54, 65]
(這一步中,[67]
和[89]
沒有歸併,因爲在最後一步分割過程中,它們被單獨分開了)。[32, 45]
和[67]
歸併爲[32, 45, 67]
,[89]
和[54, 65]
歸併爲[54, 65, 89]
。[53, 89]
和[32, 45, 67]
歸併爲[32, 45, 53, 67, 89]
,[2, 32]
和[54, 65, 89]
歸併爲[2, 32, 54, 65, 89]
。[32, 45, 53, 67, 89]
和[2, 32, 54, 65, 89]
歸併爲[2, 32, 32, 45, 53, 54, 65, 67, 89, 89]
(其中兩個32和兩個89,在歸併的過程中保留它們的原始順序)。
整個分而治之的過程我們已經清楚了,可還有一個問題沒有解決,就是具體應該怎麼去歸併呢,即如何將兩個排序子數組(或子區間)合併爲大的排序好的數組(或區間)呢?
我們可以先舉個簡單的例子:現在有[2]
和[1]
兩個數組,我們如何把它們合併爲[1, 2]
整個數組呢?很簡單,我們首先會把這兩個元素取出來,對比一下,取出2
和1
,我們一對比,發現1
小於2
, 所以我們在結果數組中先放入1
,然後再放入2
。可以發現,我們就是將兩個子數組中的元素取出來比較了一下,哪個小就把哪個先放入結果數組中。
從上面的例子中我們可以得到大概的思路就是,針對兩個有序的子數組(或子區間),我們可以從頭依次取兩個子數組(或子區間)的首元素(因爲從小到大排序後首元素肯定最小),然後作對比,把小的元素放入結果數組中,並且這個元素在下次選取的時候剔除,下一個元素也應用同樣的方法得到,放入結果數組中,依次進行,直到兩個數組的元素都取完爲止,如果發現其中一個子數組(或子區間)率先取完,就直接將另外一個子數組(或子區間)中剩下的元素全部放入結果數組中即可。具體步驟描述如下:
- 判斷兩個子數組(或子區間)是否含有剩餘元素,如果都有剩餘元素,進入第2步;如果只有一個有剩餘元素,進入第5步;如果沒有,則退出。
- 取出左子數組(或左子區間)的首個元素和右子數組(或右子區間)的首個元素。
- 兩個元素對比,將小的元素放入結果數組,並且從對應數組中剔除該元素。
- 回到第1步(上一步選中的元素已被剔除)。
- 將剩餘元素直接全部放入結果數組中,退出(因爲元素全部移動完畢)。
其中,剔除這一步在代碼實現中可看成索引的移動。
上述這個過程我們取[53, 89]
和[32, 45, 67]
這兩個子數組的合併來描述一下,如圖所示:
- 取出左子數組中的首個元素
53
和右子數組中的首個元素32
,兩個作對比,發現32 < 53
,所以我們將32
放入結果數組:
- 取出左子數組中的首個元素
53
和右子數組中的首個元素45
,兩個作對比,發現45 < 53
,所以我們將45
放入結果數組:
- 取出左子數組中的首個元素
53
和右子數組中的首個元素67
,兩個作對比,發現53 < 67
,所以我們將53
放入結果數組:
- 取出左子數組中的首個元素
89
和右子數組中的首個元素67
,兩個作對比,發現67 < 89
,所以我們將67
放入結果數組:
- 此時我們發現只有左子數組存在元素,所以直接將左子數組的剩下所有元素,此時只有
89
放入結果數組:
- 至此,所有元素移動完畢,退出。
通過以上分析,我們可以知道整個歸併排序算法總體上分爲一個整體的大邏輯(分而治之)和一個局部的小邏輯(歸併),在大邏輯(分而治之,將整個數組切分,並在確認子數組有序後歸併)的基礎上,結合使用小邏輯(歸併,將兩個有序子數組歸併爲一個大的有序數組)即可實現對整個數組的排序。
最終代碼實現如下:
/**
* 數組的歸併排序算法
*
* @param nums 數組
* @param lo 區間的lo索引(包含)
* @param hi 區間的hi索引(不包含)
*/
public static void mergeSort(int[] nums, int lo, int hi) {
// 數組爲null則直接返回
if (nums == null) {
return;
}
// 索引檢查
if (lo < 0 || nums.length <= lo) {
throw new IllegalArgumentException("lo索引必須大於0並且小於數組長度,數組長度:" + nums.length);
}
if (hi < 0 || nums.length < hi) {
throw new IllegalArgumentException("hi索引必須大於0並且小於等於數組長度,數組長度:" + nums.length);
}
if (hi <= lo) {
// lo索引必須小於hi索引(等於也不行,因爲區間是左閉右開,如果等於,區間內元素數量就爲0了)
throw new IllegalArgumentException("lo索引必須小於hi索引");
}
if (lo + 1 >= hi) {
// 區間元素個數最多爲1
// 無需排序
return;
}
int mid = (lo + hi) / 2;
// 對左子區間排序
mergeSort(nums, lo, mid);
// 對右子區間排序
mergeSort(nums, mid, hi);
// 對兩個排序好的子區間歸併,得到一個整體有序的區間
merge(nums, lo, mid, hi);
}
public static void merge(int[] nums, int lo, int mid, int hi) {
// 這裏不用檢查索引,調用方已經決定了索引是有效的
// 結果區間和右子區間使用原有數組
// 左子區間使用臨時數組(因爲結果區間可能會覆蓋左子區間的元素,所以需要開闢新數組保存)
int leftLen = mid - lo;
int[] left = new int[leftLen];
System.arraycopy(nums, lo, left, 0, leftLen);
// 左子區間索引
int leftIdx = 0;
// 右子區間索引
int rightIdx = mid;
// 結果區間索引
int resultIdx = lo;
while (true) {
if (leftIdx < leftLen && rightIdx < hi) {
// 兩個子區間都存在元素
// 取兩個子區間的有效首元素對比
if (left[leftIdx] <= nums[rightIdx]) {
// 左子區間首元素小於右子區間首元素
// 將左子區間首元素放到結果位置,同時更新索引位置
nums[resultIdx++] = left[leftIdx++];
} else {
// 右子區間首元素小於左子區間首元素
// 將右子區間首元素放到結果位置,同時更新索引位置
nums[resultIdx++] = nums[rightIdx++];
}
} else {
if (leftIdx < leftLen) {
// 左子區間還有剩餘元素
// 直接將左區間所有元素一起移動到結果位置
System.arraycopy(left, leftIdx, nums, resultIdx, leftLen - leftIdx);
} else {
// 右子區間還有剩餘元素
// 因爲經過上一次判斷,左子區間和右子區間只會有一個存在剩餘元素
// 直接將右區間所有元素一起移動到結果位置
System.arraycopy(nums, rightIdx, nums, resultIdx, hi - rightIdx);
}
// 全部元素移動完畢,退出
break;
}
}
}
測試代碼如下:
List<Integer> numList = IntStream.range(0, 10).boxed().collect(Collectors.toList());
for (int i = 1; i <= 5; i++) {
System.out.println("================第" + i + "次================");
Collections.shuffle(numList);
int[] nums = new int[numList.size()];
for (int j = 0; j < nums.length; j++) {
nums[j] = numList.get(j);
}
System.out.println("排序前:" + Arrays.toString(nums));
mergeSort(nums, 0, numList.size());
System.out.println("排序後:" + Arrays.toString(nums));
}
運行結果如下:
================第1次================
排序前:[8, 4, 1, 6, 7, 0, 5, 9, 2, 3]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
================第2次================
排序前:[2, 5, 6, 7, 9, 4, 3, 1, 0, 8]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
================第3次================
排序前:[2, 0, 5, 6, 7, 3, 4, 9, 8, 1]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
================第4次================
排序前:[4, 0, 3, 8, 1, 5, 9, 7, 2, 6]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
================第5次================
排序前:[7, 9, 8, 2, 0, 5, 6, 3, 4, 1]
排序後:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
測試代碼中5次隨機將數組打亂,然後運行我們的歸併排序算法,均得到有序結果,符合我們的預期。