1. 程式人生 > >程式設計師程式設計藝術-----第三章-----尋找最小的k個數

程式設計師程式設計藝術-----第三章-----尋找最小的k個數

                    程式設計師程式設計藝術:第三章、尋找最小的k個數

作者:July。
時間:二零一一年四月二十八日。
致謝:litaoye, strugglever,yansha,luuillu,Sorehead,及狂想曲創作組。
微博:http://weibo.com/julyweibo
出處:http://blog.csdn.net/v_JULY_v
----------------------------------


前奏 
    @July_____:1、當年明月:“我寫文章有個習慣,由於早年讀了太多學究書,所以很痛恨那些故作高深的文章,其實歷史本身很精彩,所有的歷史都可以寫得很好看,...。”2、IT技術文章,亦是如此,可以寫得很通俗,很有趣,而非故作高深。希望,我可以做到。

    下面,我試圖用最清晰易懂,最易令人理解的思維或方式闡述有關尋找最小的k個數這個問題(這幾天一直在想,除了計數排序外,這題到底還有沒有其它的O(n)的演算法? )。希望,有任何問題,歡迎不吝指正。謝謝。


尋找最小的k個數
題目描述:5.查詢最小的k個元素
題目:輸入n個整數,輸出其中最小的k個。
例如輸入1,2,3,4,5,6,7和8這8個數字,則最小的4個數字為1,2,3和4。


第一節、各種思路,各種選擇

  • 0、   咱們先簡單的理解,要求一個序列中最小的k個數,按照慣有的思維方式,很簡單,先對這個序列從小到大排序,然後輸出前面的最小的k個數即可。
  • 1、   至於選取什麼的排序方法,我想你可能會第一時間想到快速排序,我們知道,快速排序平均所費時間為n*logn,然後再遍歷序列中前k個元素輸出,即可,總的時間複雜度為O(n*logn+k)=O(n*logn)
  • 2、   咱們再進一步想想,題目並沒有要求要查詢的k個數,甚至後n-k個數是有序的,既然如此,咱們又何必對所有的n個數都進行排序列?
           這時,咱們想到了用選擇或交換排序,即遍歷n個數,先把最先遍歷到得k個數存入大小為k的陣列之中,對這k個數,利用選擇或交換排序,找到k個數中的最大數kmax(kmax設為k個元素的陣列中最大元素),用時O(k)(你應該知道,插入或選擇排序查詢操作需要O(k)的時間),後再繼續遍歷後n-k個數,x與kmax比較:如果x<kmax,則x代替kmax,並再次重新找出k個元素的陣列中最大元素kmax‘(多謝kk791159796 提醒修正);如果x>kmax,則不更新陣列。這樣,每次更新或不更新陣列的所用的時間為O(k)或O(0),整趟下來,總的時間複雜度平均下來為:n*O(k)=O(n*k)
  • 3、   當然,更好的辦法是維護k個元素的最大堆,原理與上述第2個方案一致,即用容量為k的最大堆儲存最先遍歷到的k個數,並假設它們即是最小的k個數,建堆費時O(k)後,有k1<k2<...<kmax(kmax設為大頂堆中最大元素)。繼續遍歷數列,每次遍歷一個元素x,與堆頂元素比較,x<kmax,更新堆(用時logk),否則不更新堆。這樣下來,總費時O(k+(n-k)*logk)=O(n*logk)。此方法得益於在堆中,查詢等各項操作時間複雜度均為logk(不然,就如上述思路2所述:直接用陣列也可以找出前k個小的元素,用時O(n*k))。
  • 4、 按程式設計之美第141頁上解法二的所述,類似快速排序的劃分方法,N個數儲存在陣列S中,再從陣列中隨機選取一個數X(隨機選取樞紐元,可做到線性期望時間O(N)的複雜度,在第二節論述),把陣列劃分為Sa和Sb倆部分,Sa<=X<=Sb,如果要查詢的k個元素小於Sa的元素個數,則返回Sa中較小的k個元素,否則返回Sa中k個小的元素+Sb中小的k-|Sa|個元素。像上述過程一樣,這個運用類似快速排序的partition的快速選擇SELECT演算法尋找最小的k個元素,在最壞情況下亦能做到O(N)的複雜度。不過值得一提的是,這個快速選擇SELECT演算法是選取陣列中“中位數的中位數”作為樞紐元,而非隨機選取樞紐元。
  • 5、   RANDOMIZED-SELECT,每次都是隨機選取數列中的一個元素作為主元,在0(n)的時間內找到第k小的元素,然後遍歷輸出前面的k個小的元素。 如果能的話,那麼總的時間複雜度為線性期望時間:O(n+k)=O(n)(當k比較小時)

         Ok,稍後第二節中,我會具體給出RANDOMIZED-SELECT(A, p, r, i)的整體完整偽碼。在此之前,要明確一個問題:我們通常所熟知的快速排序是以固定的第一個或最後一個元素作為主元,每次遞迴劃分都是不均等的,最後的平均時間複雜度為:O(n*logn),但RANDOMIZED-SELECT與普通的快速排序不同的是,每次遞迴都是隨機選擇序列從第一個到最後一個元素中任一一個作為主元。

  • 6、   線性時間的排序,即計數排序,時間複雜度雖能達到O(n),但限制條件太多,不常用。
  • 7、   updated: huaye502在本文的評論下指出:“可以用最小堆初始化陣列,然後取這個優先佇列前k個值。複雜度O(n)+k*O(log n)”。huaye502的意思是針對整個陣列序列建最小堆,建堆所用時間為O(n)(演算法導論一書上第6章第6.3節已經論證,線上性時間內,能將一個無序的陣列建成一個最小堆),然後取堆中的前k個數,總的時間複雜度即為:O(n+k*logn)

    關於上述第7點思路的繼續闡述:至於思路7的O(n+k*logn)是否小於上述思路3的O(n*logk),即O(n+k*logn)?< O(n*logk)。粗略數學證明可參看如下第一幅圖,我們可以這麼解決:當k是常數,n趨向於無窮大時,求(n*logk)/(n+k*logn)的極限T,如果T>1,那麼可得O(n*logk)>O(n+k*logn),也就是O(n+k*logn)< O(n*logk)。雖然這有違我們慣常的思維,然事實最終證明的確如此,這個極值T=logk>1,即採取建立n個元素的最小堆後取其前k個數的方法的複雜度小於採取常規的建立k個元素最大堆後通過比較尋找最小的k個數的方法的複雜度。但,最重要的是,如果建立n個元素的最小堆的話,那麼其空間複雜度勢必為O(N),而建立k個元素的最大堆的空間複雜度為O(k)。所以,綜合考慮,我們一般還是選擇用建立k個元素的最大堆的方法解決此類尋找最小的k個數的問題。

也可以如gbb21所述粗略證明:要證原式k+n*logk-n-k*logn>0,等價於證(logk-1)n-k*logn+k>0。當when n -> +/inf(n趨向於正無窮大)時,logk-1-0-0>0,即只要滿足logk-1>0即可。原式得證。即O(k+n*logk)>O(n+k*logn) =>O(n+k*logn)< O(n*logk)與上面得到的結論一致。

    事實上,是建立最大堆還是建立最小堆,其實際的程式執行時間相差並不大,執行時間都在一個數量級上。因為後續,我們還專門寫了個程式進行測試,即針對1000w的資料尋找其中最小的k個數的問題,採取兩種實現,一是採取常規的建立k個元素最大堆後通過比較尋找最小的k個數的方案,一是採取建立n個元素的最小堆,然後取其前k個數的方法,發現兩相比較,執行時間實際上相差無幾。結果可看下面的第二幅圖。

  • 8、   @lingyun310:與上述思路7類似,不同的是在對元素陣列原地建最小堆O(n)後,然後提取K次,但是每次提取時,換到頂部的元素只需要下移頂多k次就足夠了,下移次數逐次減少(而上述思路7每次提取都需要logn,所以提取k次,思路7需要k*logn。而本思路8只需要K^2)。此種方法的複雜度為O(n+k^2)。@July:對於這個O(n+k^2)的複雜度,我相當懷疑。因為據我所知,n個元素的堆,堆中任何一項操作的複雜度皆為logn,所以按理說,lingyun310方法的複雜度應該跟下述思路8一樣,也為O(n+k*logn),而非O(n+k*k)。ok,先放到這,待時間考證06.02。

updated:
   經過和幾個朋友的討論,已經證實,上述思路7lingyun310所述的思路應該是完全可以的。下面,我來具體解釋下他的這種方法。

    我們知道,n個元素的最小堆中,可以先取出堆頂元素得到我們第1小的元素,然後把堆中最後一個元素(較大的元素)上移至堆頂,成為新的堆頂元素(取出堆頂元素之後,把堆中下面的最後一個元素送到堆頂的過程可以參考下面的第一幅圖。至於為什麼是怎麼做,為什麼是把最後一個元素送到堆頂成為堆頂元素,而不是把原來堆頂元素的兒子送到堆頂呢?具體原因可參考相關書籍)。

    此時,堆的性質已經被破壞了,所以此後要調整堆。怎麼調整呢?就是一般人所說的針對新的堆頂元素shiftdown,逐步下移(因為新的堆頂元素由最後一個元素而來,比較大嘛,既然是最小堆,當然大的元素就要下沉到堆的下部了)。下沉多少步呢?即如lingyun310所說的,下沉k次就足夠了。

    下移k次之後,此時的堆頂元素已經是我們要找的第2小的元素。然後,取出這個第2小的元素(堆頂元素),再次把堆中的最後一個元素送到堆頂,又經過k-1次下移之後(此後下移次數逐步減少,k-2,k-3,...k=0後演算法中斷)....,如此重複k-1趟操作,不斷取出的堆頂元素即是我們要找的最小的k個數。雖然上述演算法中斷後整個堆已經不是最小堆了,但是求得的k個最小元素已經滿足我們題目所要求的了,就是說已經找到了最小的k個數,那麼其它的咱們不管了。

    我可以再舉一個形象易懂的例子。你可以想象在一個水桶中,有很多的氣泡,這些氣泡從上到下,總體的趨勢是逐漸增大的,但卻不是嚴格的逐次大(正好這也符合最小堆的性質)。ok,現在我們取出第一個氣泡,那這個氣泡一定是水桶中所有氣泡中最小的,把它取出來,然後把最下面的那個大氣泡(但不一定是最大的氣泡)移到最上面去,此時違反了氣泡從上到下總體上逐步變大的趨勢,所以,要把這個大氣泡往下沉,下沉到哪個位置呢?就是下沉k次。下沉k次後,最上面的氣泡已經肯定是最小的氣泡了,把他再次取出。然後又將最下面最後的那個氣泡移至最上面,移到最上面後,再次讓它逐次下沉,下沉k-1次...,如此迴圈往復,最終取到最小的k個氣泡。

    ok,所以,上面方法所述的過程,更進一步來說,其實是第一趟調整保持第0層到第k層是最小堆,第二趟調整保持第0層到第k-1層是最小堆...,依次類推。但這個思路只是下述思路8中正規的最小堆演算法(因為它最終對全部元素都進行了調整,演算法結束後,整個堆還是一個最小堆)的調優,時間複雜度O(n+k^2)沒有量級的提高,空間複雜度為O(N)也不會減少。

原理理解透了,那麼寫程式碼,就不難了,完整粗略程式碼如下(有問題煩請批評指正):

  1. //[email protected] 泡泡魚  
  2. //July、2010.06.02。  
  3. //@lingyun310:先對元素陣列原地建最小堆,O(n)。然後提取K次,但是每次提取時,  
  4. //換到頂部的元素只需要下移頂多k次就足夠了,下移次數逐次減少。此種方法的複雜度為O(n+k^2)。  
  5. #include <stdio.h>  
  6. #include <stdlib.h>  
  7. #define MAXLEN 123456  
  8. #define K 100  
  9. //  
  10. void HeapAdjust(int array[], int i, int Length)  
  11. {  
  12.     int child,temp;  
  13.     for(temp=array[i];2*i+1<Length;i=child)  
  14.     {  
  15.         child = 2*i+1;  
  16.         if(child<Length-1 && array[child+1]<array[child])  
  17.             child++;  
  18.         if (temp>array[child])  
  19.             array[i]=array[child];  
  20.         else  
  21.             break;  
  22.         array[child]=temp;  
  23.     }  
  24. }  
  25. void Swap(int* a,int* b)  
  26. {  
  27.     *a=*a^*b;  
  28.     *b=*a^*b;  
  29.     *a=*a^*b;  
  30. }  
  31. int GetMin(int array[], int Length,int k)  
  32. {  
  33.     int min=array[0];  
  34.     Swap(&array[0],&array[Length-1]);  
  35.     int child,temp;  
  36.     int i=0,j=k-1;  
  37.     for (temp=array[0]; j>0 && 2*i+1<Length; --j,i=child)  
  38.     {  
  39.         child = 2*i+1;  
  40.         if(child<Length-1 && array[child+1]<array[child])  
  41.             child++;  
  42.         if (temp>array[child])  
  43.             array[i]=array[child];  
  44.         else  
  45.             break;  
  46.         array[child]=temp;  
  47.     }  
  48.     return min;  
  49. }  
  50. void Kmin(int array[] , int Length , int k)  
  51. {  
  52.     for(int i=Length/2-1;i>=0;--i)   
  53.         //初始建堆,時間複雜度為O(n)  
  54.         HeapAdjust(array,i,Length);  
  55.     int j=Length;  
  56.     for(i=k;i>0;--i,--j)   
  57.         //k次迴圈,每次迴圈的複雜度最多為k次交換,複雜度為o(k^2)  
  58.     {  
  59.         int min=GetMin(array,j,i);  
  60.         printf("%d,", min);  
  61.     }  
  62. }  
  63. int main()  
  64. {  
  65.     int array[MAXLEN];  
  66.     for(int i=MAXLEN;i>0;--i)  
  67.         array[MAXLEN-i] = i;  
  68.     Kmin(array,MAXLEN,K);  
  69.     return 0;  
  70. }  

    在演算法導論第6章有下面這樣一張圖,因為開始時曾一直糾結過這個問題,“取出堆頂元素之後,把堆中下面的最後一個元素送到堆頂”。因為演算法導論上下面這張圖給了我一個假象,從a)->b)中,讓我誤以為是取出堆頂元素之後,是把原來堆頂元素的兒子送到堆頂。而事實上不是這樣的。因為在下面的圖中,16被刪除後,堆中最後一個元素1代替16成為根結點,然後1下沉(注意下圖所示的過程是最大堆的堆排序過程,不再是上面的最小堆了,所以小的元素當然要下移),14上移到堆頂。所以,圖中小圖圖b)是已經在小圖a)之和被調整過的最大堆了,只是調整了logn次,非上面所述的k次。

     ok,接下來,咱們再著重分析下上述思路4。或許,你不會相信上述思路4的觀點,但我馬上將用事實來論證我的觀點。這幾天,我一直在想,也一直在找資料查詢類似快速排序的partition過程的分治演算法(即上述在程式設計之美上提到的第4點思路),是否能做到O(N)的論述或證明,

     然找了三天,不但在演算法導論上找到了RANDOMIZED-SELECT,在平均情況下為線性期望時間O(N)的論證(請參考本文第二節),還在mark allen weiss所著的資料結構與演算法分析--c語言描述一書(還得多謝朋友sheguang提醒)中,第7章第7.7.6節(本文下面的第4節末,也有關此問題的闡述也找到了在最壞情況下,為線性時間O(N)(是的,不含期望,是最壞情況下為O(N))的快速選擇演算法(此演算法,本文文末,也有闡述),請看下述文字(括號裡的中文解釋為本人新增):

    Quicksort can be modified to solve the selection problem, which we have seen in chapters 1 and 6. Recall that by using a priority queue, we can find the kth largest (or smallest) element in O(n + k log n)(即上述思路7). For the special case of finding the median, this gives an O(n log n) algorithm.

    Since we can sort the file in O(nlog n) time, one might expect to obtain a better time bound for selection. The algorithm we present to find the kth smallest element in a set S is almost identical to quicksort. In fact, the first three steps are the same. We will call     this algorithm quickselect(叫做快速選擇). Let |Si| denote the number of elements in Si(令|Si|為Si中元素的個數). The steps of quickselect are(快速選擇,即上述程式設計之美一書上的,思路4,步驟如下):

    1. If |S| = 1, then k = 1 and return the elements in S as the answer. If a cutoff for small files is being used and |S| <=CUTOFF, then sort S and return the kth smallest element.
    2. Pick a pivot element, v (- S.(選取一個樞紐元v屬於S)
    3. Partition S - {v} into S1 and S2, as was done with quicksort.
(將集合S-{v}分割成S1和S2,就像我們在快速排序中所作的那樣)

    4. If k <= |S1|, then the kth smallest element must be in S1. In this case, return quickselect (S1, k). If k = 1 + |S1|, then the pivot is the kth smallest element and we can return it as the answer. Otherwise, the kth smallest element lies in S2, and it is the (k - |S1| - 1)st smallest element in S2. We make a recursive call and return quickselect (S2, k - |S1| - 1).
(如果k<=|S1|,那麼第k個最小元素必然在S1中。在這種情況下,返回quickselect(S1,k)。如果k=1+|S1|,那麼樞紐元素就是第k個最小元素,即找到,直接返回它。否則,這第k個最小元素就在S2中,即S2中的第(k-|S1|-1)(多謝王洋提醒修正)個最小元素,我們遞迴呼叫並返回quickselect(S2,k-|S1|-1))。

    In contrast to quicksort, quickselect makes only one recursive call instead of two. The worst case of quickselect is identical to that of quicksort and is O(n2). Intuitively, this is because quicksort's worst case is when one of S1 and S2 is empty; thus, quickselect(快速選擇) is not really saving a recursive call. The average running time, however, is O(n)(不過,其平均執行時間為O(N)。看到了沒,就是平均複雜度為O(N)這句話). The analysis is similar to quicksort's and is left as an exercise.

    The implementation of quickselect is even simpler than the abstract description might imply. The code to do this shown in Figure 7.16. When the algorithm terminates, the kth smallest element is in position k. This destroys the original ordering; if this is not desirable, then a copy must be made. 

並給出了程式碼示例:

  1. //[email protected] mark allen weiss  
  2. //July、updated,2011.05.05凌晨.  
  3. //q_select places the kth smallest element in a[k]  
  4. void q_select( input_type a[], int k, int left, int right )  
  5. {  
  6.     int i, j;   
  7.     input_type pivot;    
  8.     if( left + CUTOFF <= right )  
  9.     {   
  10.         pivot = median3( a, left, right );     
  11.         //取三數中值作為樞紐元,可以消除最壞情況而保證此演算法是O(N)的。不過,這還只侷限在理論意義上。  
  12.         //稍後,除了下文的第二節的隨機選取樞紐元,在第四節末,您將看到另一種選取樞紐元的方法。  
  13.         i=left; j=right-1;    
  14.         for(;;)   
  15.         {    
  16.             while( a[++i] < pivot );    
  17.             while( a[--j] > pivot );    
  18.             if (i < j )    
  19.                 swap( &a[i], &a[j] );    
  20.             else     
  21.                 break;     
  22.         }   
  23.         swap( &a[i], &a[right-1] ); /* restore pivot */      
  24.         if( k < i)   
  25.             q_select( a, k, left, i-1 );    
  26.         else    
  27.             if( k > i )    
  28.                 q-select( a, k, i+1, right );     
  29.     }  
  30.     else    
  31.         insert_sort(a, left, right );    
  32. }  
     結論:
  1. 與快速排序相比,快速選擇只做了一次遞迴呼叫而不是兩次。快速選擇的最壞情況和快速排序的相同,也是O(N^2),最壞情況發生在樞紐元的選取不當,以致S1,或S2中有一個序列為空。
  2. 這就好比快速排序的執行時間與劃分是否對稱有關,劃分的好或對稱,那麼快速排序可達最佳的執行時間O(n*logn),劃分的不好或不對稱,則會有最壞的執行時間為O(N^2)。而樞紐元的選取則完全決定快速排序的partition過程是否劃分對稱。
  3. 快速選擇也是一樣,如果樞紐元的選取不當,則依然會有最壞的執行時間為O(N^2)的情況發生。那麼,怎麼避免這個最壞情況的發生,或者說就算是最壞情況下,亦能保證快速選擇的執行時間為O(N)列?對了,關鍵,還是看你的樞紐元怎麼選取。
  4. 像上述程式使用三數中值作為樞紐元的方法可以使得最壞情況發生的概率幾乎可以忽略不計。然而,稍後,在本文第四節末,及本文文末,您將看到:通過一種更好的方法,如“五分化中項的中項”,或“中位數的中位數”等方法選取樞紐元,我們將能徹底保證在最壞情況下依然是線性O(N)的複雜度。

     至於程式設計之美上所述:從陣列中隨機選取一個數X,把陣列劃分為Sa和Sb倆部分,那麼這個問題就轉到了下文第二節RANDOMIZED-SELECT,以線性期望時間做選擇,無論如何,程式設計之美上的解法二的複雜度為O(n*logk)都是有待商榷的。至於最壞情況下一種全新的,為O(N)的快速選擇演算法,直接跳轉到本文第四節末,或文末部分吧)。

     不過,為了公正起見,把程式設計之美第141頁上的原始碼貼出來,由大家來評判:

  1. Kbig(S, k):  
  2.      if(k <= 0):  
  3.           return []     // 返回空陣列  
  4.      if(length S <= k):  
  5.           return S  
  6.      (Sa, Sb) = Partition(S)  
  7.      return Kbig(Sa, k).Append(Kbig(Sb, k – length Sa)  
  8. Partition(S):  
  9.      Sa = []            // 初始化為空陣列  
  10.      Sb = []        // 初始化為空陣列  
  11.      Swap(s[1], S[Random()%length S])   // 隨機選擇一個數作為分組標準,以  
  12.                         // 避免特殊資料下的演算法退化,也可  
  13.                         // 以通過對整個資料進行洗牌預處理  
  14.                         // 實現這個目的  
  15.      p = S[1]  
  16.      for i in [2: length S]:  
  17.          S[i] > p ? Sa.Append(S[i]) : Sb.Append(S[i])  
  18.                             // 將p加入較小的組,可以避免分組失敗,也使分組  
  19.                             // 更均勻,提高效率   
  20. length Sa < length Sb ? Sa.Append(p) : Sb.Append(p)  
  21. return (Sa, Sb)  

     你已經看到,它是隨機選取陣列中的任一元素為樞紐的,這就是本文下面的第二節RANDOMIZED-SELECT的問題了,只是要修正的是,此演算法的平均時間複雜度為線性期望O(N)的時間。而,稍後在本文的第四節或本文文末,您還將會看到此問題的進一步闡述(SELECT演算法,即快速選擇演算法),此SELECT演算法能保證即使在最壞情況下,依然是線性O(N)的複雜度。

updated:

   1、為了照顧手中沒程式設計之美這本書的friends,我拍了張照片,現貼於下供參考(提醒:1、書上為尋找最大的k個數,而我們面對的問題是尋找最小的k個數,兩種形式,一個本質(該修改的地方,上文已經全部修改)。2、書中描述與上文思路4並無原理性出入,不過,勿被圖中記的筆記所誤導,因為之前也曾被書中的這個n*logk複雜度所誤導過。ok,相信,看完本文後,你不會再有此疑惑):

    2、同時,在程式設計之美原書上此節的解法五的開頭提到,“上面類似快速排序的方法平均時間複雜度是線性的”,我想上面的類似快速排序的方法,應該是指解法(即如上所述的類似快速排序partition過程的方法),但解法二得出的平均時間複雜度卻為O(N*logk),明擺著前後矛盾(參見下圖)。

    3、此文創作後的幾天,已把本人的意見反饋給鄒欣等人,下面是程式設計之美bop1的改版修訂地址的頁面截圖(本人也在參加其改版修訂的工作),下面的文字是我的記錄(同時,本人宣告,此狂想曲系列文章系我個人獨立創作,與其它的事不相干):


第二節、Randomized-Select,線性期望時間
   下面是RANDOMIZED-SELECT(A, p, r)完整偽碼(來自演算法導論),我給了註釋,或許能給你點啟示。在下結論之前,我還需要很多的時間去思量,以確保結論之完整與正確。

PARTITION(A, p, r)         //partition過程 p為第一個數,r為最後一個數
1  x ← A[r]               //以最後一個元素作為主元
2  i ← p - 1
3  for j ← p to r - 1
4       do if A[j] ≤ x
5             then i ← i + 1
6                  exchange A[i] <-> A[j]
7  exchange A[i + 1] <-> A[r]
8  return i + 1

RANDOMIZED-PARTITION(A, p, r)      //隨機快排的partition過程
1  i ← RANDOM(p, r)                                 //i  隨機取p到r中個一個值
2  exchange A[r] <-> A[i]                         //以隨機的 i作為主元
3  return PARTITION(A, p, r)            //呼叫上述原來的partition過程

RANDOMIZED-SELECT(A, p, r, i)       //以線性時間做選擇,目的是返回陣列A[p..r]中的第i 小的元素
1  if p = r          //p=r,序列中只有一個元素 
2      then return A[p]
3  q ← RANDOMIZED-PARTITION(A, p, r)   //隨機選取的元素q作為主元 
4  k ← q - p + 1                     //k表示子陣列 A[p…q]內的元素個數,處於劃分低區的元素個數加上一個主元元素
5  if i == k                        //檢查要查詢的i 等於子陣列中A[p....q]中的元素個數k
6      then return A[q]        //則直接返回A[q] 
7  else if i < k       
8      then return RANDOMIZED-SELECT(A, p, q - 1, i)   
          //得到的k 大於要查詢的i 的大小,則遞迴到低區間A[p,q-1]中去查詢
9  else return RANDOMIZED-SELECT(A, q + 1, r, i - k)
          //得到的k 小於要查詢的i 的大小,則遞迴到高區間A[q+1,r]中去查詢。  

    寫此文的目的,在於起一個拋磚引玉的作用。希望,能引起你的重視及好的思路,直到有個徹底明白的結果。

     updated:演算法導論原英文版有關於RANDOMIZED-SELECT(A, p, r)為O(n)的證明。為了一個徹底明白的闡述,我現將其原文的證明自個再翻譯加工後,闡述如下:

此RANDOMIZED-SELECT最壞情況下時間複雜度為Θ(n2),即使是要選擇最小元素也是如此,因為在劃分時可能極不走運,總是按餘下元素中的最大元素進行劃分,而劃分操作需要O(n)的時間。

然而此演算法的平均情況效能極好,因為它是隨機化的,故沒有哪一種特別的輸入會導致其最壞情況的發生。

演算法導論上,針對此RANDOMIZED-SELECT演算法平均時間複雜度為O(n)的證明,引用如下,或許,能給你我多點的啟示(本來想直接引用第二版中文版的翻譯文字,但在中英文對照閱讀的情況下,發現第二版中文版的翻譯實在不怎麼樣,所以,得自己一個一個字的敲,最終敲完修正如下),分4步證明:

1、當RANDOMIZED-SELECT作用於一個含有n個元素的輸入陣列A[p ..r]上時,所需時間是一個隨機變數,記為T(n),我們可以這樣得到線性期望值E [T(n)]的下界:程式RANDOMIZED-PARTITION會以等同的可能性返回陣列中任何一個元素為主元,因此,對於每一個k,(1 ≤k ≤n,子陣列A[p ..q]有k個元素,它們全部小於或等於主元元素的概率為1/n.對k = 1, 2,...,n,我們定指示器Xk,為:

Xk = I{子陣列A[p ..q]恰有k個元素} ,

我們假定元素的值不同,因此有

          E[Xk]=1/n

當呼叫RANDOMIZED-SELECT並且選擇A[q]作為主元元素的時候,我們事先不知道是否會立即找到我們所想要的第i小的元素,因為,我們很有可能需要在子陣列A[p ..q - 1], 或A[q + 1 ..r]上遞迴繼續進行尋找.具體在哪一個子陣列上遞迴尋找,視第i小的元素與A[q]的相對位置而定.

2、假設T(n)是單調遞增的,我們可以將遞迴所需時間的界限限定在輸入陣列時可能輸入的所需遞迴呼叫的最大時間(此句話,原中文版的翻譯也是有問題的).換言之,我們斷定,為得到一個上界,我們假定第i小的元素總是在劃分的較大的一邊,對一個給定的RANDOMIZED-SELECT,指示器Xk剛好在一個k值上取1,在其它的k值時,都是取0.當Xk =1時,可能要遞迴處理的倆個子陣列的大小分別為k-1,和n-k,因此可得到遞迴式為

         

取期望值為:
        

為了能應用等式(C.23),我們依賴於XkT(max(k - 1,n - k))是獨立的隨機變數(這個可以證明,證明此處略)。

3、下面,我們來考慮下表達式max(k - 1,n -k)的結果.我們有:

         

如果n是偶數,從T()T(n - 1)每個項在總和中剛好出現倆次,T()出現一次。因此,有

         

我們可以用替換法來解上面的遞迴式。假設對滿足這個遞迴式初始條件的某個常數c,有T(n) ≤cn。我們假設對於小於某個常數c(稍後再來說明如何選取這個常數)的n,有T(n) =O(1)。 同時,還要選擇一個常數a,使得對於所有的n>0,由上式中O(n)項(用來描述這個演算法的執行時間中非遞迴的部分)所描述的函式,可由an從上方限界得到(這裡,原中文版的翻譯的確是有點含糊)。利用這個歸納假設,可以得到:

(此段原中文版翻譯有點問題,上述文字已經修正過來,對應的此段原英文為:We solve the recurrence by substitution. Assume thatT(n)≤cn for some constant c that satisfies the initial conditions of the recurrence. We assume thatT(n) =O(1) forn less than some constant; we shall pick this constant later. We also pick a constanta such that the function described by theO(n) term above (which describes the non-recursive component of the running time of the algorithm) is bounded from above byan for alln> 0. Using this inductive hypothesis, we have)

        

4、為了完成證明,還需要證明對足夠大的n,上面最後一個表示式最大為cn,即要證明:cn/4 -c/2 -an ≥ 0.如果在倆邊加上c/2,並且提取因子n,就可以得到n(c/4 -a) ≥c/2.只要我們選擇的常數c能滿足c/4 -a > 0, i.e.,即c > 4a,我們就可以將倆邊同時除以c/4 -a, 最終得到:

                 

綜上,如果假設對n < 2c/(c -4a),有T(n) =O(1),我們就能得到E[T(n)] =O(n)。所以,最終我們可以得出這樣的結論,並確認無疑:在平均情況下,任何順序統計量(特別是中位數)都可以線上性時間內得到。

      結論: 如你所見,RANDOMIZED-SELECT有線性期望時間O(N)的複雜度,但此RANDOMIZED-SELECT演算法在最壞情況下有O(N^2)的複雜度。所以,我們得找出一種在最壞情況下也為線性時間的演算法。稍後,在本文的第四節末,及本文文末部分,你將看到一種在最壞情況下是線性時間O(N)的複雜度的快速選擇SELECT演算法。

第三節、各執己見,百家爭鳴

updated :本文昨晚釋出後,現在朋友們之間,主要有以下幾種觀點(在徹底弄清之前,最好不要下結論):

  1. luuillu:我不認為隨機快排比直接快排的時間複雜度小。使用快排處理資料前,我們是不知道資料的排列規律的,因此一般情況下,被處理的資料本來就是一組隨機資料,對於隨機資料再多進行一次隨機化處理,