「演算法之美系列」排序(JS版)

wuwhs發表於2021-11-09

前言

最近一段時間重(入)拾(門)演算法,演算法渣渣的我只有做筆記換來一絲絲心裡安慰,在這裡也記錄分享一下,後面將會歸納成一系列吧。比如「遞迴與回溯」、「深度與廣度優先」、「動態規劃」、「二分搜尋」和「貪婪」等。

氣泡排序(Bubble Sort)

氣泡排序基本思想

給定一個陣列,我們把陣列裡的元素通通倒入到水池中,這些元素將通過相互之間的比較,按照大小順序一個一個地像氣泡一樣浮出水面。

氣泡排序實現

每一輪,從雜亂無章的陣列頭部開始,每兩個元素比較大小並進行交換,直到這一輪當中最大或最小的元素被放置在陣列的尾部,然後不斷地重複這個過程,直到所有元素都排好位置。其中,核心操作就是元素相互比較。

氣泡排序例題分析

給定陣列 [2, 1, 7, 9, 5, 8],要求按照從左到右、從小到大的順序進行排序。

氣泡排序解題思路

從左到右依次冒泡,把較大的數往右邊挪動即可。

bubble-sort

  • 首先指標指向第一個數,比較第一個數和第二個數的大小,由於 21 大,所以兩兩交換,[1, 2, 7, 9, 5, 8]
  • 接下來指標往前移動一步,比較 27,由於 27 小,兩者保持不動,[1, 2, 7, 9, 5, 8]。到目前為止,7 是最大的那個數。
  • 指標繼續往前移動,比較 79,由於 79 小,兩者保持不動,[1, 2, 7, 9, 5, 8]。現在,9 變成了最大的那個數。
  • 再往後,比較 95,很明顯,95 大,交換它們的位置,[1, 2, 7, 5, 9, 8]
  • 最後,比較 9898 大,交換它們的位置,[1, 2, 7, 5, 8, 9]。經過第一輪的兩兩比較,9 這個最大的數就像冒泡一樣冒到了陣列的最後面。
  • 接下來進行第二輪的比較,把指標重新指向第一個元素,重複上面的操作,最後,陣列變成了:[1, 2, 5, 7, 8, 9]
  • 在進行新一輪的比較中,判斷一下在上一輪比較的過程中有沒有發生兩兩交換,如果一次交換都沒有發生,就證明其實陣列已經排好序了。

氣泡排序程式碼示例

// 氣泡排序演算法
const bubbleSort = function (arr) {
  const len = arr.length
  // 標記每一輪是否發生來交換
  let hasChange = true

  // 如果沒有發生交換則已經是排好序的,直接跳出外層遍歷
  for (let i = 0; i < len && hasChange; i++) {
    hasChange = false
    for (let j = 0; j < len - 1 - i; j++) {
      if (arr[j] > arr[j + 1]) {
        let temp = arr[j]
        arr[j] = arr[j + 1]
        arr[j + 1] = temp
        hasChange = true
      }
    }
  }
}

const arr = [2, 1, 7, 9, 5, 8]
bubbleSort(arr)
console.log('arr: ', arr)

氣泡排序演算法分析

氣泡排序空間複雜度

假設陣列的元素個數是 n,由於在整個排序的過程中,我們是直接在給定的陣列裡面進行元素的兩兩交換,所以空間複雜度是 O(1)

氣泡排序時間複雜度

給定的陣列按照順序已經排好

  • 在這種情況下,我們只需要進行 n−1 次的比較,兩兩交換次數為 0,時間複雜度是 O(n)。這是最好的情況。
  • 給定的陣列按照逆序排列。在這種情況下,我們需要進行 n(n-1)/2 次比較,時間複雜度是 O(n2)。這是最壞的情況。
  • 給定的陣列雜亂無章。在這種情況下,平均時間複雜度是 O(n2)

由此可見,氣泡排序的時間複雜度是 O(n2)。它是一種穩定的排序演算法。(穩定是指如果陣列裡兩個相等的數,那麼排序前後這兩個相等的數的相對位置保持不變。)

插入排序(Insertion Sort)

插入排序基本思想

不斷地將尚未排好序的數插入到已經排好序的部分。

插入排序特點

在氣泡排序中,經過每一輪的排序處理後,陣列後端的數是排好序的;而對於插入排序來說,經過每一輪的排序處理後,陣列前端的數都是排好序的。

插入排序例題分析

對陣列 [2, 1, 7, 9, 5, 8] 進行插入排序。

插入排序解題思路

  • 首先將陣列分成左右兩個部分,左邊是已經排好序的部分,右邊是還沒有排好序的部分,剛開始,左邊已排好序的部分只有第一個元素 2。接下來,我們對右邊的元素一個一個進行處理,將它們放到左邊。

insertion-sort

  • 先來看 1,由於 12 小,需要將 1 插入到 2 的前面,做法很簡單,兩兩交換位置即可,[1, 2, 7, 9, 5, 8]
  • 然後,我們要把 7 插入到左邊的部分,由於 7 已經比 2 大了,表明它是目前最大的元素,保持位置不變,[1, 2, 7, 9, 5, 8]
  • 同理,9 也不需要做位置變動,[1, 2, 7, 9, 5, 8]。
  • 接下來,如何把 5 插入到合適的位置。首先比較 59,由於 59 小,兩兩交換,[1, 2, 7, 5, 9, 8],繼續,由於 57 小,兩兩交換,[1, 2, 5, 7, 9, 8],最後,由於 52 大,此輪結束。
  • 最後一個數是 8,由於 89 小,兩兩交換,[1, 2, 5, 7, 8, 9],再比較 78,發現 87 大,此輪結束。到此,插入排序完畢。

插入排序程式碼示例

// 插入排序
const insertionSort = function (arr) {
  const len = arr.length
  for (let i = 1; i < len; i++) {
    let current = arr[i]
    for (let j = i - 1; j >= 0; j--) {
      // current 小於 j 指向的左側值,將 j 指向左側值右移一位
      if (current < arr[j]) {
        arr[j + 1] = arr[j]
      } else {
        // 否則將 current 插入到 j 位置,跳出內迴圈
        arr[j] = current
        break
      }
    }
  }
}

const arr = [2, 1, 7, 9, 5, 8]
insertionSort(arr)
console.log('arr: ', arr)

插入排序演算法分析

插入排序空間複雜度

假設陣列的元素個數是 n,由於在整個排序的過程中,是直接在給定的陣列裡面進行元素的兩兩交換,空間複雜度是 O(1)

插入排序時間複雜度

  • 給定的陣列按照順序已經排好。只需要進行 n-1 次的比較,兩兩交換次數為 0,時間複雜度是 O(n)。這是最好的情況。
  • 給定的陣列按照逆序排列。在這種情況下,我們需要進行 n(n-1)/2 次比較,時間複雜度是 O(n2)。這是最壞的情況。
  • 給定的陣列雜亂無章。在這種情況下,平均時間複雜度是 O(n2)

由此可見,和氣泡排序一樣,插入排序的時間複雜度是 O(n2),並且它也是一種穩定的排序演算法。

歸併排序(Merge Sort)

歸併排序基本思想

核心是分治,就是把一個複雜的問題分成兩個或多個相同或相似的子問題,然後把子問題分成更小的子問題,直到子問題可以簡單的直接求解,最原問題的解就是子問題解的合併。歸併排序將分治的思想體現得淋漓盡致。

歸併排序實現

一開始先把陣列從中間劃分成兩個子陣列,一直遞迴地把子陣列劃分成更小的子陣列,直到子陣列裡面只有一個元素,才開始排序。

排序的方法就是按照大小順序合併兩個元素,接著依次按照遞迴的返回順序,不斷地合併排好序的子陣列,直到最後把整個陣列的順序排好。

歸併排序程式碼示例

// 歸併排序
const mergeSort = function (arr, lo, hi) {
  if (lo === undefined) {
    lo = 0
  }
  if (hi === undefined) {
    hi = arr.length - 1
  }

  // 判斷是否剩下最後一個元素
  if (lo >= hi) return

  // 從中間將陣列分成兩部分
  let mid = lo + Math.floor((hi - lo) / 2)
  console.log('mid', mid)

  // 分別遞迴將左右兩邊排好序
  mergeSort(arr, lo, mid)
  mergeSort(arr, mid + 1, hi)

  // 將排好序的左右兩半合併
  merge(arr, lo, mid, hi)
}

const merge = function (arr, lo, mid, hi) {
  // 複製一份原來的陣列
  const copy = [...arr]

  // 定義一個 k 指標表示從什麼位置開始修改原來的陣列,
  // i 指標表示左邊半的起始位置
  // j 指標便是右半邊的其實位置
  let k = lo
  let i = lo
  let j = mid + 1

  while (k <= hi) {
    if (i > mid) {
      arr[k++] = copy[j++]
    } else if (j > hi) {
      arr[k++] = copy[i++]
    } else if (copy[j] < copy[i]) {
      arr[k++] = copy[j++]
    } else {
      arr[k++] = copy[i++]
    }
  }
}
const arr = [2, 1, 7, 9, 5, 8]
mergeSort(arr)
console.log('arr: ', arr)

其中,While 語句比較,一共可能會出現四種情況。

  • 左半邊的數都處理完畢,只剩下右半邊的數,只需要將右半邊的數逐個拷貝過去。
  • 右半邊的數都處理完畢,只剩下左半邊的數,只需要將左半邊的數逐個拷貝過去就好。
  • 右邊的數小於左邊的數,將右邊的數拷貝到合適的位置,j 指標往前移動一位。
  • 左邊的數小於右邊的數,將左邊的數拷貝到合適的位置,i 指標往前移動一位。

歸併排序例題分析

利用歸併排序演算法對陣列 [2, 1, 7, 9, 5, 8] 進行排序。

歸併排序解題思路

merge-sort

首先不斷地對陣列進行切分,直到各個子陣列裡只包含一個元素。
接下來遞迴地按照大小順序合併切分開的子陣列,遞迴的順序和二叉樹裡的前向遍歷類似。

  • 合併 [2][1][1, 2]
  • 子陣列 [1, 2][7] 合併。
  • 右邊,合併 [9][5]
  • 然後合併 [5, 9][8]
  • 最後合併 [1, 2, 7][5, 8, 9][1, 2, 5, 8, 9],就可以把整個陣列排好序了。

合併陣列 [1, 2, 7][5, 8, 9] 的操作步驟如下。

merge-array

  • 把陣列 [1, 2, 7]L 表示,[5, 8, 9]R 表示。
  • 合併的時候,開闢分配一個新陣列 T 儲存結果,陣列大小應該是兩個子陣列長度的總和
  • 然後下標 ijk 分別指向每個陣列的起始點。
  • 接下來,比較下標 i 和 j 所指向的元素 L[i]R[j],按照大小順序放入到下標 k 指向的地方,1 小於 5
  • 移動 ik,繼續比較 L[i]R[j]25 小。
  • ik 繼續往前移動,57 小。
  • 移動 jk,繼續比較 L[i] 和 R[j],78 小。
  • 這時候,左邊的陣列已經處理完畢,直接將右邊陣列剩餘的元素放到結果陣列裡就好。

合併之所以能成功,先決條件必須是兩個子陣列都已經分別排好序了。

歸併排序演算法分析

歸併排序空間複雜度

由於合併 n 個元素需要分配一個大小為 n 的額外陣列,合併完成之後,這個陣列的空間就會被釋放,所以演算法的空間複雜度就是 O(n)。歸併排序也是穩定的排序演算法。

歸併排序時間複雜度

歸併演算法是一個不斷遞迴的過程。

舉例:陣列的元素個數是 n,時間複雜度是 T(n) 的函式。

解法:把這個規模為 n 的問題分成兩個規模分別為 n/2 的子問題,每個子問題的時間複雜度就是 T(n/2),那麼兩個子問題的複雜度就是 2×T(n/2)。當兩個子問題都得到了解決,即兩個子陣列都排好了序,需要將它們合併,一共有 n 個元素,每次都要進行最多 n-1 次的比較,所以合併的複雜度是 O(n)。由此我們得到了遞迴複雜度公式:T(n) = 2×T(n/2) + O(n)

對於公式求解,不斷地把一個規模為 n 的問題分解成規模為 n/2 的問題,一直分解到規模大小為 1。如果 n 等於 2,只需要分一次;如果 n 等於 4,需要分 2 次。這裡的次數是按照規模大小的變化分類的。

以此類推,對於規模為 n 的問題,一共要進行 log(n) 層的大小切分。在每一層裡,我們都要進行合併,所涉及到的元素其實就是陣列裡的所有元素,因此,每一層的合併複雜度都是 O(n),所以整體的複雜度就是  O(nlogn)

快速排序(Quick Sort)

快速排序基本思想

快速排序也採用了分治的思想。

快速排序實現

把原始的陣列篩選成較小和較大的兩個子陣列,然後遞迴地排序兩個子陣列。

舉例:把班級裡的所有同學按照高矮順序排成一排。

解法:老師先隨機地挑選了同學 A,讓所有其他同學和同學 A 比高矮,比 A 矮的都站在 A 的左邊,比 A 高的都站在 A 的右邊。接下來,老師分別從左邊到右邊的同學裡選擇了同學 B 和同學 C,然後不斷的篩選和排列下去。

在分成較小和較大的兩個子陣列過程中,如何選定一個基準值(也就是同學 A、B、C 等)尤為關鍵。

快速排序實現例題分析

對陣列[2,1,7,9,5,8]進行排序。

快速排序解題思路

quick-sort

  • 按照快速排序的思想,首先把陣列篩選成較小和較大的兩個子陣列。
  • 隨機從陣列裡選取一個數作為基準值,比如 7,於是原始的陣列就被分成裡兩個子陣列。注意:快速排序是直接在原始陣列裡進行各種交換操作,所以當子陣列被分割出去的時候,原始陣列裡的排列也被改變了。
  • 接下來,在較小的子陣列裡選 2 作為基準值,在較大的子陣列裡選 8 作為基準值,繼續分割子陣列。
  • 繼續將元素個數大於 1 的子陣列進行劃分,當所有子陣列裡的元素個數都為 1 的時候,原始陣列也被排好序了。

快速排序程式碼示例

// 快速排序
const quickSort = function (arr, lo, hi) {
  if (lo === undefined) {
    lo = 0
  }
  if (hi === undefined) {
    hi = arr.length - 1
  }

  // 判斷是否只剩下一個元素,是,則直接返回
  if (lo >= hi) return

  // 利用 partition 函式找到一個隨機的基準點
  const p = partition(arr, lo, hi)

  // 遞迴對基準點左半邊和右半邊的數進行排序
  quickSort(arr, lo, p - 1)
  quickSort(arr, p + 1, hi)
}

// 交換陣列位置
const swap = function (arr, i, j) {
  let temp = arr[i]
  arr[i] = arr[j]
  arr[j] = temp
}

// 隨機獲取位置索引
const randomPos = function (lo, hi) {
  return lo + Math.floor(Math.random() * (hi - lo))
}

const partition = function (arr, lo, hi) {
  const pos = randomPos(lo, hi)
  console.log('pos: ', pos)
  swap(arr, pos, hi)

  let i = lo
  let j = lo

  // 從左到右用每個數和基準值比較,若比基準值小,則放在指標 i 指向的位置
  // 迴圈完畢後,i 指標之前的數都比基準值小
  while (j < hi) {
    if (arr[j] <= arr[hi]) {
      swap(arr, i++, j)
    }
    j++
  }
  // 末尾的基準值放置到指標 i 的位置, i 指標之後的數都比基準值大
  swap(arr, i, j)

  // 返回指標 i,作為基準點的位置
  return i
}

const arr = [2, 1, 7, 9, 5, 8]
quickSort(arr)
console.log(arr)

快速排序演算法分析

快速排序時間複雜度

1、最優情況:被選出來的基準值都是當前子陣列的中間數。
這樣的分割,能保證對於一個規模大小為 n 的問題,能被均勻分解成兩個規模大小為 n/2 子問題(歸併排序也採用了相同的劃分方法),時間複雜度就是: T(n)=2xT(n/2) + O(n)

把規模大小為 n 的問題分解成 n/2 的兩個子問題時,和基準值進行了 n-1 次比較,複雜度就是 O(n)。很顯然,在最優情況下,快速排序的複雜度也是 O(nlogn)

2、最壞情況:基準值選擇了子陣列裡的最大後者最小值。

每次都把子陣列分成了兩個更小的子陣列,其中一個的長度為 1,另外一個的長度只比原子陣列少 1

舉例:對於陣列來說,每次挑選的基準值分別是 9、8、7、5、2

解法:劃分過程和氣泡排序的過程類似。

演算法複雜度為 O(n^2)

提示:可以通過隨機地選取基準值來避免出現最壞的情況。

快速排序空間複雜度

和歸併排序不同,快速排序在每次遞迴的過程中,只需要開闢 O(1) 的儲存空間來完成交換操作實現直接對陣列的修改,又因為遞迴次數為 logn,所以它的整體空間複雜度完全取決於壓堆疊的次數,因此,它的空間複雜度是 O(logn)

相關文章