一道經典的面試題:如何從N個數中選出最大(小)的n個數

寫了兩個小時,捧捧場,別亂拍!!!轉載請註明北京交大LuoBin

一道經典的面試題:如何從N個數中選出最大(小)的n個數?

這個問題我前前後後考慮了有快一年了,也和不少人討論過。據我得到的消息,Google和微軟都面過這道題。這道題可能很多人都聽說過,或者知道答案(所謂的“堆”),不過我想把我的答案寫出來。我的分析也許存有漏洞,以交流爲目的。但這是一個滿複雜的問題,蠻有趣的。看完本文,也許會啓發你一些沒有想過的解決方案(我一直認爲堆也許不是最高效的算法)。在本文中,將會一直以尋找n個最“大”的數爲分析例子,以便統一。注:本文寫得會比較細節一些,以便於絕大多數人都能看懂,別嫌我羅嗦:) 我很不確定多少人有耐心看完本文!

Naive 方法:

     首先,我們假設n和N都是內存可容納的,也就是說N個數可以一次load到內存裏存放在數組裏(如果非要存在鏈表估計又是另一個challenging的問題了)。從最簡單的情況開始,如果n=1,那麼沒有任何疑惑,必須要進行N-1次的比較才能得到最大的那個數,直接遍歷N個數就可以了。如果n=2呢?當然,可以直接遍歷2遍N數組,第一遍得到最大數max1,但是在遍歷第二遍求第二大數max2的時候,每次都要判斷從N所取的元素的下標不等於max1的下標,這樣會大大增加比較次數。對此有一個解決辦法,可以以max1爲分割點將N數組分成前後兩部分,然後分別遍歷這兩部分得到兩個“最大數”,然後二者取一得到max2。



     也可以遍歷一遍就解決此問題,首先維護兩個元素max1,max2(max1>=max2),取到N中的一個數以後,先和max1比,如果比max1大(則肯定比max2大),直接替換max1,否則再和max2比較確定是否替換max2。採用類似的方法,對於n=2,3,4……一樣可以處理。這樣的算法時間複雜度爲O(nN)。當n越來越大的時候(不可能超過N/2,否則可以變成是找N-n個最小的數的對偶問題),這個算法的效率會越來越差。但是在n比較小的時候(具體多小不好說),這個算法由於簡單,不存在遞歸調用等系統損耗,實際效率應該很不錯就如同排序的時候筆薪隙淌保捎彌苯硬迦肱判蚧岣噝В?


堆:
     當n較大的時候採用什麼算法呢?首先我們分析上面的算法,當從N中取出一個新的數m的時候,它需要依次和max1,max2,max3……max n比較,一直找到一個比m小的max x,就用m來替換max x,平均比較次數是n/2。可不可以用更少的比較次數來實現替換呢?最直觀的方法是,也就是網上文章比較推崇的堆。堆有這麼一些好處:1.它是一個完全二叉樹,樹的深度是相同節點的二叉樹中最少的,維護效率較高;2.它可以通過數組來實現,而且父節點p與左右子節l,r點的數組下標的關係是s[l] = 2*s[p]+1和s[r] = 2*s[p]+2。在計算機中2*s[p]這樣的運算可以用一個左移1位操作來實現,十分高效。再加上數組可以隨機存取,效率也很高。3.堆的Extract操作,也就是將堆頂拿走並重新維護堆的時間複雜度是O(logn),這裏n是堆的大小。

     具體到我們的問題,如何具體實現呢?首先開闢一個大小爲n的數組區A,從N中讀入n個數填入到A中,然後將A維護成一個小頂堆(即堆頂A[0]中存放的是A中最小的數)。然後從N中取出下一個數,即第n+1個數m,將m與堆頂A[0]比較,如果m<=A[0],直接丟棄m。否則應該用m替換A[0]。但此時A的堆特性可能已被破壞,應該重新維護堆:從A[0]開始,將A[0]與左右子節點分別比較(特別注意,這裏需要比較“兩次”才能確定最大數,在後面我會根據這個來和“敗者樹”比較),如果A[0]比左右子節點都小,則堆特性能夠保證,勿需繼續,否則如左(右)節點最大,則將A[0]與左(右)節點交換,並繼續維護左(右)子樹。依次執行,直到遍歷完N,堆中保留的n個數就是N中最大的n個數。這都是堆排序的基本知識,唯一的trick就是維護一個小頂堆,而不是大頂堆。不明白的稍微想一下。維護一次堆的時間複雜度爲O(logn),總體的複雜度是O(Nlogn)這樣一來,比起上面的O(nN),當n足夠大時,堆的效率肯定是要高一些的。當然,直接對N數組建堆,然後提取n次堆頂就能得到結果,而且其複雜度是O(nlogN),當n不是特別小的時候這樣會快很多。但是對於online數據就沒辦法了,比如N不能一次load進內存,甚至是一個流,根本不知道N是多少。

敗者樹:
     有沒有別的算法呢?我先來說一說敗者樹(loser tree)。也許有些人對loser tree不是很瞭解,其實它是一個比較經典的外部排序方法,也就是有x個已經排序好的文件,將其歸併爲一個有序序列。敗者樹的思想咋一看有些繞,其實是爲了減小比較次數。首先簡單介紹一下敗者樹:敗者樹的葉子節點是數據節點,然後兩兩分組(如果節點總數不是2的冪,可以用類似完全樹的結構構成樹),內部節點用來記錄左右子樹的優勝者中的“敗者”(注意記錄的是輸的那一方),而優勝者則往上傳遞繼續比較,一直到根節點。如果我們的優勝者是兩個數中較小的數,則根節點記錄的是最後一次比較中的“敗者”,也就是所有葉子節點中第二小的那個數,而最小的那個數記錄在一個獨立的變量中。這裏要注意,內部節點不但要記錄敗者的數值,還要記錄對應的葉子節點。如果是用鏈表構成的樹,則內部節點需要有指針指向葉子節點。這裏可以有一個trick,就是內部節點只記錄“敗者”對應的葉子節點,具體的數值可以在需要的時候間接訪問(這一方法在用數組來實現敗者樹時十分有用,後面我會講到)。關鍵的來了,當把最小值輸出後,最小值所對應的葉子節點需要變成一個新的數(或者改爲無窮大,在文件歸併的時候表示文件已讀完)。接下來維護敗者樹,從更新的葉子節點網上,依次與內部節點比較,將“敗者”更新,勝者往上繼續比較。由於更新節點佔用的是之前的最小值的葉子節點,它往上一直到根節點的路徑與之前的最小值的路徑是完全相同的。內部節點記錄的“敗者”雖然稱爲“敗者”,但卻是其所在子樹中最小的數。也就是說,只要與“敗者”比較得到的勝者,就是該子樹中最小的那個數(這裏講得有點繞了,看不明白的還是找本書看吧,對照着圖比較容易理解)。
注:也可以直接對N構建敗者樹,但是敗者樹用數組實現時不能像堆一樣進行增量維護,當葉子節點的個數變動時需要完全重新構建整棵樹。爲了方便比較堆和敗者樹的性能,後面的分析都是對n個數構建的堆和敗者樹來分析的。

     總而言之,敗者樹在進行維護的時候,比較次數是logn+1。與堆不同的是,敗者樹是從下往上維護,每上一層,只需要和敗者節點比較“一次”即可。而堆在維護的時候是從上往下,每下一層,需要和左右子節點都比較,需要比較兩次。從這個角度,敗者樹比堆更優一些。但是,請注意但是,敗者樹每一次維護必定需要從葉子節點一直走到根節點,不可能中間停止;而堆維護時,“有可能”會在中間的某個層停止,不需要繼續往下。這樣一來,雖然每一層敗者樹需要的比較次數比堆少一倍,但是走的層數堆會比敗者樹少。具體少多少,從平均意義上到底哪一個的效率會更好一些?那我就不知道了,這個分析起來有點麻煩。感興趣的人可以嘗試一下,討論討論。但是至少說明了,也許堆並非是最優的。

     具體到我們的問題。類似的方法,先構建一棵有n個葉子節點的敗者樹,勝出者w是n箇中最小的那一個。從N中讀入一個新的數m後,和w比較,如果比w小,直接丟棄,否則用m替換w所在的葉子節點的值,然後維護該敗者樹。依次執行,直到遍歷完N,敗者樹中保留的n個數就是N中最大的n個數。時間複雜度也是O(Nlogn)

     前面有提到,堆的優點中包括“完全樹”,“用數組實現”,以及“父節點與左右子節點之間的下標的特殊關係”。其實敗者樹也可以用數組來實現。其實我以前沒有這麼想過,我進微軟實習的時候的考我的編程題就是文件歸併,我是用敗者樹做的,當時是用指針來構建樹,2個小時的題,我弄了3個小時才弄出來,差點沒弄死我。指針維護起來太麻煩了。昨天旁邊的David提到敗者樹可以用數組實現,恍然大悟。其原理和堆的數組實現是相同的,唯一的區別是堆的所有節點都是數據節點,而敗者樹只有葉子節點是數據節點。所以在空間複雜度上,敗者樹所需的空間大小是堆的一倍(完全樹的內部節點的個數是葉子節點個數減一)。

這個問題還要接着分析下去,是不是都快睡着了?呵呵

類快速排序方法:
     快速排序大家大家都不陌生了。主要思想是找一個“軸”節點,將數列交換變成兩部分,一部分全都小於等於“軸”,另一部分全都大於等於“軸”,然後對兩部分遞歸處理。其平均時間複雜度是O(NlogN)。從中可以受到啓發,如果我們選擇的軸使得交換完的“較大”那一部分的數的個數j正好是n,不也就完成了在N個數中尋找n個最大的數的任務嗎?當然,軸也許不能選得這麼恰好。可以這麼分析,如果j>n,則最大的n個數肯定在這j個數中,則問題變成在這j個數中找出n個最大的數;否則如果j<n,則這j個數肯定是n個最大的數的一部分,而剩下的j-n個數在小於等於軸的那一部分中,同樣可遞歸處理。
    令人愉悅的是,這個算法的平均複雜度是O(N)的。怎麼樣?比堆的O(Nlogn)可能會好一些吧?!(n如果比較大肯定會好)
    需要注意的是,這裏的時間複雜度是平均意義上的,在最壞情況下,每次分割都分割成1:N-2,這種情況下的時間複雜度爲O(n)。但是我們還有殺手鐗,可以有一個在最壞情況下時間複雜度爲O(N)的算法,這個算法是在分割數列的時候保證會按照比較均勻的比例分割,at least 3n/10-6。具體細節我就不再說了,感興趣的人蔘考算法導論(Introduction to Algorithms 第二版第九章 “Medians and Orders Statistics”)。

    還是那個結論,堆不見得會是最優的。

本文快要結束了,但是還有一個問題:如果N非常大,存放在磁盤上,不能一次裝載進內存呢?怎麼辦?對於介紹的Naive方法,堆,敗者樹等等,依然適用,需要注意的就是每次從磁盤上儘量多讀一些數到內存區,然後處理完之後再讀入一批。減少IO次數,自然能夠提高效率。而對於類快速排序方法,稍微要麻煩一些:分批讀入,假設是M個數,然後從這M個數中選出n個最大的數緩存起來,直到所有的N個數都分批處理完之後,再將各批次緩存的n個數合併起來再進行一次類快速排序得到最終的n個最大的數就可以了。在運行過程中,如果緩存數太多,可以不斷地將多個緩存合併,保留這些緩存中最大的n個數即可。由於類快速排序的時間複雜度是O(N),這樣分批處理再合併的辦法,依然有極大的可能會比堆和敗者樹更優。當然,在空間上會佔用較多的內存。

總結:對於這個問題,我想了很多,但是覺得還有一些地方可以繼續深挖:1. 堆和敗者樹到底哪一個更優?可以通過理論分析,也可以通過實驗來比較。也許會有人覺得這個很無聊;2. 有沒有近似的算法或者概率算法來解決這個問題?我對這方面實在不熟悉,如果有人有想法的話可以一塊交流。如果有分析錯誤或遺漏的地方,請告知,我不怕丟人,呵呵!最後請時刻謹記,時間複雜度不等於實際的運行時間,一個常數因子很大的O(logN)算法也許會比常數因子小的O(N)算法慢很多。所以說,n和N的具體值,以及編程實現的質量,都會影響到實際效率。我看過一篇論文,給出的算法在進行字符串查找時,比hash還要快,是不是難以想象?

我的mail:[email protected]

                                                           2006.9.1 22:00

最後附上堆和敗者樹的數組實現,尤其是這個敗者樹的實現,有一些小的技巧在裏面:


void maintainHeap(int* heap, int startPoint, int size)
{
    
int minIdx = startPoint;
    
int min = heap[startPoint];
    
int leftNode = 2*startPoint + 1;
    
int rightNode = leftNode + 1;
    
if(leftNode < size)
    
{
        
if(heap[leftNode] < min)
        
{
            min 
= heap[leftNode];
            minIdx 
= leftNode;
        }

        
if(rightNode < size && heap[rightNode] < min)
        
{
            min  
= heap[rightNode];
            minIdx 
= rightNode;
        }

        
if(minIdx != startPoint)
        
{
            heap[minIdx] 
= heap[startPoint];
            heap[startPoint] 
= min;            
            maintainHeap(heap, minIdx, size);
        }

    }

}


void makeHeap(int* heap, int size)
{
    
for(int i = size/2 - 1; i>=0; i--)
    
{
        maintainHeap(heap, i, size);
    }

}


int makeLoserTree(int* loserTree, int startPoint, int size)
{
    
if( startPoint > (size / 2 - 1))
        
return startPoint;
    
else
    
{
        
int leftNodeIdx = 2*startPoint + 1;
        
int leftWinner =  makeLoserTree(loserTree, leftNodeIdx, size);
        
int rightWinner = makeLoserTree(loserTree, leftNodeIdx + 1, size);
        
if(loserTree[leftWinner] < loserTree[rightWinner])
        
{
            loserTree[startPoint] 
= rightWinner;
            
return leftWinner;
        }

        
else
        
{
            loserTree[startPoint] 
= leftWinner;
            
return rightWinner;
        }

    }

}


int maintainLoserTree(int* loserTree, int nodeIdx, int size)
{
    
int parent = (nodeIdx-1)/2;
    
int winner = nodeIdx;
    
while(true)
    
{
        
if(loserTree[loserTree[parent]] < loserTree[winner])
        
{
            
int temp = loserTree[parent];
            loserTree[parent] 
= winner;
            winner 
= temp;
        }

        
if(parent == 0)
            
return winner;
        parent 
= (parent-1)/2;
    }

}


void topNWithHeap(int* input, int* output, int inputSize, int outputSize)
{
    assert(input 
&& output && inputSize >= 0 && outputSize >= 0);    
    
//using heap
    int* p = input + outputSize;
    memcpy((
void*)output, (void*)input, outputSize*sizeof(int));
    makeHeap(output, outputSize);
    
while(p != input + inputSize)
    
{
        
if(*> output[0])
        
{
            output[
0= *p;
            maintainHeap(output, 
0, outputSize);
        }

        p
++;
    }

}


void topNWithLoserTree(int* input, int* output, int inputSize, int outputSize)
{
    assert(input 
&& output && inputSize >= 0 && outputSize >= 0);    
    
//using loser tree
    int* p = input + (outputSize/2 + 1);
    memcpy((
void*)(output + outputSize/2), (void*)input, (outputSize/2 + 1)*sizeof(int));
    
int winner = makeLoserTree(output, 0, outputSize);
    
while(p != input + inputSize)
    
{
        
if(*> output[winner])
        
{
            output[winner] 
= *p;
            winner 
= maintainLoserTree(output, winner, outputSize);
        }

        p
++;
    }

}


int _tmain(int argc, _TCHAR* argv[])
{  
int input[]={6,7,8,9,10,1,3,19,22,13,5,4,23,17,11};
int output1[9];
//top-5 elements are stored in output1[4],output1[5],output1[6],output1[7],output1[8]
topNWithLoserTree(input, output, sizeof(input)/sizeof(int), sizeof(output1)/sizeof(int));


int output2[5];
//top-5 elements are stored in output2[0],output2[1],output2[2],output2[3],output2[4]
topNWithHeapinput, output, sizeof(input)/sizeof(int), sizeof(output2)/sizeof(int));
  
  
return 0;
}


--

※ 修改:·rbofnjtu 於 Sep  6 23:01:52 修改本文·[FROM: 207.46.89.*]
※ 來源:·水木社區



發信人: rbofnjtu (誘因), 信區: Programming
標  題: 班門弄斧,發一篇自己寫的算法分析文章(續)
發信站: 水木社區 (Sat Sep  2 18:12:16 2006), 站內

昨晚睡覺之前,還在想堆和敗者樹到底哪一個更優的問題。最終通過心算得出答案(感興趣的人可以算一下,並不難):如果將N個數的分佈看作是完全隨機分佈,則通過求和可以計算出,當從N個數中提取出一個新的數m時,如果該數比之前找到的n個最大的數中的最小的x大,則會替換x進入“堆”或者“敗者樹”。

在這一次重新維護堆和敗者樹的時候,堆的期望比較次數是2logn-1,而敗者樹是logn。但是由於敗者樹內部節點存放的是葉子節點的下標,在進行比較的時候需要間接取值(先訪問內部節點得到下標,在用此下標取到葉子節點中存放的數),所以比較時敗者樹訪問內存次數是堆的兩倍。

而在交換次數上(堆是父節點和子節點交換,敗者樹是往上走的優勝者和內部節點存放的敗者進行交換,每次交換需要三次操作),堆的交換次數期望值是1/2logn-1/4,而敗者樹是1/2logn(這裏全都忽略了logn的取整問題)。

我不能確定到底一次比較和一次內存訪問到底哪一個更快,我猜想是內存訪問更快,那樣的話,敗者樹的比較性能會比堆更優。而在交換方面,到底那1/4的差會有多大影響,我也不好說了。

看來除非做實驗,很難分出堆和敗者樹的高下了。
--

※ 來源:·水木社區 http://newsmth.net·[FROM: 207.46.89.*]

http://newsmth.net·[FROM: 207.46.89.*]
發佈了38 篇原創文章 · 獲贊 3 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章