這道題最簡單的思路是排序,時間復雜度是O(nlog(n))。但是這樣做在那n-k 個數的排序上浪費了資源。
改進一下,將數組的前k個數作為最小的k數的緩存。從第k+1個數開始遍歷,如果有比前k個數小的,就將其和前k個數那個較大交換。
照這個思路,可以引入一個結構,使得前k個數總是最大的數在第一個,這樣每次遇到一個數值需要和前k個數中排在第一位的那個最大數比較就可以了。
這個結構就是最大堆。
思路一:維護一個maxSize為k的最大堆,用來存放這k個數,遍歷數組,如果堆未滿,插入堆中。如果堆已滿,如果數字比堆的根節點小,則刪除堆的根節點,向堆中插入這個數字。
時間復雜度為 O(nlog(k))。如果求最大的k個數,就是使用最小堆了。
思路二:其實我們也可以在n個整數的范圍內構建最小堆,然后每次將堆頂的最小值取走,然后調整,再取走堆頂值,取k次,自然就找到了最小的k個數。
這樣做的時間復雜度是多少呢?基於n個無序數構建最小堆,時間是O(n),而取走堆頂元素,將堆末元素放到堆頂重新調整的時間復雜度是 O(h),h為堆高度,這里堆高度h總是logn保持不變。
因此時間復雜度是O(n+k*logn),這個思路可以再改進:每次重新調整,只需要基於堆頂部k層進行調整,堆末元素被放到堆頂后,最多只需要下調至k層即可,因此調整的時間復雜度成了O(k)。這樣做的時間復雜度成了 O(n+k*k)。
可以證明O(n+k*k) < O(nlog(k)),但是實際情況下,是否思路二更好呢?
我們別忘了思路二的初始化需要基於整個n個數構建堆,而思路一則不需要。實際情況下,我們往往需要基於大量分布式存儲的n個數找出k個數,例如:在分布式存在10台服務器上的總大小大約2T的訪問頁面記錄中找出訪問量最高的100個頁面。想要跨10台服務器整體構建最大堆,顯然不現實,而思路一則只需要維護一個100的最小堆,然后順序遍歷10台服務器上的記錄即可。
因此思路一更加具有實際意義。
這里給出思路一的實現。
如果真正寫代碼,要知道堆是沒有STL的,也就是說我們要自己實現。
書中使用了multiset,multiset和set一樣,都是基於紅黑樹實現,區別是set不允許重復而multiset允許重復。
那么multiset和vector區別在哪里?區別在於multiset支持排序。multiset的插入和刪除的時間復雜度也都為 O(logk)
樹上代碼:
typedef multiset<int, greater<int> > intSet; typedef multiset<int, greater<int> >::iterator setIterator; void GetLeastNumbers_Solution2(const vector<int>& data, intSet& leastNumbers, int k) { leastNumbers.clear(); if(k < 1 || data.size() < k) return; vector<int>::const_iterator iter = data.begin(); for(; iter != data.end(); ++ iter) { if((leastNumbers.size()) < k) leastNumbers.insert(*iter); else { setIterator iterGreatest = leastNumbers.begin(); if(*iter < *(leastNumbers.begin())) { leastNumbers.erase(iterGreatest); leastNumbers.insert(*iter); } } } }
引申
還有第三種思路,快速選擇 法。這個方法參考了July的文章,以及csdn上huagong_adu所寫的另一篇對它的解讀。
試想一下,如果存在這么一個數,它在數組的第k位,而且正好前k-1個數比它小,后n-k個數比它大。那么這個時候,我們只需要這個數前面的那k個數即可。
這是不有點像快排中的 pivot?
先選取一個數作為基准比較數(作者稱為“樞紐元”,即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已經是解的一部分了)。
當pivot選擇的足夠好的時候,可以做到時間復雜度是O(n)
那么如何選擇一個好的pivot?
這里必須提BFPRT算法,這個算法就是為了尋找數組中第k小的數而設。
BFPRT是一種獲得較優秀pivot的方法。其過程有一個flash動畫作為演示。其過程是將n個數5個一組划分,求出沒一個5元組的中位數,然后再基於這些中位數繼續求中位數,這個重復的次數應該是由k的大小來定,隨后將選出的中位數作為pivot,將小於pivot的數交換到數組的左側。接着基於這些小於pivot的值,繼續通過“尋找中位數,定pivot,交換法” 來縮小范圍,直到最后在一個較小范圍內找到k個最小值。
BFPRT算法的時間復雜度做到了O(n)。這個算法的原文鏈接在此:Time Bounds for Selection。
附加,最大堆的插入刪除操作:
template <typename T> Class MaxHeap(){ public: MaxHeap(int maxSize); ~MaxHeap(); bool Insert(T element); bool DeleteMax(); private: T *elements; int MaxSize = 0; int size = 0; } //創建堆,記住堆的根節點是element[1] //這樣做的目的,是為了可以使用i/2來訪問其父結點,可以使用2i表示其左結點。 template <typename T> MaxHeap::MaxHeap(int maxSize){ MaxSize = maxSize; elements = new T[maxSize+1]; size = 0; } //插入節點,其實就是從末節點開始,往上找,直到找一個地方,讓新元素放進去后,比它的父節點小。 //找的過程中,找過的節點下移到它的子女所在的平台,為了給新結點騰地方 //找到這個地方后,就把新結點安進去 template <typename T> bool MaxHeap::Insert(T ele){ if(size == MaxSize){ return false; } size ++; int i = size; while(i > 1){ if(elements[i/2] >= ele) break; //找到合適的位子了 element[i] = element[i/2]; //沒找到,把父節點往子節點上移,騰位置 i = i/2; } element[i] = ele; return true; } //刪除節點,就是將根節點刪除,但是我們知道,接下來必須調整,因為數組中間不能有空缺。 //調整的過程其實就是將數組末尾的那個元素找個地方放。根節點原來的空位不行,就把空位往下挪, //一直挪到這個空位放末尾節點合適了,所謂合適,就是末尾節點放在這里比其子女都大, //(就算中間沒有合適的位置,移到葉節點,必然合適) ,把末尾節點放進去。 template <typename T> bool MaxHeap::DeleteMax(){ if(size == 0){ return false; } T temp = elements[size]; //末尾節點記下來。 size--; int i = 1; while(2*i <= size){ //當i變成葉節點,就會跳出循環 int j = 2 * i; if(j+1 <= size && elements[j+1] > elements[j]) //別忘右子節點可能不存在的情況 j++; //如果存在右子節點,而且比左子節點大,我們就和右子節點比 (就是驗證是否temp比左右子節點都大罷了,所以和大的比) if(element[j] <= temp) //符合條件,左右子節點都比temp,也就是末節點大,那么空位置找到了,退出循環 break; elements[i] = elements[j]; //不符合條件,繼續往下騰位置 i *= 2; } elementp[i] = temp; //就算一直找到最后也沒找到,這個時候i已經是某一個葉節點,而且這個葉節點的值已經轉移到父節點上,我們把temp付給它就可以 return true; }