【譯】Swift算法俱樂部-快速排序

本文是對 Swift Algorithm Club 翻譯的一篇文章。git

Swift Algorithm Clubraywenderlich.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 ]
複製代碼

這是一個很好的拆分,由於lessgreater大體包含相同數量的元素。 因此咱們選擇了一個很好的基準,將數組從中間分開。

請注意,lessgreater數組還沒有排序,所以咱們再次調用quicksort()來排序這兩個子數組。這與以前徹底相同:選擇一箇中間元素並將子數組分紅三個更小的部分。

來看看less數組:

[ 0, 3, 2, 1, 5, -1 ]
複製代碼

基準元素是中間的1(你也能夠選擇2,這不要緊)。咱們再次圍繞基準元素建立了三個子數組:

less:    [ 0, -1 ]
equal:   [ 1 ]
greater: [ 3, 2, 5 ]
複製代碼

咱們尚未完成,quicksort()再次在lessmore數組上被遞歸調用。 讓咱們再看一下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 ]
複製代碼

惟一能夠保證的是在基準元素左邊是全部較小的元素,而右邊是全部較大的元素。 由於分區改變相等元素的原始順序,因此快速排序不會產生「穩定」排序(與歸併排序不一樣)。 這大部分時間都不是什麼大不了的事。

Lomuto的分區方案

在快速排序的第一個例子中,我告訴你,分區是經過調用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參數傳遞)。 這比分配新的數組對象更有效。

lowhigh參數是必要的,由於當在快速排序時並不必定排序整個數組,可能只是在某個區間。

之前咱們使用中間數組元素做爲基準,如今Lomuto方案的基準老是使用最後元素,a [high] 。 由於以前咱們一直在以8做爲基準,因此我在示例中交換了826的位置,以便8位於數組的最後而且在這裏也用做樞基準。

通過Lomuto方案分區後,數組以下所示:

[ 0, 3, 2, 1, 5, 8, -1, 8, 9, 10, 14, 26, 27 ]
                        *
複製代碼

變量ppartitionLomuto()的調用的返回值,是7。這是新數組中的基準元素的索引(用星號標記)。

左分區從0到p-1,是[0,3,2,1,5,8,-1]。 右分區從p + 1到結尾,而且是[9,10,14,26,27](右分區已經排序的實屬是巧合)。

您可能會注意到一些有趣的東西......值8在數組中出現不止一次。 其中一個8並無整齊地在中間,而是在左分區。 這是Lomuto算法的一個小缺點,若是存在大量重複元素,它會使快速排序變慢。

那麼Lomuto算法其實是如何工做的呢? 魔術發生在for循環中。 此循環將數組劃分爲四個區域:

  1. a [low ... i] 包含 <= pivot 的全部值
  2. a [i + 1 ... j-1] 包含 > pivot 的全部值
  3. a [j ... high-1] 是咱們「未查看」的值
  4. 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
複製代碼

循環依次查看從lowhigh-1的每一個元素。 若是當前元素的值小於或等於基準,則使用swap將其移動到第一個區域。

注意: 在Swift中,符號(x, y) = (y, x)是在xy的值之間執行交換的便捷方式。 你也可使用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。 這比基準小嗎? 是的,因此將100交換,而後將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的分區方案

這種分區方案是由快速排序的發明者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)
  }
}
複製代碼

與以前有兩個重要的區別:

  1. random(min:max:)函數返回min...max範圍內的整數,這是咱們基準的索引。
  2. 由於Lomuto方案指望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)
    }
}
複製代碼

擴展閱讀

快速排序的維基百科

做者:Matthijs Hollemans
翻譯:Andy Ron
校對:Andy Ron

相關文章
相關標籤/搜索