堆和堆傻傻分不清?一文告訴你 Java 集合中「堆」的最佳打開方式

上一篇的 「Java 集合框架」裏,還剩下一個大問題沒有說的,那就是 PriorityQueue,優先隊列,也就是堆,Heap。面試

什麼是堆?

堆其實就是一種特殊的隊列——優先隊列。算法

普通的隊列遊戲規則很簡單:就是先進先出;但這種優先隊列搞特殊,不是按照進隊列的時間順序,而是按照每一個元素的優先級來比拼,優先級高的在堆頂api

這也很容易理解吧,好比各類軟件都有會員制度,某軟件用了會員就能加速下載的,不一樣等級的會員速度還不同,那就是優先級不一樣呀。數組

還有其實每一個人回覆微信消息也是默默的把消息放進堆裏排個序:先回男友女友的,而後再回其餘人的。微信

這裏要區別於操做系統裏的那個「堆」,這兩個雖然都叫堆,可是沒有半毛錢關係,都是借用了 Heap 這個英文單詞而已。數據結構

咱們再來回顧一下「」在整個 Java 集合框架中的位置:框架

也就是說,編輯器

  • PriorityQueue 是一個類 (class);
  • PriorityQueue 繼承自 Queue 這個接口 (Interface);

<span style="display:block;color:blue;">那 heap 在哪呢?spa

heap 實際上是一個抽象的數據結構,或者說是邏輯上的數據結構,並非一個物理上真實存在的數據結構。操作系統

<span style=";color:blue;">heap 其實有不少種實現方式,</span>好比 binomial heap, Fibonacci heap 等等。可是面試最常考的,也是最經典的,就是 binary heap 二叉堆,也就是用一棵徹底二叉樹來實現的。

<span style="display:block;color:blue;">那徹底二叉樹是怎麼實現的?

實際上是用數組來實現的!

因此 binary heap/PriorityQueue 其實是用數組來實現的。

這個數組的排列方式有點特別,由於它總會維護你定義的(或者默認的)優先級最高的元素在數組的首位,因此不是隨便一個數組都叫「堆」,實際上,它在你內心,應該是一棵「徹底二叉樹」。

這棵徹底二叉樹,只存在你內心和各大書本上;實際在在內存裏,哪有什麼樹?就是數組罷了。

那爲何徹底二叉樹能夠用數組來實現?是否是全部的樹都能用數組來實現?

這個就涉及徹底二叉樹的性質了,咱們下一篇會細講,簡單來講,由於徹底二叉樹的定義要求了它在層序遍歷的時候沒有氣泡,也就是連續存儲的,因此能夠用數組來存放;第二個問題固然是否。

堆的特色

  1. 堆是一棵徹底二叉樹;
  2. 堆序性 (heap order): 任意節點都優於它的全部孩子

    a. 若是是任意節點都大於它的全部孩子,這樣的堆叫大頂堆,Max Heap;

    b. 若是是任意節點都小於它的全部孩子,這樣的堆叫小頂堆,Min Heap;

左圖是小頂堆,能夠看出對於每一個節點來講,都是小於它的全部孩子的,注意是全部孩子,包括孫子,曾孫...

  1. 既然堆是用數組來實現的,那麼咱們能夠找到每一個節點和它的父母/孩子之間的關係,從而能夠直接訪問到它們。

好比對於節點 3 來講,

  • 它的 Index = 1,
  • 它的 parent index = 0,
  • 左孩子 left child index = 3,
  • 右孩子 right child index = 4.

能夠概括出以下規律:

  • 設當前節點的 index = x,
  • 那麼 parent index = (x-1)/2,
  • 左孩子 left child index = 2*x + 1,
  • 右孩子 right child index = 2*x + 2.

有些書上可能寫法稍有不一樣,是由於它們的數組是從 1 開始的,而我這裏數組的下標是從 0 開始的,都是能夠的。

這樣就能夠從任意一個點,一步找到它的孫子、曾孫子,真的太方便了,在後文講具體操做時你們能夠更深入的體會到。

基本操做

任何一個數據結構,無非就是增刪改查四大類:

功能 方法 時間複雜度
offer(E e) O(logn)
poll() O(logn)
無直接的 API 刪 + 增
peek() O(1)

這裏 peek() 的時間複雜度很好理解,由於堆的用途就是可以快速的拿到一組數據裏的最大/最小值,因此這一步的時間複雜度必定是 O(1) 的,這就是堆的意義所在。

那麼咱們具體來看 offer(E e)poll() 的過程。

offer(E e)

好比咱們新加一個 0 到剛纔這個最小堆裏面:

那很明顯,0 是要放在最上面的,但是,直接放上去就不是一棵徹底二叉樹了啊。。

因此說,

  • 咱們先保證加了元素以後這棵樹仍是一棵徹底二叉樹,
  • 而後再經過 swap 的方式進行微調,來知足堆序性。

這樣就保證知足了堆的兩個特色,也就是保證了加入新元素以後它仍是個堆

那具體怎麼作呢:

Step 1.

先把 0 放在最後接上,別一上來就想着上位;

OK!總算先上岸了,而後咱們再一步步往上走。

這裏「可否往上走」的標準在於:
是否知足堆序性

也就是說,如今 5 和 0 之間不知足堆序性,那麼交換位置,換到直到知足堆序性爲止

這裏對於最小堆來講的堆序性,就是小的數要在上面

Step 2. 與 5 交換

此時 0 和 3 不知足堆序性了,那麼再交換。

Step 3. 與 3 交換

還不行,0 還比 1 小,因此繼續換。

Step 4. 與 1 交換

OK!這樣就換好了,一個新的堆誕生了~

總結一下這個方法:

先把新元素加入數組的末尾,再經過不斷比較與 parent 的值的大小,決定是否交換,直到知足堆序性爲止。

這個過程就是 siftUp(),源碼以下:

時間複雜度

這裏不難發現,其實咱們只交換了一條支路上的元素,

也就是最多交換 O(height) 次。

那麼對於徹底二叉樹來講,除了最後一層都是滿的,O(height) = O(logn)

因此 offer(E e) 的時間複雜度就是 O(logn) 啦。

poll()

poll() 就是把最頂端的元素拿走。

對了,沒有辦法拿走中間的元素,畢竟要 VIP 先出去,小弟才能出去。

那麼最頂端元素拿走後,這個位置就空了:

咱們仍是先來知足堆序性,由於比較容易知足嘛,直接從最後面拿一個來補上就行了,先放個傀儡上來。

Step1. 末尾元素上位

這樣一來,堆序性又不知足了,開始交換元素。

那 8 比 7 和 3 都大,應該和誰交換呢?

假設與 7 交換,那麼 7 仍是比 3 大,還得 7 和 3 換,麻煩。

因此是與左右孩子中較小的那個交換。

Step 2. 與 3 交換

下去以後,還比 5 和 4 大,那再和 4 換一下。

Step 3. 與 4 交換

OK!這樣這棵樹總算是穩定了。

總結一下這個方法:

先把數組的末位元素加到頂端,再經過不斷比較與左右孩子的值的大小,決定是否交換,直到知足堆序性爲止。

這個過程就是 siftDown(),源碼以下:

時間複雜度

一樣道理,也只交換了一條支路上的元素,也就是最多交換 O(height) 次。

因此 offer(E e) 的時間複雜度就是 O(logn) 啦。

heapify()

還有一個大名鼎鼎的很是重要的操做,就是 heapify() 了,它是一個很神奇的操做,

能夠用 O(n) 的時間把一個亂序的數組變成一個 heap。

可是呢,heapify() 並非一個 public API,看:

因此咱們沒有辦法直接使用。

惟一使用 heapify() 的方式呢,就是使用
PriorityQueue(Collection<? extends E> c)

這個 constructor 的時候,人家會自動調用 heapify() 這個操做。

<span style="display:block;color:blue;">那具體是怎麼作的呢?

哈哈源碼已經暴露了:

從最後一個非葉子節點開始,從後往前作 siftDown().

由於葉子節點不必操做嘛,已經到了最下面了,還能和誰 swap?

舉個例子:

咱們想把這個數組進行 heapify() 操做,想把它變成一個最小堆,拿到它的最小值。

那就要從 3 開始,對 3,7,5進行 siftDown().

Step 1.

尷尬 😅,3 並不用交換,由於以它爲頂點的這棵小樹已經知足了堆序性。

Step 2.

7 比它的兩個孩子都要大,因此和較小的那個交換一下。

交換完成後;

Step 3.

最後一個要處理的就是 5 了,那這裏 5 比它的兩個孩子都要大,因此也和較小的那個交換一下。

換完以後結果以下,注意並無知足堆序性,由於 4 還比 5 小呢。

因此接着和 4 換,結果以下:

這樣整個 heapify() 的過程就完成了。

好了難點來了,爲何時間複雜度是 O(n) 的呢?

怎麼計算這個時間複雜度呢?

其實咱們在這個過程裏作的操做無非就是交換交換。

那到底交換了多少次呢?

沒錯,交換了多少次,時間複雜度就是多少。

那咱們能夠看出來,其實同一層的節點最多交換的次數都是相同的。

那麼這個總的交換次數 = 每層的節點數 * 每一個節點最多交換的次數

這裏設 k 爲層數,那麼這個例子裏 k=3.

每層的節點數是從上到下以指數增加:

$$\ce{1, 2, 4, ..., 2^{k-1}}$$

每一個節點交換的次數,

從下往上就是:

$$ 0, 1, ..., k-2, k-1 $$

那麼總的交換次數 S(k) 就是二者相乘再相加:

$$S(k) = \left(2^{0} *(k-1) + 2^{1} *(k-2) + ... + 2^{k-2} *1 \right)$$

這是一個等比等差數列,標準的求和方式就是錯位相減法

那麼
$$2S(k) = \left(2^{1} *(k-1) + 2^{2} *(k-2) + ... + 2^{k-1} *1 \right)$$

二者相減得:

$$S(k) = \left(-2^{0} *(k-1) + 2^{1} + 2^{2} + ... + 2^{k-2} + 2^{k-1} \right)$$

化簡一下:

(很差意思我實在受不了這個編輯器了。。。

因此 heapify() 時間複雜度是 O(n).

以上就是堆的三大重要操做,最後一個 heapify() 雖然不能直接操做,可是堆排序中用到了這種思路,以前的「選擇排序」那篇文章裏也提到了一些,感興趣的同窗能夠後臺回覆「選擇排序」得到文章~至於堆排序的具體實現和應用,以及爲何實際生產中並不愛用它,咱們以後再講。


最後再說一點題外話,最近發現了幾篇搬運個人文章到其餘平臺的現象。每篇文章都是我精心打造的,都是本身的心肝寶貝,看到別人直接搬運過去也沒有標明做者和來源出處實在是太難受了。。爲了最好的閱讀體驗,文中的圖片我都沒有加水印,但這也方便了他人搬運。今天考慮再三,仍是不想違背本身的本意,畢竟個人讀者更爲重要。

因此若是以後有小夥伴看到了,懇請你們後臺或者微信告訴我一下呀,很是感謝!

我在各大平臺同名,請認準「碼農田小齊」~

若是你喜歡個人文章或者有收穫的話,麻煩給我點個「贊」或者「在看」給我個小鼓勵呀,會讓我開心很久~

想跟我一塊兒玩轉算法和麪試的小夥伴,記得關注我,我是小齊,咱們下期見。

相關文章
相關標籤/搜索