再溫排序
先來個總覽,知其龐然大體,而入之其微,后而一窩端
需要先知道的幾個概念:
穩(wěn)定排序:在待排序的文件中,若存在若干個相同關(guān)鍵字的記錄,經(jīng)過排序后這些具有相同關(guān)鍵字的記錄相對順序不改變。
不穩(wěn)定排序:與穩(wěn)定排序相反
內(nèi)部排序:待排序的記錄存放在計算機隨機存儲器(RAM)中進(jìn)行排序的過程
-
外部排序:待排序記錄的數(shù)量很大,以至于內(nèi)存不能容納完全部的記錄,需要在排序過程中對外存進(jìn)行訪問的排序(也就是說涉及到了內(nèi)外存交換)
?
排序算法性能評價
- 執(zhí)行時間和所需輔助空間
- 算法本身的復(fù)雜度
空間復(fù)雜度:所需輔助空間不依賴于排序問題的規(guī)模n,則輔助空間為O(1),稱之為就地排序。而非就地排序的輔助空間一般為O(n);
時間復(fù)雜度:大多數(shù)排序算法的時間開銷主要是關(guān)鍵字的比較與移動。
穩(wěn)定排序
排序算法 | 時間復(fù)雜度 | 空間復(fù)雜度 |
---|---|---|
冒泡排序 | 平均,最差都是O(n^2),最好O(n) | O(1) |
雙向冒泡排序 | 平均,最差都是O(n^2),最好O(n) | O(1) |
插入排序 | 平均,最差都是O(n^2),最好O(n) | O(1) |
歸并排序 | 最差,最差,平均都是O(nlogn) | O(n) |
桶排序 | O(n) | O(k) |
基數(shù)排序 | O(dn) (d是常數(shù)) | O(n) |
二叉樹排序 | O(nlogn) | O(n) |
圖書館排序 | O(nlogn) | (1+x)n |
不穩(wěn)定排序
排序算法 | 時間復(fù)雜度 | 空間復(fù)雜度 |
---|---|---|
選擇排序 | 最差,平均都是O(n^2) | 1 |
希爾排序 | O(nlogn) | 1 |
堆排序 | 最差,最好,平均都是O(nlogn) | 1 |
快速排序 | 平均O(nlogn),最壞O(n^2) | O(logn) |
內(nèi)部排序:冒泡排序,插入排序,選擇排序,快速排序,堆排序,基數(shù)排序
外部排序:歸并排序,桶排序,基數(shù)排序
一些實踐總結(jié):
簡單排序中直接插入最好,快速排序最快,待排序記錄為正序的時候直接插入和貓婆都最佳
n<=50的時候,可以直接插入或者選排序,記錄規(guī)模小的時候,直接插入比較好,否則因為直接選擇移動的記錄次數(shù)少于直接插入,則選擇 選擇排序較好。
待排序記錄基本有序的情況下,選用直接插入,冒泡,隨機快速排序較好。
若n的規(guī)模較大的時候,采用事假復(fù)雜度為 O(nlogn)的排序算法較好:快速排序,歸并排序,堆排序。
冒泡排序(穩(wěn)定)
思想:以正序為例,每一趟排序比較都將未排序部分的最小元素移動交換到未排序部分的第一位。
經(jīng)過優(yōu)化的冒泡排序,在最好情況下,也就是進(jìn)行第一次冒泡即可的情況(已有正序),而且第一次冒泡肯定是n次比較交換,無法避免。
最壞情況是逆序的情況比較交換次數(shù)是:1+2+...+n-1+n = n +(n^2-n)/2 = (1+n)*n/2=O(n^2)
public static void bubbleSort(int a[]){
for(int i = 0;i<a.length;i++){ //冒泡次數(shù)
for(int j = a.length-1;j>i;j--){ //從底往上冒泡的過程
if(a[j] < a[j-1]) // 比前一個小則上冒,等于的時候不做出來,所以是穩(wěn)定排序
swap(a,j,j-1);
}
}
}
public static void bubbleSortGood(int a[]){
boolean flag = true; //只要一趟冒泡過程沒有發(fā)生交換,則已有序,直接退出冒泡的循環(huán)即可
for(int i = 0;i<a.length;i++){
flag = true;
for(int j = a.length-1;j>i;j--){
if(a[j] < a[j-1]){
swap(a,j,j-1);
flag = false;
}
}
if(flag)
break;
}
}
選擇排序(不穩(wěn)定)
思想:每趟比較交換中將最小的元素交換打動未排序部分的第一位。交換原則是當(dāng)前記錄小于未排序部分的第一位則此兩個數(shù)交換。
public static void selectSort(int a[]){
for(int i=0;i<a.length-1;i++){
for(int j= i+1;j<a.length;j++){
if(a[i]>a[j])
swap(a,i,j);
}
}
}
舉個例子說明為什么會不穩(wěn)定:
假設(shè)待排序序列為:7 , 8 , 9 ,7, 5 , 3 。記a1 =0 第一個7出現(xiàn)的位置,a2=3,第二個7出現(xiàn)的位置。在第一趟選擇排序的時候 index = 0-3時均沒有交換。index = 4,此時5<7,則交換。交換后序列變成了:5,8 , 9 ,7 , 7 , 3,那么顯然的,原來第一個7的位置變成了4,此時與未排序的序列中的同關(guān)鍵字7的相對位置發(fā)生了改變。
復(fù)雜度同冒泡排序一樣分析即可。
插入排序(穩(wěn)定)
思想:將未排序部分的第一個記錄插入到已排好序的部分之中。想想你玩撲克的場景。
public static void insertSort(int a[]){
for(int i=1;i<a.length;i++){
//待排序的元素,從以排好序的部分的后面向前比較,知道第一次遇到小魚或者等于的元素則停止
//否則比較一次則交換一次(相當(dāng)于將插入位置后的元素后移)
for(int j = i;j>0 && a[j]<a[j-1];j--)
swap(a,j,j-1);
}
}
最壞的情況下,序列已經(jīng)是降序序列,此時需要進(jìn)行n(n-1)/2次比較。最好的情況是已經(jīng)是升序序列,則只需要進(jìn)行n-1次比較即可。
希爾排序(不穩(wěn)定)
思想:先將整個待排序序列分成若干個子序列,分別對每個子序列進(jìn)行直接插入排序,待每個子序列都有序后,在對整個序列進(jìn)行一次直接插入排序。其實分割子序列是按照一個“增量”進(jìn)行分割的,“增量;“增量”的選取會直接影響到算法的性能。
public static void shellSort(int a[]){
int step = a.length/2; //這里的劃分策略是折半劃分
while(step!=0){ //劃分的子序列數(shù)減少
for(int k=0;k<step;k++){ //對每個子序列進(jìn)行直接插入排序
for(int i=k;i<k+step;i++){
for(int j=i;j>k && a[j]<a[j-1];j--){
swap(a,j,j-1);
}
}
}
step=step>>1;
}
}
測試:
對序列:3 , 5 , 33 , 2 , 4 , 6 , 23 , 56 , 23 , 24 , 4 , 54 , 6 進(jìn)行直接插入排序與希爾排序,執(zhí)行swap()方法的次數(shù)分別是:
直接插入排序:25次
希爾排序:16次
以上只是從單一數(shù)據(jù)進(jìn)行說明,并非對每個序列排序都符合以上的交換次數(shù)對比關(guān)系。
有人大量測試得到希爾排序比較和移動次數(shù)大約在:n^1.25 - 1.6* n^1.25
以下分析為什么希爾排序相對直接插入排序有更好的性能。
- 待排序序列基本有序的時候,直接插入排序需要的移動交換次數(shù)是均相對少的(相對于冒泡,選擇);
- n 值較小的時候 n 與 n^2的差別也較小,所以直接插入排序的最好時間復(fù)雜度O(n) 與最壞時間復(fù)雜度O(n^2)差別也不大。
- 希爾排序開始的時候增量較大,那個劃分的子序列就較小,所以子序列的直接插入排序較快。而后來增量逐漸較小,劃分的子序列數(shù)就減少,然而之前的劃分中已經(jīng)是現(xiàn)在的子序列基本有序,所以在新的子序列進(jìn)行直接插入排序也比較快。
快速排序(不穩(wěn)定)
基本思想:通過一趟排序?qū)⒋判虻挠涗浄指畛瑟毩⒌膬刹糠帧F渲幸徊糠值年P(guān)鍵字均比另一部分的記錄關(guān)鍵字小,則可以分別繼續(xù)對這兩部分進(jìn)行排序,已達(dá)到整個序列有序的目的。
步驟
一:
- 初始是 l ,h 分別為0,序列的長度
- 如果l>=h則排序完成,否則進(jìn)行步驟3
- 進(jìn)行步驟二,返回值記為 p。
- 對序列arr[l ... p]重復(fù)步驟2
- 對序列arr[p+1... h]重復(fù)步驟2
二:
- 從序列中選取一個記錄作為軸樞,一般是當(dāng)前序列的第一個元素作為軸樞。記為 p 。此外low,high分別為序列的起始下標(biāo),結(jié)束下標(biāo)。
- 從結(jié)束下標(biāo)往左進(jìn)行比較(也就是執(zhí)行 --high 操作),找到第一個比 p 小的記錄,并將該記錄設(shè)置到 low 位置,也就是執(zhí)行arr[low] = arr[high]。
- 從low位置開始向右進(jìn)行比較(++low),找到第一個比p大的記錄,并將該記錄設(shè)置到high位置(arr[high] = arr[low]);
- 重復(fù)步驟2,3,直到low>=high(此時完成的是一次劃分序列的操作),返回此時的位置索引low
public static void quickSort(int a[],int low,int high){
if(low<high){
int p = partition(a, low, high);
quickSort(a, low, p-1);//遞歸對軸樞左邊的序列進(jìn)行排序
quickSort(a, p+1, high);//遞歸對軸樞右邊的序列進(jìn)行排序
//可以發(fā)現(xiàn),只需要對子序列遞歸的排序即可,不需要合并子序列排序的結(jié)果
}
}
public static int partition(int a[],int low,int high){
int t = a[low];
while(low<high){//交替從左右兩邊向中間掃描比較
//將比軸樞位置小的記錄移動到軸樞位置的左邊
while(low<high && t<=a[high])
--high;
a[low] = a[high];
//將比軸樞位置大的記錄移動到軸樞位置的右邊
while(low<high && t>=a[low])
++low;
a[high] = a[low];
}
a[low] = t;//將軸樞位置的值放到正確的位置,一趟排序后應(yīng)該在的位置
return low;
}
以上遞歸實現(xiàn)的快速排序中,因為遞歸會需要一個額外的棧空間進(jìn)行維護(hù)每個遞歸。假若每趟排序都將記錄分割成接近的兩個子序列,那個就是說類似滿二叉樹的結(jié)構(gòu),此時棧的最大深度為 log2 n +1。而加入每趟排序之后,軸樞位置總偏于兩端的話,類似只有左子樹或者右子樹的二叉樹,那個棧的最大深度是 n 。經(jīng)過優(yōu)化,也就是說每次的到的分割的子序列,先對長度較短的序列進(jìn)行快速排序,那么棧的最大深度可以降到log n 。
當(dāng)待排序記錄(基本)有序的時候,快排會退化成冒泡排序。
以上方法的快速排序的時間復(fù)雜度主要來自partition函數(shù)。所以 T(n) = P(n) +T(k-1) +T(n-k) 。P(n)是對n個記錄進(jìn)行一趟快排的時間,而且在隨機的序列中一趟快速排序之后 k 在 1 ~ n之間的任何一個值的概率相等,那么所需要的平均時間是:T(n) = (n+1)T(n-1)/n+(2n-1)c/n ,c是一個常數(shù)。最后得到的是O(nlogn)的數(shù)量級。、
堆排序(不穩(wěn)定)
要理解堆排序,我們需要先了解什么事堆。
堆:n個元素的序列{k1,k2,k3,...,kn}滿足如下的關(guān)系:
最小堆:ki<=k(2i) && ki<=k(2i+1)
最大堆:ki>=k(2i) && ki>=k(2i+1)
i=1~floor(n/2)
一個堆可以對應(yīng)一個完全二叉樹
思想:通過將一個無序的序列構(gòu)建成一個最大堆或者最小堆,每次從堆頂就可以獲取得到當(dāng)前堆的最大或者最小值,當(dāng)堆頂無元素的時候則可以得到一個有序的序列了。
建堆的過程其實就是一個反復(fù)調(diào)成的過程(保證當(dāng)前建立好的是一個堆)。如果將這個序列看做是一個完全二叉樹的話,那個最后一個非終端結(jié)點就是floor(n/2)。
public static void heapAdjust(int a[],int s,int l){
int t = a[s]; //先保存當(dāng)前節(jié)點的值
//從當(dāng)前節(jié)點的左孩子開始尋找,直到?jīng)]有孩子節(jié)點(也就是自己就是葉子節(jié)點的時候)
for(int i=2*s;i<l;i*=2){
//t節(jié)點存在右孩子,且右孩子大于左孩子(說明右孩子可能大于t對應(yīng)的節(jié)點,大于就需要調(diào)整~)
//那么令當(dāng)前節(jié)點加一(指向右孩子)
if(i<l && a[i]<a[i+1])
i++;
if(t>a[i]) //t對應(yīng)的節(jié)點大于兩個孩子節(jié)點,所以不用調(diào)整直接退出
break;
//將找到的大于的t節(jié)點對應(yīng)的節(jié)點,交換到 s 對應(yīng)的節(jié)點處,同時更新s為當(dāng)前的i值
//也就是如果下次還存在交換,那么就是針對最新的s(上次交換的s)
a[s] = a[i];
s = i;
}
a[s] = t; //t最終被交換到的是s處(上面的for循環(huán)直接被break的話,相當(dāng)于沒有交換)
}
public static void HeapSort(int a[]){
//從左后一個非終端結(jié)點向前,因為如果是葉子節(jié)點
//那么不會存在比自己大的孩子節(jié)點了
for(int i = a.length/2;i>=0;i--)
heapAdjust(a, i, a.length);
for(int i=a.length-1;i>0;i--){
//最大堆堆頂是最大的元素
//那么這里是不斷交換到i(i從末尾向前移動)
//那么最大值就會從最后遞減向前排列
swap(a,0,i);
//只調(diào)整0~i-1的元素,i-1之后的已經(jīng)有序了
heapAdjust(a, 0, i-1);
}
}
由上可見,堆排序的主要耗費時間是在于初始建堆以及反復(fù)調(diào)整新堆上,而對于深度為k的堆,調(diào)整算法中進(jìn)行關(guān)鍵字比較的次數(shù)最多為2(k-1)次。,所以在建立n個元素,深度為h的堆的時候,總共進(jìn)行的關(guān)鍵字比較次數(shù)不超過4n。而堆的深度為floor(log2 n )+1。新堆調(diào)整次數(shù)為n-1次,則總共進(jìn)行的比較次數(shù)不超過:
2(floor(log2 (n-1)) + floor(log2 (n-1)) +... + floor(log2 2))< 2nlog2 n
所以時間復(fù)雜度是:O(nlogn)
空間復(fù)雜度:O(1)
因為只有一個保存臨時節(jié)點信息的變量。
歸并排序(穩(wěn)定)
思想:將兩個已經(jīng)排好序的序列合并成一個有序序列。
步驟:
- 將相鄰兩個記錄進(jìn)行歸并操作,得到floor(n/2)個子序列,排序后每個序列包含兩個記錄。
- 將上訴序列再次進(jìn)行歸并操作,將形成floor(n/4)個子序列,每個子序列包含四個記錄
- 重復(fù)步驟2直到所有記錄排序完畢。
public static void merge(int a[],int temp[],int s,int mid,int e){
int i = s;
int j = mid+1;
int x = s;
//先將需要合并的兩個序列的公共部分由小到大復(fù)制到緩存數(shù)組
while(i<=mid && j<=e){
if(a[i]>a[j])
temp[x++] = a[j++];
else
temp[x++] = a[i++];
}
//以下兩部是將由剩余沒有復(fù)制到緩存數(shù)組的記錄直接放到緩存數(shù)組后面
//因為兩個需要合并的序列長度不一樣的時候肯定會由一個序列的會有剩下
//沒有復(fù)制到緩存數(shù)組的
while(i<=mid)
temp[x++] = a[i++];
while(j<=e)
temp[x++] = a[j++];
//將緩存數(shù)組的記錄,重新復(fù)制回原數(shù)組
x = s;
while(x<=e)
a[x] =temp[x++];
}
public static void MSort(int a[],int temp[],int s,int e){
if(s<e){
//演示的是二路歸并(最簡單的歸并)
int mid = (s+e)/2;
MSort(a,temp,s,mid);
MSort(a,temp,mid+1,e);
merge(a,temp,s,mid,e);//從最遞歸低層返回的時候開始合并
}
}
public static void mergeSort(int a[]){
int temp[] = new int[a.length];
MSort(a,temp,0,a.length-1);
}
在歸并排序中,首先需要一個和原來序列長度異常的輔助序列。空間是O(n),而在遞歸進(jìn)行劃分歸并的時候需要借用遞歸棧,遞歸的次數(shù)就是進(jìn)行歸并的次數(shù),也就是log2 n 。所以總的歸并排序的空間復(fù)雜度還是O(n)的。
先看看分割序列的時候,如果給予以上的二路歸并的話,也就是遞歸的深度,其時間復(fù)雜度是O(nlogn)。而在合并的時候時間不會超過O(n)。所以歸并的時間復(fù)雜度是O(nlogn)。