數據結構和算法(Golang實現)(24)排序算法-優先隊列及堆排序

優先隊列及堆排序

堆排序(Heap Sort)由威爾士-加拿大計算機科學家J. W. J. Williams1964年發明,它利用了二叉堆(A binary heap)的性質實現了排序,並證實了二叉堆數據結構的可用性。同年,美國籍計算機科學家R. W. Floyd在其樹排序研究的基礎上,發佈了一個改進的更好的原地排序的堆排序版本。算法

堆排序屬於選擇類排序算法。segmentfault

1、優先隊列

優先隊列是一種能完成如下任務的隊列:插入一個數值,取出最小或最大的數值(獲取數值,而且刪除)。數組

優先隊列能夠用二叉樹來實現,咱們稱這種結構爲二叉堆。數據結構

最小堆和最大堆是二叉堆的一種,是一顆徹底二叉樹(一種平衡樹)。併發

最小堆的性質:數據結構和算法

  1. 父節點的值都小於左右兒子節點。
  2. 這是一個遞歸的性質。

最大堆的性質:函數

  1. 父節點的值都大於左右兒子節點。
  2. 這是一個遞歸的性質。

最大堆和最小堆實現方式同樣,只不過根節點一個是最大的,一個是最小的。性能

1.1. 最大堆特徵

最大堆實現細節(兩個操做):spa

  1. push:向堆中插入數據時,首先在堆的末尾插入數據,若是該數據比父親節點還大,那麼交換,而後不斷向上提高,直到沒有大小顛倒爲止。
  2. pop:從堆中刪除最大值時,首先把最後一個值複製到根節點上,而且刪除最後一個數值,而後和兒子節點比較,若是值小於兒子,與兒子節點交換,而後不斷向下交換, 直到沒有大小顛倒爲止。在向下交換過程當中,若是有兩個子兒子都大於本身,就選擇較大的。

最大堆有兩個核心操做,一個是上浮,一個是下沉,分別對應pushpop3d

這是一個最大堆:

用數組表示爲:[11 5 8 3 4]

1.2. 上浮操做

咱們要往堆裏push一個元素15,咱們先把X = 15放到樹最尾部,而後進行上浮操做。

由於15大於其父親節點8,因此與父親替換:

這時15仍是大於其父親節點11,繼續替換:

操做一次push的最好時間複雜度爲:O(1),由於第一次上浮時若是不大於父親,那麼就結束了。最壞的時間複雜度爲:O(logn),至關於每次都大於父親,會一直往上浮到根節點,翻轉次數等於樹的高度,而樹的高度等於元素個數的對數:log(n)

1.3. 下沉操做

咱們如今要將堆頂的元素pop出。如圖咱們要移除最大的元素11

咱們先將根節點移除,而後將最尾部的節點4放在根節點上:

接着對根節點4進行下沉操做,與其兩個兒子節點比較,發現較大的兒子節點84大,那麼根節點4與其兒子節點8交換位置,向下翻轉:

這樣一直向下翻轉就維持了最大堆的特徵。

操做一次pop最好的時間複雜度也是:O(1),由於第一次比較時根節點就是最大的。最壞時間複雜度仍然是樹的高度:O(logn)

1.4. 時間複雜度分析

構建一個最大堆,從空堆開始,每次添加元素到尾部後,須要向上翻轉,最壞翻轉次數是:

第一次添加元素翻轉次數: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)

1.5. 最大堆實現

// 一個最大堆,一顆徹底二叉樹
// 最大堆要求節點元素都不小於其左右孩子
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
}

以上爲最大堆的實現。

3、普通堆排序

根據最大堆,堆頂元素一直是最大的元素特徵,能夠實現堆排序。

先構建一個最小堆,而後依次把根節點元素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)

3、自底向上堆排序

自底向上堆排序,僅僅將構建堆的時間複雜度從O(nlogn)改進到O(n),其餘保持不變。

這種堆排序,再也不每次都將元素添加到尾部,而後上浮翻轉,而是在混亂堆的基礎上,從底部向上逐層進行下沉操做,下沉操做比較的次數會減小。步驟以下:

  1. 先對最底部的全部非葉子節點進行下沉,即這些非葉子節點與它們的兒子節點比較,較大的兒子和父親交換位置。
  2. 接着從次二層開始的非葉子節點重複這個操做,直到到達根節點最大堆就構建好了。

從底部開始,向上推動,因此這種堆排序又叫自底向上的堆排序。

爲何自底向上構建堆的時間複雜度是: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

相關文章
相關標籤/搜索