目錄
1.介紹1
摘自 https://www.cnblogs.com/itxiaok/archive/2019/02/15/10385676.html
前兩天面試3面學長問我的這個問題(想說TEG的3個面試學長都是好和藹,希望能完成最後一面,各方面原因造成我無比想去鵝場的心已經按捺不住了),這個問題還是建立最小堆比較好一些。
先拿10000個數建堆,然後一次添加剩餘元素,如果大於堆頂的數(10000中最小的),將這個數替換堆頂,並調整結構使之仍然是一個最小堆,這樣,遍歷完後,堆中的10000個數就是所需的最大的10000個。建堆時間複雜度是O(mlogm),算法的時間複雜度爲O(nmlogm)(n爲10億,m爲10000)。
優化的方法:可以把所有10億個數據分組存放,比如分別放在1000個文件中。這樣處理就可以分別在每個文件的10^6個數據中找出最大的10000個數,合併到一起在再找出最終的結果。
以上就是面試時簡單提到的內容,下面整理一下這方面的問題:
top K問題 在大規模數據處理中,經常會遇到的一類問題:在海量數據中找出出現頻率最好的前k個數,或者從海量數據中找出最大的前k個數,這類問題通常被稱爲top K問題。例如,在搜索引擎中,統計搜索最熱門的10個查詢詞;在歌曲庫中統計下載最高的前10首歌等。 針對top K類問題,通常比較好的方案是分治+Trie樹/hash+小頂堆(就是上面提到的最小堆),即先將數據集按照Hash方法分解成多個小數據集,然後使用Trie樹活着Hash統計每個小數據集中的query詞頻,之後用小頂堆求出每個數據集中出現頻率最高的前K個數,最後在所有top 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個,最後在剩下的10010000個數據裏面找出最大的10000個。如果100萬數據選擇足夠理想,那麼可以過濾掉1億數據裏面99%的數據。100萬個數據裏面查找最大的10000個數據的方法如下:用快速排序的方法,將數據分爲2堆,如果大的那堆個數N大於10000個,繼續對大堆快速排序一次分成2堆,如果大的那堆個數N大於10000個,繼續對大堆快速排序一次分成2堆,如果大堆個數N小於10000個,就在小的那堆裏面快速排序一次,找第10000-n大的數字;遞歸以上過程,就可以找到第1w大的數。參考上面的找出第1w大數字,就可以類似的方法找到前10000大數字了。此種方法需要每次的內存空間爲10^64=4MB,一共需要101次這樣的比較。
第四種方法是Hash法。如果這1億個書裏面有很多重複的數,先通過Hash法,把這1億個數字去重複,這樣如果重複率很高的話,會減少很大的內存用量,從而縮小運算空間,然後通過分治法或最小堆法查找最大的10000個數。
第五種方法採用最小堆。首先讀入前10000個數來創建大小爲10000的最小堆,建堆的時間複雜度爲O(mlogm)(m爲數組的大小即爲10000),然後遍歷後續的數字,並於堆頂(最小)數字進行比較。如果比最小的數小,則繼續讀取後續數字;如果比堆頂數字大,則替換堆頂元素並重新調整堆爲最小堆。整個過程直至1億個數全部遍歷完爲止。然後按照中序遍歷的方式輸出當前堆中的所有10000個數字。該算法的時間複雜度爲O(nmlogm),空間複雜度是10000(常數)。
實際運行: 實際上,最優的解決方案應該是最符合實際設計需求的方案,在時間應用中,可能有足夠大的內存,那麼直接將數據扔到內存中一次性處理即可,也可能機器有多個核,這樣可以採用多線程處理整個數據集。
2.介紹2
摘自 https://blog.csdn.net/wufaliang003/article/details/82940218
TopK,是問得比較多的幾個問題之一,到底有幾種方法,這些方案裏蘊含的優化思路究竟是怎麼樣的,今天和大家聊一聊。
問題描述:
從arr[1, n]這n個數中,找出最大的k個數,這就是經典的TopK問題。
栗子:
從arr[1, 12]={5,3,7,1,8,2,9,4,7,2,6,6} 這n=12個數中,找出最大的k=5個。
一、排序
排序是最容易想到的方法,將n個數排序之後,取出最大的k個,即爲所得。
僞代碼:
sort(arr, 1, n);
return arr[1, k];
時間複雜度:O(n*lg(n))
分析:明明只需要TopK,卻將全局都排序了,這也是這個方法複雜度非常高的原因。那能不能不全局排序,而只局部排序呢?這就引出了第二個優化方法。
二、局部排序
不再全局排序,只對最大的k個排序。
冒泡是一個很常見的排序方法,每冒一個泡,找出最大值,冒k個泡,就得到TopK。
僞代碼:
for(i=1 to k){
bubble_find_max(arr,i);
}
return arr[1, k];
時間複雜度:O(n*k)
分析:冒泡,將全局排序優化爲了局部排序,非TopK的元素是不需要排序的,節省了計算資源。不少朋友會想到,需求是TopK,是不是這最大的k個元素也不需要排序呢?這就引出了第三個優化方法。
三、堆
思路:只找到TopK,不排序TopK。
先用前k個元素生成一個小頂堆,這個小頂堆用於存儲,當前最大的k個元素。
接着,從第k+1個元素開始掃描,和堆頂(堆中最小的元素)比較,如果被掃描的元素大於堆頂,則替換堆頂的元素,並調整堆,以保證堆內的k個元素,總是當前最大的k個元素。
直到,掃描完所有n-k個元素,最終堆中的k個元素,就是猥瑣求的TopK。
僞代碼:
heap[k] = make_heap(arr[1, k]);
for(i=k+1 to n){
adjust_heap(heep[k],arr[i]);
}
return heap[k];
時間複雜度:O(n*lg(k))
畫外音:n個元素掃一遍,假設運氣很差,每次都入堆調整,調整時間複雜度爲堆的高度,即lg(k),故整體時間複雜度是n*lg(k)。
分析:堆,將冒泡的TopK排序優化爲了TopK不排序,節省了計算資源。堆,是求TopK的經典算法,那還有沒有更快的方案呢?
四、隨機選擇
隨機選擇算在是《算法導論》中一個經典的算法,其時間複雜度爲O(n),是一個線性複雜度的方法。
這個方法並不是所有同學都知道,爲了將算法講透,先聊一些前序知識,一個所有程序員都應該爛熟於胸的經典算法:快速排序。
畫外音:
(1)如果有朋友說,“不知道快速排序,也不妨礙我寫業務代碼呀”…額...
(2)除非校招,我在面試過程中從不問快速排序,默認所有工程師都知道;
其僞代碼是:
void quick_sort(int[]arr, int low, inthigh){
if(low== high) return;
int i = partition(arr, low, high);
quick_sort(arr, low, i-1);
quick_sort(arr, i+1, high);
}
其核心算法思想是,分治法。
分治法(Divide&Conquer),把一個大的問題,轉化爲若干個子問題(Divide),每個子問題“都”解決,大的問題便隨之解決(Conquer)。這裏的關鍵詞是“都”。從僞代碼裏可以看到,快速排序遞歸時,先通過partition把數組分隔爲兩個部分,兩個部分“都”要再次遞歸。
分治法有一個特例,叫減治法。
減治法(Reduce&Conquer),把一個大的問題,轉化爲若干個子問題(Reduce),這些子問題中“只”解決一個,大的問題便隨之解決(Conquer)。這裏的關鍵詞是“只”。
二分查找binary_search,BS,是一個典型的運用減治法思想的算法,其僞代碼是:
int BS(int[]arr, int low, inthigh, int target){
if(low> high) return -1;
mid= (low+high)/2;
if(arr[mid]== target) return mid;
if(arr[mid]> target)
return BS(arr, low, mid-1, target);
else
return BS(arr, mid+1, high, target);
}
從僞代碼可以看到,二分查找,一個大的問題,可以用一個mid元素,分成左半區,右半區兩個子問題。而左右兩個子問題,只需要解決其中一個,遞歸一次,就能夠解決二分查找全局的問題。
通過分治法與減治法的描述,可以發現,分治法的複雜度一般來說是大於減治法的:
快速排序:O(n*lg(n))
二分查找:O(lg(n))
話題收回來,快速排序的核心是:
i = partition(arr, low, high);
這個partition是幹嘛的呢?
顧名思義,partition會把整體分爲兩個部分。
更具體的,會用數組arr中的一個元素(默認是第一個元素t=arr[low])爲劃分依據,將數據arr[low, high]劃分成左右兩個子數組:
左半部分,都比t大
右半部分,都比t小
中間位置i是劃分元素
以上述TopK的數組爲例,先用第一個元素t=arr[low]爲劃分依據,掃描一遍數組,把數組分成了兩個半區:
左半區比t大
右半區比t小
中間是t
partition返回的是t最終的位置i。
很容易知道,partition的時間複雜度是O(n)。
畫外音:把整個數組掃一遍,比t大的放左邊,比t小的放右邊,最後t放在中間N[i]。
partition和TopK問題有什麼關係呢?
TopK是希望求出arr[1,n]中最大的k個數,那如果找到了第k大的數,做一次partition,不就一次性找到最大的k個數了麼?
畫外音:即partition後左半區的k個數。
問題變成了arr[1, n]中找到第k大的數。
再回過頭來看看第一次partition,劃分之後:
i = partition(arr, 1, n);
如果i大於k,則說明arr[i]左邊的元素都大於k,於是只遞歸arr[1, i-1]裏第k大的元素即可;
如果i小於k,則說明說明第k大的元素在arr[i]的右邊,於是只遞歸arr[i+1, n]裏第k-i大的元素即可;
畫外音:這一段非常重要,多讀幾遍。
這就是隨機選擇算法randomized_select,RS,其僞代碼如下:
int RS(arr, low, high, k){
if(low== high) return arr[low];
i= partition(arr, low, high);
temp= i-low; //數組前半部分元素個數
if(temp>=k)
return RS(arr, low, i-1, k); //求前半部分第k大
else
return RS(arr, i+1, high, k-i); //求後半部分第k-i大
}
這是一個典型的減治算法,遞歸內的兩個分支,最終只會執行一個,它的時間複雜度是O(n)。
再次強調一下:
分治法,大問題分解爲小問題,小問題都要遞歸各個分支,例如:快速排序
減治法,大問題分解爲小問題,小問題只要遞歸一個分支,例如:二分查找,隨機選擇
通過隨機選擇(randomized_select),找到arr[1, n]中第k大的數,再進行一次partition,就能得到TopK的結果。
五、總結
TopK,不難;其思路優化過程,不簡單:
全局排序,O(n*lg(n))
局部排序,只排序TopK個數,O(n*k)
堆,TopK個數也不排序了,O(n*lg(k))
分治法,每個分支“都要”遞歸,例如:快速排序,O(n*lg(n))
減治法,“只要”遞歸一個分支,例如:二分查找O(lg(n)),隨機選擇O(n)
TopK的另一個解法:隨機選擇+partition
知其然,知其所以然。
思路比結論重要。
3.介紹3 包括各種排序的空間及時間複雜度