近期由於要找工作,經常被問到一些排序算法,確切地說是內部排序算法的實現。參考《算法設計與分析基礎(第2版)》,又重溫了一遍積極向上的本科時光,幸運的是我還沒有感覺到自己已老。
本篇文章包括的排序有:冒泡排序、選擇排序、插入排序、快速排序、歸併排序、堆排序共六中,可分類爲:
交換類排序:冒泡排序、快速排序
選擇類排序:選擇排序、堆排序
插入類排序:插入排序
歸併類排序:歸併排序
其中冒泡排序、選擇排序屬於蠻力法,快速排序、歸併排序屬於分治法,插入排序屬於減治法,堆排序屬於變治法。
內部排序指的是待排序列可以完全放在內存中的排序,這意味着待排序列不會很大,8、16、32到幾百G。基於鍵值交換的排序最小的時間複雜度爲O(nlogn)。
假設以下討論都是針對整數數組的升序排列。
1. 冒泡排序
有人說冒泡排序之所以還能活躍在人們的腦中是因爲它有一個好記的名字。
冒泡排序由2層循環構成,外層循環從數組最後一個元素循環到數組第二個元素,內層循環從數組第一個元素循環到數組倒數第二個元素,所以先寫兩層循環:
for(int i=array.length-1;i>0;i--) {
for(int j=0;j<i-1;j++) {
...
}
}
當外層循環將要結束當次循環時(i–執行前),數組中最大的元素就放在array[i]中,就好像最大的泡泡冒出來一樣;而內層循環每次都比較array[j]和array[j+1],如果前者大於後者,則交換兩者的值,所以冒泡排序應該看起來是這樣子的:
for(int i=array.length-1;i>0;i--) {
for(int j=0;j<i-1;j++) {
if (array[j]<array[j+1]) {
swap(array[j],array[j+1]);
}
}
}
可以看到兩層循環的執行次數爲length項自然數列求和1+2+3+…+length,因此算法時間複雜度O(pow(n,2)),冒泡排序由於只交換相鄰數組元素,所以是穩定的排序算法。
該吃午飯了,吃完飯繼續編輯…Back from lunch…看了一中午電影纔回來的
2. 快速排序
由卓越的英國計算機科學家C.A.R.Hoare發明,在Hoare年輕的時候曾爲一個俄譯英的機器翻譯項目工作,再對俄語詞典進行排序的過程中,他發明了快速排序算法,Hoare說:“我最早曾考慮過用冒泡排序來做,但經過一番幸運的嘗試後,我的第二個念頭就是快速排序。”我們不得不贊同他的觀點。“我是非常幸運的。以發明一種新的排序方法開始一個人的計算機職業生涯實在是太美妙了!”
不是每個人都有這樣的honor的,是吧。實際上快速排序可以由三步完成:
(1)從待排數組中選取一個值(可以是任意一個值)作爲樞值,通過比較和交換,將樞值放置於最終其應該所在的位置:位於其位置前面的元素都小於它,位於其位置後面的元素都大於它;
(2)對第一步中該位置前面的子數組(如果存在的話)執行(1)(2)(3);
(3)對第一步中該位置後面的子數組(如果存在的話)執行(1)(2)(3);
從上述描述中可以得出快速排序的主要結構就是:
void quicksort(int[] array, int start, int end) {
if(start<end) {
int position = partition(array, start, end);
quicksort(array, start, position-1);
quicksort(array, position+1, end);
}
}
現在需要把第一步細化:
每次都選取子數組中的最後一個值作爲樞值並記錄下來,然後分別從子數組第一個、倒數第二個位置開始,向後、向前掃描這個數組,分別記做i、j,
a. 向後掃描直到array[i]>=樞值,
b. 向前掃描直到array[j]<=樞值,
c. 交換array[i]和array[j]的值,
循環執行a、b、c直到兩個索引相等或者交叉;
將array[j]和樞值交換,並返回j作爲樞值所在的最終位置。
下面是partition的實現:
int partition(int[] array, int start, int end) {
int i,j,pivot;
i = start; j = end - 1;
pivot = array[end];
while (i <= j) {
for(; array[i]<pivot; i++);
for(; j>=start && array[j]>pivot; j--);
if (j>=start) {
swap(array[i],array[j]);
} else {
swap(array[end], array[start]);
return start;
}
}
swap(array[i],array[j]); //當i>=j撤銷最後一次交換
swap(array[end],array[i]);
return i;
}
在實現過程中,向後的掃描不需要加入數組越界判斷,但是向前的掃描需要加入數組越界判斷,並且需要考慮子數組中樞值如果是最小的情況,對應while循環中的else。
除此之外,退出while循環時多做了一次交換,並且i指向的值一定大於等於pivot,j指向的值一定小於等於pivot,所以在while之後要撤銷最後一次交換,並將pivot放置於i處。
該運動了,總結確實挺耗時的,還想看看spring struts的知識,哎,時不我待呀。。。運動完再繼續寫吧。
快速排序在平均情況下,僅比最優情況多執行38%的比較操作爲(1.38nlogn)。此外,它的最內層循環效率非常高,使得在處理隨機排列的數組時,速度要比歸併排序、堆排序快。
另外,快速排序交換的鍵值相距經常比較遠,所以它是不穩定的排序算法。
3. 選擇排序
記得本科學習排序時,第一個自己能想到的算法就是選擇排序,作爲蠻力法的兩個代表(選擇排序+冒泡排序)之一,選擇排序具有更加清晰的實現方法。
它的思想是:對長度爲n的待排數組進行n-1次掃描,每次掃描找到剩餘數組中最小的數,然後交換這個數和掃描起始位置上的數。
for(i=0;i<array.length-2;i++) {
min = array[i];
minindex = i;
for(j=i+1;j<array.length-1;j++) {
if (array[j] < min) {
min = array[j];
minindex = j;
}
}
swap(array[i],array[j]);
}
對於任何輸入來說,選擇排序時間複雜度都是O(n2),但是選擇排序的鍵值交換次數僅爲n-1次,這個特性使得選擇排序超過了許多其他的排序算法。
4. 歸併排序
歸併排序是一個既適合內部排序又適合外部排序的算法,而且在衆多排序中既穩定又有O(nlogn)的時間複雜度。
但是歸併排序不是在位的排序方法,what a pitty!
藉助分治法的思想:
(1)將問題的實例分爲同一個問題的幾個較小的實例,最好擁有同樣的規模
(2)對這些較小的實例求解(一般採用遞歸的方法,但在問題規模小於一定閾值時,有時也會利用另一個算法)
(3)如果必要的話,合併這些較小問題的解,已得到原始問題的解
說明歸併排序的思想:
(1)遞歸地將待排數組劃分成前半個數組和後半個數組
(2)當劃分後的數組都是一個元素時,這個數組是一個有序數組(因爲只有一個元素)
(3)把這兩個有序的子數組合併爲一個有序的數組(1+1=2,2+2=4…)
算法由兩個方法實現recursiveMerge和merge,其中recursiveMerge包含上述三步,如下所示:
void recursiveMerge(ArrayList<Integer> array, int start, int end) {
if (start < end) {
int m = (start+end)/2;
recursiveMerge(array, start, m);
recursiveMerge(array, m+1, end);
merge(array, start, m, end);
}
}
merge的工作包括array從start到middle拷貝到一個新的數組,從middle+1到end拷貝到一個新的數組
注意,這兩個數組都是有序的,然後合併這個兩個有序數組,代碼如下:
i,j,k三個索引分別指示array、lowHalf、highHalf這三個數組
private void merge(ArrayList<Integer> array, int start, int middle, int end) {
int i,j,k;
ArrayList<Integer> lowHalf = new ArrayList<Integer>();
ArrayList<Integer> highHalf = new ArrayList<Integer>();
if ((middle-start)==0 && (end-middle)==0) {
return;
}
for (i=start;i<=middle;i++) {
lowHalf.add(array.get(i));
}
for (i=middle+1;i<=end;i++) {
highHalf.add(array.get(i));
}
j=0;
k=0;
for (i=start;i<=end;) {
while(j<=(middle-start) && k<=(end-middle-1) && lowHalf.get(j)<=highHalf.get(k)) {
array.set(i, lowHalf.get(j));
j++;
i++;
}
while(j<=(middle-start) && k<=(end-middle-1) && highHalf.get(k)<lowHalf.get(j)) {
array.set(i, highHalf.get(k));
k++;
i++;
}
if (j<=(middle-start) && k>(end-middle-1)) {
array.set(i, lowHalf.get(j));
j++;
i++;
}
if (j>(middle-start) && k<=(end-middle-1)) {
array.set(i, highHalf.get(k));
k++;
i++;
}
}
}
正如上面所說,歸併排序的主要缺點就是該算法需要線性的額外空間。雖然歸併排序也能做到在位,但會導致算法太過負載。而且因爲它的增長次數具有一個很大
的常係數,所以在位的歸併排序算法只具有理論上的意義。
5. 插入排序
藉助減治法思想,使用2個索引 i和j分別指示待排序元素和當前有序列表的最後一位,然後從j向前遍歷當前有序列表,直到將i指示的元素插入合適的位置爲止,此爲完成一趟排序;共需完成n-1趟這樣的排序。代碼如下:
public void insertSort(ArrayList<Integer> arrayList) {
int i, j;
int currentUnorderdElement;
for (i=1;i<arrayList.size();i++) {
currentUnorderdElement = arrayList.get(i);
for (j=i-1;j>=0;j--) {
if(arrayList.get(j)>currentUnorderdElement) {
arrayList.set(j+1, arrayList.get(j));
} else {
arrayList.set(j+1, currentUnorderdElement);
break;
}
}
}
}
算法的最壞時間複雜度對應倒序的待排數組,此時時間複雜度爲O(pow(n,2));然而,對於基本有序的文件來說,插入排序可以達到良好的性能。一個可能的應用情形就是將插入排序與快速排序融合起來使用:當快速排序子數組的規模變得小於某些預定義的值時(比如,10個元素),可以用插入排序來完成10個元素的排序來替代快速排序中使用迭代來排序。有文獻指出,對快速排序做了這種改動之後,一般會減少10%的運行時間。該算法的平均性能比最壞性能快2倍,以及遇到基本有序的數組時表現出的優異性能,使得插入排序領先於它在基本排序領域中的主要競爭對手—-選擇排序和冒泡排序,另外,它有一種擴展算法,是以發明者D. L. Shell的名字命名的—-Shell排序,此排序方法提供了一種更好的算法來對較大的文件進行排序。Shell排序沒有在這篇文章中出現。
6. 堆排序
現在看來,變治法很好的形容了堆排序的實質,本來一個排序問題,卻通過引入一個大頂堆或小頂堆(本質上是一樣的)的數據結構,然後通過每次將“頂”移出堆並將剩下的節點重新組成大頂堆或小頂堆的數據結構來逐個找到待排序元素中的最大元素。有點像高中時候的數學證明大題,讓你證明一個東西,但是給你兩問,第二問是基於第一問的結論來回答的。
先來說一下什麼是大頂堆:大頂堆可以定義爲一顆二叉樹,輸的節點中包含鍵(每個節點一個鍵),並且滿足下面兩個條件:
(1)樹的形狀要求—-這棵二叉樹是完全二叉樹,輸的每一層都是滿的,除了最後一層最右邊的元素有可能缺位。
(2)父母優勢要求—-每一個節點的鍵都要大於或等於它子女的鍵
在本例中,存儲大頂堆的數據結構是數組,設父節點在數組中的索引爲i,則左子節點索引 2*i+1,右子節點索引 2*i+2,i=0, … , (n-2)/2
在介紹完大頂堆的概念後,堆排序的算法heapSort可以這樣形容:
(1)將待排數組構建成大頂堆constructBigTopHeap;
(2)將待排序數組第一個元素(最大值)和最後一個元素交換,這時破壞了大頂堆,因此調整大頂堆,adjustBigTopHeap
(3)待排序數組規模減1,重複(2)(3)直到待排序數組規模爲1
主程序heapSort:
public void sort(ArrayList<Integer> list) {
int tmp,size;
size = list.size();
constructBigTopHeap(list);
for (int i=size-1;i>0;i--) {
tmp = list.get(0);
list.set(0, list.get(i));
list.set(i, tmp);
adjustBigTopHeap(list, i-1);
}
}
子程序constructBigTopHeap:
private void constructBigTopHeap(ArrayList<Integer> list) {
int i,parent,tmp,j,k;
for (i=list.size()-1;i>0;i--) {
parent = (i-1)/2;
if (parent>=0 && list.get(i)>list.get(parent)) {
tmp = list.get(parent);
list.set(parent, list.get(i));
list.set(i, tmp);
j=i;
k=2*j+1;
while(k<list.size()) {
if (k+1<list.size()) {
if (list.get(k)>=list.get(k+1) && list.get(k)>list.get(j)) {
tmp = list.get(j);
list.set(j, list.get(k));
list.set(k, tmp);
j=k;
k=2*j+1;
} else if (list.get(k+1)>list.get(k) && list.get(k+1)>list.get(j)) {
tmp = list.get(j);
list.set(j, list.get(k+1));
list.set(k+1, tmp);
j=k+1;
k=2*j+1;
} else {
break;
}
} else if (list.get(j)<list.get(k)) {
tmp = list.get(j);
list.set(j, list.get(k));
list.set(k, tmp);
j=k;
k=2*j+1;
} else {
break;
}
}
}
}
}
子程序adjustBigTopHeap:
private void adjustBigTopHeap(ArrayList<Integer> list, int end) {
int j,k,tmp;
if (end == 0) {
return;
}
j = 0;
k = 2*j+1;
while(k<=end) {
if (k+1<=end) {
if (list.get(k)>=list.get(k+1) && list.get(k)>list.get(j)) {
tmp = list.get(j);
list.set(j, list.get(k));
list.set(k, tmp);
j=k;
k=2*j+1;
} else if (list.get(k+1)>list.get(k) && list.get(k+1)>list.get(j)) {
tmp = list.get(j);
list.set(j, list.get(k+1));
list.set(k+1, tmp);
j=k+1;
k=2*j+1;
} else {
break;
}
} else if (list.get(j)<list.get(k)) {
tmp = list.get(j);
list.set(j, list.get(k));
list.set(k, tmp);
j=k;
k=2*j+1;
} else {
break;
}
}
}
構建大頂堆的時間複雜度爲O(n),循環調整堆的時間複雜度爲O(nlogn),實際上,無論是最差情況還是平均情況,堆排序的時間效率都屬於O(nlogn),因此,堆排序的時間效率和歸併排序的時間效率屬於同一類。而且,與後者不同,堆排序是在位的。針對隨機文件的計時實驗指出,堆排序比快速排序運行得慢,但和歸併排序相比還是有競爭力的。
斷斷續續終於把這篇博客寫完了,現在才知道寫博客這麼耗費精力,尤其是精心組織、插圖、引用的博客,這三個本文都沒很好做到,向無私奉獻的博主們致敬!