Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

一起用 JavaScript 來複習經典排序法吧! #22

Open
aszx87410 opened this issue Dec 3, 2018 · 5 comments
Open

一起用 JavaScript 來複習經典排序法吧! #22

aszx87410 opened this issue Dec 3, 2018 · 5 comments
Labels
Algorithm Algorithm JS JavaScript

Comments

@aszx87410
Copy link
Owner

前言

最近剛好上到 CS50 Week3,這一週的主題是:Algorithms,裡面介紹到了幾種經典的排序法,像是選擇排序、泡沫排序、插入排序以及合併排序。

我覺得身為一個軟體工程師,大概一輩子都脫離不了排序了,畢竟這是經典演算法之一嘛!與其每次要面試之前都凌亂的準備,不如現在就整理出一篇,紀錄一下各個排序法的心得,幫自己做個統整。

因此,這一篇將利用 JavaScript 來實作各個經典排序演算法。

這次實做的排序法都會是由小到大排序,並且為了方便起見,每一個排序法「都會直接更改原本的 array」,但如果你不想改到原本的也很簡單,在每一個的函式最開頭加上:arr = arr.slice()複製一份原本的即可。

還有,因為文章裡面比較難放動畫,所以我只能放一些圖片而已,若是想搭配視覺化演算法一起學習的話,我非常推薦 VISUALGO,這網站絕對會讓你對排序的理解度更上一層樓。

選擇排序法(Selection Sort)

選擇排序是我認為最好理解的排序法,因為它的原理超級簡單:

找到最小值,移到最左邊。

當你第一輪跑完之後,你就找到整個陣列的最小值了,然後你把尋找範圍從 0 ~ n-1 變成 1 ~ n-1,重複做一樣的事情就可以了。或是,你也可以想成是:找到最小值,第二小值,第三小值...第 n 小值。

selection

(圖片來源:http://cheetahonfire.blogspot.sg/2009/05/selection-sort-vs-insertion-sort.html

const selectionSort = (arr) => {
  const length = arr.length;
  
  // 有幾個元素,就要找幾輪的最小值
  // 這邊的 i 代表 i 以前的元素都排序好了
  for (let i = 0; i < length; i++) {
  
    // 先預設第一個是最小的
    let min = arr[i];
    let minIndex = i;
  
    // 從還沒排好的元素開始找最小值
    for (let j = i; j < length; j++) {
      if (arr[j] < min) {
        min = arr[j];
        minIndex = j;
      }
    }
  
    // ES6 的用法,交換兩個數值
    [arr[minIndex], arr[i]] = [arr[i], arr[minIndex]];
  }
  return arr;
}

時間複雜度就是大家所熟知的 O(n^2),最好、最壞、平均都是一樣的,因為無論原本的陣列長怎樣,都要經過這麼多輪比較。

泡沫排序法(Bubble Sort)

泡沫排序應該是很多人第一個接觸的排序法,原理也很簡單好懂:

跟隔壁互相比較,順序錯了就交換,讓大的元素一直浮到最後

就是這樣交換的過程,才讓它稱為「泡沫」排序法,因為元素很像「浮」了上來。

bubble

(圖片來源:http://www.opentechguides.com/how-to/article/c/51/bubble-sort-c.html

const bubbleSort = (arr) => {
  const n = arr.length;
  
  // 一共要跑 n 輪
  for (let i = 0; i < n; i++) {
  
    // 從第一個元素開始,不斷跑到第 n - 1 - i 個
    // 原本是 n - 1,會再加上 - i 是因為最後 i 個元素已經排好了
    // 所以沒必要跟那些排好的元素比較
    for (let j = 0; j < n - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  
  return arr;
}

雖然泡沫排序法的平均跟最壞時間複雜度都是O(n^2),但值得注意的是 best case,出現在輸入的陣列已經是排序好的情況下。在這種情況下呢,時間複雜度是 O(n),不會做任何的交換。

但是呢,如果你要做到最優的情形是 O(n),你必須要加上一個小優化才行。不然以我們上面的情況,雖然不會做任何交換,但還是會把每一個元素都 check 一遍。

可以加上一個 flag 標注內圈有沒有交換的情形發生,如果沒有,就代表陣列已經排序好了,就可以直接跳掉。

function optimzedBubbleSort = (arr) => {
  const  n = arr.length;
  let swapped = true;
  
  // 一共要跑 n 輪
  for (let i = 0; i < n && swapped; i++) {
  
    // 從第一個元素開始,不斷跑到第 n - 1 - i 個
    // 原本是 n - 1,會再加上 - i 是因為最後 i 個元素已經排好了
    // 所以沒必要跟那些排好的元素比較
    swapped = false;
    for (let j = 0; j < n - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        swapped = true;
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  return arr;
}

改良之後,如果輸入是已經排好的陣列,就只會跑一次內圈,然後就跳掉了,所以時間複雜度會是O(n)

插入排序法(Insertion Sort)

插入排序法是我認為相當直覺的一個排序法,簡單來說就是:

你玩撲克牌的時候會用到的排序法

就是不斷把撲克牌插入到適合的位置嘛,只是你玩牌的時候可能一次插入好多牌,而插入排序法是一次插入一張牌。

insertion

(圖片來源:https://commons.wikimedia.org/wiki/File:Insertion-sort-example.gif

這邊比較值得注意的是在插入時候的演算法,不斷往前找到適合的位置,並且在邊找的時候就邊挪動元素了,所以等找到的時候就可以直接插入。

const insertionSort = (arr) => {
  const n = arr.length;
  
  // 假設第一個元素已經排好,所以從 1 開始跑
  for (let i = 1; i < n; i++) {
  
    // position 表示可以插入的位置
    let position = i;
  
    // 先把要插入的元素存起來
    const value = arr[i];
  
    // 開始往前找,只要符合這條件就代表這個位置是可以插入的
    // 邊找的時候就可以邊把元素往後挪,騰出空間
    while (i >= 0 && arr[position - 1] > value) {
      [arr[position], arr[position - 1]] = [arr[position - 1], arr[position]];
      position--;
    }
  
    // 找到適合的位置,放入元素
    arr[position] = value;
  }
  return arr;
}

插入排序法的最佳情形出現在輸入元素已經是排序好的情況,這時候裡面的while只要跑一次就會結束了,所以時間複雜到就是外圈的O(n)而已。

這邊提一個小插曲,我當初在寫示範跟測試的程式碼的時候沒寫好,導致拿來測試的陣列都是已經排好的,我就想說:「怎麼插入排序法比快速排序法還快,不合理啊!」

合併排序法(Merge Sort)

接著要進入到比較快的排序法了,合併排序法算是滿好理解的一個:

切一半,排好左邊,排好右邊,合併

談合併排序法的時候我喜歡先談合併這個步驟,其實就是把兩個各自排序好的陣列合併成一個。這一步其實也滿簡單,因為兩邊都已經排序好了嘛,所以就是不斷看兩邊的第一個元素,誰小就抓誰下來,接著左邊抓完就抓右邊,反之亦然。

merge

(圖片來源:http://www.java2novice.com/java-sorting-algorithms/merge-sort/

我自己之前在看合併排序的時候,發現可以寫成一個比較好懂,但是空間耗費比較多的版本:

const simpleMergeSort = (arr) => {
  
  // 合併
  const merge = (leftArray, rightArray) => {
    let result = [];
    let nowIndex = 0, left = 0, right = 0;
    const leftLength = leftArray.length;
    const rightLength = rightArray.length;
  
    // 如果左右兩邊都沒抓完,就看誰比較小抓誰
    while (left < leftLength && right < rightLength) {
      if (leftArray[left] < rightArray[right]) {
        result[nowIndex++] = leftArray[left++];
      } else {
        result[nowIndex++] = rightArray[right++];
      }
    }
  
    // 跑到這裡代表左右兩邊其中一邊抓完了
    // 如果是左邊沒抓完,全部抓下來
    while (left < leftLength) {
      result[nowIndex++] = leftArray[left++];
    }
  
    // 右邊沒抓完,全部抓下來
    while (right < rightLength) {
      result[nowIndex++] = rightArray[right++];
    }
  
    // 把合併好的陣列直接傳回去
    return result;
  }
  const _mergeSort = (arr) => {
    const length = arr.length;
    if (length <= 1) return arr;
  
    // 切兩半
    const middle = Math.floor(length / 2);
  
    // 排左邊
    const leftArray = _mergeSort(arr.slice(0, middle));
  
    // 排右邊
    const rightArray = _mergeSort(arr.slice(middle, length));
  
    // 合併後丟回去
    return merge(leftArray, rightArray);
  }
  return _mergeSort(arr);
}

對我來說,比較簡單的理由是滿直覺的,你就直接用 slice 切成兩個陣列,排序好之後合併起來就好。

但比較省空間的做法是直接更改原來的陣列就好,這時候我們的參數會變得不太一樣:

function mergeSort = (arr) => {
  const merge = (array, start, middle, end) => {  
  
    // 宣告一個暫時的陣列來放合併後的結果
    let temp = [];
    let nowIndex = 0;
    let left = start;
    let right = middle + 1;
  
    // 這邊都跟上面一樣
    while (left <= middle && right <= end) {
      if (array[left] < array[right]) {
        temp[nowIndex++] = array[left++];
      } else {
        temp[nowIndex++] = array[right++];
      }
    }
  
    while (left <= middle) {
      temp[nowIndex++] = array[left++];
    }
  
    while (right <= end) {
      temp[nowIndex++] = array[right++];
    }
  
    // 要把合併後的陣列放回去 array[start ~ end]
    for (let i = start; i <= end; i++) {
      array[i] = temp[i - start];
    }
  }

  // 代表要從 start 排到 end
  const _mergeSort = (array, start, end) => {
    if (end <= start) return;
    const middle = Math.floor((start + end) / 2);
  
    // 對左右兩半排序
    _mergeSort(array, start, middle);
    _mergeSort(array, middle + 1, end);
    merge(array, start, middle, end);
    return array;
  }
  return _mergeSort(arr, 0, arr.length - 1);
}

因為是直接更改原本的陣列,所以要多傳幾個數字進去,代表我要排序這個陣列的那一段。而呼叫完之後,你就可以預設這一段的陣列已經是排序好的了。

基本上流程都跟上面簡單版的沒兩樣,但省了一些記憶體空間。

快速排序法(Quick Sort)

快速排序法我一開始覺得滿複雜,知道原理之後就覺得沒那麼難了,其實原理滿簡單:

找一個數,並且把這個數調整到:讓左邊的元素比它小,右邊的元素比它大,再對左右兩遍做一樣的事

那個數我們稱作 pivot,會把數列分割成左右兩邊。

例如說現在有一個數列是:14, 7, 6, 9, 10, 20, 15

我們挑選 14 當作 pivot,調整過後變成:7, 6, 9 , 10, 14, 20, 15,左邊都比它小,右邊都比它大。

而當你把 14 調整好的時候,其實這個元素就排好了!因為左邊比它小,右邊比它大嘛,所以這一個數字就排好了。接著只要對左右兩邊還沒排好的也做快速排序就行了。

而快速排序的核心在於你要怎麼找到那個數,如果你找的數字剛好是數列的中位數,那當然效率最高。如果找的是最小的數,那就是最壞的情形,時間複雜度就變成O(n^2),有分割跟沒分割一樣。

我們直接假設第一個數就是 pivot,這樣比較方便。

那再來有一個問題是,要怎麼把這個數字調整到左邊比它小,右邊比它大呢?我們可以維護一個變數叫做 splitIndex,讓這個 index 左邊的元素都比 pivot 小,而這個 index 本身以及它右邊的元素都比 pivot 大。

當你掃一遍陣列,發現某個元素比 pivot 小的時候,就把這個元素跟 splitIndex 上的元素交換,並且把 splitIndex + 1,就可以做到我們上面想做的事情了。最後記得把 pivot 跟 splitIndex - 1(也就是最後一個比它小的元素)交換,就能夠把 pivot 放到正確的位置上了。

可以參考下面的 gif,或是直接去VISUALGO看看。

quick

(來源:https://github.com/hustcc/JS-Sorting-Algorithm/blob/master/6.quickSort.md

function quickSort = (arr) => {
  const swap = (array, i , j) => {
    [array[i], array[j]] = [array[j], array[i]];
  }
  const partition = (array, start, end) => {
    let splitIndex = start + 1;
    for (let i = start + 1; i <= end; i++) {
      if (array[i] < array[start]) {
        swap(array, i, splitIndex);
        splitIndex++;
      }
    }
  
    // 記得把 pivot 跟最後一個比它小的元素互換
    swap(array, start, splitIndex - 1);
    return splitIndex - 1;
  }
  const _quickSort = (array, start, end) => {
    if (start >= end) return array;
  
    // 在 partition 裡面調整數列,並且回傳 pivot 的 index
    const middle = partition(array, start, end);
    _quickSort(array, start, middle - 1);
    _quickSort(array, middle + 1, end);
    return array;
  };
  return _quickSort(arr, 0, arr.length - 1);
}

堆排序(Heap Sort)

Heap 是一種資料結構,並且有分兩種:max heap 跟 min heap,兩種的原理其實雷同,我們直接拿 max heap 來講。

先讓大家看一張 max heap 的圖片:

heap

(資料來源:https://www.tutorialspoint.com/data_structures_algorithms/heap_data_structure.htm

大家可以發現,max heap 滿足了兩個性質:

  1. 父節點一定大於子節點
  2. 整個樹的根節點一定是最大值(可以由 1 推出來)

而要用陣列表示 heap 也很簡單,會像這樣:

heap2

(資料來源:http://notepad.yehyeh.net/Content/Algorithm/Sort/Heap/Heap.php

所以 heap sort 就是利用這個資料結構做排序,流程很簡單:

  1. 先把讀進來的陣列建成 max heap(這時候 arr[0] 一定是這陣列最大值)
  2. 把 arr[0] 跟最後一個節點互換(其實是最後一個還沒排序過的節點)
  3. 調整成 max heap,回到步驟 2

heap sort 其實有點複雜,複雜到可以再獨立出來一篇了...

但簡單來說呢,就是改良版的選擇排序法,每一次都選最大值出來,然後把剩下的數字再調整成 max heap。

function heapSort = (arr) => {  
  function heapify(arr, length, node) {
    const left = node * 2 + 1;
    const right = node * 2 + 2;
  
    // 先預設最大的節點是自己
    let max = node;
  
    if (left < length && arr[left] > arr[max]) {
      max = left;
    }
  
    if (right < length && arr[right] > arr[max]) {
      max = right;
    }
  
    // 如果左右兩邊有任何一個比 node 大的話
    if (max !== node) {
      // 就把兩個互換
      [arr[node], arr[max]] = [arr[max], arr[node]];
  
      // 接著繼續 heapfiy
      heapify(arr, length, max);
    }
  }
  
  // build max heap
  const length = arr.length;
  for (let i = Math.floor(length / 2) - 1; i>=0; i--) {
    heapify(arr, length, i);
  }
  
  // 排序
  for (let i = length - 1; i > 0; i--) {
    [arr[0], arr[i]] = [arr[i], arr[0]];
    heapify(arr, i, 0);
  }
  return arr;
}

總結

其實仔細研究過後,就會發現每一個排序演算法都有值得參考的地方,而且每個排序法都滿有趣的。也會發現懂原理是一回事,寫不寫的出來又是另外一回事了。這篇就當作自己的排序法筆記吧,如果有任何錯誤麻煩不吝指出。

如果想要自己玩玩看的話,我有放到 Github 上,有寫好 testcase,改一改就可以直接測了,應該滿方便的。

因為要測試的關係,所以每個排序法前面都會加上:arr = arr.slice()避免修改到原本的 array。

測試的過程也滿有趣的,我發現有些 ES6 語法(例如說很潮的交換語法或甚至是let)有時候會拖慢執行速度,因此我之前有把語法全部改回 ES5,發現效率快了不少,但這篇因為重點不在效能,所以還是全部用 ES6 的語法。

參考資料

  1. [演算法] 堆積排序法(Heap Sort)
  2. 常见排序算法 - 堆排序 (Heap Sort)
  3. 排序之堆積排序法(Heap Sort)
  4. js算法:heap sort 使用堆排序
  5. JS-Sorting-Algorithm/7.heapSort.md
  6. 用 JavaScript 學習資料結構和演算法:排序(Sort)與搜尋(Search)篇
@aszx87410 aszx87410 added JS JavaScript Algorithm Algorithm labels Dec 3, 2018
@iamlockon
Copy link

感謝整理分享。我感覺Insertion sort的code當中,while loop的第一個檢查"i >= 0"怪怪的:

while (i >= 0 && arr[position - 1] > value) {

我想你可能想要檢查index out of bound,但是會decrement的是position,所以應該改成"position > 0"之類的?不過其實這個檢查也不必要,因為當arr[-1]時會是undefined,結果一定是false。

@aszx87410
Copy link
Owner Author

aszx87410 commented Feb 24, 2019

@iamlockon 感謝提醒,這邊的確是要檢查 out of bound,所以應該要改成 position 才是正確的,這邊是筆誤沒錯。

不過儘管 arr[-1] 會是 undefined,我認為檢查範圍還是滿重要的,至少是個好習慣XD 儘管在 JS 上面跑可能沒問題,但在其他程式語言上可能就會出錯。

@objcxiaobai
Copy link

objcxiaobai commented Nov 18, 2019

感謝整理分享。快速排序的splitIndex 開始的時候為什麼要+1,返回的時候為什麼-1,有點理解不了。請賜教

@aszx87410
Copy link
Owner Author

@objcxiaobai 感謝提問,這部分的確是文中沒有說明清楚的地方
雖然只問了一小部分,但我覺得整個重新講一遍會比較容易理解,同時也是幫我自己做個筆記
因此下面就直接細講一遍了,覺得太冗長的話可以快速看過

先講一下 partition 這個 function 做了什麼事
為了簡化先忽略 start 跟 end 這兩個參數,先預設他們就是 0 跟 arr.length - 1 就好

所以 partition 就是傳進一個陣列,把 pivot 調整到陣列最中間,並且保證:左邊的元素都比 pivot 小,右邊的元素都比 pivot 大,並且回傳 pivot 這個元素調整完的 index,就代表這個 index 已經排完了,順序是不會變的。

pivot 的挑選方式有不同種方法,最簡單的一種就是挑選陣列的第一個元素(arr[0]),本文的做法也是這樣,因此目標就是把第一個元素調整到陣列中間,左邊把它小右邊比它大,並且回傳 pivot 的 index

所以現在的重點就是,我們到底怎麼做到「把 pivot 調整到陣列中間」這件事?

我們以這個數列當做例子:7, 44, 33, 22, 5, 2,取 7 作為 pivot

因為 arr[0] 是 pivot,所以直接從 arr[1] 開始看,arr[1] 是 44,比 7 大,所以只看 [7, 44] 的話順序是對的,不用做任何動作

再來看 arr[2],這個元素是 33,一樣不用管它
arr[3] 是 22,一樣不用理

然後是 arr[4],這個元素是 5,比 7 還要小
現在先忽略第一個 7 不看,前五個元素是 [44, 33, 22, 5],我們應該要把 5 調整到哪邊比較好?
顯然是跟 44 交換位置嘛,這樣會變成 [5, 33, 22, 44]
只要把 pivot(7) 放到 5 的後面,就可以滿足 7 左邊比它小,右邊比它大這個條件了

那如果下一個又碰到比 7 小的元素,例如說 2 好了,像是:[5, 33, 22, 44, 2],那應該要怎麼交換?
應該要把 33 跟 2 交換,變成 [5, 2, 22, 44, 33]
理由跟上面一樣,因為只要把 7 放到 2 後面,就可以滿足 partition 的條件

而 code 裡面的 splitIndex,指的就是「每次交換時要換的那個 index」
所以初始值會是 pivotIndex + 1
就如同上面提到的範例 [7, 44, 33, 22, 5] 一樣,5 要跟 44 換而不是跟 7 換
所以才會是 let splitIndex = start + 1;

再來,整個交換完會變怎樣?
以上面的範例來說,會變成:[7, 5, 2, 22, 44, 33]
而現在的 splitIndex 會是 3,也就是 arr[3] = 22 這個元素

splitIndex 的作用就是:讓左邊的元素都比 pivot 小,自己以及右邊的都比 pivot 大
那現在最後一步就是把 7 跟 splitIndex - 1 交換,變成 [2, 5, 7, 22, 44, 33]
就可以滿足 7 左邊比他小,右邊比他大這個條件了
交換完以後,7 的 index 就變成 splitIndex - 1
換句話說,pivot 的 index 就是 splitIndex - 1
這就是為什麼最後面要 -1 的緣故

以上就是比較詳細的解釋
主要跟「splitIndex 左邊的元素要比 pivot 小,自己跟右邊的要比 pivot 大」這個特性有關

@objcxiaobai
Copy link

@aszx87410 不會太冗長,通俗易懂,學習了,繼續消化。非常感謝

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Algorithm Algorithm JS JavaScript
Projects
None yet
Development

No branches or pull requests

3 participants