拜託,面試別再問我堆(排序)了!

彤哥讀原始碼發表於2019-04-19

何為堆?

堆是一種特殊的樹,只要滿足下面兩個條件,它就是一個堆:

(1)堆是一顆完全二叉樹;

(2)堆中某個節點的值總是不大於(或不小於)其父節點的值。

其中,我們把根節點最大的堆叫做大頂堆,根節點最小的堆叫做小頂堆。

堆詳解

滿二叉樹

滿二叉樹是指所有層都達到最大節點數的二叉樹。比如,下面這顆樹:

heap1

完全二叉樹

完全二叉樹是指除了最後一層其它層都達到最大節點數,且最後一層節點都靠左排列。比如,下面這顆樹:

heap2

可見,其實滿二叉樹是一種特殊的完全二叉樹。

那麼,使用什麼結構儲存完全二叉樹最節省空間呢?

我們可以看見,完全二叉樹的節點都是比較緊湊的,且只有最後一層是不滿的,所以使用陣列是最節省空間的,比如上面這顆完全二叉樹我們可以這樣儲存。

heap00

我們下標為0的位置不儲存元素,從下標為1的位置開始儲存元素,每層依次從左往右放到陣列裡來儲存。

為什麼下標0的位置不存在元素呢?

這是因為這樣儲存我們可以很方便地找到父節點,比如,4的父節點即4/2=2,5的父節點即5/2=2。

堆也是一顆完全二叉樹,但是它的元素必須滿足每個節點的值都不大於(或不小於)其父節點的值。比如下面這個堆:

heap3

前面我們說過完全二叉樹適合使用陣列來儲存,那上面這個堆應該怎麼儲存呢?

同樣地,我們下標為0的位置不存在元素,最後就變成下面這樣。

heap01

這時候我們要找8的父節點就拿8的位置下標5/2=2,也就是5這個節點的位置,這也是為了我們後面堆化。

插入元素

往堆中插入一個元素後,我們需要繼續滿足堆的兩個特性,即:

(1)堆是一顆完全二叉樹;

(2)堆中某個節點的值總是不大於(或不小於)其父節點的值。

為了滿足條件(1),所以我們把元素插入到最後一層最後一個節點往後一位的位置,但是插入之後可能不再滿足條件(2)了,所以這時候我們需要堆化。

比如,上面那個堆我們需要插入元素2,我們把它放在9後面,這時不滿足條件(2)了,我們就需要堆化。(這是一個小頂堆)

heap4

將完全二叉樹和陣列對照著來看。

在完全二叉樹中,插入的節點與它的父節點相比,如果比父節點小,就交換它們的位置,再往上和父節點相比,如果比父節點小,再交換位置,直到比父節點大為止。

在陣列中,插入的節點與n/2位置的節點相比,如果比n/2位置的節點小,就交換它們的位置,再往前與n/4位置的節點相比,如果比n/4位置的節點小,再交換位置,直到比n/(2^x)位置的節點大為止。

這就是插入元素時進行的堆化,也叫自下而上的堆化。

從插入元素的過程,我們知道每次與n/(2^x)的位置進行比較,所以,插入元素的時間複雜度為O(log n)。

刪除堆頂元素

我們知道,在小頂堆中堆頂儲存的是最小的元素,這時候我們把它刪除會怎樣呢?

刪除了堆頂元素後,要使得還滿足堆的兩個特性,首先,我們可以把最後一個元素移到根節點的位置,這時候就滿足條件(1),之後就是使它滿足條件(2),就需要堆化了。

heap5

將完全二叉樹和陣列對照著來看。

在完全二叉樹中,把最後一個節點放到堆頂,然後與左右子節點中小的交換位置(因為是小頂堆),依次往下,直到其比左右子節點都小為止。

在陣列中,把最後一個元素移到下標為1的位置,然後與下標為2和3的位置對比,發現8比2大,且2是2和3中間最小的,所以與2交換位置;然後再下標為4和5的位置對比,發現8比5大,且5是5和7中最小的,所以與5交換位置,沒有左右子節點了,堆化結束。

這就是刪除元素時進行的堆化,也叫自上而下的堆化。

從刪除元素的過程,我們知道把最後一個元素拿到根節點後,每次與2n和(2n+1)位置的元素比較,取其小者,所以,刪除元素的時間複雜度也為O(log n)。

建堆

假定給定一組亂序的陣列,我們該怎麼建堆呢?

如下圖所示,我們模擬依次往堆中新增元素。

(1)插入6這個元素,只有一個,不需要比較;

(2)插入8這個元素,比6大,不需要交換;

(3)插入3這個元素,比下標3/2=1的位置上的元素6小,交換位置;

(4)插入2這個元素,比下標4/2=2的位置上的元素8小,交換位置,比下標2/2=1的位置上的元素3小,交換位置;

(5)...

(10)最後,全部插入完成,即完成了建堆的過程。

heap6

我們知道,完全二叉樹的高度h=log n,且第h層有1個元素,第(h-1)層有2個元素,第(h-2)層有2^2個元素,...,第1層有2^(h-1)個元素。

heap7

其實,建堆的整個過程中一個節點的比較次數是與它的高度k成正比的,比如,上圖中的1這個元素,它也是從最後一層依次比較了3次(高度h=4),才到達了現在的位置。

所以,我們可以得出第h層的元素有1個,它最多需要比較(h-1)次;第(h-1)層有2個元素,它們最多比較(h-2)次;第(h-2)層有2^2個元素,它們最多比較(h-3)次;...;第1層有2^(h-1)個元素,它們最多比較0次。

因而,總和就如下圖:

heap8

所以,建堆的時間複雜度就是O(n)。

堆排序

我們知道,對於小頂堆,堆頂儲存的元素就是最小的。

那麼,我們刪除堆頂元素,堆化,第二小的跑堆頂了,再刪除,再堆化,...,這些刪除的元素是不是正好有序的?

當然是的,所以堆排序的過程就很簡單了。

我們直接把堆頂的元素與第n個元素交換位置,再把前(n-1)個元素堆化,再把堆頂元素與第(n-1)個元素交換位置,再把前(n-2)個元素堆化,..,,進行下去,最後,陣列中的元素就整個變成倒序的了,也就排序完了。

我們知道刪除一個元素的時間複雜度是O(log n),那麼刪除n個元素正好是:

log n + log(n-1) + log(n-2) + log 1

這個公式約等於nlog n,所以堆排序的時間複雜度為O(nlog n)。

而且,這樣排序不需要佔用額外的空間,只需要交換元素的需要一個臨時變數,所以堆排序的空間複雜度為O(1)​。​

總結

(1)堆是一顆完全二叉樹;

(2)小(大)頂堆中的每一個節點都不小於(不大於)它的父節點;

(3)堆的插入、刪除元素的時間複雜度都是O(log n);

(4)建堆的時間複雜度是O(n);

(5)堆排序的時間複雜度是O(nlog n);

(6)堆排序的空間複雜度是O(1)​;​

彩蛋

堆都有哪些應用呢?

其實,堆除了堆排序以外,還有很多其它的用途,比如求中位數,99%位數,定時任務等。

比如,求中位數的大致思路,是分別建立一個大頂堆和一個小頂堆,然後往這兩個堆中放元素,當其中一個堆的元素個數比另外一個多2時,就平衡一下,這樣所有元素都放完之後,兩個堆頂的元素之一(或之二)就是中位數。


歡迎關注我的公眾號“彤哥讀原始碼”,檢視更多原始碼系列文章, 與彤哥一起暢遊原始碼的海洋。

qrcode

相關文章