堆的應用

優先級隊列

優先級隊列首先是一個隊列,他符合隊列的基本特性,先進先出。

不過,在優先級隊列中,數據的出隊順序不是先進先出,而是按照優先級來,優先級最高的,最先出隊。

實現優先級隊列的方法有很多,用堆來實現是最直接、最高效的。

堆和優先級隊列十分相似,有時候一個堆就可以看作一個優先級隊列。

往優先級隊列中插入一個元素,就相當於往堆中插入一個元素;

從優先級隊列中取出優先級最高的元素,就相當於取出堆頂元素。

 

優先級隊列的應用場景非常多。很多數據結構和算法都依賴它。

比如,赫夫曼編碼、圖的最短路徑、最小生成樹算法等等。

很多語言中,都提供了優先級隊列的實現,比如,Java 的 PriorityQueue,C++ 的 priority_queue 等。

 

優先級隊列具體應用舉例:合併有序小文件、高性能定時器。

 

合併有序小文件

假設我們有 100 個小文件,每個文件的大小是 100MB,每個文件中存儲的都是有序的字符串。

希望將這些 100 個小文件合併成一個有序的大文件。這裏就可以用到優先級隊列。

我們從這 100 個文件中,各取第一個字符串,放入數組中,然後比較大小,把最小的那個字符串放入合併後的大文件中,並從數組中刪除。(類似於歸併排序的merge方法)

假設,這個最小的字符串來自於 13.txt 這個小文件,我們就再從這個小文件取下一個字符串,放到數組中,重新比較大小,並且選擇最小的放入合併後的大文件,將它從數組中刪除。依次類推,直到所有的文件中的數據都放入到大文件爲止。

這裏我們用數組這種數據結構,來存儲從小文件中取出來的字符串。每次從數組中取最小字符串,都需要循環遍歷整個數組,顯然不是很高效。

這裏就可以用到優先級隊列,也可以說是堆。

我們將從小文件中取出來的字符串放入到小頂堆中,那堆頂的元素,也就是優先級隊列隊首的元素,就是最小的字符串。我們將這個字符串放入到大文件中,並將其從堆中刪除。然後再從小文件中取出下一個字符串,放入到堆中。循環這個過程,就可以將 100 個小文件中的數據依次放入到大文件中。

刪除堆頂數據和往堆中插入數據的時間複雜度都是 O(logn),n 表示堆中的數據個數。比原來數組存儲的方式高效了很多。

 

高性能定時器

假設我們有一個定時器,定時器中維護了很多定時任務,每個任務都設定了一個要觸發執行的時間點。

定時器每過一個很小的單位時間(比如 1 秒),就掃描一遍任務,看是否有任務到達設定的執行時間。

如果到達了,就拿出來執行。

 

但是,這樣每過 1 秒就掃描一遍任務列表的做法比較低效,主要原因有兩點:

一、 任務的約定執行時間離當前時間可能還有很久,這樣前面很多次掃描其實都是徒勞的;

二、 每次都要掃描整個任務列表,如果任務列表很大的話,勢必會比較耗時。

 

針對問題,就可以用優先級隊列來解決。我們按照任務設定的執行時間,將這些任務存儲在優先級隊列中,隊列首部(也就是小頂堆的堆頂)存儲的是最先執行的任務。

定時器就不需要每隔 1 秒就掃描一遍任務列表了。它拿隊首任務的執行時間點,與當前時間點相減,得到一個時間間隔 T。

這個時間間隔 T 就是,從當前時間開始,需要等待多久,纔會有第一個任務需要被執行。

這樣,定時器就可以設定在 T 秒之後,再來執行任務。從當前時間點到(T-1)秒這段時間裏,定時器都不需要做任何事情。

當 T 秒時間過去之後,定時器取優先級隊列中隊首的任務執行。

然後再計算新的隊首任務的執行時間點與當前時間點的差值,把這個值作爲定時器執行下一個任務需要等待的時間。

這樣,定時器既不用間隔 1 秒就輪詢一次,也不用遍歷整個任務列表,性能也就提高了。

 

TOP K 問題

可以把求 Top K 的問題抽象成兩類。

一類是針對靜態數據集合,也就是說數據集合事先確定,不會再變。

另一類是針對動態數據集合,也就是說數據集合事先並不確定,有數據動態地加入到集合中。

 

針對靜態數據,我們可以維護一個大小爲 K 的小頂堆,用數據將堆填滿。

順序遍歷數組,從數組中取出數據與堆頂元素比較。如果比堆頂元素大,我們就把堆頂元素刪除,並且將這個元素插入到堆中;

如果比堆頂元素小,則不做處理,繼續遍歷數組。這樣等數組中的數據都遍歷完之後,堆中的數據就是前 K 大數據了。

 

遍歷數組需要 O(n) 的時間複雜度,一次堆化操作需要 O(logK) 的時間複雜度。

所以最壞情況下,n 個元素都入堆一次,時間複雜度就是 O(nlogK)。

 

針對動態數據求得 Top K 就是實時 Top K。

一個數據集合中有兩個操作,一個是添加數據,另一個詢問當前的前 K 大數據。

如果每次詢問前 K 大數據,我們都基於當前的數據重新計算的話,那時間複雜度就是 O(nlogK),n 表示當前的數據的大小。

實際上,我們可以一直都維護一個 K 大小的小頂堆,當有數據被添加到集合中時,我們就拿它與堆頂的元素對比。

如果比堆頂元素大,我們就把堆頂元素刪除,並且將這個元素插入到堆中;

如果比堆頂元素小,則不做處理。這樣,無論任何時候需要查詢當前的前 K 大數據,我們都可以立刻返回給他。

 

利用堆求中位數

中位數,顧名思義,就是處在中間位置的那個數。

如果數據的個數是奇數,把數據從小到大排列,那第 n/2​+1 個數據就是中位數(注意:假設數據是從 0 開始編號的);

如果數據的個數是偶數的話,那處於中間位置的數據有兩個,第 n/2​ 個和第 n/2​+1 個數據。

這個時候,我們可以隨意取一個作爲中位數,比如取兩個數中靠前的那個,就是第 n/2 個數據。

 

對於一組靜態數據,中位數是固定的,我們可以先排序,第 n/2​ 個數據就是中位數。

每次詢問中位數的時候,返回這個固定值就好了。

 

但是對於動態數據集合,中位數在不停地變動。

如果再用先排序的方法,每次詢問中位數的時候,都要先進行排序,那效率就不高了。

 

藉助堆這種數據結構,不用排序就可以非常高效地實現求中位數操作。

維護兩個堆,一個大頂堆,一個小頂堆。

大頂堆中存儲前半部分數據,小頂堆中存儲後半部分數據,且小頂堆中的數據都大於大頂堆中的數據。

 

如果有 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)。

 

如何快速求接口的 99% 響應時間

 

中位數的概念就是將數據從小到大排列,處於中間位置,就叫中位數,這個數據會大於等於前面 50% 的數據。

99 百分位數的概念可以類比中位數,如果將一組數據從小到大排列,這個 99 百分位數就是大於前面 99% 數據的那個數據。

 

 

如果有 100 個接口訪問請求,每個接口請求的響應時間都不同,比如 55 毫秒、100 毫秒、23 毫秒等。

我們把這 100 個接口的響應時間按照從小到大排列,排在第 99 的那個數據就是 99% 響應時間,也叫 99 百分位響應時間。

 

如果有 n 個數據,將數據從小到大排列之後,99 百分位數大約就是第 n*99% 個數據,80 百分位數大約就是第 n*80% 個數據。

 

如何求99% 響應時間?

 

我們維護兩個堆,一個大頂堆,一個小頂堆。

假設當前總數據的個數是 n,大頂堆中保存 n*99% 個數據,小頂堆中保存 n*1% 個數據。

大頂堆堆頂的數據就是我們要找的 99% 響應時間。

每次插入一個數據的時候,我們要判斷這個數據跟大頂堆和小頂堆堆頂數據的大小關係,然後決定插入到哪個堆中。

如果這個新插入的數據比大頂堆的堆頂數據小,那就插入大頂堆;

如果這個新插入的數據比小頂堆的堆頂數據大,那就插入小頂堆。

 

爲了保持大頂堆中的數據佔 99%,小頂堆中的數據佔 1%,在每次新插入數據之後,我們都要重新計算。

計算這個時候大頂堆和小頂堆中的數據個數,是否還符合 99:1 這個比例。

如果不符合,我們就將一個堆中的數據移動到另一個堆,直到滿足這個比例。

移動的方法就類似於中位數的移動方法。

 

通過這樣的方法,每次插入數據,可能會涉及幾個數據的堆化操作,所以時間複雜度是 O(logn)。

每次求 99% 響應時間的時候,直接返回大頂堆中的堆頂數據即可,時間複雜度是 O(1)。

 

算法必學:經典的 Top K 問題

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章