堆 堆排序 優先隊列 圖文詳解(Golang實現)

引入

在實際應用中,我們經常需要從一組對象中查找最大值最小值。當然我們可以每次都先排序,然後再進行查找,但是這種做法效率很低。哪么有沒有一種特殊的數據結構,可以高效率的實現我們的需求呢,答案就是堆(heap)

堆分為最小堆和最大堆,它們的性質相似,我們以最小堆為例子。

最小堆

舉例

如上圖所示,就為一個最小堆。

特性

  • 是一棵完全二叉樹

如果一顆二叉樹的任何結點,或者是樹恭弘=叶 恭弘,或者左右子樹均非空,則這棵二叉樹稱做滿二叉樹(full binary tree)

如果一顆二叉樹最多只有最下面的兩層結點度數可以小於2,並且最下面一層的結點都集中在該層最左邊的連續位置上,則此二叉樹稱做完全二叉樹(complete binary tree)

  • 局部有序

最小堆對應的完全二叉樹中所有結點的值均不大於其左右子結點的值,且一個結點與其兄弟之間沒有必然的聯繫

二叉搜索樹中,左子 < 父 < 右子

存儲結構

由於堆是一棵完全二叉樹,所以我們可以用順序結構來存儲它,只需要計算簡單的代數表達式,就能夠非常方便的查找某個結點的父結點和子節點,既避免了使用指針來保持結構,又能高效的執行相應操作。

結點i的左子結點為2xi+1,右子結點為2xi+2
結點i的父節點為(i-1)/2

數據結構

// 本例為最小堆
// 最大堆只需要修改less函數即可
type Heap []int

func (h Heap) swap(i, j int) {
    h[i], h[j] = h[j], h[i]
}

func (h Heap) less(i, j int) bool {
    return h[i] < h[j]
}

如上所示,我們使用slice來存儲我們的數據,為了後續方便我們在此定義了 swapless 函數,分別用來交換兩個結點和比較大小。

插入-Push

如上圖所示,首先,新添加的元素加入末尾。為了保持最小堆的性質,需要沿着其祖先的路徑,自下而上依次比較和交換該結點與父結點的位置,直到重新滿足堆的性質為止。

這樣會出現兩種情況,要麼新結點升到最小堆的頂端,要麼到某一位置時發現父結點比新插入的結點關鍵值小。

上面的流程代碼如下:

func (h Heap) up(i int) {
    for {
        f := (i - 1) / 2 // 父親結點
        if i == f || h.less(f, i) {
            break
        }
        h.swap(f, i)
        i = f
    }
}

實現了最核心的 up 操作后,我們的插入操作 push 便很簡單,代碼如下:

// 注意go中所有參數轉遞都是值傳遞
// 所以要讓h的變化在函數外也起作用,此處得傳指針
func (h *Heap) Push(x int) {
    *h = append(*h, x)
    h.up(len(*h) - 1)
}

刪除-Remove

如上圖所示,首先把最末端的結點填入要刪除節點的位置,然後刪除末端元素,同理,這樣做也可能導致破壞最小堆的堆序特性。

為了保持堆的特性,末端元素需要與被刪除位置的父結點做比較,如果小於父結點,就要up(詳細代碼看插入)如果大於父結點,就要再和被刪除位置的子結點做比較,即down,直到該結點下降到小於最小子結點為止。

上面down的流程代碼如下:

func (h Heap) down(i int) {
    for {
        l := 2*i + 1 // 左孩子
        if l >= len(h) {
            break // i已經是恭弘=叶 恭弘子結點了
        }
        j := l
        if r := l + 1; r < len(h) && h.less(r, l) {
            j = r // 右孩子
        }
        if h.less(i, j) {
            break // 如果父結點比孩子結點小,則不交換
        }
        h.swap(i, j) // 交換父結點和子結點
        i = j        //繼續向下比較
    }
}

實現了核心的 down 操作后,我們的 Remove 便很簡單,代碼如下:

// 刪除堆中位置為i的元素
// 返回被刪元素的值
func (h *Heap) Remove(i int) (int, bool) {
    if i < 0 || i > len(*h)-1 {
        return 0, false
    }
    n := len(*h) - 1
    h.swap(i, n) // 用最後的元素值替換被刪除元素
    // 刪除最後的元素
    x := (*h)[n]
    *h = (*h)[0:n]
    // 如果當前元素大於父結點,向下篩選
    if (*h)[i] > (*h)[(i-1)/2] {
        h.down(i)
    } else { // 當前元素小於父結點,向上篩選
        h.up(i)
    }
    return x, true
}

彈出-Pop

當i=0時,Remove 就是 Pop

// 彈出堆頂的元素,並返回其值
func (h *Heap) Pop() int {
    n := len(*h) - 1
    h.swap(0, n)
    x := (*h)[n]
    *h = (*h)[0:n]
    h.down(0)
    return x
}

初始化-Init

在我們講完了堆的核心操作 updown 后,我們來講如何根據一個數組構造一個最小堆。

其實我們可以寫個循環,然後將各個元素依次 push 進去,但是這次我們利用數學規律,直接由一個數組構造最小堆。

首先,將所有關鍵碼放到一維數組中,此時形成的完全二叉樹並不具備最小堆的特徵,但是僅包含恭弘=叶 恭弘子結點的子樹已經是堆。

即在有n個結點的完全二叉樹中,當 i>n/2-1 時,以i結點為根的子樹已經是堆。

func (h Heap) Init() {
    n := len(h)
    // i > n/2-1 的結點為恭弘=叶 恭弘子結點本身已經是堆了
    for i := n/2 - 1; i >= 0; i-- {
        h.down(i)
    }
}

測試

func main() {
    var h = heap.Heap{20, 7, 3, 10, 15, 25, 30, 17, 19}
    h.Init()
    fmt.Println(h) // [3 7 20 10 15 25 30 17 19]

    h.Push(6)
    fmt.Println(h) // [3 6 20 10 7 25 30 17 19 15]

    x, ok := h.Remove(5)
    fmt.Println(x, ok, h) // 25 true [3 6 15 10 7 20 30 17 19]

    y, ok := h.Remove(1)
    fmt.Println(y, ok, h) // 6 true [3 7 15 10 19 20 30 17]

    z := h.Pop()
    fmt.Println(z, h) // 3 [7 10 15 17 19 20 30]
}

完整代碼

堆排序

在講完堆的基礎知識后,我們再來看堆排序就變得非常簡單。利用最小堆的特性,我們每次都從堆頂彈出一個元素(這個元素就是當前堆中的最小值),即可實現升序排序。代碼如下:

// 堆排序
var res []int
for len(h) != 0 { 
    res = append(res, h.Pop())
}
fmt.Println(res)

優先隊列

優先隊列是0個或者多個元素的集合,每個元素都有一個關鍵碼,執行的操作有查找,插入和刪除等。

優先隊列的主要特點是支持從一個集合中快速地查找並移出具有最大值或最小值的元素。

堆是一種很好的優先隊列的實現方法。

參考資料

  • 《數據結構與算法》張銘 王騰蛟 趙海燕 編著
  • GO SDK 1.13.1 /src/container/heap

最後

本文是自己的學習筆記,在刷了幾道LeetCode中關於堆的題目后,感覺應該系統的學習和總結一下這一重要的數據結構了。

強烈建議看Go的源碼中關於heap的實現,仔細感受面向接口編程的思想,和他們的代碼風格以及質量。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【精選推薦文章】

自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

評比前十大台北網頁設計台北網站設計公司知名案例作品心得分享

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

您可能也會喜歡…