Top K算法分析

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個。dom

 

1、排序ide

排序是最容易想到的方法,將n個數排序以後,取出最大的k個,即爲所得。優化

 

僞代碼ui

sort(arr, 1, n);3d

return arr[1, k];blog

 

時間複雜度:O(n*lg(n))
 

分析:明明只須要TopK,卻將全局都排序了,這也是這個方法複雜度很是高的緣由。那能不能不全局排序,而只局部排序呢?這就引出了第二個優化方法。

 

2、局部排序

再也不全局排序,只對最大的k個排序。

冒泡是一個很常見的排序方法,每冒一個泡,找出最大值,冒k個泡,就獲得TopK。

 

僞代碼

for(i=1 to k){

         bubble_find_max(arr,i);

}

return arr[1, k];

 

時間複雜度:O(n*k)

 

分析:冒泡,將全局排序優化爲了局部排序,非TopK的元素是不須要排序的,節省了計算資源。很多朋友會想到,需求是TopK,是否是這最大的k個元素也不須要排序呢?這就引出了第三個優化方法。

 

3、堆

思路:只找到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的經典算法,那還有沒有更快的方案呢?

 

4、隨機選擇

隨機選擇算在是《算法導論》中一個經典的算法,其時間複雜度爲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的結果。

 

5、總結

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

 

知其然,知其因此然。

思路比結論重要。

相關文章
相關標籤/搜索