【譯】Swift演算法俱樂部-歸併排序

Swift演算法俱樂部
本文是對 ofollow,noindex">Swift Algorithm Club 翻譯的一篇文章。
Swift Algorithm Club 是 raywenderlich.com 網站出品的用Swift實現演算法和資料結構的開源專案,目前在GitHub上有18000+:star:️,我初略統計了一下,大概有一百左右個的演算法和資料結構,基本上常見的都包含了,是iOSer學習演算法和資料結構不錯的資源。
:octopus: andyRon/swift-algorithm-club-cn 是我對Swift Algorithm Club,邊學習邊翻譯的專案。由於能力有限,如發現錯誤或翻譯不妥,請指正,歡迎pull request。也歡迎有興趣、有時間的小夥伴一起參與翻譯和學習 。當然也歡迎加:star:️, 。
本文的翻譯原文和程式碼可以檢視:octopus: swift-algorithm-club-cn/Merge Sort
這個主題已經有輔導 文章
目標:將陣列從低到高(或從高到低)排序
歸併排序是1945年由John von Neumann發明的,是一種有效的演算法,最佳、最差和平均時間複雜度都是 O(n log n) 。
歸併排序演算法使用 分而治之 方法,即將一個大問題分解為較小的問題並解決它們。 歸併排序演算法可分為 先拆分 和 後合併 。
假設您需要按正確的順序對長度為 n 的陣列進行排序。 歸併排序演算法的工作原理如下:
- 將數字放在未排序的堆中。
- 將堆分成兩部分。 那麼現在就有 兩個未排序的數字堆 。
- 繼續分裂 兩個未排序的數字堆 ,直到你不能分裂為止。 最後,你將擁有 n 個堆,每堆中有一個數字。
- 通過順序配對,開始 合併 堆。 在每次合併期間,將內容按排序順序排列。 這很容易,因為每個單獨的堆已經排序(譯註:單個數字沒有所謂的順序,就是排好序的)。
例子
拆分
假設給你一個長度為 n 的未排序陣列: [2,1,5,4,9]
。 目標是不斷拆分堆,直到你不能拆分為止。
首先,將陣列分成兩半: [2,1]
和 [5,4,9]
。 你能繼續拆分嗎? 是的你可以!
專注於左邊堆。 將 [2,1]
拆分為 [2]
和 [1]
。 你能繼續拆分嗎? 不能了。檢查右邊的堆。
將 [5,4,9]
拆分為 [5]
和 [4,9]
。 不出所料, [5]
不能再拆分了,但是 [4,9]
可以分成 [4]
和 [9]
。
拆分最終結果為: [2]``[1]``[5]``[4]``[9]
。 請注意,每個堆只包含一個元素。
合併
您已經拆分了陣列,您現在應該 合併並排序 拆分後的堆。 請記住,這個想法是解決許多小問題而不是一個大問題。 對於每次合併迭代,您必須關注將一堆與另一堆合併。
對於堆 [2]
[1]
[5]
[4]
[9]
,第一次合併的結果是 [1,2]
和 [4,5]
和 [9]
。 由於 [9]
的位置落單,所以在合併過程中沒有堆與之合併了。
下一次將合併 [1,2]
和 [4,5]
。 結果 [1,2,4,5]
,再次由於 [9]
的位置落單不需要合併。
只剩下兩堆 [1,2,4,5]
和 [9]
,合併後完成排序的陣列為 [1,2,4,5,9]
。
自上而下的實施(遞迴法)
歸併排序的Swift實現:
func mergeSort(_ array: [Int]) -> [Int] { guard array.count > 1 else { return array }// 1 let middleIndex = array.count / 2// 2 let leftArray = mergeSort(Array(array[0..<middleIndex]))// 3 let rightArray = mergeSort(Array(array[middleIndex..<array.count]))// 4 return merge(leftPile: leftArray, rightPile: rightArray)// 5 }
程式碼的逐步說明:
-
如果陣列為空或包含單個元素,則無法將其拆分為更小的部分,返回陣列就行。
-
找到中間索引。
-
使用上一步中的中間索引,遞迴地分割陣列的左側。
-
此外,遞迴地分割陣列的右側。
-
最後,將所有值合併在一起,確保它始終排序。
這兒是合併的演算法:
func merge(leftPile: [Int], rightPile: [Int]) -> [Int] { // 1 var leftIndex = 0 var rightIndex = 0 // 2 var orderedPile = [Int]() // 3 while leftIndex < leftPile.count && rightIndex < rightPile.count { if leftPile[leftIndex] < rightPile[rightIndex] { orderedPile.append(leftPile[leftIndex]) leftIndex += 1 } else if leftPile[leftIndex] > rightPile[rightIndex] { orderedPile.append(rightPile[rightIndex]) rightIndex += 1 } else { orderedPile.append(leftPile[leftIndex]) leftIndex += 1 orderedPile.append(rightPile[rightIndex]) rightIndex += 1 } } // 4 while leftIndex < leftPile.count { orderedPile.append(leftPile[leftIndex]) leftIndex += 1 } while rightIndex < rightPile.count { orderedPile.append(rightPile[rightIndex]) rightIndex += 1 } return orderedPile }
這種方法可能看起來很可怕,但它非常簡單:
-
在合併時,您需要兩個索引來跟蹤兩個陣列的進度。
-
這是合併後的陣列。 它現在是空的,但是你將在下面的步驟中通過新增其他陣列中的元素構建它。
-
這個while迴圈將比較左側和右側的元素,並將它們新增到
orderedPile
,同時確保結果保持有序。 -
如果前一個while迴圈完成,則意味著
leftPile
或rightPile
中的一個的內容已經完全合併到orderedPile
中。此時,您不再需要進行比較。只需依次新增剩下一個陣列的其餘內容到orderedPile
。
merge()
函式如何工作的例子。假設我們有以兩個個堆: leftPile = [1,7,8]
和 rightPile = [3,6,9]
。 請注意,這兩個堆都已單獨排序 -- 合併排序總是如此的。 下面的步驟就將它們合併為一個更大的排好序的堆:
leftPilerightPileorderedPile [ 1, 7, 8 ][ 3, 6, 9 ][ ] lr
左側索引(此處表示為 l
)指向左側堆的第一個專案 1
。 右則索引 r
指向 3
。 因此,我們新增到 orderedPile
的第一項是 1
。 我們還將左側索引 l
移動到下一個項。
leftPilerightPileorderedPile [ 1, 7, 8 ][ 3, 6, 9 ][ 1 ] -->lr
現在 l
指向 7
但是 r
仍然處於 3
。 我們將最小的項 3
新增到有序堆中。 現在的情況是:
leftPilerightPileorderedPile [ 1, 7, 8 ][ 3, 6, 9 ][ 1, 3 ] l-->r
重複上面的過程。 在每一步中,我們從 leftPile
或 rightPile
中選擇最小的項,並將該項新增到 orderedPile
中:
leftPilerightPileorderedPile [ 1, 7, 8 ][ 3, 6, 9 ][ 1, 3, 6 ] l-->r leftPilerightPileorderedPile [ 1, 7, 8 ][ 3, 6, 9 ][ 1, 3, 6, 7 ] -->lr leftPilerightPileorderedPile [ 1, 7, 8 ][ 3, 6, 9 ][ 1, 3, 6, 7, 8 ] -->lr
現在,左堆中沒有更多物品了。 我們只需從右邊的堆中新增剩餘的專案,我們就完成了。 合併的堆是 [1,3,6,7,8,9]
。
請注意,此演算法非常簡單:它從左向右移動通過兩個堆,並在每個步驟選擇最小的專案。 這是有效的,因為我們保證每個堆都已經排序。
譯註: 關於自上而下的執行(遞迴法)的歸併排序,我找了一個比較形象的動圖, 來源

遞迴的歸併排序
自下而上的實施(迭代)
到目前為止你看到的合併排序演算法的實現被稱為“自上而下”的方法,因為它首先將陣列拆分成更小的堆然後合併它們。排序陣列(而不是連結串列)時,實際上可以跳過拆分步驟並立即開始合併各個陣列元素。 這被稱為“自下而上”的方法。
下面是Swift中一個完整的自下而上的實現:
func mergeSortBottomUp<T>(_ a: [T], _ isOrderedBefore: (T, T) -> Bool) -> [T] { let n = a.count var z = [a, a]// 1 var d = 0 var width = 1 while width < n {// 2 var i = 0 while i < n {// 3 var j = i var l = i var r = i + width let lmax = min(l + width, n) let rmax = min(r + width, n) while l < lmax && r < rmax {// 4 if isOrderedBefore(z[d][l], z[d][r]) { z[1 - d][j] = z[d][l] l += 1 } else { z[1 - d][j] = z[d][r] r += 1 } j += 1 } while l < lmax { z[1 - d][j] = z[d][l] j += 1 l += 1 } while r < rmax { z[1 - d][j] = z[d][r] j += 1 r += 1 } i += width*2 } width *= 2 d = 1 - d// 5 } return z[d] }
它看起來比自上而下的版本更令人生畏,但請注意主體包含與 merge()
相同的三個 while
迴圈。
值得注意的要點:
-
歸併排序演算法需要一個臨時工作陣列,因為你不能合併左右堆並同時覆蓋它們的內容。 因為為每個合併分配一個新陣列是浪費,我們使用兩個工作陣列,我們將使用
d
的值在它們之間切換,它是0或1。陣列z[d]
用於讀,z[1 - d]
用於寫。 這稱為 雙緩衝 。 -
從概念上講,自下而上版本的工作方式與自上而下版本相同。首先,它合併每個元素的小堆,然後它合併每個堆兩個元素,然後每個堆成四個元素,依此類推。堆的大小由
width
給出。 最初,width
是1
但是在每次迴圈迭代結束時,我們將它乘以2,所以這個外迴圈確定要合併的堆的大小,並且要合併的子陣列在每一步中變得更大。 -
內迴圈穿過堆並將每對堆合併成一個較大的堆。 結果寫在
z[1 - d]
給出的陣列中。 -
這與自上而下版本的邏輯相同。 主要區別在於我們使用雙緩衝,因此從
z[d]
讀取值並寫入z [1 - d]
。它還使用isOrderedBefore
函式來比較元素而不僅僅是<
,因此這種合併排序演算法是通用的,您可以使用它來對任何型別的物件進行排序。 -
此時,陣列
z[d]
的大小width
的堆已經合併為陣列z[1-d]
中更大的大小width * 2
。在這裡,我們交換活動陣列,以便在下一步中我們將從我們剛剛建立的新堆中讀取。
這個函式是通用的,所以你可以使用它來對你想要的任何型別物件進行排序,只要你提供一個正確的 isOrderedBefore
閉包來比較元素。
怎麼使用它的示例:
let array = [2, 1, 5, 4, 9] mergeSortBottomUp(array, <)// [1, 2, 4, 5, 9]
譯註:關於迭代的歸併排序,我找到一個圖來表示, 來源

迭代的歸併排序
效能
歸併排序演算法的速度取決於它需要排序的陣列的大小。 陣列越大,它需要做的工作就越多。
初始陣列是否已經排序不會影響歸併排序演算法的速度,因為無論元素的初始順序如何,您都將進行相同數量的拆分和比較。
因此,最佳,最差和平均情況的時間複雜度將始終為 O(n log n) 。
歸併排序演算法的一個缺點是它需要一個臨時的“工作”陣列,其大小與被排序的陣列相同。 它不是 原地 排序,不像例如 quicksort 。
大多數實現歸併排序演算法是 穩定的 排序。這意味著具有相同排序鍵的陣列元素在排序後將保持相對於彼此的相同順序。這對於數字或字串等簡單值並不重要,但在排序更復雜的物件時,如果不是 穩定的 排序可能會出現問題。
譯註:當元素相同時,排序後依然保持排序之前的相對順序,那麼這個排序演算法就是 穩定 的。穩定的排序有: 插入排序 、 計數排序 、 歸併排序 、 基數排序 等等,詳見 穩定的排序 。
擴充套件閱讀
作者:Kelvin Lau. Additions , Matthijs Hollemans 翻譯: Andy Ron 校對: Andy Ron