在大規模數據處理中,經常會遇到的一類問題:在海量數據中找出出現頻率最好的前k個數,或者從海量數據中找出最大的前k個數,這類問題通常被稱為top K問題.例如搜索最熱門的商品,最活躍的用戶.
eg:有1億個浮點數,如果找出期中最大的10000個?
最容易想到的方法是將數據全部排序,然后在排序后的集合中進行查找,最快的排序算法的時間復雜度一般為O(nlogn),如快速排序。但是在32位的機器上,每個float類型占4個字節,1億個浮點數就要占用400MB的存儲空間,對於一些可用內存小於400M的計算機而言,很顯然是不能一次將全部數據讀入內存進行排序的。其實即使內存能夠滿足要求(我機器內存都是8GB),該方法也並不高效,因為題目的目的是尋找出最大的10000個數即可,而排序卻是將所有的元素都排序了,做了很多的無用功。
第二種方法為局部淘汰法,該方法與排序方法類似,用一個容器保存前10000個數,然后將剩余的所有數字——與容器內的最小數字相比,如果所有后續的元素都比容器內的10000個數還小,那么容器內這個10000個數就是最大10000個數。如果某一后續元素比容器內最小數字大,則刪掉容器內最小元素,並將該元素插入容器,最后遍歷完這1億個數,得到的結果容器中保存的數即為最終結果了。此時的時間復雜度為O(n+m^2),其中m為容器的大小,即10000。
第三種方法是分治法,將1億個數據分成100份,每份100萬個數據,找到每份數據中最大的10000個,最后在剩下的100*10000個數據里面找出最大的10000個。如果100萬數據選擇足夠理想,那么可以過濾掉1億數據里面99%的數據。100萬個數據里面查找最大的10000個數據的方法如下:用快速排序的方法,將數據分為2堆,如果大的那堆個數N大於10000個,繼續對大堆快速排序一次分成2堆,如果大的那堆個數N大於10000個,繼續對大堆快速排序一次分成2堆,如果大堆個數N小於10000個,就在小的那堆里面快速排序一次,找第10000-n大的數字;遞歸以上過程,就可以找到第1w大的數。參考上面的找出第1w大數字,就可以類似的方法找到前10000大數字了。此種方法需要每次的內存空間為10^6*4=4MB,一共需要101次這樣的比較。
第四種方法是Hash法。如果這1億個書里面有很多重復的數,先通過Hash法,把這1億個數字去重復,這樣如果重復率很高的話,會減少很大的內存用量,從而縮小運算空間,然后通過分治法或最小堆法查找最大的10000個數。
第五種方法采用最小堆。首先讀入前10000個數來創建大小為10000的最小堆,建堆的時間復雜度為O(mlogm)(m為數組的大小即為10000),然后遍歷后續的數字,並於堆頂(最小)數字進行比較。如果比最小的數小,則繼續讀取后續數字;如果比堆頂數字大,則替換堆頂元素並重新調整堆為最小堆。整個過程直至1億個數全部遍歷完為止。然后按照中序遍歷的方式輸出當前堆中的所有10000個數字。該算法的時間復雜度為O(nmlogm),空間復雜度是10000(常數)。
實際運行:
實際上,最優的解決方案應該是最符合實際設計需求的方案,在時間應用中,可能有足夠大的內存,那么直接將數據扔到內存中一次性處理即可,也可能機器有多個核,這樣可以采用多線程處理整個數據集。
下面針對不容的應用場景,分析了適合相應應用場景的解決方案。
(1)單機+單核+足夠大內存
如果需要查找10億個查詢次(每個占8B)中出現頻率最高的10個,考慮到每個查詢詞占8B,則10億個查詢次所需的內存大約是10^9 * 8B=8GB內存。如果有這么大內存,直接在內存中對查詢次進行排序,順序遍歷找出10個出現頻率最大的即可。這種方法簡單快速,使用。然后,也可以先用HashMap求出每個詞出現的頻率,然后求出頻率最大的10個詞。
(2)單機+多核+足夠大內存
這時可以直接在內存總使用Hash方法將數據划分成n個partition,每個partition交給一個線程處理,線程的處理邏輯同(1)類似,最后一個線程將結果歸並。
該方法存在一個瓶頸會明顯影響效率,即數據傾斜。每個線程的處理速度可能不同,快的線程需要等待慢的線程,最終的處理速度取決於慢的線程。而針對此問題,解決的方法是,將數據划分成c×n個partition(c>1),每個線程處理完當前partition后主動取下一個partition繼續處理,知道所有數據處理完畢,最后由一個線程進行歸並。
(3)單機+單核+受限內存
這種情況下,需要將原數據文件切割成一個一個小文件,如次啊用hash(x)%M,將原文件中的數據切割成M小文件,如果小文件仍大於內存大小,繼續采用Hash的方法對數據文件進行分割,知道每個小文件小於內存大小,這樣每個文件可放到內存中處理。采用(1)的方法依次處理每個小文件。
(4)多機+受限內存
這種情況,為了合理利用多台機器的資源,可將數據分發到多台機器上,每台機器采用(3)中的策略解決本地的數據。可采用hash+socket方法進行數據分發。
1. 堆
堆實際上是一棵完全二叉樹,其任何一非葉節點滿足性質:
Key[i]<=key[2i+1]&&Key[i]<=key[2i+2]或者Key[i]>=Key[2i+1]&&key>=key[2i+2]
即任何一非葉節點的關鍵字不大於或者不小於其左右孩子節點的關鍵字。
堆分為大頂堆和小頂堆,滿足Key[i]>=Key[2i+1]&&key>=key[2i+2]稱為大頂堆,滿足 Key[i]<=key[2i+1]&&Key[i]<=key[2i+2]稱為小頂堆。由上述性質可知大頂堆的堆頂的關鍵字肯定是所有關鍵字中最大的,小頂堆的堆頂的關鍵字是所有關鍵字中最小的
2. 堆排序的思想
利用大頂堆(小頂堆)堆頂記錄的是最大關鍵字(最小關鍵字)這一特性,使得每次從無序中選擇最大記錄(最小記錄)變得簡單。
其基本思想為(大頂堆):
1)將初始待排序關鍵字序列(R1,R2....Rn)構建成大頂堆,此堆為初始的無序區;
2)將堆頂元素R[1]與最后一個元素R[n]交換,此時得到新的無序區(R1,R2,......Rn-1)和新的有序區(Rn),且滿足R[1,2...n-1]<=R[n];
3)由於交換后新的堆頂R[1]可能違反堆的性質,因此需要對當前無序區(R1,R2,......Rn-1)調整為新堆,然后再次將R[1]與無序區最后一個元素交換,得到新的無序區(R1,R2....Rn-2)和新的有序區(Rn-1,Rn)。不斷重復此過程直到有序區的元素個數為n-1,則整個排序過程完成。
3. 小頂堆在java中的實現代碼
public int findKthLargest(int[] nums, int k) { PriorityQueue<Integer> minQueue = new PriorityQueue<>(k); for (int num : nums) { if (minQueue.size() < k || num > minQueue.peek()) minQueue.offer(num); if (minQueue.size() > k) minQueue.poll(); } return minQueue.peek();