堆排序(Heap Sort
)由威爾士-加拿大計算機科學家J. W. J. Williams
在1964
年發明,它利用了二叉堆(A binary heap)
的性質實現了排序,並證實了二叉堆數據結構的可用性。同年,美國籍計算機科學家R. W. Floyd
在其樹排序研究的基礎上,發佈了一個改進的更好的原地排序的堆排序版本。算法
堆排序屬於選擇類排序算法。segmentfault
優先隊列是一種能完成如下任務的隊列:插入一個數值,取出最小或最大的數值(獲取數值,而且刪除)。數組
優先隊列能夠用二叉樹來實現,咱們稱這種結構爲二叉堆。數據結構
最小堆和最大堆是二叉堆的一種,是一顆徹底二叉樹(一種平衡樹)。併發
最小堆的性質:數據結構和算法
最大堆的性質:函數
最大堆和最小堆實現方式同樣,只不過根節點一個是最大的,一個是最小的。性能
最大堆實現細節(兩個操做):spa
最大堆有兩個核心操做,一個是上浮,一個是下沉,分別對應push
和pop
。3d
這是一個最大堆:
用數組表示爲:[11 5 8 3 4]
咱們要往堆裏push
一個元素15
,咱們先把X = 15
放到樹最尾部,而後進行上浮操做。
由於15
大於其父親節點8
,因此與父親替換:
這時15
仍是大於其父親節點11
,繼續替換:
操做一次push
的最好時間複雜度爲:O(1)
,由於第一次上浮時若是不大於父親,那麼就結束了。最壞的時間複雜度爲:O(logn)
,至關於每次都大於父親,會一直往上浮到根節點,翻轉次數等於樹的高度,而樹的高度等於元素個數的對數:log(n)
。
咱們如今要將堆頂的元素pop
出。如圖咱們要移除最大的元素11
:
咱們先將根節點移除,而後將最尾部的節點4
放在根節點上:
接着對根節點4
進行下沉操做,與其兩個兒子節點比較,發現較大的兒子節點8
比4
大,那麼根節點4
與其兒子節點8
交換位置,向下翻轉:
這樣一直向下翻轉就維持了最大堆的特徵。
操做一次pop
最好的時間複雜度也是:O(1)
,由於第一次比較時根節點就是最大的。最壞時間複雜度仍然是樹的高度:O(logn)
。
構建一個最大堆,從空堆開始,每次添加元素到尾部後,須要向上翻轉,最壞翻轉次數是:
第一次添加元素翻轉次數:log1 第二次添加元素翻轉次數:log2 第三次添加元素翻轉次數:不大於log3的最大整數 第四次添加元素翻轉次數:log4 第五次添加元素翻轉次數:不大於log5的最大整數 ... 第N次添加元素翻轉次數:不大於logn的最大整數 近似 = log(1)+log(2)+log(3)+...+log(n) = log(n!)
從一個最大堆,逐一移除堆頂元素,而後將堆尾元素置於堆頂後,向下翻轉恢復堆特徵,最壞翻轉次數是:
第一次移除元素恢復堆時間複雜度:logn 第二次移除元素恢復堆時間複雜度:不大於log(n-1)的最大整數 第三次移除元素恢復堆時間複雜度:不大於log(n-2)的最大整數 ... 第N次移除元素恢復堆時間複雜度:log1 近似 = log(1)+log(2)+log(3)+...+log(n) = log(n!)
根據斯特林公式:
能夠進行證實log(n!)
和nlog(n)
是同階的:
因此構建一個最大堆的最壞時間複雜度是:O(nlogn)
。
從堆頂一個個移除元素,直到移完,整個過程最壞時間複雜度也是:O(nlogn)
。
從構建堆到移除堆,總的最壞複雜度是:O(nlogn)+O(nlogn)
,咱們能夠認爲是:O(nlogn)
。
若是全部的元素都同樣的狀況下,建堆和移除堆的每一步都不須要翻轉,最好時間複雜度爲:O(n)
,複雜度主要在於遍歷元素。
若是元素不全同樣,即便在建堆的時候不須要翻轉,但在移除堆的過程當中必定會破壞堆的特徵,致使恢復堆時須要翻轉。好比一個n
個元素的已排好的序的數列,建堆時每次都知足堆的特徵,不須要上浮翻轉,但在移除堆的過程當中最尾部元素須要放在根節點,這個時候致使不知足堆的特徵,須要下沉翻轉。所以,在最好狀況下,時間複雜度仍然是:O(nlog)
。
所以,最大堆從構建到移除,總的平均時間複雜度是:O(nlogn)
。
// 一個最大堆,一顆徹底二叉樹 // 最大堆要求節點元素都不小於其左右孩子 type Heap struct { // 堆的大小 Size int // 使用內部的數組來模擬樹 // 一個節點下標爲 i,那麼父親節點的下標爲 (i-1)/2 // 一個節點下標爲 i,那麼左兒子的下標爲 2i+1,右兒子下標爲 2i+2 Array []int } // 初始化一個堆 func NewHeap(array []int) *Heap { h := new(Heap) h.Array = array return h } // 最大堆插入元素 func (h *Heap) Push(x int) { // 堆沒有元素時,使元素成爲頂點後退出 if h.Size == 0 { h.Array[0] = x h.Size++ return } // i 是要插入節點的下標 i := h.Size // 若是下標存在 // 將小的值 x 一直上浮 for i > 0 { // parent爲該元素父親節點的下標 parent := (i - 1) / 2 // 若是插入的值小於等於父親節點,那麼能夠直接退出循環,由於父親仍然是最大的 if x <= h.Array[parent] { break } // 不然將父親節點與該節點互換,而後向上翻轉,將最大的元素一直往上推 h.Array[i] = h.Array[parent] i = parent } // 將該值 x 放在不會再翻轉的位置 h.Array[i] = x // 堆數量加一 h.Size++ } // 最大堆移除根節點元素,也就是最大的元素 func (h *Heap) Pop() int { // 沒有元素,返回-1 if h.Size == 0 { return -1 } // 取出根節點 ret := h.Array[0] // 由於根節點要被刪除了,將最後一個節點放到根節點的位置上 h.Size-- x := h.Array[h.Size] // 將最後一個元素的值先拿出來 h.Array[h.Size] = ret // 將移除的元素放在最後一個元素的位置上 // 對根節點進行向下翻轉,小的值 x 一直下沉,維持最大堆的特徵 i := 0 for { // a,b爲下標 i 左右兩個子節點的下標 a := 2*i + 1 b := 2*i + 2 // 左兒子下標超出了,表示沒有左子樹,那麼右子樹也沒有,直接返回 if a >= h.Size { break } // 有右子樹,拿到兩個子節點中較大節點的下標 if b < h.Size && h.Array[b] > h.Array[a] { a = b } // 父親節點的值都大於或等於兩個兒子較大的那個,不須要向下繼續翻轉了,返回 if x >= h.Array[a] { break } // 將較大的兒子與父親交換,維持這個最大堆的特徵 h.Array[i] = h.Array[a] // 繼續往下操做 i = a } // 將最後一個元素的值 x 放在不會再翻轉的位置 h.Array[i] = x return ret }
以上爲最大堆的實現。
根據最大堆,堆頂元素一直是最大的元素特徵,能夠實現堆排序。
先構建一個最小堆,而後依次把根節點元素pop
出便可:
func main() { list := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3} // 構建最大堆 h := NewHeap(list) for _, v := range list { h.Push(v) } // 將堆元素移除 for range list { h.Pop() } // 打印排序後的值 fmt.Println(list) }
輸出:
1 3 4 5 6 6 6 8 9 14 25 49
根據以上最大堆的時間複雜度分析,從堆構建到移除最壞和最好的時間複雜度:O(nlogn)
,這也是堆排序的最好和最壞的時間複雜度。
這樣實現的堆排序是普通的堆排序,性能不是最優的。
由於一開始會認爲堆是空的,每次添加元素都須要添加到尾部,而後向上翻轉,須要用Heap.Size
來記錄堆的大小增加,這種堆構建,能夠認爲是非原地的構建,影響了效率。
美國籍計算機科學家R. W. Floyd
改進的原地自底向上的堆排序,不會從空堆開始,而是把待排序的數列當成一個混亂的最大堆,從底層逐層開始,對元素進行下沉操做,一直恢復最大堆的特徵,直到根節點。
將構建堆的時間複雜度從O(nlogn)
降爲O(n)
,總的堆排序時間複雜度從O(2nlogn)
改進到O(n+nlogn)
。
自底向上堆排序,僅僅將構建堆的時間複雜度從O(nlogn)
改進到O(n)
,其餘保持不變。
這種堆排序,再也不每次都將元素添加到尾部,而後上浮翻轉,而是在混亂堆的基礎上,從底部向上逐層進行下沉操做,下沉操做比較的次數會減小。步驟以下:
從底部開始,向上推動,因此這種堆排序又叫自底向上的堆排序。
爲何自底向上構建堆的時間複雜度是:O(n)
。證實以下:
第k
層的非葉子節點的數量爲n/2^k
,每個非葉子節點下沉的最大次數爲其子孫的層數:k
,而樹的層數爲logn
層,那麼總的翻轉次數計算以下:
由於以下的公式是成立的:
因此翻轉的次數計算結果爲:2n
次。也就是構建堆的時間複雜度爲:O(n)
。
咱們用非遞歸的形式來實現,非遞歸相對容易理解:
package main import "fmt" // 先自底向上構建最大堆,再移除堆元素實現堆排序 func HeapSort(array []int) { // 堆的元素數量 count := len(array) // 最底層的葉子節點下標,該節點位置不定,可是該葉子節點右邊的節點都是葉子節點 start := count/2 + 1 // 最後的元素下標 end := count - 1 // 從最底層開始,逐一對節點進行下沉 for start >= 0 { sift(array, start, count) start-- // 表示左偏移一個節點,若是該層沒有節點了,那麼表示到了上一層的最右邊 } // 下沉結束了,如今要來排序了 // 元素大於2個的最大堆才能夠移除 for end > 0 { // 將堆頂元素與堆尾元素互換,表示移除最大堆元素 array[end], array[0] = array[0], array[end] // 對堆頂進行下沉操做 sift(array, 0, end) // 一直移除堆頂元素 end-- } } // 下沉操做,須要下沉的元素時 array[start],參數 count 只要用來判斷是否到底堆底,使得下沉結束 func sift(array []int, start, count int) { // 父親節點 root := start // 左兒子 child := root*2 + 1 // 若是有下一代 for child < count { // 右兒子比左兒子大,那麼要翻轉的兒子改成右兒子 if count-child > 1 && array[child] < array[child+1] { child++ } // 父親節點比兒子小,那麼將父親和兒子位置交換 if array[root] < array[child] { array[root], array[child] = array[child], array[root] // 繼續往下沉 root = child child = root*2 + 1 } else { return } } } func main() { list := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3} HeapSort(list) // 打印排序後的值 fmt.Println(list) }
輸出:
[1 3 4 5 6 6 6 8 9 14 25 49]
我是陳星星,歡迎閱讀我親自寫的 數據結構和算法(Golang實現),文章首發於 閱讀更友好的GitBook。