《Algorithms Unlocked》讀書筆記2——二分查找和排序算法

《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;
}

插入排序與選擇排序時間差不多,如果移動操作太過耗時最好用選擇排序。插入排序適用於數組一開始就已經“基本有序”的狀態。

歸併排序

歸併排序中使用一個被稱爲分治法的通用模式。在分治法中,我們將原問題分解爲類似原問題的子問題,並遞歸的求解這些子問題,然後再合併這些子問題的解來得出原問題的解。

  1. 分解:把一個問題分解爲多個子問題,這些子問題是更小實例上的原問題。
  2. 解決:遞歸地求解子問題。當子問題足夠小時,按照基礎情況來求解。
  3. 合併:把子問題的解合併成原問題的解。

在歸併排序中,我們把數組不斷用二分法分解成兩個小數組,直到每個數組只剩一個元素(基礎情況)。再把小數組排好序並進行合併。

mergesort1

// 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中的“第一本”進行比較,此時的“第一本”不一定是剛纔的第一本了,因爲已經有一本書放入書架了,不過該書堆的“第一本”任然是該書堆中最小的一本。直到把兩堆書全部放入書架。

mergesort2

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;
}

由於歸併排序不是在原址上工作,需要拷貝出子數組,如果你的儲存空間較小或空間非常寶貴,可能不適合使用歸併排序。

快速排序

與歸併排序類似,快速排序也是使用分治模式。與歸併排序不同的是,快速排序是在原址上工作的,歸併排序是拷貝出兩個子數組進行操作並不在原址上工作。

在書架中隨機挑選一本書作爲主元(這裏我們總是選擇位於書架最末尾的那本書),所有小於主元的書放在主元左側,所有大於或等於主元的書放在主元右側,這時就把書分爲左右兩組(不包括主元),再分別對這兩組書進行相同的操作(遞歸),直到子數組只剩一本書觸發基礎情況。

quicksort1

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的分割線向右移一位。如下圖

quicksort2

// 主元:數組中隨機挑選單獨的一個數(這裏我們總是選數組中的最後一位)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;
}

本例的快速排序總是選擇最末尾的元素作爲主元,稱爲確定的快速排序。如果每次選擇主元時都從數組中隨機選擇,則稱爲隨機快速排序,隨機快速排序在測試中會快於確定的快速排序。

根據數據量的不同,儲存空間的大小,存儲速度的快慢,每個排序方法都有不同的表現,並不是說哪個方法一定是最快的,也不一定最快就是最好的,合適纔是最好的。

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