前端學習 資料結構與演算法 快速入門 系列 —— 排序和搜尋演算法

彭加李發表於2021-12-19

排序和搜尋演算法

本篇,我們將一起學習最常用的搜尋和排序演算法,如氣泡排序、選擇排序、插入排序、歸併排序、快速排序,以及二分搜尋、插值搜尋。

同時我們得理解,首先得排好序,才能更好的搜尋需要的資訊。

著名演算法的動畫演示

https://visualgo.net/ - 資料結構和演算法動態視覺化。比如有本文介紹的排序演算法的動畫版本

排序演算法

氣泡排序

氣泡排序 是所有排序演算法中最簡單的一種。

氣泡排序演算法的原理:比較相鄰的元素,如果左側比右側元素大(或小),則交換他們。元素向上移至正確的位置,就好像氣泡升至表面。

筆者實現如下:

function bubbleSort(arr) {
  // 比較輪數,每輪都會將一個值冒泡到正確的位置
  arr.forEach(() => {  // {1}
    arr.forEach((item, index) => {
      // 出界則為 false,不會交換
      if (arr[index] > arr[index + 1]) {
        [arr[index], arr[index + 1]] = [arr[index + 1], arr[index]]
      }
    })
  })
  return arr
}

// [ 1, 2, 3, 4 ]
console.log(bubbleSort([4, 3, 2, 1]))

這個實現可以再優化兩點:

  • 比較輪數(行{1})可以減一。比如有三個數要排序,第一輪結束後,右側第一個數就已經在正確的位置,第二輪結束,右側第二個數也已經在正確位置,第一個數則無需再排序。
  • 第2輪結束,數字3和數字4已經在正確的位置,但後續比較中,它們還在一直進行著比較。

即使我們對其進行改進,還是不推薦此演算法。它的時間複雜度是O(n²)

選擇排序

選擇排序演算法大致思路:找到最小(大)值並放在第一位,接著找到第二小的值並將其放在第二位,依此類推

筆者實現如下:

function selectionSort(arr) {
  let { length } = arr
  // 例如有三個元素,那麼只需遍歷 2 次就能確定第一位和第二位的值,第三個值也就在正確的位置上了
  for (let i = 0; i < length - 1; i++) {
    // 預設第一是最小值
    let min = i
    for (let j = i + 1; j < length; j++) {
      // 如果最小值不是最小值,更新最小值索引
      if (arr[min] > arr[j]) {
        min = j
      }
    }
    // 最小值不是最小值,則互動值
    if (min !== i) {
      [arr[i], arr[min]] = [arr[min], arr[i]]
    }
  }
  return arr
}

// [ 1, 2, 3, 4 ]
console.log(selectionSort([4, 3, 2, 1]));

時間複雜度和氣泡排序一樣,也是 O(n²)。

插入排序

插入排序:是指在待排序的元素中,假設前面n-1(其中n>=2)個數已經是排好順序的,現將第n個數插到前面已經排好的序列中,然後找到合適自己的位置,使得插入第n個數的這個序列也是排好順序的。按照此法對所有元素進行插入,直到整個序列排為有序

筆者實現如下:

function insertionSort(arr) {
  let { length } = arr
  // 比如三個元素,第一個預設已經排好序了,只要給剩餘兩個元素排序即可
  for (let i = 1; i < length; i++) {
    let endIndex = i
    while (endIndex) {
      // endIndex 的元素如果不比前一個值要小,說明已經在正確位置,無需更換
      if (arr[endIndex - 1] <= arr[endIndex]) {
        break
      }
      // 更換值
      [arr[endIndex - 1], arr[endIndex]] = [arr[endIndex], arr[endIndex - 1]]
      --endIndex
    }
  }
  return arr
}

// [ 1, 2, 3, 4 ]
console.log(insertionSort([4, 3, 2, 1]))

時間複雜度O(N^(1-2))。

Tip:最好的情況(如待排陣列有序),一共需要比較 N - 1 次,時間複雜度為 O(N);最壞的情況(如待排陣列是逆序),需要比較的總次數為 1+2+3+...+(N-1),時間複雜度為 O(N²);排序小型陣列,此演算法比選擇選擇和氣泡排序的效能要好。

歸併排序

歸併排序 是一個可以實際使用的排序演算法。效能比前面介紹的三種排序演算法要好,時間複雜度為 O(n log n)。

Tip:對於 javascript 中 Array.prototype.sort() 方法,firefox 使用的就是 並歸排序,而 Chrome(V8 引擎)使用的是 快速排序 的變體。

歸併排序是一種分而治之演算法。比如要將 [3, 1, 4, 2, 5] 排序,可以將其分為兩個陣列(left = [3, 1]; right = [4, 2, 5]),分別對兩個陣列排序,然後再將兩個陣列合並。

筆者實現如下:

function mergeSort(arr) {
  let { length } = arr
  // 一個元素,直接返回
  if (length === 1) { return arr }
  // 例如三個元素,middleIndex 為 1
  const middleIndex = Math.floor(length / 2)
  const left = arr.slice(0, middleIndex)
  const right = arr.slice(middleIndex)

  return merge(mergeSort(left), mergeSort(right))
}

// 將 left 陣列和 right 陣列合並
// left 和 right 都已經是拍好序的陣列
// 例如 left = [1, 3] right = [2, 4, 5]
function merge(left, right) {
  let leftIndex = 0
  let rightIndex = 0
  const { length: leftLen } = left
  const { length: rightLen } = right
  // 儲存結果
  const result = []
  while ((leftIndex < leftLen) && (rightIndex < rightLen)) {
    if (left[leftIndex] <= right[rightIndex]) {
      result.push(left[leftIndex++])
    } else {
      result.push(right[rightIndex++])
    }
  }

  // 此時 result = [1, 2, 3]
  if (leftIndex === leftLen) {
    // 將 right 剩餘的 4、5 放入 result
    result.push(...right.slice(rightIndex))
  }

  // 如果 right 先一步遍歷完畢,則將 left 剩餘元素放入 result
  if (rightIndex === rightLen) {
    result.push(...left.slice(leftIndex))
  }
  return result
}

console.log(mergeSort([3, 1, 4, 2, 5]))
// [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
// console.log(mergeSort([3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]))

快速排序

快速排序 也是比較常見的排序演算法。時間複雜度為 O(n log n)。和並歸演算法一樣,快速排序也使用分而治之的方法,但不需要合併。

排序流程如下:

  1. 首先設定一個分界值,通過該分界值將陣列分成左右兩部分
  2. 將大於分界值的資料集中到陣列右邊,小於分界值的資料集中到陣列的左邊
  3. 對左邊陣列進行劃分操作
  4. 對右邊陣列進行劃分操作
劃分

前 2 步叫做劃分操作。我們通過一個小示例來說明一下:

// 劃分
function partition(array) {
  // 隨便選取一個值作為分界值
  let mainValue = array[0]
  let leftIndex = 0
  let rightIndex = array.length - 1

  while (leftIndex <= rightIndex) {
    while (array[leftIndex] <= mainValue) {
      leftIndex++
    }

    while (array[rightIndex] >= mainValue) {
      rightIndex--
    }
    if (leftIndex <= rightIndex) {
      [array[leftIndex], array[rightIndex]] = [array[rightIndex], array[leftIndex]]
    }
  }
  return leftIndex
}

測試:

let arr = [3, 5, 1, 6, 4, 7, 2]
let middleIndex = partition(arr)
// 3
console.log(middleIndex)
// [3, 2, 1, 6, 4, 7, 5]
console.log(arr)
// 左側陣列:[ 3, 2, 1 ]
console.log(arr.slice(0, middleIndex))
// 右側陣列:[ 6, 4, 7, 5 ]
console.log(arr.slice(middleIndex))

通過 partition() 方法,會返回一個索引,並會更新原陣列(arr),得到的左側陣列都小於等於分界值,右側陣列都大於等於分界值。

接著對左側陣列和後側陣列在進行劃分操作,最後,陣列就會完成排序。

:此方法有一個問題,比如 arr = [13, 12, 11],返回的 leftIndex 為 3,明顯不對(arr 的 leftIndex 只會為 0、1或2)。修復方法很簡單,將等號去掉即可:

+ while (array[leftIndex] < mainValue) {
- while (array[leftIndex] <= mainValue) {
    leftIndex++
  }

+ while (array[rightIndex] > mainValue) {
- while (array[rightIndex] >= mainValue) {
    rightIndex--
  }
筆者實現
// 在上面的 partition() 方法的基礎上進行稍微調整
function partition(array, leftIndex, rightIndex) {
  let mainValue = array[Math.floor((leftIndex + rightIndex) / 2)]

  while (leftIndex <= rightIndex) {
    // 注:不能包括等於,否則會出界。
    // 例如 let arr = [3, 2, 1]; partition(arr, 0, arr.length - 1) 返回 3
    while (array[leftIndex] < mainValue) {
      leftIndex++
    }

    while (array[rightIndex] > mainValue) {
      rightIndex--
    }
    if (leftIndex <= rightIndex) {
      [array[leftIndex], array[rightIndex]] = [array[rightIndex], array[leftIndex]]
      rightIndex--
      leftIndex++
    }
  }
  return leftIndex
}

function quickSort(arr, left = 0, right = arr.length - 1) {
  // 1 個值
  if ((right - left) <= 0) {
    return
  }
  // 進行劃分操作
  const partitionIndex = partition(arr, left, right)

  // partitionIndex 需要減 1,否則 right 值沒變,會造成無限迴圈
  quickSort(arr, left, partitionIndex - 1)
  quickSort(arr, partitionIndex, right)

  return arr
}

// [ 1, 2, 3 ]
console.log(quickSort([3, 2, 1]))

// [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
console.log(quickSort([3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]))

:選擇分界值(稱主元)有幾種方式,最簡單是選擇陣列的第一個值,但研究表明,這會導致該演算法最差表現,另一種是隨機選擇或者選擇中間值。

演算法我們暫且先學習這幾種,我們接著看搜尋。

搜尋演算法

順序搜尋

順序(或線性)搜尋是最基本的搜尋演算法。也是最低效的一種搜尋演算法。

它的機制是:將資料結構的每一項和我們要找的元素比較。

二分搜尋

二分搜尋 要求被搜尋的資料結構已排序。演算法的步驟如下:

  • 選擇陣列中間值
  • 如果選中值是待搜尋值,那麼演算法結束(找到了)
  • 如果待搜尋值比選中值要小,則在選中值的左邊子陣列中繼續尋找(返回步驟 1)
  • 如果待搜尋值比選中值要大,則在右邊子陣列中重複步驟 1

// 自定義排序
// 你也可以使用其他排序演算法
function customSort(arr) {
  return [...arr.sort((a, b) => a - b)]
}

// 二分搜尋
function binarySearch(array, v) {
  // 首先得將陣列排序
  let sortedArray = customSort(array)
  let start = 0;
  let end = sortedArray.length - 1;
  let middle
  // 可在加上一個條件來提交演算法效能:要搜尋的值在 [array[start], array[end]] 之間,否則直接返回 -1
  while (start <= end) {
    middle = Math.floor((start + end) / 2)
    if (sortedArray[middle] === v) {
      return middle
    } else if (v < sortedArray[middle]) {
      end = middle - 1
    } else {
      start = middle + 1
    }
  }
  return -1;
}

console.log(binarySearch([1], 1))                      // 0
console.log(binarySearch([], 1))                       // -1
// 排序後:[2, 4, 6, 7, 8, 9, 111]
console.log(binarySearch([2, 8, 9, 7, 6, 4, 111], 9))  // 5

:必須先對陣列進行排序,否則此演算法有時就會失效。例如 binarySearch([2, 8, 9, 7, 6, 4, 111], 9) 就會返回 -1。

插值搜尋

插值搜尋(或稱 插值查詢、或稱內插搜尋)是改良版的 二分搜尋。二分搜尋總是檢查 middle 位置上的值,而插值搜尋將查詢點的選擇改進為按公式查詢,提高了查詢效率。

同樣要求被搜尋的資料結構已排序。演算法的步驟與二分搜尋類似:

  • 使用 position 公式選中一個值
  • 如果選中值是待搜尋值,那麼演算法結束(找到了)
  • 如果待搜尋值比選中值要小,則在選中值的左邊子陣列中繼續尋找(返回步驟 1)
  • 如果待搜尋值比選中值要大,則在右邊子陣列中重複步驟 1

筆者實現如下:

// 根據一定規則返回 position
// 規則由你決定
function getPosition(arr, start, end, searchVal) {
  // 佔比
  // 注:需要處理  arr[end] 等於 arr[start] 的情況
  //    否則 `0 + Math.floor(0*Infinity)` => NaN
  let percentage = !Object.is(arr[end], arr[start]) ?
    (searchVal - arr[start]) / (arr[end] - arr[start]) :
    0

  return start + Math.floor((end - start) * percentage)
}

// 插值搜尋
function interpolationSearch(array, v) {
  // 首先得將陣列排序
  let sortedArray = customSort(array)
  let start = 0;
  let end = sortedArray.length - 1;
  let position
  while (start <= end &&
    (v >= array[start]) &&
    (v <= array[end])) {
    // 使用公式選中一個索引進行比較  
    position = getPosition(array, start, end, v)
    if (sortedArray[position] === v) {
      return position
    } else if (v < sortedArray[position]) {
      end = position - 1
    } else {
      start = position + 1
    }
  }
  return -1;
}

console.log(interpolationSearch([1], 1))                      // 0
console.log(binarySearch([], 1))                              // -1
// 排序後:[2, 4, 6, 7, 8, 9, 111]
console.log(interpolationSearch([2, 8, 9, 7, 6, 4, 111], 9))  // 5

隨機演算法

Fisher-Yates 隨機演算法由 Fisher 和 Yates 創造。原理直接看程式碼更加直觀:

// 洗牌:迭代陣列,從最後一位開始並將當前元素和一個隨機位置的值進行交換
function shuffle(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    // 取得隨機位置:[0, i]
    let randomPosition = Math.floor(Math.random() * (i + 1));
    // 將當前元素和隨機位置的值進行交換
    [arr[randomPosition], arr[i]] = [arr[i], arr[randomPosition]]
  }
  return arr
}

console.log(shuffle(['a', 'b', 'c', 'd', 'e']))
// 三次洗牌的輸出:

// [ 'e', 'a', 'c', 'b', 'd' ]
// [ 'a', 'c', 'd', 'e', 'b' ]
// [ 'd', 'e', 'a', 'b', 'c' ]

相關文章