十四、堆(Heap)
0、
堆(Heap)
堆排序是一種原地的、時間復雜度為O(nlogn)的排序算法。
引入:快速排序平均情況下,時間復雜度為O(nlogn),甚至堆排序比快速排序的時間復雜度還要穩定,但在實際中,快排性能要比堆排序好,為什么???
一、堆的概述
堆——是一種特殊的樹
- 堆是一個完全二叉樹(除了最后一層,其他層的節點個數都是滿的,最后一層的節點都是靠左排列)
- 堆中每一個節點的值都必須大于等于(或小于等于)其子樹中每個節點的值(也就是說,堆中每個節點的值都大于等于(或小于等于)其左右子節點的值)
大頂堆:對于每個節點的值都大于等于子樹中每個節點的堆。
小頂堆:對于每個節點的值都小于等于子樹中每個節點的堆。
注意:對于同一組數據,可以構建多種不同形態的堆(大頂堆可有不同形態,小頂堆同理)
二、堆的操作
ps:以大頂堆為例
1、插入操作
- 把新插入的元素放在堆的最后;
- 堆化(heapify):對堆進行調整,使其重新滿足堆的特性
- 兩種堆化方法:
- 思路:順著節點所在的路徑,向上或向下進行對比,然后交換
- (1)從下往上(如下圖所示)
- (2)從上往下
- 兩種堆化方法:
==》code
2、刪除堆頂元素
棧頂元素存儲的就是堆中數據的最大值或最小值。
以大頂堆為例,堆頂元素就是最大元素。當刪除堆頂元素之后,則需要把第二大的元素放到堆頂,第二大元素必為于左右子節點中。然后迭代地刪除第二大節點,以此類推,直到葉子節點被刪除。
==》會出現數組空洞,也就是堆化出來的堆并不滿足完全二叉樹的特性。
解決方法:
- 將最后一個元素放在堆頂;
- 然后利用同樣的父子節點對比方法:若不滿足父子節點大小關系,則交換兩個節點并重復該過程,直到父子節點之間滿足大小關系為止。==》從上往下的堆化方法
實現代碼:
public void removeMax() {// 堆中沒有數據if (count == 0) return -1;a[1] = a[count];--count;heapify(a, count, 1); }private void heapify(int[] a, int n, int i) {// 自上往下堆化while(true) {int maxPos = i;// 尋找最大值的位置if(i*2 <= n && a[i] < a[i/2])maxPos = i * 2;if(i*2+1 <= n && a[maxPos] < a[i*2+1])maxPos = i * 2 + 1;if(maxPos == i)break;swap(a, i, maxPos);i = maxPos;} }3、時間復雜度分析
① 一個包含 n 個節點的完全二叉樹,樹的高度不會超過 log2n;
② 堆化過程是順著節點所在路徑比較交換的
==》堆化時間復雜度與樹高成正比,也就是O(logn)
==》插入數據和刪除堆頂元素主要邏輯是堆化
==》時間復雜度O(logn)
三、堆的存儲
完全二叉樹比較適合用數組來存儲,非常節省存儲空間。
- 原因:不需要存儲左右節點的指針,可單純地通過數組的下標,來找到節點的左右子節點和父節點。(根節點存儲在數組下標為1的位置,數組下標為i的節點的左子結點的下標為2*i,右子節點的下標為2*i+1)
四、堆排序的實現
- 時間復雜度為O(n2):冒泡排序、插入排序、選擇排序
- 時間復雜度為O(nlogn):歸并排序、快速排序、線性排序
1、堆排序
堆排序:基于堆這種數據結構實現的排序算法。
堆排序時間復雜度非常穩定的原地排序算法——O(nlogn)
堆排序的過程大致分解為:建堆和排序
(1)建堆
目標:將數組原地建成一個堆。 原地就是不借助另一個數組,就在原數組上進行操作。
思路一:將元素依次插入堆中,數組下標從1開始。該思路從前往后處理數組數據,并且每個數據插入堆中時,都是從下往上堆化。
思路二
-
與思路一相反,從后往前處理數組,并且每個數據都是從上往下堆化。
-
思路二代碼實現:
-
分析:代碼僅對下標從 n/2 開始到 1 的數據進行堆化,下標從 n/2+1 到 n 的節點是葉子節點,不需要堆化
-
時間復雜度:
- 節點堆化的時間復雜度:O(logn)
- n/2 個節點堆化的總時間復雜度:O(nlogn) ==》不夠精確
- 堆排序的建堆過程的時間復雜度:O(n)
-
推導過程:
① 由于葉子節點不需要堆化,所以需要堆化的節點從倒數第二層開始;
② 每個節點堆化的過程中,需比較和交換的節點個數,與該節點的高度 k (從節點到葉節點的路徑長度)成正比
③ 將每個非葉子節點的高度求和,公式如下:
④ 由于 h = log2n,代入公式S,可得 S = O(n)
⑤ 時間復雜度——O(n)
(2)排序——大頂堆《==》小頂堆
- 建堆后,數組中的數據已經是按照大頂堆的特性組織的;
- 將數組中的第一個元素(堆頂)與最后一個元素交換,則最大元素就放在下標為 n 的位置;
- 類似“刪除堆頂元素”的操作,然后通過堆化方法將剩下的 n-1 個元素重新構建為堆;
- 重復上述過程,直到最后堆只剩一個下標為1的一個元素
-
代碼
-
分析
- 原地排序算法:整個堆排序過程,都只需要極個別臨時存儲空間。
- 時間復雜度:建堆O(n)+排序O(nlogn)
- ==》整體時間復雜度: O(nlogn)
- 不穩定排序算法:在排序過程中,存在將堆的最后一個節點跟堆頂點互換的操作,可以改變值相同數據的原始相當順序。
注意:若堆中數據從數組下標0開始存儲,則節點下標為 i 時,其左子節點下標為 2i+1,右子節點的下標為2i+2,其父節點的下標為 (i-1)/2
五、在實際開發中,為什么快排要比堆排序性能好?
- 堆排序數據訪問的方式沒有快速排序友好。
- 快排數據——順序訪問
- 堆排序數據——跳著訪問==》對CPU緩存不友好
- 對于同樣的數據,在排序過程中,,堆排序算法的數據交換次數要多于快速排序。
- 快排數據交換的次數不會比逆序度多;
- 堆排序的建堆過程會打亂數據原有的相對前后順序,導致原數據的有序度降低;
六、堆應用
場景:假設現有一個包含10億個搜索關鍵詞的日志文件,如何快速獲取熱門榜 Top 10 的搜索關鍵詞?
思路:
- 通過哈希算法求取對應的哈希值,然后對哈希值同 10 取模,得到的結果就是這個搜索關鍵詞應被分到的文件編碼。
- 然后利用散列表和堆,分別求取 Top 10,將10個Top 10放在一起,取Top 10。
1、優先級隊列
- 優先級隊列:數據的出隊順序不是先進先出,而是按照優先級來,優先級最高的,最先出隊。
- 堆可以看作為一個優先級隊列——往優先級隊列插入一個元素,就相當于往堆中插入一個元素;從優先級隊列中取出最高的元素,就相當于取出堆頂元素。
- 應用:赫夫曼編碼、圖的最短路徑、最小生成樹算法等等。
- 實現:JAVA 中 PriorityQueue,C++的priority_queue等。
(1)合并有序小文件
目標:假設存在100個小文件,每個文件大小為100M,每個文件中存儲都是有序的字符串。==》合并成一個有序的大文件。
思路1:類似歸并排序中的合并函數。分別從100個文件中,各取第一個字符串,放入數組,然后比較,將最小的字符串放入合并后的大文件中,并從數組中刪除;然后從最小字符串文件中取下一個字符串,并放入數組,重復上述過程,直到所有的文件中的數據都放入到大文件為止。
思路2:利用優先級隊列,也就是利用堆。從小文件中取出字符串放入小頂堆中,那堆頂的元素就是優先級隊列隊首的元素,即最小的字符串;將該字符串放入大文件中,并將其從堆中刪除;然后再從小文件中取出下一個字符串,放入到堆中,重復上述過程,直到可以將100個小文件中的數據依次放入大文件中。
==》刪除堆頂數據和往堆中插入數據的時間復雜度:O(logn),n表示堆中的數據個數,這里是100
(2)高性能定時器
目標:有個定時器,定時器中維護很多定時任務,每個任務都設定了一個要觸發執行的時間點。定時器每過一個很小的單位時間就要掃描一次任務,看是否有任務達到設定的執行時間,若達到,則執行。
思路:利用優先級隊列按照設定的執行時間,將這些任務存儲到優先級隊列中,隊列首部(堆頂)存儲的就是最先執行的任務。
==》只需取隊首任務的執行時間點,與當前時間相減,就得到一個時間間隔 T。
==》該時間間隔 T 就是從當前時間開始到第一個任務需要被執行的所需時間;從當前時間點到(T-1)這段時間內,定時器不需要做任何事情,當T時間過后,定時器取優先級隊列中隊首的任務執行。然后再計算新的隊首任務的執行時間點與當前時間點的差值。
2、求 Top K
場景:將求 Top K 的問題抽象成兩類。
- 針對靜態數據集合——數據集合事先確定,不再改變;
- 針對動態數據集合——數據集合事先不確定,有數據動態地插入到集合中。
(1)靜態數據
在包含n個數據的數組中,查找前 K 大數據
==》維護一個大小為 K 的小頂堆,順序遍歷數組,從數組中取出數據與堆頂元素比較。若比堆頂元素大,則刪除堆頂元素,將這個元素插入到堆中;若比堆頂元素小,則繼續遍歷數組。
==》 遍歷數組的時間復雜度:O(n)
==》一次堆化操作的時間復雜度:O(logK)
==》最壞情況下,n個元素都入堆一次,時間復雜度:O(nlogK)
(2)動態數據
針對動態數據求得 Top K 就是實時 Top K。
一個數據集合中有兩個操作:
- 添加數據:
- 詢問當前的前 K 大數據:
- 維護一個大小為 K 的小頂堆,當有數據被添加到集合中時,將其與堆頂元素比較。若比堆頂元素大,則刪除堆頂元素,將這個元素插入到堆中;若比堆頂元素小,則不做處理。
3、利用堆求中位數
目標:求動態數據集合中的中位數。
中位數:
- 若數據的個數是奇數,把數據從小往大排,第 n/2+1 個數據就是中位數;
- 若數據的個數是偶數,把數據從小往大排,第 n/2 個數據和第 n/2+1 個數據的單獨一個或均值或…就是中位數(此處為第 n/2 個數據);
(1)利用堆高效地實現求中位數
通過維護兩個堆:一個大頂堆,一個小頂堆。 大頂堆中存儲前半部分數據,小頂堆中存儲后半部分數據,且小頂堆中的數據都大于大頂堆中數據。
靜態數據
如果有 n 個數據,n 是偶數,我們從小到大排序,那前 n/2 個數據存儲在大頂堆中,后 n/2 個數據存儲在小頂堆中。這樣,大頂堆中的堆頂元素就是我們要找的中位數。如果 n 是奇數,情況是類似的,大頂堆就存儲 n/2+1 個數據,小頂堆就存儲 n/2 個數據。
動態數據
若新加入的數據小于等于大頂堆的堆頂元素,則將這個新數據插入到大頂堆;否則若新加入的數據大于等于小頂堆的堆頂元素 ,則將這個新數據插入到小頂堆。
若兩個堆中的數據個數不符合前面約定的情況:
- 若 n 是偶數,則兩個堆中數據個數都是 n/2;
- 若 n 為奇數,則大頂堆有 n/2+1 個數據,小頂堆有 n/2 個數據;
- 若兩個堆數據個數不滿足約定,則從一個堆中不停地將堆頂元素移動到另一個堆,通過這樣的調整,讓兩個堆的數據滿足上面約定。
==》插入數據需要堆化操作,所以時間復雜度:O(logn)
==》中位數只需返回大頂堆的堆頂元素,所以時間復雜度:O(1)
(2)利用堆求百分位的數據
目標:快速求接口的 99% 響應時間?
注:99 百分位數的概念可以類比中位數,如果將一組數據從
小到大排列,這個 99 百分位數就是大于前面 99% 數據。
如果有 n 個數據,將數據從小到大排列之后,99 百分位數大
約就是第 n*99% 個數據,同類,80 百分位數大約就是第 n*80% 個數據。
為了保持大頂堆中的數據占 99%,小頂堆中的數據占 1%,
在每次新插入數據之后,我們都要重新計算,這個時候大頂堆和小頂堆中的數據個數,是否還符合 99:1 這個比例。如果不符合,我們就將一個堆中的數據移動到另一個堆,直到滿足這個比例。
總結
以上是生活随笔為你收集整理的十四、堆(Heap)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 十三、红黑树
- 下一篇: 十五、图(graph)