何為堆?
堆是一種特殊的樹,只要滿足下面兩個條件,它就是一個堆:
(1)堆是一顆完全二叉樹;
(2)堆中某個節點的值總是不大於(或不小於)其父節點的值。
其中,我們把根節點最大的堆叫做大頂堆,根節點最小的堆叫做小頂堆。
堆詳解
滿二叉樹
滿二叉樹是指所有層都達到最大節點數的二叉樹。比如,下面這顆樹:
完全二叉樹
完全二叉樹是指除了最後一層其它層都達到最大節點數,且最後一層節點都靠左排列。比如,下面這顆樹:
可見,其實滿二叉樹是一種特殊的完全二叉樹。
那麼,使用什麼結構儲存完全二叉樹最節省空間呢?
我們可以看見,完全二叉樹的節點都是比較緊湊的,且只有最後一層是不滿的,所以使用陣列是最節省空間的,比如上面這顆完全二叉樹我們可以這樣儲存。
我們下標為0的位置不儲存元素,從下標為1的位置開始儲存元素,每層依次從左往右放到陣列裡來儲存。
為什麼下標0的位置不存在元素呢?
這是因為這樣儲存我們可以很方便地找到父節點,比如,4的父節點即4/2=2,5的父節點即5/2=2。
堆
堆也是一顆完全二叉樹,但是它的元素必須滿足每個節點的值都不大於(或不小於)其父節點的值。比如下面這個堆:
前面我們說過完全二叉樹適合使用陣列來儲存,那上面這個堆應該怎麼儲存呢?
同樣地,我們下標為0的位置不存在元素,最後就變成下面這樣。
這時候我們要找8的父節點就拿8的位置下標5/2=2,也就是5這個節點的位置,這也是為了我們後面堆化。
插入元素
往堆中插入一個元素後,我們需要繼續滿足堆的兩個特性,即:
(1)堆是一顆完全二叉樹;
(2)堆中某個節點的值總是不大於(或不小於)其父節點的值。
為了滿足條件(1),所以我們把元素插入到最後一層最後一個節點往後一位的位置,但是插入之後可能不再滿足條件(2)了,所以這時候我們需要堆化。
比如,上面那個堆我們需要插入元素2,我們把它放在9後面,這時不滿足條件(2)了,我們就需要堆化。(這是一個小頂堆)
將完全二叉樹和陣列對照著來看。
在完全二叉樹中,插入的節點與它的父節點相比,如果比父節點小,就交換它們的位置,再往上和父節點相比,如果比父節點小,再交換位置,直到比父節點大為止。
在陣列中,插入的節點與n/2位置的節點相比,如果比n/2位置的節點小,就交換它們的位置,再往前與n/4位置的節點相比,如果比n/4位置的節點小,再交換位置,直到比n/(2^x)位置的節點大為止。
這就是插入元素時進行的堆化,也叫自下而上的堆化。
從插入元素的過程,我們知道每次與n/(2^x)的位置進行比較,所以,插入元素的時間複雜度為O(log n)。
刪除堆頂元素
我們知道,在小頂堆中堆頂儲存的是最小的元素,這時候我們把它刪除會怎樣呢?
刪除了堆頂元素後,要使得還滿足堆的兩個特性,首先,我們可以把最後一個元素移到根節點的位置,這時候就滿足條件(1),之後就是使它滿足條件(2),就需要堆化了。
將完全二叉樹和陣列對照著來看。
在完全二叉樹中,把最後一個節點放到堆頂,然後與左右子節點中小的交換位置(因為是小頂堆),依次往下,直到其比左右子節點都小為止。
在陣列中,把最後一個元素移到下標為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)最後,全部插入完成,即完成了建堆的過程。
我們知道,完全二叉樹的高度h=log n,且第h層有1個元素,第(h-1)層有2個元素,第(h-2)層有2^2個元素,...,第1層有2^(h-1)個元素。
其實,建堆的整個過程中一個節點的比較次數是與它的高度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次。
因而,總和就如下圖:
所以,建堆的時間複雜度就是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時,就平衡一下,這樣所有元素都放完之後,兩個堆頂的元素之一(或之二)就是中位數。
歡迎關注我的公眾號“彤哥讀原始碼”,檢視更多原始碼系列文章, 與彤哥一起暢遊原始碼的海洋。