手寫演算法並記住它:歸併排序

老姚發表於2019-09-07

對於經典演算法,你是否也遇到這樣的情形:學時覺得很清楚,可過陣子就忘了?

本系列文章就嘗試解決這個問題。

研讀那些排序演算法,細品它們的名字,其實都很貼切。

比如歸併排序,“歸併”二字就是“遞迴”加“合併”。它是典型的分而治之演算法。

手寫演算法並記住它:歸併排序

上圖中,先把陣列一分為二,然後遞迴地排序好每部分,最後合併。

其中,分和歸相對容易些(後面會說),該演算法的核心是:如何合併兩個已經排好序的陣列?

解決辦法很容易想到,兩權相較取其輕。

手寫演算法並記住它:歸併排序

如上圖所示,每次比較取出一個相對小的元素放入結果陣列中。

翻譯成程式碼:

let left = [2, 4, 6], i = 0
let right = [1, 3, 5], j = 0
let result = []
while(i < left.length && j < right.length) {
  if (left[i] < right[j]) {
    result.push(left[i])
    i++
  } else {
    result.push(right[j])
    j++
  }
}
console.log(result) // [ 1, 2, 3, 4, 5 ]
複製程式碼

程式碼中,i和j分別是兩個陣列的下標。遍歷結束後,某個陣列可能會有剩餘,全部追加到結果陣列中就可以了:

if (i < left.length) {
  result.push(...left.slice(i))
} 
if (j < right.length){
  result.push(...right.slice(j))
}
複製程式碼

說明:為了清晰表達二者誰都可能剩餘,這裡沒有直接使用if...else。事實上不會出現二者都有剩餘情況的(while迴圈保證的)。另外,這裡使用了陣列相關API(concat也可以),也可以直接使用迴圈來做。

並,這個核心問題解決了,接下來我們來看看分和歸。

關於分,只要把陣列從中間劈成兩半就行:

let m = Math.floor(array.length / 2)
let left = array.slice(0, m)
let right = array.slice(m)
複製程式碼

至於遞迴,雖然它不符合線性思維,但其實也沒啥難的。

只要有遞迴步驟(遞迴公式),很容翻譯成程式碼的。

我們再回憶一下歸併演算法的步驟:

  1. 陣列分成兩半,left和right
  2. 遞迴處理left
  3. 遞迴處理right
  4. 合併二者結果

輕鬆翻譯成程式碼:

function mergeSort(array) {
  let m = Math.floor(array.length / 2)
  let left = mergeSort(array.slice(0, m))
  let right = mergeSort(array.slice(m))
  return merge(left, right)
} 
複製程式碼

遞迴是自身呼叫自身,不能無限次的呼叫下去,因此需要有遞迴出口(初始條件)。

它的遞迴出口是,當陣列元素個數為小於2時,就是已經是排好序的,不需要再遞迴呼叫了。

因此需要在前面加入程式碼:

if (array.length < 2) {
  return array
}
複製程式碼

檢視完整程式碼:codepen

至此,歸併排序原理和實現已經說完了。

這裡總結一下,歸併排序需要額外空間,空間複雜度為O(n),不是本地排序,相等元素是不會交換前後順序,因而是穩定排序。時間複雜度為O(nlogn),是比較優秀的演算法,在面試題中出現的概率也很高。

歸併排序和下一篇要講的快速排序,都是分而治之演算法,都需要分、歸、並。前者重頭戲在於如何去並,而後者重頭戲在於如何去分。

歸併排序,要做到能分分鐘手寫出來,是需要掌握其排序原理的。其關鍵在於,通過比較取小來合併兩個已遞迴排好序的陣列。至於遞迴,只要能說清楚遞迴步驟和出口,就能很容易寫出來,不需要死記硬背的。

希望有所幫助,本文完。



本系列已經發表文章:

相關文章