對於經典演算法,你是否也遇到這樣的情形:學時覺得很清楚,可過陣子就忘了?
本系列文章就嘗試解決這個問題。
研讀那些排序演算法,細品它們的名字,其實都很貼切。
比如歸併排序,“歸併”二字就是“遞迴”加“合併”。它是典型的分而治之演算法。
上圖中,先把陣列一分為二,然後遞迴地排序好每部分,最後合併。
其中,分和歸相對容易些(後面會說),該演算法的核心是:如何合併兩個已經排好序的陣列?
解決辦法很容易想到,兩權相較取其輕。
如上圖所示,每次比較取出一個相對小的元素放入結果陣列中。
翻譯成程式碼:
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)
複製程式碼
至於遞迴,雖然它不符合線性思維,但其實也沒啥難的。
只要有遞迴步驟(遞迴公式),很容翻譯成程式碼的。
我們再回憶一下歸併演算法的步驟:
- 陣列分成兩半,left和right
- 遞迴處理left
- 遞迴處理right
- 合併二者結果
輕鬆翻譯成程式碼:
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),是比較優秀的演算法,在面試題中出現的概率也很高。
歸併排序和下一篇要講的快速排序,都是分而治之演算法,都需要分、歸、並。前者重頭戲在於如何去並,而後者重頭戲在於如何去分。
歸併排序,要做到能分分鐘手寫出來,是需要掌握其排序原理的。其關鍵在於,通過比較取小來合併兩個已遞迴排好序的陣列。至於遞迴,只要能說清楚遞迴步驟和出口,就能很容易寫出來,不需要死記硬背的。
希望有所幫助,本文完。
本系列已經發表文章: