優先級隊列
優先級隊列首先是一個隊列,他符合隊列的基本特性,先進先出。
不過,在優先級隊列中,數據的出隊順序不是先進先出,而是按照優先級來,優先級最高的,最先出隊。
實現優先級隊列的方法有很多,用堆來實現是最直接、最高效的。
堆和優先級隊列十分相似,有時候一個堆就可以看作一個優先級隊列。
往優先級隊列中插入一個元素,就相當於往堆中插入一個元素;
從優先級隊列中取出優先級最高的元素,就相當於取出堆頂元素。
優先級隊列的應用場景非常多。很多數據結構和算法都依賴它。
比如,赫夫曼編碼、圖的最短路徑、最小生成樹算法等等。
很多語言中,都提供了優先級隊列的實現,比如,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)。