排序演算法(四)——歸併排序與遞迴
基本思想
分析歸併排序之前,我們先來了解一下分治演算法。
分治演算法的基本思想是將一個規模為N的問題分解為K個規模較小的子問題,這些子問題相互獨立且與原問題性質相同。求出子問題的解,就可得到原問題的解。
分治演算法的一般步驟:
(1)分解,將要解決的問題劃分成若干規模較小的同類問題;
(2)求解,當子問題劃分得足夠小時,用較簡單的方法解決;
(3)合併,按原問題的要求,將子問題的解逐層合併構成原問題的解。
歸併排序是分治演算法的典型應用。
歸併排序先將一個無序的N長陣列切成N個有序子序列(只有一個數據的序列認為是有序序列),然後兩兩合併,再將合併後的N/2(或者N/2 + 1)個子序列繼續進行兩兩合併,以此類推得到一個完整的有序陣列。過程如下圖所示:
java實現
歸併排序的核心思想是將兩個有序的陣列歸併到另一個數組中,所以需要開闢額外的空間。
第一步要理清歸併的思路。假設現在有兩個有序陣列A和B,要將兩者有序地歸併到陣列C中。我們用一個例項來推演:
上圖中,A陣列中有四個元素,B陣列中有六個元素,首先比較A、B中的第一個元素,將較小的那個放到C陣列的第一位,因為該元素就是A、B所有元素中最小的。上例中,7小於23,所以將7放到了C中。
然後,用23與B中的其他元素比較,如果小於23,繼續按順序放到C中;如果大於23,則將23放入C中。
23放入C中之後,用23之後的47作為基準元素,與B中的其他元素繼續比較,重複上面的步驟。
如果有一個數組的元素已經全部複製到C中了,那麼將另一個數組中的剩餘元素依次插入C中即可。至此結束。
按照上面的思路,用java實現:
/** * 歸併arrayA與arrayB到arrayC中 * @param arrayA 待歸併的陣列A * @param sizeA 陣列A的長度 * @param arrayB 待歸併的陣列B * @param sizeB 陣列B的長度 * @param arrayC 輔助歸併排序的陣列 */ public static void merge(int [] arrayA,int sizeA, int [] arrayB,int sizeB, int [] arrayC){ int i=0,j=0,k=0; //分別當作arrayA、arrayB、arrayC的下標指標 while(i<sizeA&& j<sizeB){ //兩個陣列都不為空 if(arrayA[i]<arrayB[j]){//將兩者較小的那個放到arrayC中 arrayC[k++]= arrayA[i++]; }else{ arrayC[k++]= arrayB[j++]; } } //該迴圈結束後,一個數組已經完全複製到arrayC中了,另一個數組中還有元素 //後面的兩個while迴圈用於處理另一個不為空的陣列 while(i<sizeA){ arrayC[k++]= arrayA[i++]; } while(j<sizeB){ arrayC[k++]= arrayA[j++]; } for(intl=0;l<arrayC.length;l++){ //列印新陣列中的元素 System.out.print(arrayC[l]+"\t"); } }
再歸併之前,還有一步工作需要提前做好,就是陣列的分解,可以通過遞迴的方法來實現。遞迴(Recursive)是演算法設計中常用的思想。這樣通過先遞迴的分解陣列,再合併陣列就完成了歸併排序。
完整的java程式碼如下:
用以下程式碼測試:public class Sort { private int [] array; //待排序的陣列 public Sort(int [] array){ this.array= array; } //按順序列印陣列中的元素 public void display(){ for(int i=0;i<array.length;i++){ System.out.print(array[i]+"\t"); } System.out.println(); } //歸併排序 public void mergeSort(){ int[] workSpace = new int [array.length]; //用於輔助排序的陣列 recursiveMergeSort(workSpace,0,workSpace.length-1); } /** * 遞迴的歸併排序 * @param workSpace 輔助排序的陣列 * @param lowerBound 欲歸併陣列段的最小下標 * @param upperBound 欲歸併陣列段的最大下標 */ private void recursiveMergeSort(int [] workSpace,int lowerBound,int upperBound){ if(lowerBound== upperBound){ //該段只有一個元素,不用排序 return; }else{ int mid = (lowerBound+upperBound)/2; recursiveMergeSort(workSpace,lowerBound,mid); //對低位段歸併排序 recursiveMergeSort(workSpace,mid+1,upperBound); //對高位段歸併排序 merge(workSpace,lowerBound,mid,upperBound); display(); } } /** * 對陣列array中的兩段進行合併,lowerBound~mid為低位段,mid+1~upperBound為高位段 * @param workSpace 輔助歸併的陣列,容納歸併後的元素 * @param lowerBound 合併段的起始下標 * @param mid 合併段的中點下標 * @param upperBound 合併段的結束下標 */ private void merge(int [] workSpace,int lowerBound,int mid,int upperBound){ int lowBegin = lowerBound; //低位段的起始下標 int lowEnd = mid; //低位段的結束下標 int highBegin = mid+1; //高位段的起始下標 int highEnd = upperBound; //高位段的結束下標 int j = 0; //workSpace的下標指標 int n = upperBound-lowerBound+1; //歸併的元素總數 while(lowBegin<=lowEnd && highBegin<=highEnd){ if(array[lowBegin]<array[highBegin]){//將兩者較小的那個放到workSpace中 workSpace[j++]= array[lowBegin++]; }else{ workSpace[j++]= array[highBegin++]; } } while(lowBegin<=lowEnd){ workSpace[j++]= array[lowBegin++]; } while(highBegin<=highEnd){ workSpace[j++]= array[highBegin++]; } for(j=0;j<n;j++){ //將歸併好的元素複製到array中 array[lowerBound++]= workSpace[j]; } } }
int [] a ={6,2,7,4,8,1,5,3}; Sort sort = newSort(a); sort.mergeSort();
列印結果如下:
歸併的順序是這樣的:先將初始陣列分為兩部分,先歸併低位段,再歸併高位段。對低位段與高位段繼續分解,低位段分解為更細分的一對低位段與高位段,高位段同樣分解為更細分的一對低位段與高位段,依次類推。
上例中,第一步,歸併的是6與2,第二步歸併的是7和4,第三部歸併的是前兩步歸併好的子段[2,6]與[4,7]。至此,陣列的左半部分(低位段)歸併完畢,然後歸併右半部分(高位段)。
所以第四步歸併的是8與1,第四部歸併的是5與3,第五步歸併的是前兩步歸併好的欄位[1,8]與[3,5]。至此,陣列的右半部分歸併完畢。
最後一步就是歸併陣列的左半部分[2,4,6,7]與右半部分[1,3,5,8]。
歸併排序結束。
在本文開始對歸併排序的描述中,第一躺歸併是對所有相鄰的兩個元素歸併結束之後,才進行下一輪歸併,並不是先歸併左半部分,再歸併右半部分,但是程式的執行順序與我們對歸併排序的分析邏輯不一致,所以理解起來有些困難。
下面結合程式碼與圖例來詳細分析一下歸併排序的過程。
虛擬機器棧(VM Stack)是描述Java方法執行的記憶體模型,每一次方法的呼叫都伴隨著一次壓棧、出棧操作。
我們要排序的陣列為:
int [] a = {6,2,7,4,8,1,5,3}
當main()方法呼叫mergeSort()方法時,被呼叫的方法被壓入棧中,然後程式進入mergeSort()方法:
此時,mergeSort()又呼叫了recursiveMergeSort(workSpace,0,7)方法,recursiveMergeSort(workSpace,0,7)方法也被壓入棧中,在mergeSort()之上。public void mergeSort(){ int[] workSpace = new int [array.length]; //用於輔助排序的陣列 recursiveMergeSort(workSpace,0,workSpace.length-1); }
然後,程式進入到recursiveMergeSort(workSpace,0,7)方法:
if(lowerBound== upperBound){ //該段只有一個元素,不用排序 return; }else{ int mid = (lowerBound+upperBound)/2; recursiveMergeSort(workSpace,lowerBound,mid); //對低位段歸併排序 recursiveMergeSort(workSpace,mid+1,upperBound); //對高位段歸併排序 merge(workSpace,lowerBound,mid,upperBound); display(); }
lowerBound引數值為0,upperBound引數值為7,不滿足lowerBound== upperBound的條件,所以方法進入else分支,然後呼叫方法recursiveMergeSort(workSpace,0,3) ,
recursiveMergeSort(workSpace,0,3)被壓入棧中,此時棧的狀態如下:
然而,recursiveMergeSort(workSpace,0,3)不能立即返回,它在內部又會呼叫recursiveMergeSort(workSpace,0,1),recursiveMergeSort(workSpace,0,1)又呼叫了recursiveMergeSort(workSpace,0,0),此時,棧中的狀態如下:
程式執行到這裡,終於有一個方法可以返回了結果了——recursiveMergeSort(workSpace,0,0),該方法的執行的邏輯是對陣列中的下標從0到0的元素進行歸併,該段只有一個元素,所以不用歸併,立即return。
方法一旦return,就意味著方法結束,recursiveMergeSort(workSpace,0,0)從棧中彈出。這時候,程式跳到了程式碼片段(二)中的第二行:
recursiveMergeSort(workSpace,1,1);
該方法入棧,與recursiveMergeSort(workSpace,0,0)類似,不用歸併,直接返回,方法出棧。
這時候程度跳到了程式碼片段(二)中的第三行:
merge(workSpace,0,0,1);
即對陣列中的前兩個元素進行合併(自然,merge(workSpace,0,0,1)也伴隨著一次入棧與出棧)。
至此,程式碼片段(二)執行完畢,recursiveMergeSort(workSpace,0,1)方法出棧,程式跳到程式碼片段(三)的第二行:
recursiveMergeSort(workSpace,2,3);
該方法是對陣列中的第三個、第四個元素進行歸併,與執行recursiveMergeSort(workSpace,0,1)的過程類似,最終會將第三個、第四個元素歸併排序。
然後,程式跳到程式跳到程式碼片段(三)的第三行:
merge(workSpace,0,1,3);
將前面已經排好序的兩個子序列(第一第二個元素為一組、第三第四個元素為一組)合併。
然後recursiveMergeSort(workSpace,0,3)出棧,程式跳到程式碼片段(四)的第二行:
recursiveMergeSort(workSpace,4,7);
對陣列的右半部分的四個元素進行歸併排序,伴隨著一系列的入棧、出棧,最後將後四個元素排好。此時,陣列的左半部分與右半部分已經有序。
然後程式跳到程式碼片段(四)第三行:
merge(workSpace,0,3,7);
對陣列的左半部分與右半部分合並。
然後recursiveMergeSort(workSpace,4,7)出棧,mergeSort()出棧,最後main()方法出棧,程式結束。
演算法分析
先來分析一下複製的次數。
如果待排陣列有8個元素,歸併排序需要分3層,第一層有四個包含兩個資料項的自陣列,第二層包含兩個包含四個資料項的子陣列,第三層包含一個8個數據項的子陣列。合併子陣列的時候,每一層的所有元素都要經歷一次複製(從原陣列複製到workSpace陣列),複製總次數為3*8=24次,即層數乘以元素總數。
設元素總數為N,則層數為log2N,複製總次數為N*log2N。
其實,除了從原陣列複製到workSpace陣列,還需要從workSpace陣列複製到原陣列,所以,最終的複製複製次數為2*N*log2N。
在大O表示法中,常數可以忽略,所以歸併排序的時間複雜度為O(N* log2N)。
一般來講,複製操作的時間消耗要遠大於比較操作的時間消耗,時間複雜度是由複製次數主導的。
下面我們再來分析一下比較次數。
在歸併排序中,比較次數總是比複製次數少一些。現在給定兩個各有四個元素的子陣列,首先來看一下最壞情況和最好情況下的比較次數為多少。
第一種情況,資料項大小交錯,所以必須進行7次比較,第二種情況中,一個數組比另一個數組中的所有元素都要小,因此只需要4次比較。
當歸並兩個子陣列時,如果元素總數為N,則最好情況下的比較次數為N/2,最壞情況下的比較次數為N-1。
假設待排陣列的元素總數為N,則第一層需要N/2次歸併,每次歸併的元素總數為2;則第一層需要N/4次歸併,每次歸併的元素總數為4;則第一層需要N/8次歸併,每次歸併的元素總數為8……最後一次歸併次數為1,歸併的元素總數為N。總層數為log2N。
最好情況下的比較總數為:
N/2*(2/2)+ N/4*(4/2)+N/8*(8/2)+...+1*(N/2) = (N/2)*log2N
最好情況下的比較總數為:
N/2*(2-1)+ N/4*(4-1)+N/8*(8-1)+...+1*(N-1) =
(N-N/2)+ (N-N/4)+(N-N/8)+...+(N-1)=
N*log2N-(1+N/2+N/4+..)< N*log2N
可見,比較次數介於(N/2)*log2N與N*log2N之間。如果用大O表示法,時間複雜度也為O(N* log2N)。