前言
本週講解兩個50多年前發明,但今天仍然很重要的經典算法 (歸併排序和快速排序) 之一 -- 歸併排序,幾乎每個軟件系統中都可以找到其中一個或兩個的實現,並研究這些經典方法的新變革。我們的涉及範圍從數學模型中解釋爲什麼這些方法有效到使這些算法適應現代系統的實際應用的細節。
Mergesort。我們研究 mergesort 算法,並證明它保證對 n 項的任何數組進行排序,最多隻能進行 nlgn 次的比較。我們還考慮一個非遞歸的自下而上版本。我們證明,在最壞的情況下,任何基於比較的排序算法必須至少進行 ~nlgn 的比較。我們討論對我們正在排序的對象使用不同的排序以及相關的穩定性概念。
上一篇:基本數據類型
下一篇:快速排序
這章我們討論歸併排序,這是計算基礎中的兩個重要排序算法之一
我們已經對一些算法有了科學全面的認知,這些算法被大量運用在系統排序和應用內排序超過50多年,我們之後所要看到的快速排序更是被在科學和工程中被譽爲20世紀10大算法之一
歸併排序
概貌
所以歸併排序到底是什麼樣的?
基本計劃流程:
- 將陣列分成兩半
- 遞歸排序每一半
- 合併兩半
它的思想其實很簡單, 只要把數組一分爲二, 然後再不斷將小數組遞歸地一分爲二下去, 經過一些排序再將它們合併起來, 這就是歸併排序的大致思想, 這是人們在計算機上實現的最早的算法之一.
(EDVAC 計算機是最早的通用型計算機之一, 馮諾依曼認爲在他的 EDVAC 中需要一種排序算法, 於是他提出了歸併排序, 因此他被公認爲是歸併排序之父)
歸併排序的核心就是“並”。所以要理解如何歸併,先考慮一種抽象的“原位歸併”。
抽象合併演示
目標 給定一個數組,它的前一半(a[lo]-[mid]) 和 後一半([mid + 1]-[hi]) 已是排好序的,我們所要做的就是將這兩個子數組合併成一個大的排好序的數組
看一個演示
1.在排序之前我們需要一個輔助數組,用於記錄數據,這是實現歸併的最簡單的方式
2.首先將原數組中所有東西拷貝進輔助數組,之後我們就要以排好的順序將它們拷貝回原數組
這時我們需要三個下標:i 用於指向左邊子數組;j 指向右邊子數組;k指向原數組即排好序的數組。
3.首先取 i 和 j 所指數字中取其中小的放入原數組k的位置,當一個被拿走之後,拿走位置的指針 (這次是 j) 和 k 遞增
4.同樣取 i 和 j 中小的那個移向 k 的位置,再同時增加移動位置的指針(這次還是 j 和 k)
以此類推。完整演示地址:在此
這就是一種歸併方式: 用了一個輔助數組,將它們移出來又排好序放回去。
這就是歸併部分的代碼,完全依着之前的演示
Java 實現
public class Merge {
private static void merge(Comparable[] a, Comparable[] aux, int lo, int mid, int hi) {
/**
* assertion功能: 方便我們找出漏洞並且確定算法的正確
* 想確定a[lo] 到 a[mid] 和 a[mid+1] 到 a[hi] 是否已是排好序的
*/
assert isSorted(a, lo, mid);
assert isSorted(a, mid + 1, hi);
//拷貝所有東西進輔助數組
for (int k = lo; k <= hi; k++)
aux[k] = a[k];
/**
* 完成歸併
* 初始化 i 在左半邊的最左端
* j 在右半邊最左端
* 指針 k 從 lo 開始
* 比較輔助數組中 i 和 j 誰更小,並將小的那個的值移向 k
**/
int i = lo, j = mid + 1;
for (int k = lo; k <= hi; k++) {
//如果 i 走到邊界了,就只將 j 的值都移上去
if (i > mid) a[k] = aux[j++];
else if (j > hi) a[k] = aux[i++];
else if (less(aux[j], aux[i])) a[k] = aux[j++];
else a[k] = aux[i++];
}
//最後再檢查最終合併後的時候排好序
assert isSorted(a, lo, hi);
}
// 遞歸的 sort 方法
private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi) {
if (hi <= lo) return;
int mid = lo + (hi - lo) / 2;
sort(a, aux, lo, mid);
sort(a, aux, mid + 1, hi);
merge(a, aux, lo, mid, hi);
}
// 對外提供接口中 sort 函數
public static void sort(Comparable[] a) {
//創建輔助數組
Comparable[] aux = new Comparable[a.length];
sort(a, aux, 0, a.length - 1);
}
}
在這個簡單的實現中傳入了 Comparable 類型的原數組 a[] 和 輔助數組 aux[], 還有三個參數 lo, mid, and hi.
lo指向的是兩個將要合併的子數組的頭部 mid指向前一個子數組的末端 所以我們的前提是lo到mid時排好的 從mid+1到hi也是排好的
有了歸併,排序中遞歸的就簡單多了。
sort() 在遞歸調用前先檢查下標,然後像二分查找那樣計算中點值。sort前半部分,再sort後半部分,然後merge
對外提供接口中 sort 函數只接收一個參數,創建輔助數組的任務就交給這個 sort()
這裏關鍵在於不要將輔助數組在遞歸的 sort() 中創建, 因爲那會多出許多額外的小數組的花費, 如果一個歸併排序效率很低通常都是由這引起 這是一個很直接的實現方式。也是依據了我們看到多次的一個思想--分治法:即解決問題時將其一分爲二,分別解決兩個小問題,再將它們合併起來
Assertion
一般來說Java程序員,認爲加入這些 assert 是有益的:
1.幫助我們發現漏洞
2.同時也提示了之間的代碼的功能
這個歸併代碼就是很好的例子,如此以代碼的形式加入 assert 語句表明了接下來你想做什麼,在代碼最後加上 assert 語句表明了你做了什麼。
你不僅確定了代碼的正確,也告訴閱讀代碼的人你所幹的事情。
Java 中 asset 語句接受一個 boolean 值。isSorted 函數前面已經寫過了(請回復 -- 基本排序),如果排好序返回 true,反之返回 false. assert 在驗證到沒正確排序時會拋出異常.
assert 可以在運行時禁用.
這很有用因爲你可以把 asset 語句一直放在代碼中, 編程時供自己所需, 禁用後在最終上線程序中不會有額外代碼。因此 assertion 默認是禁用的。出錯的時候人們還可以啓用assertion然後找到錯誤所在。
java -ea MyProgram //啓用 assertions
java -da MyProgram //禁用 assertions(默認)
所以平時最好像之前的例子那樣加入assert語句,並且不讓他們出現在產品代碼中,而且不要用額外的參數來做檢查。