歸併排序與快速排序的簡明實現及對比

天方夜發表於2017-12-08

前言

歸併排序與快速排序是兩種有實際應用的排序演算法,它們有一些共同的特點,整體思路上也比較相近。本文會從更簡單的一些排序演算法開始,過渡到歸併排序和快速排序的實現,並對它們做一些簡單的對比思考和總結。在這之前,先簡單介紹一下排序演算法的意義。

排序演算法就是將一串資料依照特定排序方式進行排列,它們在電腦科學中有大量研究以及應用。

想象一下下列場景:

  1. 從通訊錄中尋找某個聯絡人
  2. 從一大堆檔案中尋找某個檔案
  3. 到了影廳之後,尋找電影票上指定的座位

如果以上情況中,聯絡人、檔案、影廳座位這些“資料”沒有按照需要的順序組織,如何找到想要的特定“資料”呢?會非常麻煩!所以說,對於需要搜尋的資料,往往應該先排個序!

熱身一:選擇排序

本文的示例都是數值排序,對於這個問題,最簡單直觀的方法是:先找出最小的、再找出第二小的、接著找出第三小的……這就是選擇排序的思路。

function selectionSort(array) {
  const len = array.length;

  for (let i = 0; i < len - 1; i++) {
    let min = i;

    for (let j = i + 1; j < len; j++) {
      if (array[min] > array[j]) {
        min = j;
      }
    }

    [ array[min], array[i] ] = [ array[i], array[min] ];
  }

  return array;
}

實現解析:

  1. 遍歷陣列
  2. 找到當前範圍內最小的元素,用 minIndex 記錄它的下標,第一次遍歷時範圍就是整個陣列
  3. 將下標為 minIndex 的元素的值與當前最小下標的元素交換,第一次遍歷時下標最小的元素就是 a[0]
  4. 第二次遍歷時,範圍就從第二個資料元素的下標開始,那麼當前最小下標元素就是 a[1]
  5. 重複交換直至遍歷結束

用一段輔助程式碼,做一些展示用的示例。

function createUnsortedArray(size) {
  const array = [];

  for (let i = size; i > 0; i--) {
    const num = (i / 10 > 1) ? i : 10;
    array.push( Math.round(Math.random(i) * num + Math.round(Math.random(i)) * Math.random(i) * num * 10) );
  }

  return array;
}

function show(fn, size = 11) {
  console.log('------------------------------------------');
  console.log(`Method: ${fn.name}`);
  console.log('------------------------------------------');
  const array = createUnsortedArray(size);
  console.log('before:');
  console.log(array.toString());
  console.log('after:');
  console.log(fn(array).toString());
}

先建立一個隨機生成的未排序的陣列,然後列印結果。

show(selectionSort);

// ------------------------------------------
// Method: selectionSort
// ------------------------------------------
// before:
// 9,22,3,27,74,54,8,41,80,74,3
// after:
// 3,3,8,9,22,27,41,54,74,74,80

熱身二:氣泡排序

氣泡排序與選擇排序有些類似,區別在於氣泡排序是先將最大值冒泡到最後的位置。早在 1956 年,就已經有人研究氣泡排序。

function bubbleSort(array) {
  for (let first = 0, len = array.length; first < len; first++) {
    let isSorted = true;

    for (let second = 0; second < len - first - 1; second++) {
      if (array[second] > array[second + 1]) {
        let temp = array[second];
        array[second] = array[second + 1]
        array[second + 1] = temp;
        isSorted = false;
      }
    }

    if (isSorted) {
      break;
    }
  }

  return array;
}

show(bubbleSort);

// ------------------------------------------                                      
// Method: bubbleSort                                                              
// ------------------------------------------                                      
// before:                                                                         
// 35,8,2,2,8,1,3,4,2,10,4                                                         
// after:                                                                          
// 1,2,2,2,3,4,4,8,8,10,35

實現解析:

  1. 遍歷陣列
  2. 做第二層遍歷,從前到後依次對比相鄰兩項,前一項的值大於後一項,則交換(冒泡)。第一遍冒泡,將最大的元素值冒泡至最後
  3. 由於每一遍冒泡都確定一個當前最大值並放到當前範圍的最後的位置,每一遍的冒泡就可以少檢查一個位置
  4. 可以使用一個變數記錄當前一遍的冒泡有沒有產生元素交換,如果沒有,說明當前已經是排序完成的狀態,終止迴圈

熱身三:插入排序

插入排序的思想在日常生活其實很常見,例如如何排定盧俊義的座次?綜合出身、能力、江湖地位、形勢人心等各項指標,他在梁山泊排名第二,地位僅次於宋江。這就是插入排序的思路。資料量很小,或類似“給盧俊義排座次”這種在已排序資料中增加一條資料的情況,插入排序優於本文提到的其他排序方式。

function insertionSort(array) {
  for (let i = 0, len = array.length; i < len; i++) {
    let j = i;
    const temp = array[i]

    while (j > 0 && array[j - 1] > temp) {
      array[j] = array[j - 1];
      j--;
    }

    array[j] = temp;
  }

  return array;
}

show(insertionSort);

// ------------------------------------------                                      
// Method: insertionSort                                                              
// ------------------------------------------                                      
// before:
// 3,8,68,30,28,56,35,30,2,4,13
// after:
// 2,3,4,8,13,28,30,30,35,56,68

實現解析:

  1. 從第一個元素開始,該元素可以認為已經被排序
  2. 取出下一個元素,在已經排序的元素序列中從後向前掃描
  3. 如果該元素(已排序)大於新元素,將該元素移到下一位置
  4. 重複步驟3,直到找到已排序的元素小於或者等於新元素的位置
  5. 將新元素插入到該位置後
  6. 重複步驟2~5

歸併排序(遞迴實現)

選擇排序和氣泡排序的時間複雜度都是 O(n^2),很少用在實際工程中;歸併排序的時間複雜度是 O(nlog(n)),是實際工程中可選的排序方案。

function mergeSort(unsorted) {
  function merge(leftArr, rightArr) {
    const lenL = leftArr.length;
    const lenR = rightArr.length;
    let indexL = 0;
    let indexR = 0;
    const result = [];

    while (indexL < lenL && indexR < lenR) {
      if (leftArr[indexL] < rightArr[indexR]) {
        result.push(leftArr[indexL++]);
      } else {
        result.push(rightArr[indexR++]);
      }
    }

    while (indexL < lenL) {
      result.push(leftArr[indexL++]);
    }

    while (indexR < lenR) {
      result.push(rightArr[indexR++]);
    }

    return result;
  }

  function split(array) {
    const len = array.length;

    if (len <= 1) {
      return array;
    }

    const mid = Math.floor(len / 2);

    const leftArr = array.slice(0, mid);
    const rightArr = array.slice(mid, len);

    return merge( split(leftArr), split(rightArr) );
  }

  return split(unsorted);
}

show(mergeSort);

// ------------------------------------------
// Method: mergeSort
// ------------------------------------------
// before:
// 86,55,0,31,104,6,5,49,89,19,6
// after:
// 0,5,6,6,19,31,49,55,86,89,104

實現分析:

  1. 將陣列從中間切分為兩個陣列
  2. 切分到最小之後,開始歸併操作,即合併兩個已排序的陣列
  3. 遞迴合併的過程,由於是從小到大合併,所以待合併的兩個陣列總是已排序的,一直做同樣的歸併操作就可以

快速排序(遞迴實現)

快速排序是實際應用非常多的排序演算法,它通常比其他 O(nlog(n)) 時間複雜度的演算法更快。

function quickSort(unsorted) {
  function partition(array, left, right) {
    const pivot = array[ Math.floor((left + right) / 2) ];

    while (left <= right) {
      while (array[left] < pivot) {
        left++;
      }

      while (array[right] > pivot) {
        right--;
      }

      if (left <= right) {
        [array[left], array[right]] = [array[right], array[left]];
        left++;
        right--;
      }
    }

    return left;
  }

  function quick(array, left, right) {
    if (array.length <= 1) {
      return array;
    }

    const index = partition(array, left, right);

    if (left < index - 1) {
      quick(array, left, index - 1);
    }

    if (right > index) {
      quick(array, index, right);
    }

    return array;
  }

  return quick(unsorted, 0, unsorted.length - 1);
}

show(quickSort);

// ------------------------------------------
// Method: quickSort
// ------------------------------------------
// before:
// 41,9,22,4,1,32,10,28,4,94,3
// after:
// 1,3,4,4,9,10,22,28,32,41,94

實現分析:

  1. 將當前陣列分割槽
  2. 分割槽時先選擇一個基準值,再建立兩個指標,左邊一個指向陣列第一個項,右邊一個指向陣列最後一個項。移動左指標直至找到一個比基準值大的元素,再移動右指標直至找到一個比基準值小的元素,然後交換它們,重複這個過程,直到左指標的位置超過了右指標。如此分割槽、交換使得比基準值小的元素都在基準值之前,比基準值大的元素都在基準值之後,這就是分割槽(partition)操作。
  3. 對於上一次分割槽後的兩個區域重複進行分割槽、交換操作,直至分割槽到最小。

對比歸併排序與快速排序

  1. 都用了分治的思想。相比選擇排序和氣泡排序,歸併排序與快速排序使用了切分而不是直接遍歷,這有效減少了交換次數。
  2. 歸併排序是先切分、後排序,過程可以描述為:切分、切分、切分……排序、排序、排序……
  3. 快速排序是分割槽、排序交替進行,過程可以描述為:分割槽、排序、分割槽、排序……
  4. 上兩條所說的“排序”,在歸併排序與快速排序中並非同樣的操作,歸併排序中的操作是將兩個陣列合併為一(歸併操作),而快速排序中的操作是交換。

參考資料

  1. 學習JavaScript資料結構與演算法(第2版)
  2. Sorting algorithm

相關文章