數據結構之內外排序
- 2019 年 12 月 2 日
- 筆記
一、內排序
排序類別 |
排序方法 |
最好時間複雜度 |
平均時間複雜度 |
最壞時間複雜度 |
輔助空間 |
穩定性 |
備註 |
---|---|---|---|---|---|---|---|
插入類 |
插入 |
O(n) |
O(n2) |
O(n2) |
O(1) |
穩定 |
大部分已排序時較好 |
希爾排序 |
– |
O(ns),1<s<2 |
– |
O(1) |
不穩定 |
s是所選分組 |
|
交換類 |
冒泡排序 |
O(n) |
O(n2) |
O(n2) |
O(1) |
穩定 |
n小時較好 |
快速排序 |
O(nlogn) |
O(nlogn) |
O(n2) |
O(logn) |
不穩定 |
n大時較好 |
|
選擇類 |
選擇 |
O(n2) |
O(n2) |
O(n2) |
O(1) |
不穩定 |
n小時較好 |
堆排序 |
O(nlogn) |
O(nlogn) |
O(nlogn) |
O(1) |
不穩定 |
n大時較好 |
|
|
歸併排序 |
O(nlogn) |
O(nlogn) |
O(nlogn) |
O(n) |
穩定 |
n大時較好 |
基數排序 |
O(d(n+rd)) |
O(d(n+rd)) |
O(d(n+rd)) |
O(rd) |
穩定 |
見下文 |
1.插入排序(InsertSort)
插入排序是在一個已經有序的小序列的基礎上,一次插入一個元素。當然,剛開始這個有序的小序列只有1個元素,就是第一個元素。比較是從有序序列的末尾開始,也就是想要插入的元素和已經有序的最大者開始比起,如果比它大則直接插入在其後面,否則一直往前找直到找到它該插入的位置。如果碰見一個和插入元素相等的,那麼插入元素把想插入的元素放在相等元素的後面。這樣,相等元素的前後順序沒有改變,從原無序序列出去的順序就是排好序後的順序,所以插入排序是穩定的。
插入排序是對冒泡排序的改進。它比冒泡排序快2倍。一般不用在數據大於1000的場合下使用插入排序,或者重複排序超過200數據項的序列。
void insertSort(int a[],int n) //插入排序 { int i,j; int t; for(i=1;i<n;i++) { t=a[i]; //保存當前無序表中的第一個數據 j=i-1; while(j>=0 && a[j]>t) { a[j+1]=a[j]; j--; } a[j+1]=t; //將數據插入有序表中 } }
2.希爾排序(ShellSor)
希爾排序是按照不同步長對元素進行插入排序,當剛開始元素很無序的時候,步長最大,所以插入排序的元素個數很少,速度很快;當元素基本有序了,步長很小,插入排序對於有序的序列效率很高。所以,希爾排序的時間複雜度會比O(n2)好一些。由於多次插入排序,我們知道一次插入排序是穩定的,不會改變相同元素的相對順序,但在不同的插入排序過程中,相同的元素可能在各自的插入排序中移動,最後其穩定性就會被打亂,所以shell排序是不穩定的。
Shell排序的分組合理性會對演算法產生重要的影響。現在多用D.E.Knuth的分組方法。Shell排序比冒泡排序快5倍,比插入排序大致快2倍。Shell排序比起QuickSort,MergeSort,HeapSort慢很多。但是它相對比較簡單,它適合於數據量在5000以下並且速度並不是特別重要的場合。它對於數據量較小的數列重複排序是非常好的。
void shellSort(int a[],int n) //希爾排序 { int i,j,gap; int t; for(gap=n/2;gap>0;gap/=2) for(i=gap;i<n;i++) { t=a[i]; j=i-gap; while(j>=0 &&a[j]>t) { a[j+gap]=a[j]; j-=gap; } a[j+gap]=t; } }
3.冒泡排序(BubbleSort)
冒泡排序就是把小的元素往前調或者把大的元素往後調。比較是相鄰的兩個元素比較,交換也發生在這兩個元素之間。所以,如果兩個元素相等,就不會再把它們倆交換一下的;如果兩個相等的元素沒有相鄰,那麼即使通過前面的兩兩交換把兩個相鄰起來,這時候也不會交換,相同元素的前後順序並沒有改變,所以冒泡排序是一種穩定排序演算法。
冒泡排序是最慢的排序演算法,在實際運用中它是效率最低的演算法。
void bubbleSort(int a[],int n) //冒泡排序 { int i,j; int t; for(i=0;i<n;i++) for(j=0;j<n-i-1;j++) if(a[j]>a[j+1]) { t=a[j]; a[j]=a[j+1]; a[j+1]=t; } }
4.快速排序(QuickSort)
快速排序有兩個方向,左邊的i下標一直往右走,當a[i]<= a[center_inde],其中center_index是中樞元素的數組下標,一般取為數組第0個元素。而右邊的j下標一直往左走,當a[j]> a[center_index]。如果i和j都走不動了,i<= j, 和a[j],重複上面的過程,直到i>j。交換a[j]和a[center_index],完成一趟快速排序。在中樞元素和a[j]交換的時候,很有可能把前面的元素的穩定性打亂,比如序列為{5,3,3,4,3,8,9,10,11},現在中樞元素5和3(第5個元素,下標從1開始計)交換就會把元素3的穩定性打亂,所以快速排序是一個不穩定的排序演算法,不穩定發生在中樞元素和a[j]交換的時刻。
快速排序是一個就地排序,分而治之,大規模遞歸的演算法。從本質上來說,它是歸併排序的就地版本。快速排序可以由下面四步組成。 ⑴如果不多於1個數據,直接返回。 ⑵一般選擇序列最左邊的值作為支點數據。 ⑶將序列分成2部分,一部分都大於支點數據,另外一部分都小於支點數據。 ⑷對兩邊利用遞歸排序數列。
快速排序比大部分排序演算法都要快。儘管我們可以在某些特殊的情況下寫出比快速排序快的演算法,但是就通常情況而言,沒有比它更快的了。快速排序是遞歸的,對於記憶體非常有限的機器來說,它不是一個好的選擇。
void quickSort(int a[],int s,int e) //對a[s]至a[e]的元素進行快速排序 { int i=s,j=e; int t; if(s<e) { t=a[s]; while(i!=j) { while(j>i && a[j]>t) j--; //從右向左掃描,找第一個小於t的a[j] if(i<j) //表示找到這樣的a[j] { a[i]=a[j]; i++; } while(i<j && a[i]<=t) i++; //從左向右掃描,找第一個大於t的a[i] if(i<j) //表示找到這樣的a[i] { a[j]=a[i]; j--; } } a[i]=t; //將a[s]放到a[s]至a[e]的恰當位置i處,使得其左邊的元素都不大於它,其右邊的元素都不小於它。 quickSort(a,s,i-1); //對左區間遞歸排序 quickSort(a,i+1,e); //對右區間遞歸排序 } }
5.選擇排序(SelectSort)
選擇排序是給每個位置選擇當前元素最小的,比如給第一個位置選擇最小的,在剩餘元素裡面給第二個元素選擇第二小的,依次類推,直到第n-1個元素,第n個元素不用選擇了,因為只剩下它一個最大的元素了。那麼,在一趟選擇,如果當前元素比一個元素小,而該小的元素又出現在一個和當前元素相等的元素後面,那麼交換後穩定性就被破壞了。例如,序列{5,8,5,2,9},第一趟選擇第1個元素5會和2交換,那麼原序列中2個5的相對前後順序就被破壞了,所以選擇排序不是一個穩定的排序演算法。
在實際應用中處於和冒泡排序基本相同的地位。它們只是排序演算法發展的初級階段,在實際中使用較少。
void selectSort1(int a[],int n) //選擇排序 { int i,j; int t; for(i=0;i<n-1;i++) //數據起始位置,從0到倒數第二個數據 for(j=i+1;j<n;j++) ////在剩下的數據中循環 { if(a[i]>a[j]) // //如果有比它小的,交換兩者 { t=a[i]; a[i]=a[j]; a[j]=t; } } } void selectSort2(int a[],int n) //選擇排序的改進,減少了交換的次數 { int i,j,small; int t; for(i=0;i<n-1;i++) //數據起始位置,從0到倒數第二個數據 { small=i; //記錄最小數據的下標 for(j=i+1;j<n;j++) //在剩下的數據中尋找最小數據 { if(a[j]<a[small]) //如果有比它更小的,記錄下標 small=j; } t=a[small]; //將最小數據和未排序的第一個數據交換 a[small]=a[i]; a[i]=t; } }
6.堆排序(HeapSort)
堆的結構是結點i的孩子為2i和2i+1節點,大頂堆要求父結點大於等於其2個子結點,小頂堆要求父結點小於等於其2個子結點。在一個長為n的序列,堆排序的過程是從第n/2開始和其子結點共3個值選擇最大(大頂堆)或者最小(小頂堆),這3個元素之間的選擇當然不會破壞穩定性。但當為n/2-1,n/2-2, …1這些個父結點選擇元素時,就會破壞穩定性。有可能第n/2個父節點交換把後面一個元素交換過去了,而第n/2-1個父結點把後面一個相同的元素沒有交換,那麼這2個相同的元素之間的穩定性就被破壞了。所以,堆排序不是穩定的排序演算法。
堆排序適合於數據量非常大的場合(百萬數據)。堆排序不需要大量的遞歸或者多維的暫存數組。這對於數據量非常巨大的序列是合適的。比如超過數百萬條記錄,由於快速排序,歸併排序都使用遞歸來設計演算法,在數據量非常大的時候,可能會發生堆棧溢出錯誤。堆排序會將所有的數據建成一個堆,最大的數據在堆頂,然後將堆頂數據和序列的最後一個數據交換。接下來再次重建堆,交換數據,依次下去,就可以排序所有的數據。
void max_heapify(int a[], int start, int end) //調整為大頂堆 { //父結點和子結點下標 int dad = start, son = start * 2 + 1; while(son <= end) //子結點下標在數組範圍內才能比較 { //先比較左右孩子的大小,選擇大孩子的下標 if(son+1 <= end && a[son+1] > a[son]) son++; if(a[son] > a[dad]) { int t = a[son]; a[son] = a[dad]; a[dad] = t; dad = son; son = dad * 2 + 1; } else break; } } void heapSort(int a[], int n) //堆排序 { int i; //初始化數組為大頂堆,i=n/2-1表示最後一個父結點的下標 for(i=n/2-1; i>=0; i--) max_heapify(a, i, n-1); for(i=n-1; i>0; i--) //根作為最大值調整到當前序列的最後 { int t = a[0]; a[0] = a[i]; a[i] = t; max_heapify(a, 0, i-1); } }
7.歸併排序(MergeSort)
歸併排序是把序列遞歸地分成短序列,遞歸出口是短序列只有1個元素(認為直接有序)或者2個序列(1次比較和交換),然後把各個有序的段序列合併成一個有序的長序列,不斷合併直到原序列全部排好序。可以發現,在1個或2個元素時,1個元素不會交換,2個元素如果大小相等也沒有人故意交換,這不會破壞穩定性。那麼,在短的有序序列合併的過程中,穩定是是否受到破壞?沒有,合併過程中我們可以保證如果兩個當前元素相等時,我們把處在前面的序列的元素保存在結果序列的前面,這樣就保證了穩定性。所以,歸併排序也是穩定的排序演算法。
歸併排序比堆排序稍微快一點,但是需要比堆排序多一倍的記憶體空間,因為它需要一個額外的數組。
void mergearray(int a[], int first, int mid, int last, int temp[]) //將有二個有序數列a[first...mid]和a[mid+1...last]合併。 { int i=first, j=mid+1; int m=mid, n=last; int k=0; while (i<=m && j<=n) { if (a[i]<a[j]) temp[k++]=a[i++]; else temp[k++]=a[j++]; } while (i<=m) temp[k++]=a[i++]; while (j<=n) temp[k++]=a[j++]; for (i=0;i<k;i++) a[first+i]=temp[i]; } void mergesort(int a[], int first, int last, int temp[]) { if (first<last) { int mid=(first+last)/2; mergesort(a, first, mid, temp); //左邊有序 mergesort(a, mid+1, last, temp); //右邊有序 mergearray(a, first, mid, last, temp); //再將二個有序數列合併 } } void MergeSort(int a[], int n) { int *p=(int *)malloc(n*sizeof(int)); mergesort(a, 0, n - 1, p); free(p); }
8.基數排序(RadixSort)
基數排序是按照低位先排序,然後收集;再按照高位排序,然後再收集;依次類推,直到最高位。有時候有些屬性是有優先順序順序的,先按低優先順序排序,再按高優先順序排序,最後的次序就是高優先順序高的在前,高優先順序相同的低優先順序高的在前。基數排序基於分別排序,分別收集,所以其是穩定的排序演算法。
基數排序和通常的排序演算法並不走同樣的路線。它是一種比較新穎的演算法,但是它只能用於整數的排序,如果我們要把同樣的辦法運用到浮點數上,我們必須了解浮點數的存儲格式,並通過特殊的方式將浮點數映射到整數上,然後再映射回去,這是非常麻煩的事情,因此,它的使用同樣也不多。而且,最重要的是,這樣演算法也需要較多的存儲空間。
時間效率:設待排序列為n個記錄,d個關鍵碼,關鍵碼的取值範圍為radix,則基數排序的時間複雜度為O(d(n+radix)),其中,一趟分配時間複雜度為O(n),一趟收集時間複雜度為O(radix),共進行d趟分配和收集。
int maxbit(int a[],int n) //求數組元素的最大位數 { int d=1,i=0; //保存最大的位數 int p=10; for(i=0;i<n;i++) { while(a[i]>=p) { p*=10; d++; } } return d; } void radixsort(int a[],int n) //基數排序 { int d = maxbit(a,n); long *tmp=(long *)malloc(n*sizeof(long)); long count[10]; //計數器,統計每位基數的個數 long i,j,k; int radix=1; for(i=1;i<=d;i++) //進行d次排序 { for(j=0;j<10;j++) //每次分配前清空計數器 count[j]=0; for(j=0;j<n;j++) //統計基數出現的次數 { k=(a[j]/radix)%10; count[k]++; } for(j=1;j<10;j++) //將tmp中的位置依次分配給每個計數器 count[j]=count[j-1]+count[j]; for(j=n-1;j>=0;j--) //根據計數器,將記錄依次收集到tmp中 { k=(a[j]/radix)%10; count[k]--; tmp[count[k]]=a[j]; } for(j=0;j<n;j++) //將臨時數組的內容複製到數組a中 a[j] = tmp[j]; radix = radix*10; } free(tmp); }
二、外排序
當待排序的文件比記憶體的可使用容量還大時,文件無法一次性放到記憶體中進行排序,需要藉助於外部存儲器(例如硬碟、U盤、光碟),這時就需要用外部排序演算法來解決。
外部排序演算法由兩個階段構成: 按照記憶體大小,將大文件分成若干長度為 l 的子文件(l 應小於記憶體的可使用容量),然後將各個子文件依次讀入記憶體,使用適當的內部排序演算法對其進行排序(排好序的子文件統稱為「歸併段」或者「順段」),將排好序的歸併段重新寫入外存,為下一個子文件排序騰出記憶體空間; 對得到的順段進行合併,直至得到整個有序的文件為止。
例如,有一個含有 10000 個記錄的文件,但是記憶體的可使用容量僅為 1000 個記錄,毫無疑問需要使用外部排序演算法,具體分為兩步: 1. 將整個文件其等分為 10 個臨時文件(每個文件中含有 1000 個記錄),然後將這 10 個文件依次進入記憶體,採取適當的記憶體排序演算法對其中的記錄進行排序,將得到的有序文件(初始歸併段)移至外存。 2. 對得到的 10 個初始歸併段進行如圖所示的兩路歸併,直至得到一個完整的有序文件。
有 10 個初始歸併段到一個有序文件,共進行了 4 次歸併,每次都由 m 個歸併段得到 ⌈m/2⌉ 個歸併段,這種歸併方式被稱為 2-路平衡歸併。
對於外部排序演算法來說,影響整體排序效率的因素主要取決於讀寫外存的次數,即訪問外存的次數越多,演算法花費的時間就越多,效率就越低。
對於同一個文件來說,對其進行外部排序時訪問外存的次數同歸併的次數成正比,即歸併操作的次數越多,訪問外存的次數就越多。使用2-路平衡歸併的方式,舉一反三,還可以使用 3-路歸併、4-路歸併甚至是 10-路歸併的方式,下圖為 5-路歸併的方式: 對於 k-路平衡歸併中 k 值得選擇,增加 k 可以減少歸併的次數,從而減少外存讀寫的次數,最終達到提高演算法效率的目的。除此之外,一般情況下對於具有 m 個初始歸併段進行 k-路平衡歸併時,歸併的次數為:s=」logkm」(其中 s 表示歸併次數)。
從公式上可以判斷出,想要達到減少歸併次數從而提高演算法效率的目的,可以從兩個角度實現: 1. 增加 k-路平衡歸併中的 k 值; 2. 盡量減少初始歸併段的數量 m,即增加每個歸併段的容量。