天天看點

優先隊列的核心,面試的常客,帶你深入了解堆

本文始發于個人公衆号:TechFlow,原創不易,求個關注

今天是算法和資料結構的第21篇,我們來聊一個新的資料結構——堆(heap)。

和連結清單、二叉樹以及數組這些熱門的資料結構相比,堆相對比較冷門。如果你對資料結構了解不深的話,可能很少聽說。但是我們經常用到它,雖然可能你并不一定能感覺到。比如說優先隊列,我們就經常使用。我們需要用到這樣一個資料結構,能夠根據我們存入資料的優先級進行排序,将優先級高的排在前面。在和排程相關的一些系統和算法當中,優先隊列是必然會用到的。但是很少有人知道,優先隊列說是一個隊列,但其實是通過堆實作的。

那麼堆究竟是一個怎樣的資料結構呢?

堆的定義

堆的實質其實是二叉樹,并且還不是一般的二叉樹,而是比較特别的二叉樹。

特别在什麼地方呢,在于這棵二叉樹除了葉子之外的所有節點都是“滿”的。所謂的滿,也就是沒有空缺的意思。

從上圖當中我們可以看到,如果去掉最後一層,那麼這棵二叉樹就是全滿的。最後一層葉子節點也是有要求的,要求葉子節點都靠左對齊。滿足這兩個條件的二叉樹成為完全二叉樹(complete binary tree)。這個概念如果記不住也沒有關系,我好像也隻在堆當中遇到。

堆是完全二叉樹,但是顯然不是所有的完全二叉樹都是堆,堆還有一個特殊的性質,就是大小的傳遞性。

堆根據大小傳遞性的不同分為大頂堆和小頂堆,所謂的大頂堆就是堆頂的元素也就是樹根的元素是最大的。如果是小頂堆則相反,堆頂元素是最小的。是以這兩者基本一樣,我們就以大頂堆舉例。這個傳遞性其實很好了解,其實隻有一條,就是所有節點的值大于它兩個孩子的值。也就是說從樹根到樹葉每一條路徑都是遞減的。

我們可以看下這張圖:

img

100有兩個孩子節點,19和36,100比19和36都大。19有兩個孩子17和3,19比它們都大。這應該是很好了解的,堆巧妙的點在哪裡呢,巧妙的點在于我們可以用數組來存儲這個結構,而不需要自己建樹。用數組存儲的方法也很簡單,我們給圖上的點标上下标,可以得到:

image-20200520203331364

我們找下規律,可以發現對于一個樹上的一個節點,假設它在數組當中的下标是i的話。那麼它的左孩子的下标是i * 2 + 1, 它的右孩子下标為i * 2 + 2,它的父節點是(i - 1) // 2。

有了這個規律之後,我們隻要知道某個點的坐标,就可以知道它的父親和兩個孩子的坐标。這也是用數組來存儲堆非常巧妙和友善的點。

堆的插入

有了這個性質有什麼用呢?其實很有用,我們可以利用它非常友善地完成堆的維護。

想象一下,首先,往堆的末尾插入元素并不會破壞完全二叉樹這個性質。因為完全二叉樹的葉子節點是往左對齊的,我們插入元素必然也是在最左邊,是以不管我們插入的元素數值是多大,這都不會影響完全二叉樹這個性質。但是顯然,我們插入的數值大小會影響堆結構的正确性,比如如果我們插入9,插入的位置就不滿足大頂堆的性質了。

image-20200520203932970

這個時候,為了維護堆的性質我們需要調整當中的資料。是以其實堆插入元素的過程就是一個調整順序的過程,那怎麼調整呢,其實很簡單,就是将我們插入的元素向上比較,如果它比它的父節點元素更大,那麼就将它和父節點進行交換,一直到它小于父節點或者是它稱為堆頂為止。

假如說,我們插入的元素不是9,而是105,那麼最後得到的結果應該是這樣的:

image-20200520204847608

這個就是堆的插入過程,因為我們隻有插入的位置可能會導緻破壞堆結構,是以我們隻需要從插入的位置開始往上維護即可。其餘的位置并不會影響,并且對于整個這條鍊路上可能會被影響的節點而言,它們隻可能變大,不可能減小,是以也不會出現交換了之後有新的不滿足堆性質的情況産生。

由于這個插入的過程是自下而上的,是以整個過程也被稱為是向上更新。

我們來看下向上維護的代碼,隻有幾行,非常簡單:

def maintain(self):
   # 因為向上更新出現在插入元素之後,是以從最後一個位置開始維護
    n = self._size - 1
    # cmp是自定義比較函數,用來自定義大頂堆還是小頂堆
    while n > 0 and self.cmp(self.items[n], self.items[(n-1)//2]):
       # 如果目前節點比父節點“大”,則交換
        self.items[n], self.items[(n-1)//2] = self.items[(n-1)//2], self.items[n]
        n = (n - 1) // 2
           

了解了堆的插入之後,那麼堆的建立也應該很好了解了。所謂建堆的過程,其實就是将元素一個一個插入堆當中的過程。

我們利用maintain方法,很容易實作建堆的過程。

def push(self, item):
        if self._size_limit is not None and self._size > self._size_limit:
            raise IndexError('Heap is full')
        
        # array_size是數組的長度
        # 由于允許彈出元素,是以會導緻數組的長度大于目前元素的數量的情況
        if self._size < self.array_size:
            self.items[self._size] = item
            self._size += 1
        # 如果數組長度等于元素數量,那麼append到數組末尾
        else:
            self.items.append(item)
            self._size += 1
            self.array_size += 1
        # 調用maintain,維護堆性質
        self.maintain()

    @staticmethod
    def heapify(elements, min_or_max='min', compare_function=None):
       # 初始化, min_or_max表示是大頂堆還是小頂堆,允許傳入自定義比較函數
        heap = HeapQueue(min_or_max=min_or_max, compare_function=compare_function)
        for i in elements:
            heap.push(i)
        return heap
           

堆的查詢與彈出

堆和其他資料結構不同,它對于資料的查詢非常有限,隻允許查詢堆頂的元素。如果是大頂堆就是允許查詢最大元素,否則則是允許查詢最小元素。同樣,也隻允許删除堆頂的元素,由于删除堆頂的元素好像擠牙膏一樣将元素擠出去一樣,是以也稱為“彈出”(pop)。

隻是查詢很好了解,我們傳回數組下标為0的元素即可,但是如果是彈出該怎麼辦呢?彈出了之後,不是堆頂元素就空缺了嗎?産生空缺了之後怎麼辦呢?

一種辦法是從根節點的孩子當中選擇一個作為替補頂上來,但是替補頂上之後又會産生新的空缺,這樣一調整可能完全二叉樹的性質就保不住了。是以隻有一個辦法,也很直覺,就是将末尾的元素拿出來作為替補放到堆頂。但是顯然末尾的元素不是最大的,是以這樣操作之後還需要調整。

怎麼調整呢?

當然是從堆頂開始将它和左右孩子進行比較,如果左右孩子當中存在比它大的,那麼就交換,将這個元素往下傳遞。比如下圖當中,我們彈出堆頂的元素100,根據算法的邏輯,我們會用末尾的7作為替補。

image-20200520210941652

第一次我們将它和19與36進行比較,由于要滿足大頂堆的性質,我們選擇其中最大的36交換。于是我們将7往下傳遞到了原來36的位置,我們繼續将它和兩個孩子節點進行比較。我們發現25大于7,于是我們繼續交換,這個時候到達了葉子節點,停止。

我們再反觀維護之後的結果,仍然滿足大頂堆的性質,并且仍然是一棵完全二叉樹。和剛才插入時候的維護進行對比,我們會發現其實這整個過程是一個向下更新的過程。堆這個資料結構的核心其實就在這兩個更新當中,在插入的時候向上更新,在彈出的時候向下更新。

代碼也非常簡單:

def pop(self):
        front = self.items[0]
       # 彈出下标為0的節點之後,将末尾元素插入頭部
        self.items[0] = self.items[self._size-1]
        self._size -= 1
        self.downward_maintain()
        return front

    def downward_maintain(self):
        n = 0
        while n * 2 + 1 < self._size:
           # 計算左右孩子的下标
            left_child = n * 2 + 1
            right_child = n * 2 + 2
            # 判斷左右孩子的大小關系,隻會和其中大的發生交換
            if self.cmp(self.items[left_child], self.items[right_child]):
               # 如果左孩子大于右孩子也大于父節點,則交換
                if self.cmp(self.items[left_child], self.items[n]):
                    self.items[n], self.items[left_child] = self.items[left_child], self.items[n]
                    n = left_child
                else:
                    break
            # 如果右孩子最大,也交換
            elif self.cmp(self.items[right_child], self.items[n]):
                self.items[n], self.items[right_child] = self.items[right_child], self.items[n]
                n = right_child
            else:
                break
           

堆與優先隊列

到這裡,整個堆的主體部分就實作完了。整個過程還是比較直覺的,并不複雜,隻要記住了兩個更新就足夠了。

了解了堆之後我們再來看優先隊列,我們使用優先隊列的時候,希望每次取出優先級最大的資料,然後當我們填入資料的時候,隊列會自動根據我們設定的優先級對資料進行排序。這不剛好就是堆的功能嗎?是以通過堆,我們可以很友善地實作優先隊列,當然我們沒有必要這麼做,因為在Python當中為我們封裝了現成的堆,我們隻需要調用它,就可以很輕松地自己封裝一個優先隊列。

import heapq

class PriorityQueue:
  
    def __init__(self):
        self._queue = []
        self._index = 0

    def push(self, item, priority):
        # 傳入兩個參數,一個是存放元素的數組,另一個是要存儲的元素,這裡是一個元組。
        # 由于heap内部預設由小到大排,是以對priority取負數
        heapq.heappush(self._queue, (-priority, self._index, item))
        self._index += 1

    def pop(self):
        return heapq.heappop(self._queue)[-1]
           

如果你願意,你還可以往這個類當中加入一些其他的輔助函數。當然,我還是建議大家,都能自己從頭用堆實作一個屬于自己的優先隊列,體會一下親手實作資料結構的成就感。如果你想要參考我的代碼,可以在公衆号内回複“堆”,我把我的代碼發給你。

堆很實用,也不難寫,希望大家都能體會到手撸資料結構的快樂。

今天的文章就到這裡,原創不易,掃碼關注我,擷取更多精彩文章。