本文是對 Swift Algorithm Club 翻譯的一篇文章。git
Swift Algorithm Club是 raywenderlich.com網站出品的用Swift實現算法和數據結構的開源項目,目前在GitHub上有18000+⭐️,我初略統計了一下,大概有一百左右個的算法和數據結構,基本上常見的都包含了,是iOSer學習算法和數據結構不錯的資源。github
🐙andyRon/swift-algorithm-club-cn是我對Swift Algorithm Club,邊學習邊翻譯的項目。因爲能力有限,如發現錯誤或翻譯不妥,請指正,歡迎pull request。也歡迎有興趣、有時間的小夥伴一塊兒參與翻譯和學習🤓。固然也歡迎加⭐️,🤩🤩🤩🤨🤪。算法
本文的翻譯原文和代碼能夠查看🐙swift-algorithm-club-cn/Quicksortswift
快速排序(Quicksort)數組
目標:將數組從低到高(或從高到低)排序。數據結構
快速排序是歷史上最着名的算法之一。 它是由Tony Hoare於1959年發明的,當時遞歸仍然是一個至關模糊的概念。less
這是Swift中的一個實現,應該很容易理解:dom
func quicksort<T: Comparable>(_ a: [T]) -> [T] {
guard a.count > 1 else { return a }
let pivot = a[a.count/2]
let less = a.filter { $0 < pivot }
let equal = a.filter { $0 == pivot }
let greater = a.filter { $0 > pivot }
return quicksort(less) + equal + quicksort(greater)
}
複製代碼
譯註:pivot 中心點,樞軸,基準。本文的pivot都翻譯成「基準」。ide
將此代碼放在playground 進行測試:函數
let list = [ 10, 0, 3, 9, 2, 14, 8, 27, 1, 5, 8, -1, 26 ]
quicksort(list)
複製代碼
談一談工做原理。 給定一個數組時,quicksort()
根據「基準」變量將它分紅三部分。這裏,基準被視爲數組中間的元素(稍後您將看到選擇基準的其餘方法)。
比基準元素小的全部元素都進入一個名爲less
的新數組。 全部等於基準元素都進入equal
數組。你猜對了,全部比基準更大的元素進入第三個數組,greater
。 這就是泛型類型T
必須符合Comparable
協議的緣由,由於咱們須要將元素與<
,==
和>
進行比較。
一旦咱們有了這三個數組,quicksort()
遞歸地對less
數組和more
數組進行排序,而後將那些已排序的子數組與equal
數組組合在一塊兒,獲得最終結果。
讓咱們來看看這個例子。 數組最初是:
[ 10, 0, 3, 9, 2, 14, 8, 27, 1, 5, 8, -1, 26 ]
複製代碼
首先,咱們選擇基準8
,由於它在數組的中間。 如今咱們將數組拆分爲少,相等和大的部分:
less: [ 0, 3, 2, 1, 5, -1 ]
equal: [ 8, 8 ]
greater: [ 10, 9, 14, 27, 26 ]
複製代碼
這是一個很好的拆分,由於less
和greater
大體包含相同數量的元素。 因此咱們選擇了一個很好的基準,將數組從中間分開。
請注意,less
和greater
數組還沒有排序,所以咱們再次調用quicksort()
來排序這兩個子數組。這與以前徹底相同:選擇一箇中間元素並將子數組分紅三個更小的部分。
來看看less
數組:
[ 0, 3, 2, 1, 5, -1 ]
複製代碼
基準元素是中間的1
(你也能夠選擇2
,這不要緊)。咱們再次圍繞基準元素建立了三個子數組:
less: [ 0, -1 ]
equal: [ 1 ]
greater: [ 3, 2, 5 ]
複製代碼
咱們尚未完成,quicksort()
再次在less
和more
數組上被遞歸調用。 讓咱們再看一下less
:
[ 0, -1 ]
複製代碼
此次基準元素選擇-1
。 如今的子數組是:
less: [ ]
equal: [ -1 ]
greater: [ 0 ]
複製代碼
less
數組是空的,由於沒有小於-1
的值; 其餘數組各包含一個元素。 這意味着咱們已經完成了遞歸,如今咱們返回以對前一個greater
數組進行排序。
greater
數組是:
[ 3, 2, 5 ]
複製代碼
這與之前的工做方式相同:咱們選擇中間元素2
做爲基準元素,子數組爲:
less: [ ]
equal: [ 2 ]
greater: [ 3, 5 ]
複製代碼
請注意,若是在這裏選擇3
做爲基準會更好 —— 會早點完成。 然而如今咱們必須再次遞歸到greater
數組以確保它被排序。這就體現,選擇好的基準有多重要了。當你選擇太多「bad」基準時,快速排序實際上變得很是慢。 以後會有更多說明。
當對greater
子數組進行分區時,咱們發現:
less: [ 3 ]
equal: [ 5 ]
greater: [ ]
複製代碼
如今咱們已經完成了這層遞歸,由於咱們沒法進一步拆分數組。
重複此過程,直到全部子數組都已排序。 過程圖:
如今,若是您從左到右閱讀彩色框,則會得到已排序的數組:
[ -1, 0, 1, 2, 3, 5, 8, 8, 9, 10, 14, 26, 27 ]
複製代碼
這代表8
是一個很好的初始基準,由於它也出如今排好序數組的中間。
我但願這已經清楚地代表快速排序的工做原理了。 不幸的是,這個版本的快速排序不是很快,由於咱們對相同的數組使用filter()
三次。有更聰明的方法分割數組。
圍繞數據塊劃分數組稱爲 分區,而且存在一些不一樣的分區方案。 若是一個數組是,
[ 10, 0, 3, 9, 2, 14, 8, 27, 1, 5, 8, -1, 26 ]
複製代碼
而後咱們選擇中間元素8
做爲一個數據塊,而後分區後數組以下:
[ 0, 3, 2, 1, 5, -1, 8, 8, 10, 9, 14, 27, 26 ]
----------------- -----------------
all elements < 8 all elements > 8
複製代碼
要實現上面操做的關鍵是,在分區以後,基準元素已經處於其最終排序位置。 其他的數字還沒有排序,它們只是以基準數分區了。 快速排序對數組進行屢次分區,直到全部值都在最終位置。
沒法保證每次分區將元素保持在相同的相對順序中,所以在使用基準「8」進行分區以後,也可能獲得相似這樣的內容:
[ 3, 0, 5, 2, -1, 1, 8, 8, 14, 26, 10, 27, 9 ]
複製代碼
惟一能夠保證的是在基準元素左邊是全部較小的元素,而右邊是全部較大的元素。 由於分區改變相等元素的原始順序,因此快速排序不會產生「穩定」排序(與歸併排序不一樣)。 這大部分時間都不是什麼大不了的事。
在快速排序的第一個例子中,我告訴你,分區是經過調用Swift的filter()
函數三次來完成的。 這不是很高效。 所以,讓咱們看一個更智能的分區算法,它能夠 in place,即經過修改原始數組。
這是在Swift中實現Lomuto的分區方案:
func partitionLomuto<T: Comparable>(_ a: inout [T], low: Int, high: Int) -> Int {
let pivot = a[high]
var i = low
for j in low..<high {
if a[j] <= pivot {
(a[i], a[j]) = (a[j], a[I])
i += 1
}
}
(a[i], a[high]) = (a[high], a[I])
return I
}
複製代碼
在playground中測試:
var list = [ 10, 0, 3, 9, 2, 14, 26, 27, 1, 5, 8, -1, 8 ]
let p = partitionLomuto(&list, low: 0, high: list.count - 1)
list // show the results
複製代碼
注意list
須要是var
,由於partitionLomuto()
直接改變數組的內容(使用inout
參數傳遞)。 這比分配新的數組對象更有效。
low
和high
參數是必要的,由於當在快速排序時並不必定排序整個數組,可能只是在某個區間。
之前咱們使用中間數組元素做爲基準,如今Lomuto方案的基準老是使用最後元素,a [high]
。 由於以前咱們一直在以8
做爲基準,因此我在示例中交換了8
和26
的位置,以便8
位於數組的最後而且在這裏也用做樞基準。
通過Lomuto方案分區後,數組以下所示:
[ 0, 3, 2, 1, 5, 8, -1, 8, 9, 10, 14, 26, 27 ]
*
複製代碼
變量p
是partitionLomuto()
的調用的返回值,是7。這是新數組中的基準元素的索引(用星號標記)。
左分區從0到p-1
,是[0,3,2,1,5,8,-1]
。 右分區從p + 1
到結尾,而且是[9,10,14,26,27]
(右分區已經排序的實屬是巧合)。
您可能會注意到一些有趣的東西......值8
在數組中出現不止一次。 其中一個8
並無整齊地在中間,而是在左分區。 這是Lomuto算法的一個小缺點,若是存在大量重複元素,它會使快速排序變慢。
那麼Lomuto算法其實是如何工做的呢? 魔術發生在for
循環中。 此循環將數組劃分爲四個區域:
a [low ... i]
包含 <= pivot
的全部值a [i + 1 ... j-1]
包含 > pivot
的全部值a [j ... high-1]
是咱們「未查看」的值a [high]
是基準值In ASCII art the array is divided up like this: 用ASCII字符表示,數組按以下方式劃分:
[ values <= pivot | values > pivot | not looked at yet | pivot ]
low i i+1 j-1 j high-1 high
複製代碼
循環依次查看從low
到high-1
的每一個元素。 若是當前元素的值小於或等於基準,則使用swap將其移動到第一個區域。
注意: 在Swift中,符號
(x, y) = (y, x)
是在x
和y
的值之間執行交換的便捷方式。 你也可使用swap(&x,&y)
。
循環結束後,基準仍然是數組中的最後一個元素。 因此咱們將它與第一個大於基準的元素交換。 如今,基準位於<=和>區域之間,而且數組已正確分區。
讓咱們逐步完成這個例子。 咱們開始的數組是:
[| 10, 0, 3, 9, 2, 14, 26, 27, 1, 5, 8, -1 | 8 ]
low high
I
j
複製代碼
最初,「未查看」區域從索引0延伸到11。基準位於索引12。「values <= pivot」和「values> pivot」區域爲空,由於咱們尚未查看任何值。
看第一個值,10
。 這比基準小嗎? 不,跳到下一個元素。
[| 10 | 0, 3, 9, 2, 14, 26, 27, 1, 5, 8, -1 | 8 ]
low high
I
j
複製代碼
如今「未查看」區域從索引1到11,「values> pivot」區域包含數字「10」,「values <= pivot」仍爲空。
看第二個值,0
。 這比基準小嗎? 是的,因此將10
與0
交換,而後將i
向前移動一個。
[ 0 | 10 | 3, 9, 2, 14, 26, 27, 1, 5, 8, -1 | 8 ]
low high
I
j
複製代碼
如今「未查看」區域從索引2到11,「values> pivot」仍然包含「10」,「values <= pivot」包含數字「0」。
看第三個值,3
。 這比基準小,因此用10
換掉它獲得:
[ 0, 3 | 10 | 9, 2, 14, 26, 27, 1, 5, 8, -1 | 8 ]
low high
I
j
複製代碼
「values <= pivot」區域如今是[0,3]
。 讓咱們再作一次......9
大於樞軸,因此簡單地向前跳:
[ 0, 3 | 10, 9 | 2, 14, 26, 27, 1, 5, 8, -1 | 8 ]
low high
I
j
複製代碼
如今「values> pivot」區域包含[10,9]
。 若是咱們繼續這樣作,那麼咱們最終會獲得:
[ 0, 3, 2, 1, 5, 8, -1 | 27, 9, 10, 14, 26 | 8 ]
low high
I j
複製代碼
最後要作的是經過將a[i]
與a[high]
交換來將基準放到特定位置:
[ 0, 3, 2, 1, 5, 8, -1 | 8 | 9, 10, 14, 26, 27 ]
low high
I j
複製代碼
而後咱們返回i
,基準元素的索引。
** 注意:** 若是您仍然不徹底清楚算法是如何工做的,我建議您在playground 試驗一下,以確切瞭解循環如何建立這四個區域。
讓咱們使用這個分區方案來構建快速排序。 這是代碼:
func quicksortLomuto<T: Comparable>(_ a: inout [T], low: Int, high: Int) {
if low < high {
let p = partitionLomuto(&a, low: low, high: high)
quicksortLomuto(&a, low: low, high: p - 1)
quicksortLomuto(&a, low: p + 1, high: high)
}
}
複製代碼
如今這很是簡單。 咱們首先調用partitionLomuto()
來以基準元素(它始終是數組中的最後一個元素)從新排序數組。 而後咱們遞歸調用quicksortLomuto()
來對左右分區進行排序。
試試看:
var list = [ 10, 0, 3, 9, 2, 14, 26, 27, 1, 5, 8, -1, 8 ]
quicksortLomuto(&list, low: 0, high: list.count - 1)
複製代碼
Lomuto方案不是惟一的分區方案,但它多是最容易理解的。 它不如Hoare的方案有效,後者須要的交換操做更少。
這種分區方案是由快速排序的發明者Hoare完成的。
下面是代碼:
func partitionHoare<T: Comparable>(_ a: inout [T], low: Int, high: Int) -> Int {
let pivot = a[low]
var i = low - 1
var j = high + 1
while true {
repeat { j -= 1 } while a[j] > pivot
repeat { i += 1 } while a[i] < pivot
if i < j {
a.swapAt(i, j)
} else {
return j
}
}
}
複製代碼
在playground中測試:
var list = [ 8, 0, 3, 9, 2, 14, 10, 27, 1, 5, 8, -1, 26 ]
let p = partitionHoare(&list, low: 0, high: list.count - 1)
list // show the results
複製代碼
注意,使用Hoare的方案,基準老是數組中的 first 元素,a [low]
。 一樣,咱們使用8
做爲基準元素。 結果是:
[ -1, 0, 3, 8, 2, 5, 1, 27, 10, 14, 9, 8, 26 ]
複製代碼
請注意,此次基準根本不在中間。 與Lomuto的方案不一樣,返回值不必定是新數組中基準元素的索引。
結果,數組被劃分爲區域[low ... p]
和[p + 1 ... high]
。 這裏,返回值p
是6,所以兩個分區是[-1,0,3,8,2,5,1]
和[27,10,14,9,8,26]
。
因爲存在這些差別,Hoare快速排序的實施略有不一樣:
func quicksortHoare<T: Comparable>(_ a: inout [T], low: Int, high: Int) {
if low < high {
let p = partitionHoare(&a, low: low, high: high)
quicksortHoare(&a, low: low, high: p)
quicksortHoare(&a, low: p + 1, high: high)
}
}
複製代碼
Hoare的分區方案是如何工做的?我將把它做爲練習讓讀者本身弄清楚。:-)
Lomuto的分區方案老是爲基準選擇最後一個數組元素。 Hoare的分區方案使用第一個元素。 但這都不能保證這些基準是好的。
如下是爲基準選擇錯誤值時會發生的狀況。 若是一個數組是:
[ 7, 6, 5, 4, 3, 2, 1 ]
複製代碼
咱們使用Lomuto的方案。 基準是最後一個元素,1
。 分區後:
less than pivot: [ ]
equal to pivot: [ 1 ]
greater than pivot: [ 7, 6, 5, 4, 3, 2 ]
複製代碼
如今以遞歸方式對「更大的」子數組進行分區,獲得:
less than pivot: [ ]
equal to pivot: [ 2 ]
greater than pivot: [ 7, 6, 5, 4, 3 ]
複製代碼
再次:
less than pivot: [ ]
equal to pivot: [ 3 ]
greater than pivot: [ 7, 6, 5, 4 ]
複製代碼
等等。。。
這並很差,由於這樣的快速排序可能比插入排序更慢。 爲了使快速排序高效,須要將數組分紅兩個大約相等的部分。
這個例子的最佳基準是4
,因此咱們獲得:
less than pivot: [ 3, 2, 1 ]
equal to pivot: [ 4 ]
greater than pivot: [ 7, 6, 5 ]
複製代碼
您可能認爲這意味着咱們應該始終選擇中間元素而不是第一個或最後一個,但想象在如下狀況下會發生什麼:
[ 7, 6, 5, 1, 4, 3, 2 ]
複製代碼
如今,中間元素是1
,它給出了與前一個例子相同的糟糕結果。
理想狀況下,基準是您要分區的數組的 中位數(譯註:大小在中間的) 元素,即位於排玩序數組中間的元素。固然,在你對數組進行排序以前,你不會知道中位數是什麼,因此這就回到 雞蛋和雞 問題了。然而,有一些技巧能夠改進。
一個技巧是「三個中間值」,您能夠在找到數組中第一個,中間和最後一個的中位數。 從理論上講,這一般能夠很好地接近真實的中位數。
另外一種常見的解決方案是隨機選擇基準。 有時這可能會選擇次優的基準,但平均而言,這會產生很是好的結果。
如下是如何使用隨機選擇的基準進行快速排序:
func quicksortRandom<T: Comparable>(_ a: inout [T], low: Int, high: Int) {
if low < high {
let pivotIndex = random(min: low, max: high) // 1
(a[pivotIndex], a[high]) = (a[high], a[pivotIndex]) // 2
let p = partitionLomuto(&a, low: low, high: high)
quicksortRandom(&a, low: low, high: p - 1)
quicksortRandom(&a, low: p + 1, high: high)
}
}
複製代碼
與以前有兩個重要的區別:
random(min:max:)
函數返回min...max
範圍內的整數,這是咱們基準的索引。a[high]
成爲基準,咱們將a[pivotIndex]
與a[high]
交換,將基準元素放在末尾,而後再調用partitionLomuto()
。在相似排序函數中使用隨機數彷佛很奇怪,但讓快速排序在全部狀況下都能有效地運行,這是有必要的。 壞的基準,快速排序的表現可能很是糟糕,O(n^2)。 可是若是平均選擇好的基準,例如使用隨機數生成器,預期的運行時間將變爲O(nlogn),這是好的排序算法。
還有更多改進! 在我向您展現的第一個快速排序示例中,咱們最終獲得了一個像這樣分區的數組:
[ values < pivot | values equal to pivot | values > pivot ]
複製代碼
可是正如您在Lomuto分區方案中看到的那樣,若是屢次出現基準元素,則重複項最後會在左分區。 而經過Hoare方案,重複基準元素能夠遍及任意分區。 解決這個問題的方法是「荷蘭國旗」分區,以荷蘭國旗有三個頻段命名,就像咱們想擁有三個分區同樣。
該方案的代碼是:
func partitionDutchFlag<T: Comparable>(_ a: inout [T], low: Int, high: Int, pivotIndex: Int) -> (Int, Int) {
let pivot = a[pivotIndex]
var smaller = low
var equal = low
var larger = high
while equal <= larger {
if a[equal] < pivot {
swap(&a, smaller, equal)
smaller += 1
equal += 1
} else if a[equal] == pivot {
equal += 1
} else {
swap(&a, equal, larger)
larger -= 1
}
}
return (smaller, larger)
}
複製代碼
這與Lomuto方案的工做方式很是類似,只是循環將數組劃分爲四個(可能爲空)區域:
[low ... smaller-1]
包含< pivot
的全部值[less ... equal-1]
包含 == pivot
的全部值[equal ... larger]
包含 > pivot
的全部值[large ... high]
是咱們「未查看」的值Note that this doesn't assume the pivot is in a[high]
. Instead, you have to pass in the index of the element you wish to use as a pivot. 請注意,這並不假設基準處於a[high]
。 而是,必須傳入要用做基準的元素的索引。
如何使用它的一個例子:
var list = [ 10, 0, 3, 9, 2, 14, 8, 27, 1, 5, 8, -1, 26 ]
partitionDutchFlag(&list, low: 0, high: list.count - 1, pivotIndex: 10)
list // show the results
複製代碼
只是爲了好玩,咱們此次給它的另外一個8
的索引。 結果是:
[ -1, 0, 3, 2, 5, 1, 8, 8, 27, 14, 9, 26, 10 ]
複製代碼
注意兩個8
如今是如何在中間的。 partitionDutchFlag()
的返回值是一個元組,(6,7)
。 這是包含基準的範圍。
如下是如何在快速排序中使用它:
func quicksortDutchFlag<T: Comparable>(_ a: inout [T], low: Int, high: Int) {
if low < high {
let pivotIndex = random(min: low, max: high)
let (p, q) = partitionDutchFlag(&a, low: low, high: high, pivotIndex: pivotIndex)
quicksortDutchFlag(&a, low: low, high: p - 1)
quicksortDutchFlag(&a, low: q + 1, high: high)
}
}
複製代碼
若是數組包含許多重複元素,則使用荷蘭國旗分區能夠提升效率。 (並且我不僅是這麼說,由於我是荷蘭人!)
注意:
partitionDutchFlag()
的上述實現使用自定義swap()
來交換兩個數組元素的內容。 與Swift自帶的swapAt()
不一樣,當兩個索引引用相同的數組元素時,這不會產生錯誤。public func swap<T>(_ a: inout [T], _ i: Int, _ j: Int) { if i != j { a.swapAt(i, j) } } 複製代碼