今天在CSDN無意中看到July一篇號稱《當今世界最為經典的十大演算法》的博文,感覺這文章名字挺霸氣,於是進去瞅了一眼。看到其中有一個叫做BFPRT的演算法,據說可以最壞情況下也能以O(N)複雜度找到數組中的第K大元素。博文裡有連結到詳細解釋這個演算法的另外一篇博文,於是又點進去,準備看看這演算法是如何神奇,居然可以如此高效!
文章是以這樣一個問題開始的:如何在一堆資料中找出最小的K個數。
隨便想了一下,想了幾種方法:
1. 先對資料排序,然後取出前K個數。排序演算法很多,什麼插入排序、快速排序等等,不過都不可能最壞也能到達O(N);
2. 開一個 |K| 大小的數組,先從這堆資料中裝入前K個數,找出這K個數中的最大數Max(K),然後從第K+1個數開始向後找,如果有小於這個Max(K)的,則替換掉這個數,然後從這K個數中重新找出最大的Max(K)。這樣一直向後掃描,得到結果。這個演算法的複雜度最壞是O(Kn),也不行;
3. 堆,腦子裡閃過這麼個想法,只能算是靈感之類的東西,不過沒細想。
因為急切想看看這個所謂的BFPRT演算法到底是怎麼回事,所以只是稍微思考了一下,沒怎麼細想。
博文也列出了幾個可能解決這個問題的解法:
1. 跟我想的一樣,排序,取數,最笨的方法,也是最容易想到的;
2. 也跟我想的一樣,同上面的2(看來我還不是最笨的);
3. 比較笨的方法,掃描資料堆K遍,每遍找出最小的那個數,複雜度為O(Kn);
4. 果然用到堆(可惜我沒細想)!想法跟2類似,先用資料中的前K個數建一個最大堆,建堆複雜度是O(K),然後從第K+1個數開始向後掃描,遇到小於堆頂元素時替換掉堆頂元素,更新堆,這個操作的複雜度是O(logK)。所以總的時間是O(K+(n-K)*logK)=O(n*logK),比方法2的O(nK)稍微好一點。
這種方法有個好處,就是當資料量很大時,如果記憶體放不下所有的資料,用這方法可以解決這個問題。先讀出一部分資料,建堆,處理完這部分資料,再讀出一部分資料,如此迴圈下去,直達資料處理完為止。
5. 也是用堆,不過是對整個資料建一個最小堆(O(n)),然後取出堆頂元素,每取完一次更新一次堆(O(logn)),取K次,所以總的複雜度是O(n+K*logn);
可以證明O(n+K*logn) < O(n*logK),即建立n個元素是最小堆然後取前K個堆頂元素的方法比建立一個K個元素的最大堆然後比較所有資料得到最小的K個數的方法在時間複雜度上稍微優越一點,但兩者實際上是一個數量級,在那篇博文裡面,作者特意寫了實現了這兩種方法去處理一組大資料,結果表明兩種方法的時間實際上相差不多。
但在空間上,最大堆只需要O(K)的空間複雜度,而最小堆需要O(n),所以綜合來講,最大堆解決這種方法比最小堆有優勢。
演算法改進:每次取走堆頂元素更新堆時,正常是把堆中最後一個元素放到堆頂(暫且稱為 !Top),然後調整堆把 !Top下調到他應該在的位置。改進後, !Top不用下調到他原所應該在的位置,而是下調頂多K次就可以了。具體如下:
建立n的最小堆之後,取走堆頂元素(第一個數),然後將最後的數 !Top調到堆頂,把 !Top下調至多K-1層形成新的堆;接著取走堆頂元素(第二個數),同樣,更新堆的時候 !Top下調至多K-2層...直到取走第K個數時,不再更新堆(此時的堆已經不是最小堆),演算法結束,已經取得最小的K個數,最後的“堆”是不是堆已經跟我沒關係了。
改進後的複雜度:建堆O(n),更新堆O(K),K次更新為O(K*K)=O(K^2),所以總的複雜度是O(n+K^2),比改進前的O(n+K*logn)要好。
6. 用快速排序的思想,先選取一個數作為基準比較數(作者稱為“樞紐元”,即pivot),用快排方法把資料分為兩部分Sa和Sb。
如果K< |Sa|( |Sa|表示Sa的大小),則對Sa部分用同樣的方法繼續操作;
如果K= |Sa|,則Sa是所求的數;
如果K= |Sa| + 1,則Sa和這個pivot一起構成所求解;
如果K> |Sa| + 1,則對Sb部分用同樣的方法尋找最小的(K- |Sa|-1)個數(其中Sa和pivot已經是解的一部分了)。
與快排不同,快排每次都要對劃分後的兩部分資料都繼續進行同樣的快排操作,快速選擇(暫時這麼稱呼這種演算法吧)不同,只對其中一部分進行操作即可。
BFPRT演算法就是在這個方法的基礎上進行改進的。BFPRT演算法主要改進是在選取pivot上面,一般快排是在資料堆取第一個或最後一個數最為pivot,而BFPRT演算法採用“五分化中位元的中位元”方法取得這個pivot,從而使演算法複雜度降低到O(N),具體方法如下:
以5個數為一組對資料進行劃分,最後一組資料的個數為n%5,然後對每組資料用插入排序方法選出中位元,對選出的中位元用同樣的方法繼續選,最後選出這些數的中位元作為pivot,即可達到O(N)的效率。
演算法的具體證明我沒仔細看。那篇博文實在太長,估計是續寫了好多次,修改了n遍,感覺組織有點亂,看到頭有點暈,再找時間仔細看這演算法的證明。具體可以參考:
Mark Allen Weiss的資料結構與演算法分析--c語言描述,第10章,第10.2.3節
演算法導論,第9.2、9.3節
編程之美第一版,第141頁,第2.5節 尋找最大的k個數
M. Blum, R.W. Floyd, V. Pratt, R. Rivest and R. Tarjan, "Time bounds for selection"
編程珠璣II 第15章第15.2節程式
總結:如果當時細想的話說不定也能多想幾個方法;有瞭解法之後,多想想這樣的演算法是不是已經最優,還能不能再最佳化,比如一開始用排序,排序需要浪費時間,是不是可以不用排序的方法,不用排序方法裡面可能想到堆,堆是不是每次都必須調整到“完全正確的堆”,也可能用快排,快排是不是每次都排兩部分劃分的資料,等等;多想想把學過的資料結構靈活應用,比如這裡面用到的堆和快排,以前是用於資料排序,現在用來資料選擇,blahblah...總結完畢。
本文參考:《程式員編程藝術:第三章、尋找最小的k個數》http://blog.csdn.net/v_JULY_v/article/details/6370650