《Algorithms Unlocked》是 《算法導論》的合著者之一 Thomas H. Cormen 寫的一本算法基礎,算是啃CLRS前的開胃菜和輔助教材。如果CLRS的厚度讓人望而生畏,這本200多頁的小讀本剛好合適帶你入門。
書中沒有涉及編程語言,直接用文字描述算法,我用 JavaScript 對書中的算法進行描述。
二分查找
在排好序的數組中查找目標值x。在p到r區間中,總是取索引爲q的中間值與x進行比較,如果array[q]大於x,則比較p到q-1區間,否則比較q+1到r區間,直到array[q]等於x或p>r。
// 利用二分法在已經排好序的數組中查找值x
function binarySearch(array, x) {
let p = 1;
let r = array.length - 1;
while (p <= r) {
let q = Math.round((p + r) / 2); //四捨五入取整
if (array[q] === x) {
return q;
} else {
if (array[q] > x) {
// 如果q沒有減一,遇到找不到x的情況,
// 就會陷入while循環中出不來,因爲p會一直等於r
r = q - 1;
} else {
p = q + 1;
}
}
}
return 'NOT-FOUND';
}
也可以把二分查找寫成遞歸風格。
// 二分法遞歸風格
function recursiveBinarySearch(array, p, r, x) {
if (p > r) { // 基礎情況
console.log('NOT-FOUND');
return;
}
let q = Math.round((p + r) / 2);
if (array[q] === x) { // 基礎情況
console.log(q);
return;
} else {
if (array[q] > x) {
recursiveBinarySearch(array, p, q-1, x);
} else {
recursiveBinarySearch(array, q+1, r, x);
}
}
}
排序
選擇排序
從第一個元素開始遍歷,把該元素跟在它之後的所有元素進行比較,選出最小的元素放入該位置。
以書架上的書本排序爲例。我們看一眼書架上的第一本書的書名,接着與第二本進行比較,如果第二本書的書名第一個字母的順序小於第一本,那我們忘掉第一本書的書名,記下第二本書的書名,此時我們並沒有對書籍進行移動,只是比較了書名的順序,並把順序最小的書名記在腦子裏。直到與最後一本進行比較結束,我們把腦子裏順序最小的書名對應的書與第一本書對調了一下位置。
function selectionSort (array) {
for (let i = 0; i < array.length - 1; i++) {
let smallest = i;
let key = array[i]; // 保存當前值
for (let j = i + 1; j < array.length; j++) {
// 比較當前值和最小值,如果當前值小於最小值則把當前值的索引賦給smallest
if (array[j] < array[smallest]) {
smallest = j;
}
}
// 最小值和當前值交換
array[i] = array[smallest];
array[smallest] = key;
}
return array;
}
選擇排序效率很低,因爲選擇排序進行了較多的比較操作,但移動元素的操作次數很少。所以當遇到移動元素相當耗時——或者它們所佔空間很大或者它們存儲在一個存儲較慢的設備中——那麼選擇排序可能是一個合適的算法。
插入排序
以書架爲例,假設前4個位置已經排好序了,我們拿起第五本書與第四本進行比較,如果第四本大於第五本,把第四本向右移動一個位置,再把第三本與第五本進行比較,如果第三本還大於第五本,把第三本向右移動一個位置,剛好放入第四本空出來的位置。直到遇到一本小於第五本的書或者已經沒有書可以比較了,把第五本書插入小於它的那本書的後面。
function insertionSort (array) {
for (let i = 1; i < array.length; i++) {
let key = array[i]; // 把當前操作值保存到key中
let j = i - 1; // j 爲當前值的前一位
// 在j大於等於0且前一位大於當前值時,前一位向右移動一個位置
while (j >= 0 && array[j] > key) {
array[j+1] = array[j];
j -= 1;
};
// 直到遇到array[j]小於當前操作值或者j小於0時,把當前值插入所空出來的位置
array[j+1] = key;
}
return array;
}
插入排序與選擇排序時間差不多,如果移動操作太過耗時最好用選擇排序。插入排序適用於數組一開始就已經“基本有序”的狀態。
歸併排序
歸併排序中使用一個被稱爲分治法的通用模式。在分治法中,我們將原問題分解爲類似原問題的子問題,並遞歸的求解這些子問題,然後再合併這些子問題的解來得出原問題的解。
- 分解:把一個問題分解爲多個子問題,這些子問題是更小實例上的原問題。
- 解決:遞歸地求解子問題。當子問題足夠小時,按照基礎情況來求解。
- 合併:把子問題的解合併成原問題的解。
在歸併排序中,我們把數組不斷用二分法分解成兩個小數組,直到每個數組只剩一個元素(基礎情況)。再把小數組排好序並進行合併。
// array: 數組
// p: 開始索引
// r: 末尾索引
function mergeSort (array, p, r) {
if (p >= r) {
return;
} else {
// 不可以用四捨五入,找了一夜的bug竟然是因爲四捨五入這個小蹄子
let q = Math.floor((p + r) / 2);
// 遞歸調用,把數組拆分成兩部分,直到每個數組只剩一個元素
mergeSort(array, p, q);
mergeSort(array, q + 1, r);
// 把兩個子數組排序併合並
merge(array, p, q, r);
}
return array;
}
程序的真正工作發生在 merge
函數中。歸併排序不是原址的。
假設有兩堆已經排好序的書,書堆A和書堆B。把A中的第一本與B中的第一本拿起來比較,小的那本放入書架中,再把A中的“第一本”和B中的“第一本”進行比較,此時的“第一本”不一定是剛纔的第一本了,因爲已經有一本書放入書架了,不過該書堆的“第一本”任然是該書堆中最小的一本。直到把兩堆書全部放入書架。
function merge (array, p, q, r) {
let n1 = q - p + 1; // 子數組的長度
let n2 = r - q;
// 把兩個子數組拷貝到B、C數組中
// slice不包含end參數,所以end參數要加一
let arrB = array.slice(p, q + 1);
let arrC = array.slice(q + 1, r + 1);
// 兩個數組的最後一個元素設爲無窮大值,確保了無需再檢查數組中是否有剩餘元素
arrB[n1] = Number.MAX_VALUE;
arrC[n2] = Number.MAX_VALUE;
// 因爲回填入原數組的個數是固定的,所以無窮大值不會被填入,也無需判斷是否有剩餘
// 一旦B、C兩個數組中的所有元素拷貝完就自動終止
// 因爲B、C中的元素已經按照非遞減順序排好了,所以最小索引值對應的就是最小值
// 兩個子數組的最小值比較,小的則爲當前最小值
let i = j = 0;
for (let k = p; k < r + 1; k++) {
if (arrB[i] < arrC[j]) {
array[k] = arrB[i];
i++;
} else {
array[k] = arrC[j];
j++;
}
}
return;
}
由於歸併排序不是在原址上工作,需要拷貝出子數組,如果你的儲存空間較小或空間非常寶貴,可能不適合使用歸併排序。
快速排序
與歸併排序類似,快速排序也是使用分治模式。與歸併排序不同的是,快速排序是在原址上工作的,歸併排序是拷貝出兩個子數組進行操作並不在原址上工作。
在書架中隨機挑選一本書作爲主元(這裏我們總是選擇位於書架最末尾的那本書),所有小於主元的書放在主元左側,所有大於或等於主元的書放在主元右側,這時就把書分爲左右兩組(不包括主元),再分別對這兩組書進行相同的操作(遞歸),直到子數組只剩一本書觸發基礎情況。
function quickSort (array, p, r) {
if (p >= r) {
return;
} else {
let q = partition(array, p, r);
// 遞歸中不再包含array[q],因爲它已經處在正確的位置(左邊所有元素都小於它,右邊所有元素都大於或等於它)
// 如果遞歸調用還包含array[q],就會陷入死循環
quickSort(array, p, q - 1);
quickSort(array, q + 1, r);
}
return array;
}
重要的操作都在 partition
函數中。這個函數把數組按照大於或小於主元分爲左右兩堆,並返回主元所在位置的索引q。注意,左右兩堆數組並不是有序的(見上圖),只是大於或小於主元。
在書架中隨機挑選一本書作爲主元(這裏我們總是選擇位於書架最末尾的那本書),此時主元位於最末尾。還未進行比較的爲未知組,稱爲組U,位於主元左側。小於主元的稱爲組L,位於書架最左側。大於或小於主元的稱爲組R,位於組L左側組U右側。如下圖。
我們拿起組U中最左側的那本書,與主元進行比較,如果小於主元則放入組L,大於或等於主元則放入組R。放入組R的操作比較簡單,只需要把組R和組U的分割線往右移一位,無需移動書籍。
放入組L的操作則比較複雜。我們將它與組R中最左側的書籍進行調換,並將組L和組R之間的分割線向右移一位,將組R和組U的分割線向右移一位。如下圖
// 主元:數組中隨機挑選單獨的一個數(這裏我們總是選數組中的最後一位)array[r]
// 組L(左側組):所有小於主元的數,array[p...q-1]
// 組R(右側組):所有大於或等於主元的數,array[q...u-1]
// 組U(未知組):還未進行比較的數,array[u...r-1]
function partition(array, p, r) {
let q = p;
// 遍歷array[p...r-1]
for (let u = p; u < r; u++) {
// 如果未知數小於主元,放入組L
if (array[u] < array[r]) {
// 把未知數和組R最左側值(array[q])進行交換,並讓q和u往右移一位(加1)
let key = array[q];
array[q] = array[u];
array[u] = key;
q += 1;
}
// 如果未知數大於或等於主元,放入組R
// 無需其他操作,只需要把u往右移一位
}
// 把主元和組R最左側值(array[q])進行交換,讓主元位於組L合組R中間
let key = array[q];
array[q] = array[r];
array[r] = key;
return q;
}
本例的快速排序總是選擇最末尾的元素作爲主元,稱爲確定的快速排序。如果每次選擇主元時都從數組中隨機選擇,則稱爲隨機快速排序,隨機快速排序在測試中會快於確定的快速排序。
根據數據量的不同,儲存空間的大小,存儲速度的快慢,每個排序方法都有不同的表現,並不是說哪個方法一定是最快的,也不一定最快就是最好的,合適纔是最好的。