程式員編程藝術:三之三續、求數組中給定下標區間內的第K小(大)元素

來源:互聯網
上載者:User

                  第三章三續、求數組中給定下標區間內的第K小(大)元素

作者:July、上善若水、編程藝術室。
出處:http://blog.csdn.net/v_JULY_v 。

前奏

    原狂想曲系列已更名為:程式員編程藝術系列。原狂想曲創作組更名為編程藝術室。編程藝術室致力於以下三點工作:1、針對一個問題,不斷尋找更高效的演算法,並予以編程實現。2、解決實際中會碰到的應用問題,如第十章、如何給磁碟檔案排序。3、經典演算法的研究與實現。總體突出一點:編程,如何高效的編程解決實際問題。歡迎有志者加入。

    ok,扯遠了。在上一章,我們介紹了第十章、如何給10^7個資料量的磁碟檔案排序,下面介紹下本章的主題。我們知道,通常來講,尋找給定區間內的第k小(大)的元素的問題是ACM中一類常用的資料結構的一個典型例題,即劃分樹/逆向歸併樹,通常用線段樹的結構儲存。

    當然這裡暫且不表,尚不說劃分樹思想的神奇,就是線段樹的結構,一般沒有ACM基礎的人也都覺得難以理解。所以,這裡提供一個時間效率尚可,空間代價還要略小的巧妙解法—伴隨數組。

    如果看過此前程式員編程藝術:第六章、求解500萬以內的親和數中,有關親和數的那個題目的伴隨數組的解法,也就是利用數組下標作為伴隨數組,相信就會對這個方法有一定程度的理解。

第一節、尋找給定區間內的第k小(大)的元素

    給定數組,給定區間,求第K小的數如何處理?常規方法請查閱:程式員編程藝術:第三章、尋找最小的k個數。

    1、排序,快速排序。我們知道,快速排序平均所費時間為n*logn,從小到大排序這n個數,然後再遍曆序列中後k個元素輸出,即可,總的時間複雜度為O(n*logn+k)=O(n*logn)。

    2、排序,選擇排序。用選擇或交換排序,即遍曆n個數,先把最先遍曆到得k個數存入大小為k的數組之中,對這k個數,利用選擇或交換排序,找到k個數中的最小數kmax(kmax設為k個元素的數組中最小元素),用時O(k)(你應該知道,插入或選擇排序尋找操作需要O(k)的時間),後再繼續遍曆後n-k個數,x與kmax比較:如果x<kmax,則x代替kmax,並再次重新找出k個元素的數組中最大元素kmax‘(多謝jiyeyuran 提醒修正);如果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、按編程之美上解法二的所述,類似快速排序的劃分方法,N個數儲存在數組S中,再從數組中隨機選取一個數X,把數組劃分為Sa和Sb倆部分,Sa<=X<=Sb,如果要尋找的k個元素小於Sa的元素個數,則返回Sa中較小的k個元素,否則返回Sa中所有的元素+Sb中較小的k-|Sa|個元素。不斷遞迴下去,把問題分解成更小的問題,平均時間複雜度為O(N)(編程之美所述的n*logk的複雜度有誤,應為O(N),特此訂正。其嚴格證明,請參考第三章:程式員面試題狂想曲:第三章、尋找最小的k個數、updated 10次)......。

    下面我們給出伴隨數組解法,首先,定義一個結構體,一個是數組元素,另一個是數組原來的標號,記錄每個數在數組的原順序。

    我們以下面的測試資料舉例(紅體部分表示下標為2~5之間的數5,2,6,3,淺色部分表示數組中的數各自對應的數組下標,淡藍色部分為給定的下標區間,注,這裡,我們讓數組下標從1開始):

      a[i].data   1 5 2 6 3 7 4
      a[i].num   1 2 3 4 5 6 7 

    現在,題目給定了下標區間,如在原序列中下標2~5(即下標為2、3、4、5)區間找到第3小的數。問題亦相當於要你找原序列裡給定下標區間即第2個數到第5個數之中(5 2 6 3)第3小的數(當然,答案很明顯,第3小的數就是5)。

那麼對原數組進行排序,然後得到的序列應該是(註:原下標始終保持不變):

      a [i].data  1 2 3 4 5 6 7
      a [i].num  1 3 5 7 2 4 6

    如上,既然資料現在已經從小到大排好了,那麼,我們只需要進行一次檢索,從最小的數到最大的數,我們找第k(k=3)小的數,當我們發現下標a[i].num等於原給定下標區間在2~5中,即a[i].num==2 || 3 || 4 || 5的時候,k--,那麼當k==0的時候,我們也就找到了第k(3)小的數了。如下(紅色部分表示原給定下標區間中的數,淺色部分依然是原各數對應的下標,淡藍色部分為原來給定的下標區間所對應的索引):

      a [i].data   1 2 3 4 5 6 7
      a [i].num   1 3 5 7 2 4 6
         k           3 2 1 1 0

 故下標索引為2~5之間第k(3)小的數是5。

    程式的構造與解釋:由於排序後,我們能保證原序列已經從小到大的排好序了,所以,當遍曆或掃描到原序列給定下標區間中的數時,則k--,最終能在k==0時,找到第k小的數,且這個數是在原來給定下標區間中的某一個數。
    而這個伴隨數組,或者說原序列各數的索引則幫我們或者說是幫電腦記下了原來的數,已讓我們後來遍曆時能識別排序後序列中的數是否是給定下標區間中的某一個數。如果是原給定下標區間中的數,則k--,否則k不變。

第二節、採用伴隨數組方案的實現

    上述採用伴隨數組的方法巧妙且簡單,也很好理解和實現,關鍵 就是在於題目要求是在給定下標區間中找尋第k小(大)的元素,所以,基本上在排序n*logn完了之後,總能 在O(n)的時間內找到想找的數。原始碼如下:

//copyright@ 水 && July<br />//總的時間複雜度為O(N*logN+N)=O(N*logN)。<br />//July、updated,2011.05.28.淩晨。<br />#include<iostream><br />#include<algorithm><br />using namespace std;</p><p>struct node{<br /> int num,data;<br /> bool operator < (const node &p) const<br /> {<br /> return data < p.data;<br /> }<br />};<br />node p[100001];</p><p>int main()<br />{<br /> int n=7;<br /> int i,j,a,b,c;//c:flag;</p><p> for(i=1;i<=n;i++)<br /> {<br /> scanf("%d",&p[i].data);<br /> p[i].num = i;<br /> }<br /> sort(p+1,p+1+n); //調用庫函數sort完成排序,複雜度n*logn</p><p> scanf("%d %d %d",&a,&b,&c);<br /> for(i=1;i<=n;i++) //掃描一遍,複雜度n<br /> {<br /> if(p[i].num>=a && p[i].num<=b)<br /> c--;<br /> if(c == 0)<br /> break;<br /> }<br /> printf("%d\n",p[i].data);<br /> return 0;<br />}

程式測試:輸入的第1行數字1 5 2 6 3 7 4代表給定的數組,第二行的數字中,2 5代表給定的下標區間2~5,3表示要在給定的下標區間2~5中尋找第3小的數,第三行的5表示找到的第3小的數。程式運行結果如下:

 

水原來寫的代碼(上面我的改造,是為了達到後來掃描時O(N)的視覺效果)://copyright@ 水<br />#include<iostream><br />#include<algorithm><br />using namespace std;</p><p>struct node{<br /> int num,data;<br /> bool operator < (const node &p) const<br /> {<br /> return data < p.data;<br /> }<br />};<br />node p[100001];</p><p>int main()<br />{<br /> int n,m,i,j,a,b,c;//c:flag;<br /> while(scanf("%d %d",&n,&m)!=EOF)<br /> {<br /> for(i=1;i<=n;i++)<br /> {<br /> scanf("%d",&p[i].data);<br /> p[i].num = i;<br /> }<br /> sort(p+1,p+1+n);</p><p> for(j=1;j<=m;j++)<br /> {<br /> scanf("%d %d %d",&a,&b,&c);<br /> for(i=1;i<=n;i++)<br /> {<br /> if(p[i].num>=a && p[i].num<=b)<br /> c--;<br /> if(c == 0)<br /> break;<br /> }<br /> printf("%d\n",p[i].data);<br /> }<br /> }<br /> return 0;<br />}

第三節、直接排序給定下標區間的數

    你可能會忽略一個重要的事實,不知讀者是否意識到。題目是要求我們在數組中求給定下標區間內某一第k小的數,即我們只要找到這個第k小的數,就夠了。但上述程式顯示的一個弊端,就是它先對整個數組進行了排序,然後採用伴隨數組的解法尋找到第k小的數。而事實是,我們不需要對整個數組進行排序,我們只需要對我們要尋找的那個數的數組中給定下標區間的數進行部分排序,即可。

    對,事情就是這麼簡單。我們摒棄掉伴隨數組的方法,只需要直接對數組中給定的那部分下標區間中的數進行排序,而不是對整個數組進行排序。如此的話,演算法的時間複雜度降到了L*logK。其中,L=|b-a+1|,L為給定下標區間的長度,相對整個數組的程度n,L<=n。程式碼如下。

//copyright@ 蒼狼<br />//直接對給定區間的數進行排序,沒必要用伴隨數組。<br />#include<iostream><br />#include<algorithm><br />using namespace std; </p><p>struct node{<br /> int data;<br /> bool operator < (const node &p) const<br /> {<br /> return data < p.data;<br /> }<br />};<br />node p[100001]; </p><p>int main()<br />{<br /> int n=7;<br /> int i,a,b,c;//c:flag; </p><p> for(i=1;i<=n;i++)<br /> {<br /> scanf("%d",&p[i].data);<br /> }</p><p> scanf("%d%d%d", &a, &b, &c); //b,a為原數組的下標索引<br />sort(p+a, p+b+1); //直接對給定區間進行排序,|b-a+1|*log(b-a+1)</p><p>printf("The number is %d\n", p[a-1+c].data);<br /> return 0;<br />}

程式測試:我們同樣採取第二節的測試案例。輸入的第1行數字1 5 2 6 3 7 4代表給定的數組,第二行的數字中,2 5代表給定的下標區間2~5,3表示要在給定的下標區間2~5中的數,即從a[2]~a[5]中尋找第3小的數,第三行的5表示找到的第3小的數。程式運行結果如下。

    貌似上述直接對給定區間內的數進行排序,效率上較第二節的伴隨數組方案更甚一籌。既然如此,那麼伴隨數組是不是多此一舉呢?其實不然,@水:假如,我對2-5之間進行了排序,那麼資料就被摧毀了,怎麼進行2次的操作?就是現在的2位置已經不是初始的2位置的資料了。也就是說,快排之後下標直接定位的方法明顯只能用一次。

    ok,更多請看下文第四節中的“百家爭鳴”與“經典對白”。

第四節、伴隨數組的優勢所在 

百家爭鳴

  • @雨翔:伴隨數組這種方式確實比較新穎 ,伴隨數組的前提是在排序後的 ,但總的複雜度還是 0(N*logN+N)=O(N*logN),找第K大的數的此類面試題都是有這幾點限制:1、數很多,讓你在記憶體中放不下,2、複雜度嚴格要求,即不能用排序。當然,即便第三節中,直接對給定下標區間進行排序,複雜度同樣為L*logL,L為給定區間的長度。事實上,我們在解決 “從給定下標區間中的數找尋第k小(大)的元素” 這個問題,還是選擇堆為好,在之前的基礎上:入堆的時候 只需檢測這個元素的下標是否是給定下標區間內的,不是則不入這樣的複雜度會低,不需要排序。然後便是平均時間複雜度雖為O(N),但並不常用的快速選擇SELECT演算法,參考:第三章再續:快速選擇SELECT演算法的深入分析與實現。
  • @水:伴隨數組的解法是為了達到預先處理開銷換尋找開銷目的。直接對給定不同的下標區間的數進行排序,在小資料量處理時複雜度還可以接受,但當面臨大資料量,即海量資料處理時,比如10G的資料量,每次取1G的段的問題,則使用伴隨數組的方法會凸顯優勢,只不過預先處理的開銷的確是大了點。伴隨數組的精髓就是穩定的時間之內解決對相同資料的多次訪問尋找。說白了,就是同一個數組,要不斷尋找數組中給定的不同下標區間中的第k小的數時優勢明顯。具體,還可以看看這道題:http://poj.org/problem?id=2104。
  • @July:不用看我了,基本上同意上述水的觀點。雨翔之所以認為伴隨數組不可取,是因為沒有考慮到水提出的問題,即如果要多次或不斷的從數組中不同的下標區間中尋找第k小的數的情況。這時,伴隨數組的優勢就體現出來了。ok,讀者還可以繼續看下面的經典對白。相信,你能找到你想要的答案。

經典對白

  • 尋找a[0]~a[n-1]內第K小,然後再找a[1]~a[n]內第K小,依次往複,找個幾次就優勢明顯了。其實是比較採取伴隨數組解法n log n +m*n的代價(m為給定不同區間的個數)和直接排序m*(L*log L )(L為給定下標區間的長度)的代價,哪個更低。其中,採用伴隨數組尋找最差情況是nlogn + m(n-1),而直接排序代價,最差情況為m*((n-1)*log(n-1))。當m>>0且n>>0時,排序時間-伴隨時間=m*n*logn-n*logn-mn =(m-1)n*logn -mn恒正,結論:即在需要不斷的從不同給定下標區間中尋找第k小數的情況下,當資料規模大的時候伴隨數組效果恒優於每次都直接對給定的下標區間的部分數進行排序。
  • 是的,好比我現在給定不同的另外一個下標區間,要你從中尋找第k小的數,你總不能每次都排序吧。而採取伴隨數組的方案的話,由於伴隨數組記下了各自給定的下標區間對應的數。所以,第二次在不同的下標區間中尋找第k小的數時,還是只要掃描一遍即可找到,複雜度還是 O(N)。從而,給定不同的下標區間尋找第k小的數,複雜度為m*N加上之前排序預先處理的複雜度,N*logN,總的時間複雜度為O(N*logN+m*N)(m為給定不同區間的個數)。而直接對給定下標區間中的數進行排序的代價則為l1*logl1+l2*logl2+...+li*logli。當m>>0且n>>0時,哪個複雜度誰大誰小,一眼就看出來了伴隨數組所體現的巨大優勢。
  • 恩,實際範例是這樣的,我們有每天超過100萬次點擊的網頁,我們常見的來源有n種,然後,我們要確定每天的每個時段和一周乃至整個月的點擊來源地分析。資料庫的庫存資料量龐大,copy花銷很大,內排序花銷更大,如果要做出這樣的統計圖,我擦淚,如果每次都排序,玩死了。

原例重現 

   ok,說了這麼多,你可能還根本就不明白到底是怎麼一回事。讓我們從第一節舉的那個例子說起。我們要找給定下標區間2~5的數中第3小的數,誠然,此時,我們有兩種選擇,1、如上第一節、第二節所述的伴隨數組,2、直接對下標區間2-5的數進行排序。下面,只回顧下伴隨數組的方案。

伴隨數組

      a[i].data   1 5 2 6 3 7 4
      a[i].num   1 2 3 4 5 6 7 

第一次排序後:

      a [i].data  1 2 3 4 5 6 7
      a [i].num  1 3 5 7 2 4 6

伴隨數組方案尋找:

      a [i].data   1 2 3 4 5 6 7
      a [i].num   1 3 5 7 2 4 6
         k           3 2 1 1 0

 

好的,那麼現在,如果題目要求你在之前數組的下標區間3~6的數中找第3小的數呢(答案很明顯,為6)?

      a[i].data   1 5 2 6 3 7 4
      a[i].num   1 2 3 4 5 6 7 

  1. 直接排序麼?ok,退萬一步講,假設有的讀者可能還是會依然選擇直接排序下標3~6之間的數。但你是否可曾想到,每次對不同的下標區間所對應的數進行排序,你不但破壞了原有的資料,而且如果區間有覆蓋的話,那麼將使得我們無法再能依靠原有的直接的下標定位找到原來的資料,且每進行一次排序,都要花費平均時間複雜度為N*logN的時間開銷。如上面的經典對白所述,這樣下去的開銷將非常大,將為l1*logl1+l2*logl2+...+li*logli。
  2. 那麼,如果是採取伴隨數組的方法,我們要怎麼做呢?如下所示,我們在k=0的時候,同樣找到了第3小的數6,如此是不是只要在之前的一次排序,以後不論是換各種不同的下標區間時都能掃描一遍O(N)搞定?複雜度為O(N*logN+m*N)(m為給定不同的下標區間的區間數)。
  3. 由上面的經典對白裡面的內容,我們已經知道,當m>>0且n>>0時(m為給定不同的下標區間的區間數,n為數組大小),排序時間-伴隨時間=m*n*logn-n*logn-mn =(m-1)n*logn -mn恒正。yeah,相信,你已經明白了。

伴隨數組

原第一次排序後:

      a [i].data  1 2 3 4 5 6 7
      a [i].num  1 3 5 7 2 4 6

再次掃描,直接O(N)搞定:

      a [i].data   1 2 3 4 5 6 7
      a [i].num   1 3 5 7 2 4 6
         k           3 2 1 1 1 0

(而之前有的讀者意識不到伴隨數組的意義,是因為一般的人只考慮找一次,不會想到第二次或多次尋找)

編程獨白

    給你40分鐘的時間,你可以思考十分鐘,然後用三十分鐘的時間來寫代碼,最後浪費在無謂的調試上;你也可以思考半個小時,徹底弄清問題的本質與程式的脈絡,然後用十分鐘的時間來編寫代碼,體會代碼如行雲流水而出的感覺。

本章完。

著作權,本人對本blog內所有任何內容享有著作權及著作權。網路轉載,請以連結形式註明出處。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.