圖解七大排序算法

“排序是計算機的核心內容。事實上,從很多方面看,如果沒有排序,計算機就不會變成現實。”
《算法之美:指導工作與生活的算法》
 
排序算法,或許是我們日常最常見也是使用頻率最多的算法。比如你在電商網站買東西,推薦商品往往基於相似度或者基於銷售量等維度排序。我們每日接收的郵件是程序中按照時間進行排序好的,我們點外賣時候推送的列表是按照評分和地理位置進行排序的。搜索引擎檢索內容也是按照一定的相似度算法進行排序好才呈現給你的,所以搜索引擎實際上就是一個排序引擎。
 
 
本篇文章通過圖例的方式逐一講解最常使用的七大排序算法,如下表所示,其中前四種爲比較排序法,基於元素與元素之間的比較實現排序,這種排序方法的最壞情況理論邊界爲Θ(nlgn),也就是這類排序的最壞情況必定會大於和等於該時間複雜度。後三種則屬於非比較排序,理論最佳的時間可以達到線性的處理時間。
 
 
 
算法
最壞運行時間
平均運行時間
是否穩定
原址排序
插入排序
Θ(n^2)
Θ(n^2)
歸併排序
Θ(nlgn)
Θ(nlgn)
堆排序
O(nlgn)
----
快速排序
Θ(n^2)
Θ(nlgn)
計數排序
Θ(n+k)
Θ(n+k)
基數排序
Θ(d(n+k))
Θ(d(n+k))
桶排序
Θ(n^2)
Θ(n)
 
 
 
 
 

1. 插入排序

 
插入排序是一種原址(in-place)排序,原址的意思就是對於任何的輸入數據序列都可以在只佔用常量的額外存儲空間來處理臨時數據的情況下完成排序。插入排序對於已經排序好的數據可以實現Θ(n)的最佳排序時間,對於逆序的數據序列排序運行時間最差Θ(n^2). 插入排序的示意圖如下:
 
  • 黃色代表尚未實現排序的元素
  • 綠色代表已經排序好的元素
  • 每一輪循環確定一個元素的位置
  • 箭頭代表元素之間的比較,如果比較發現左邊的元素大於右邊的該元素,則兩個位置進行交換,如果小於則退出本輪循環
 
 

 
插入排序的理解,我們可以想象一下,我們與朋友一起玩撲克牌。在摸牌階段,從牌堆中一張張的取牌,爲了保持手裏面一副牌一直保持有序狀態,每次都會把新取得的撲克牌與手頭的牌進行比較,比較的方式是從右向左,找到合適的位置,插入即可,這就是一種典型的插入排序。唯一需要注意的是,排序算法中的插入實際上是一種逐個交換實現的,而不是直接插到指定的位置。以下就是整個插入排序的源碼:
 
func insertSort(arr []int){
  size := len(arr)
  if size == 0 || size == 1{
    return
  }
  for j:=1;j<=size-1;j++{
    current := j
    for i:=j-1;i>=0;i--{
      if arr[current] < arr[i] {
        arr[current], arr[i] = arr[i],arr[current]
        current = i
      }else{
        break
      }
    }
  }
}

我們上面的表格中提及到插入排序爲穩定排序,穩定的意思是,假如一組元素中有多個重複的元素,那麼排序後多個重複元素的相對位置不會發生變化。
 

2. 歸併排序

 

歸併排序(MergeSort)利用分治法來實現的排序方式,分治法的思想是:將原問題分解爲幾個規模較小的但類似於原問題的子問題,遞歸求解這些子問題,然後再合併這些問題來建立原問題的解。對於歸併排序的話:
  • 分解: 將待排序的n個元素序列切分爲兩個n/2的子序列
  • 解決: 使用同樣的算法歸併排序遞歸排序兩個子序列
  • 合併: 合併兩個已經排序的子序列用以差生已排序的答案
 
下面就是整個的歸併排序的流程,圖中黃色爲尚未發生改變的元素,綠色代表已經排序好的元素(包含小範圍內排序完成)。我們首先對於整個的排序數組進行切分,切分完成後再逐層進行合併,這裏合併是整個歸併排序的核心算法部分,需要逐一比較待合併的元素並將元素順序寫入到新的臨時數組中,假設待合併的數組長度爲n, 整個的合併操作爲O(2n)的複雜度。
 
 
 
 
 
排序算法本身不屬於原址排序,在合併階段需要額外的存儲空間來管理中間數據。歸併排序的代碼如下:
 
 
func mergeSort(arr []int) []int{
  size := len(arr)
  if size < 2 {
    return arr
  }

  left := mergeSort(arr[0:size/2])
  right := mergeSort(arr[size/2:])
  leftSize := len(left)
  rightSize := len(right)
  i := 0
  j := 0
  var result []int
  for i < leftSize && j <rightSize{
    if left[i] < right[j]{
      result = append(result, left[i])
      i++
    }else{
      result = append(result, right[j])
      j++
    }

  }
  if i < leftSize {
    result = append(result, left[i:]...)
  }else if j < rightSize{
    result = append(result, right[j:]...)
  }

  return result
}

 

3. 堆排序

 
堆排序時間複雜度爲O(nlgn), 且是一種原址排序,因此算是同時擁有了上面兩種排序算法的優勢。堆排序本身需要藉助於一種稱之爲堆的數據結構實現,堆數據結構內部使用數組存儲元素即可,無需額外的容器。堆主要在於對於元素的操作需要按照屬性結構完成,可以把堆數據結構可以看做是一個近似完全二叉樹。樹上每個節點對應數組中的一個元素,除了最底層外,樹形結構爲完全滿的。比如下面的數組和其對應的堆結構,這是一個大頂堆,也就是根節點元素要比左右節點元素均大。對應的小頂堆就是根元素要比左右節點要小。下面就是數組到二叉樹的一種映射關係。
 
 
 
堆數據結構除了實現堆排序外,還可以實現優先隊列。針對我們要介紹的堆排序包含以下的幾個步驟:
 
  • 構建堆結構(Build), 從一個普通數組轉換爲具有堆性質的數組
  • 維護堆結構(Heapify),只是針對其中一個節點,保持節點以及子樹爲一個堆的過程,用於維護堆的性質(大頂或小頂),時間複雜度爲O(logn)
  • 堆排序(HeapSort): 對一個數組實現其原址排序。
 
我們先來看下維護堆結構(Heapify)的過程,這是一個遞歸處理的過程, 首先選擇一個待處理的節點與子節點執行比較操作,如果均大於子節點,則退出,如果不是的話,與值較大的子節點進行交換,重複此過程還需要考慮交換後的節點。比如下面的實例:
  • 我們選擇處理的元素爲1
  • 由於元素1的兩個子元素爲8和3,均大於1,我們選擇最大8與當前的元素1進行交換
  • 交換完成後元素1下移到8的位置。
  • 比較元素1和新的兩個子元素,繼續重複相同的流程,直到當前的節點滿足大頂的要求
 
 
 
 
 
我們構建堆的過程,其實就是對於多個元素逐一執行Heapify的過程,需要執行Heapify的元素佔總元素的1/2, 因爲葉子節點在根節點執行的時候會被一起處理掉,所以不需要單獨的處理。這裏我們通過一個示例來完成整個的堆排序的過程。
 
 
 
 
 
 
其中對於上面的第一個二叉樹,我們只是將數組映射爲了一個樹結構,而沒有做任何的處理,我們開始構建大頂堆結構的過程需要先知道哪些節點需要執行heapify, 圖中用藍色標記的需要執行,而且需要逆序執行直到根節點。這樣執行完成後的結果如第二個二叉樹所示。
 
 
 
爲了實現堆排序,我們需要交換第一個和最後一個元素,然後對於第一個進行heapify操作。執行完成後最大的元素自動的移動到數組的最後一位上。我們下一步操作的數組容量自動減少一位,不再處理最後一個元素。重複上述的過程,直到完成最後一個元素的處理。此時整個數組序列爲一個有序的數組,如下圖的最後一個二叉樹所示。
 
 
 
 
 
整個堆排序的處理代碼如下所示,其中heapify爲輔助函數,heapSort爲實際執行排序的函數:
 
func heapify(arr []int,size int , index int){

  left := 2 * index
  right := 2 * index +1
  max := index-1
  if size >= left && arr[max] < arr [left-1]{
    max = left-1
  }
  if size >= right && arr[max] < arr[right-1]{
    max = right-1
  }
  if max != index-1 {
    arr[max], arr[index-1] = arr[index-1], arr[max]
    heapify(arr, size, max+1)
  }
}

func heapSort(arr []int){
  size := len(arr)
  for i:= size/2; i>=1; i--{
    heapify(arr,size, i)
  }

  lenOfArr := size
  for i:=size-1; i >=1 ;i--{
    arr[i], arr[0] = arr[0], arr[i]
    heapify(arr,lenOfArr-1, 1)
    lenOfArr--
  }
}

 

4. 快速排序算法

 
快速排序算法是一種最壞情況下時間複雜度爲θ(n^2)的比較排序算法,期望時間複雜度爲θ(nlgn), 其算法本身隱藏的常數因子相對於堆排序和歸併排序都要小, 本身是一種原址排序,所以實際使用中較爲常見。同歸並排序,算法本身也是採用分治法來實現。分治的步驟包括:
  • 分解: 數組劃分爲兩個子數組,其中左數組中所有元素都小於等於Arr[x], 右數組中所有元素都大於等於Arr[x], x爲開始選擇的其中一個元素索引。
  • 解決: 通過遞歸調用快速排序,對於其左數組和右數組進行不斷的切分和排序
  • 合併: 子數組爲原址排序,因此本身就已經是排序好的,無需類似於歸併排序一樣的合併操作。
 
 
對與快速排序算法, 分解階段,也就是劃分子數組的階段較爲複雜,也是整個程序執行的主要步驟,我們通過一個示例的方式來介紹。
 
對於輸入的數組[7,4,1,3,5,2,8,6], 我們選擇最左和最右點作爲控制節點,內循環從[0, right-1], 選擇right作爲比較節點。分解的目的就是分成兩個數組,大於right=6的節點組和小於等於right=6的節點組。
 
 
內循環執行節點不斷位移i,當發現較小的值的時候就與left進行交換,這時候left和i增加1, 繼續循環,否則只增加i即可。
 
 
 
分解的其餘步驟不再給出詳細的偏移變化,整個的變化結束後需要將right節點與left節點交換即可,這樣左右分別分解完成。
 
 
 
當分解完成後,我們遞歸的對於上述分解的兩個數組執行同樣的邏輯即可,最終快速排序將獲得一個完全排序好的數組。
 
 
 
整個快速排序的代碼其實並不複雜,主要是獲取兩個數組的過程。假設我們每次獲取做比較的都是最大的值,則會產生最差的性能,降級爲θ(n^2), 因爲每次左數組大小=0,右數組大小=n-1。 比如已經有序的數據也會導致排序性能的下降。
 

func quickSort(a []int) []int{
  size := len(a)
  if size < 2 {
    return a
  }
  left, right := 0, size-1
  for i:=0; i< size-1; i++{
    if a[i] < a[right]{
      a[left], a[i] = a[i], a[left]
      left++
    }
  }
  a[left], a[right] = a[right], a[left]
  quickSort(a[:left])
  quickSort(a[left+1:])
  return a
}
 
 
上面每次都是選擇最右端的值進行比較,這在現實的數據序列可能會出現一些有序序列,且這也是一種很常見的模式, 但是對於快速排序則可能導致數據不均衡。爲了降低不均衡的可能性,可以採用隨機化的方式完成,每次選擇一個隨機值與最右端的值進行交換,這樣可以極大的降低不均衡產生的情況。
 

func quickSort(a []int) []int{
  size := len(a)
  if size < 2 {
    return a
  }
  left, right := 0, size-1

pivot := rand.Int() % size
a[pivot], a[right] = a[right], a[pivot]

  for i:=0; i< size-1; i++{
    if a[i] < a[right]{
      a[left], a[i] = a[i], a[left]
      left++
    }
  }
  a[left], a[right] = a[right], a[left]
  quickSort(a[:left])
  quickSort(a[left+1:])
  return a
}
 
 
另外比較有意思的一點是,有一篇論文專門介紹瞭如何去構造一個輸入,使得快速排序總是產生O(n^2)的排序複雜度,
 
 

5. 計數排序算法

 
計數排序算法是一種非比較的排序算法,可以達到線性的排序時間,但是前提就是該排序算法對於輸入序列有一定的限制, 它要求對於序列中的每一個元素都是0-k區間內的數據,k爲一個整數。至於爲什麼可以實現線性的複雜度我們可以看下排序的方式:
 
  • 初始化一個[0..k]的計數數組,用於存儲n個數中每個不同的數出現的次數
  • 循環迭代n數組中的元素,並統計每個數的個數進行累加
  • 最後循環計數數組產生輸出結果
 
初始化階段, 根據輸入的範圍,比如下面的實例中,所有取值的範圍爲[0-4],生成對應的計數數組,長度爲5,初始值設置爲0.
 
 
統計階段: 循環原始數組中的值,並更新計數數組,比如當原始數組爲2時候,更新計數數組中countArr[2]+1,最終形成完整的計數數組。
 
 
 
還原階段: 根據計數數組中數據項和統計計數,生成對應的數組。比如當我們發現countArr[0]=1, 我們就插入一個0到數組,當我們發現countArr[4]=4, 我們就插入四個4到數組。按順序寫入後新的排序樹組就產生了。
 
 
 
 
程序的源代碼如下所示:
 
func countSort(arr []int, max int) []int{
  size := len(arr)
  if size < 2 {
    return arr
  }
  countArr := make([]int, max+1)
  result := make([]int, len(arr))
  for _, v := range arr{
    countArr[v]++
  }
  for i:=1; i< max+1 ;i ++{
    countArr[i] = countArr[i-1]+countArr[i]
  }

  for i:=size-1;i>=0;i--{
    result[countArr[arr[i]]-1] =arr[i]
    countArr[arr[i]]--
  }
  return result
}

 

計數排序的時間複雜度爲θ(k+n),適用於比如類型統計,取值範圍有限的數據序列的統計等,這類數據藉助於計數排序可以實現線性的排序時間,同時注意上面的最後一個for循環,我們是按照逆序迭代取值,如果正序取值,可導致整個的排序結果不穩定,也就是本來在前面的元素會被排到後面。這在一些排序中是不可接受的。
 
 
 

6. 基數排序算法

 
基數排序法也是非比較排序算法的一種,是一種非常古老的排序算法,最早可以追溯到1887年 應用在打孔機設備上。但是儘管比較古老,但是當今仍舊用很多的系統在使用,比如部分大數據分析系統中使用這種排序來完成數據的排序。這種排序方式非常的簡單,我們可以通過一個實例來看一下:
 
 
我們對於下面的8個三位數進行排序,
  • 第一次排序的時候僅僅排序個位數上的數組,形成排序結果。
  • 第二次排序在上一次排序基礎上再對於十位數上數字進行排序,
  • 最後一次排序對於百位數進行排序。我們排序完成後可以看到整個的排序已經完成了。
 
基數排序就是這麼簡單。假設我們最大位數爲d,每一個位數均有k個可選值。則基數排序的時間複雜度爲θ(d(n+k))。 因爲每一次排序的過程都需要藉助於計數排序的方式,計數排序爲θ(n+k), 而這樣排序需要進行d輪。
 
 
程序源代碼如下所示: 這裏需要注意的是如果我們能夠直到確定的範圍,也就不需要執行一次完整O(n)的查找最大值的過程,並且最外層for循環實際執行的是計數排序的過程,並將結果保存在一箇中間數組中。每次循環向前執行一位,最終完成排序。
 
 
func radixSort(arr []int)[]int {
  largestNum := findLargestNum(arr)
  size := len(arr)
  significantDigit := 1

  semiSorted := make([]int, size, size)

  for largestNum / significantDigit > 0 {
    bucket := [10]int{0}
    for i := 0; i < size; i++ {
      bucket[(arr[i] / significantDigit) % 10]++
    }
    for i := 1; i < 10; i++ {
      bucket[i] += bucket[i - 1]
    }

    for i := size - 1; i >= 0; i-- {
      bucket[(arr[i] / significantDigit) % 10]--
      semiSorted[bucket[(arr[i] / significantDigit) % 10]] = arr[i]
    }
    for i := 0; i < size; i++ {
        arr[i] = semiSorted[i]
    }
   
    significantDigit *= 10
  }

  return arr

}

 

 
由於本身並非原址排序算法,所以對於內存有限的環境下,基數排序並非一種好的選擇,同時基數排序儘管可以達到線性運行時間,執行的循環次數少,但是本身由於每輪執行的時間相對於比如快速排序要更長一些,所以排序效率依舊需要根據實際的特性(環境,數據集合,硬件等)而定。
 
 
 

7. 桶排序算法

 
桶排序和計數排序一樣,也是一種非比較排序算法, 計數排序要求數據集合是一個小區間的整數集合,而桶排序則要求數據集合可以進行範圍切分的集合,這種假設也是對於桶排序算法的一種實用約束條件。比如數據都是屬於0-1範圍內, 那麼我們可以將這段範圍切分多個大小相同的子區間, [0,0.1), [0.1, 0.2)… [0.9,1)十個子區間,這些子區間稱之爲桶。
 
 
每個區間包含一個鏈表結構(或其他數據存儲結構),存儲屬於這個範圍內的數據。循環讀取數據並將數據寫入對應的鏈表中,結束後對於鏈表進行排序。再循環寫出數據到結果集合。程序執行的示意圖:
 
 
 
 
這裏針對上述實例的桶排序的算法程序如下面所示:
 
func bucketSort(arr []float64){
  buckets := make([][]float64,10)

  for _, v := range arr{
    bucketIndex := int(v * 10) % 10
    buckets[bucketIndex]= append(buckets[bucketIndex], v)
  }

  for _ , bucket := range buckets{
    sort.Float64s(bucket)
  }

  currentIndex := 0
  for _, bucket := range buckets{
    for _, v := range bucket{
      arr[currentIndex] = v
      currentIndex++
    }
  }
}

 

這裏我們簡化了一下,使用內置的slice來代替鏈表,從而實現更簡單的編碼,實際使用中需要考慮針對鏈表或者數組進行排序的代價,從而選擇更合適的數據結構存儲。同時選擇合適的排序算法也很重要,比如數據集合比較集中則使用插入排序這種算法可能會導致θ(n^2)的時間排序複雜度。
 
 
大家如果對於算法和數據結構感興趣,可以掃描訂閱下面的微信公衆號,我會定期發佈一些算法相關的文章,與大家一同交流學習,歡迎大家訂閱訪問。
 
 
 
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章