排序的基本概念
在計算機程序開發過程中,經常需要一組數據元素(或記錄)按某個關鍵字進行排序,排序完成的序列可用于快速查找相關記錄。
排序概述
排序是程序開發中一種非常常見的操作,對一組任意的數據元素(或記錄)經過排序操作后,就可以把它們變成一組關鍵字排序的有序序列。
假設含有n個記錄的序列為{R1,R2,...,Rn},其相應的關鍵字序列為{K1,k2,...,kn}。將這些記錄重新排序為{Ri1,Ri2,...,Rin},使得相應的關鍵字滿足條件Ki1<=Ki2<=...<=Kin,這樣的一種操作稱為排序。
一旦將一組雜亂無章的記錄重排成一組有序記錄,就能快速地從這組記錄中找到目標記錄。因此通常來說,排序的目的是快速查找。
對于一個排序算法來說,一般從如下三個方面來衡量算法的優劣。
- 時間復雜度:主要是分析關鍵字的比較次數和記錄的移動次數。
- 空間復雜度:分析排序算法中需要多少輔助內存。
- 穩定性:若兩個記錄A和B的關鍵字值相等,但排序后A,B的先后次序保持不變,則稱這種排序算法是穩定的;反之,就是不穩定的。
即現有的排序算法來看,排序大致可分為內部排序和外部排序。如果整個排序過程不需要借助外部存儲器(如磁盤等),所有排序操作都在內存中完成,這種排序就被稱為內部排序。
如果參與排序的數據元素非常多,數據量非常大,計算機無法把整個排序過程放在內存中完成,必須借助外部存儲器(如磁盤),這種排序就被稱為外部排序。
外部排序包括以下兩個步驟:
- 1.把排序的文件中的一組記錄讀入內存的排序區,對讀入的記錄按上面講到的內部排序法進行排序,排序之后輸出到外部存儲器。不斷重復這一過程,每次讀取一組記錄,知道原文件的所有記錄被處理完畢。
- 將下一步分組排序好的記錄兩組兩組地合并排序。在內存容量允許的條件下。每組中包含的記錄越大越好,這樣可減少合并的次數。
對于外部排序來說,程序必須將數據分批調入內存來排序,中間結果還要及時放入外存顯然外部排序要比內部排序更復雜二實際上,也可認為外部排序是由多次內部排序組成的。
常說的排序都是指內部排序,而不是外部排序。
內部排序的分類
可以分為如下幾類:
- 選擇排序
- 交換排序
- 插入排序
- 歸并排序
- 桶式排序
- 基數排序
上面這些內部排序方法人致有如下圖所示的分類。
選擇排序法
常用的選擇排序方法有兩種:直接選擇排序和堆排序.直接選擇排序簡單直觀,但性能略差,堆排序是一種較為高效的選擇排序方法,但實現起來略微復雜.
直接選擇排序
直接選擇排序的思路很簡單,它需要經過n-1趟比較。
第1趟比較:程序將記錄定位在第1個數據上,拿第1個數據依次和它后面的每個數據進行比較,如果第1個數據人于后面某個數據,就交換它們.....以此類推。經過第1趟比較,組數據中最小的數據被選出,它被排在第1位。
第2趟比較:程序將記錄定位在第2個數據上,拿第2個數據依次和它后面的每個數據進行比較,如果第2個數據大于后面某個數據,就交換它們......依此類推。經過第2趟比較,這組數據中第2小的數據被選出,它被排在第2位。
......
按此規則一共進行n-l趟比較,這組數據中第n-l小(第2大)的數據被選出,被排在第n-1位(倒數第1位);剩下的就是最大的數據,它排在最后。
直接選擇排序的優點是算法簡單,容易實現。
直接選擇排序的缺點是每趟只能確定一個元索,n個數據需要進行。一!趟比較。
假設有如下一組數據:
21,30,49,30*,16,9
如果對它使用直接選擇排序,因為上面這組數據包含6個數據,所以要經過5趟比較.如下所示。
第1趟比較后:9,30,49,30*,21,16
第2趟比較后:9,16,49,30*,30,21
第3趟比較后:9,21,49,49,30,30*
第4趟比較后:9,16,21,30,49,30*
第5趟比較后:9,16,21,30,30*,49
基于上面思路,用Java程序實現上面的直接選擇排序,如下所示:
public class DataWrap {
int data;
String flag;
public DataWrap(int data,String flag){
this.data=data;
this.flag=flag;
}
public int compareTo(DataWrap dw){
return this.data>dw.data?1:(this.data==dw.data?0:-1);
}
public String toString() {
return data+flag;
}
}
public class SelectSort {
public static void selectSort(DataWrap[] data){
System.out.println("開始排序");
int arrayLength=data.length;
for(int i=0;i<arrayLength-1;i++){
int minIndex=i;
for(int j=i+1;j<arrayLength;j++){
if(data[minIndex].compareTo(data[j])>0){
minIndex=j;
}
}
if(minIndex!=i){
DataWrap tmp=data[i];
data[i]=data[minIndex];
data[minIndex]=tmp;
}
}
}
public static void main(String[] args) {
DataWrap[] data={
new DataWrap(21,""),
new DataWrap(30,""),
new DataWrap(49,""),
new DataWrap(30,""),
new DataWrap(16,""),
new DataWrap(9,"")
};
System.out.println("排序之前:"+Arrays.toString(data));
selectSort(data);
System.out.println("排序之后:"+Arrays.toString(data));
}
}
直接選擇排序的第n趟比較至多交換一次,永遠總是拿n-1位的數據和中間某個數據(本趟比較中最小的數據)進行交換。如果本趟比較時第n-1位(本趟比較的第i位)的數據已經是最小的,那就無須交換。
對于直接選擇排序算法而言,假設有n個數據,數據交換的次數最多有n-1次,但程序比較的次數較多。總體來說,其時間效率為O(n*n)
直接選擇排序算法的空間效率很高,它只需要一個附加程序.單元用于交換,其空問效率為O(1).
堆排序
在介紹堆排序之前,先來介紹一下于堆有關的概念。
假設有n個數據元素的序列K0,K1,...,Kn-1,當且滿足如下關系時,可以將這組數據稱為小頂堆(小根堆);
Ki<=K2i-1且Ki<=K2i+2(其中i=0,2,...,(n-1)/2)
或者,滿足如下關系時,可以將這組數據稱為大頂堆(大根堆)
Ki>=K2i+1且Ki>=K2i+2(其中i=0,2,...,(n-1/2))
對于滿足小頂堆的數據序列K0,K1,...,Kn-1,如果將它們順序排成一棵完全二叉樹,則此樹的特點是,樹中所有節點的值都小于其左、右子節點的值,此樹的根節點的值必然最小。反之,對于滿足大頂堆的數據序列k0,k1,...,kn-1,如果將它們順序排成一棵完全二叉樹,則此樹的特點是,樹中所有節點的值都大于其左、右子節點的值,此樹的根節點的值必然最大。
通過上面的介紹不難發現一點,小頂堆的任意子樹也是小頂堆,大頂堆的任意子樹還是大頂堆。
比如,判斷數據序列如:9,30,499,46,58,79 是否為堆,將其轉換為一顆完全二叉樹,如下圖:
上圖中每個節點上的灰色數字代表該節點數據在底層數組中的索引。上圖所示的完全二叉樹完全滿足小頂堆的要求,每個父節點的值總是小于等于它的左、右子節點的值。
再比如,判斷數據序列:93,82,76,63,58,67,55是否為堆,將其轉換為一顆完全二叉樹,如下圖:
上圖的完全二叉樹完全滿足大頂堆的要求:每個父節點的值總是大于等于它的左、右子節點的值。
經過上面的介紹不難發現一點,大頂堆的根竹點一定是這組數據中值最大的竹點。也就是說,如果需要對一組數據進行排序,只需先將這組數據建成大項堆,就選擇出了這組數據的最大值。
堆排序的關鍵在于健堆,它按如下步驟完成排序。
- 第1趟:將索引0~n-1處的全部數據建成大頂〔或小項)堆,就可以選擇出這組數據中的最大(或最小)值。
將上一步所建的大頂(或小頂)堆的根節點與這組數據的最后一個節點交換,就使得這組數據中的最大(或最小)值排在最后。
- 第2趟:將索引0~n-2處的全部數據建成大頂〔或小頂)堆,就可以選擇出這組數據中的最大<或最小)值。
將上一步所建的大頂(或小頂)堆的根節點與這組數據的倒數第2個節點交換,就使得這組數據中的最人(或最小)值排在倒數第2位。
........
第k趟;將索引O一。一處的全部數據建成大頂(或小頂)堆,就可以選擇出這組數據中
的最大(或最小)值。
將上一步所建的大項(或小頂)堆的根節點與這組數據的倒數第k個節點交換,使得這組數據中的最大(或最小)值排在倒數第k位。
通過上面的介紹不難發現,堆排序的步驟就是重復執仃以下兩步。
- 建堆
- 拿堆的根節點和最后一個節點交換
由此可見,對于包含N個數據元素的數據組而言,堆排序需要經過N-1次建堆,每次建堆的作用就是選出該堆的最大值或最小值。堆排序本質上依然是一種選擇排序。
堆排序與直接選擇排序的差別在于,堆排序可通過樹形結構保存部分比較結果,可減少比較次數。對于直接選擇排序而言,為了從a0,a1,a2,a3,...,an-1中選出最小的數據,必須進行n-1次比較:然后在a1,a2,a3,...,an-1中選出關鍵字最小的記錄,又需要做n-2次比較。事實上,在后面的。n-2次比較中,有許多比較可能在前面的n-1次比較中己經做過,但由于前一趟排序時未保留這些比較結果,所以后一趟排序時又重復執行了這些比較操作_堆排序可通過樹形結構保存前面的部分比較結果,從而提高效率。
接下來的關鍵就是建堆的過程。建堆其實比較簡單,不斷地重復如下步驟即可〔以建大頂堆為例)。
從最后一個非葉子節點開始,比較該節點和它兩個子節點的值;如果某個子節點的值大于父節點的值,就把父節點和較大的子節點交換。
向前逐步調整直到根節點,即保證每個父節點的值都人于等于其左、右子節點的值,建堆完成。
例如,有如下數據組:
9,79,46,30,58,49
下面逐步介紹對其建堆的過程。
- 先將其轉換為完全二義樹,轉換得到的完全二義樹如圖下所示。
- 完全二叉樹的最后一個非葉子節點,也就是最后一個節點的父節點。最后一個節點的索引為數組長度-1。也就是len-1 ,那么最后一個非葉子節點的索引應該為(len-2)/2。也就是從索引為2的節點開始,如果其子節點的值大于它本身的值,則把它和較大的子節點進行交換,即將索引為2的節點和索引為5的元素交換,交換后的結果如下圖所示。
向前處理前一個非葉子節點(索引為(len-2)1)-1),也就是處理索引為1的節點,此時79>30,79>58,因此無須交換。
向前處理前一個非葉子節點,也就是處理索引為0的節點,此時9<79,因此需要交換。應該拿索引為0的節點和索引為1的節點交換〔在9的兩個子節點中。索引為1的節點的
值較大),交換后的完全二叉樹如下圖所示。
- 如果某個節點和它的某個子節點交換后,該子節點又有子節點,那么系統還需要再次對該子節點進行判斷。例如,上圖中索引為0的節點和索引為1的節點交換后,索引為1
的節點還有子節點,因此程序必須再次保證索引為l的節點的值大于等于其左、右子節點的值。因此還需要交換一次,交換后的大頂堆如下圖所示。
public class SelectSort {
static void heapSort(DataWrap[] data){
System.out.println("開始排序");
int arrayLength=data.length;
for(int i=0;i<arrayLength-1;i++){
builMaxdHeap(data, arrayLength-1-i);
swap(data, 0, arrayLength-1-i);
}
}
public static void builMaxdHeap(DataWrap[] data,int lastIndex){
for(int i=(lastIndex-1)/2;i>=0;i--){
int k=i;
while(k*2+1<=lastIndex){
int biggerIndex=2*k+1;
if(biggerIndex<lastIndex){
if(data[biggerIndex].compareTo(data[biggerIndex+1])<0){
biggerIndex++;
}
}
if(data[k].compareTo(data[biggerIndex])<0){
swap(data, k, biggerIndex);
k=biggerIndex;
}else{
break;
}
}
}
}
private static void swap(DataWrap[] data,int i,int j){
DataWrap tmp=data[i];
data[i]=data[j];
data[j]=tmp;
}
public static void main(String[] args) {
DataWrap[] data={
new DataWrap(21,""),
new DataWrap(30,""),
new DataWrap(49,""),
new DataWrap(30,""),
new DataWrap(16,""),
new DataWrap(9,"")
};
System.out.println("排序之前:"+Arrays.toString(data));
heapSort(data);
System.out.println("排序之后:"+Arrays.toString(data));
}
}
運行結果如下:
對于堆排序算法而言,假設有n個數據,需要進行n-1次建堆,每次建堆本身耗時為log2n則其時間效率為例O(n*log2n)。
堆排序算法的空間效率很高,它只需要一個附加程序單元用于交換,其空間效率為O(1).
交換排序
交換排序的主體操作是對數據組中的數據不斷地進行交換操作。交換排序主要有冒泡排序和快速排序,這兩種排序都是廣為人知且應用及廣的排序算法。
冒泡排序
冒泡排序是最廣為人知的交換排序之一,它具有算法思路簡單、容易實現的特點。
對于包含,個數據的一組記錄,在最壞的情況卜,冒泡排序需要進行n-1趟比較。
第1趟:依次比較0和1、1和2、2和3、...、n-2和n-1索引處的元素,如果發現第一個數據大于后一個數據,則交換它們,經過第1趟比較,最大的元素排到了最后。
第2趟:依次比較0和1、1和2、2和3、...、n-3和n-2索引處的元素,如果發現第一個數據大于后一個數據,則交換它們。經過第2趟比較,第2大的元素排到了倒數第2位。
.......
- 第n-1趟:依次比較0和1元素,如果發現第一個數據大于后一個數據,則交換它們。經過第n-1趟比較,第2小(第n-1大)的元素排到了第2位,
實際上,冒泡排序的每趟交換結束后,不僅能將當前最大值擠出最后面位置,還能部分理順前面的其他元素;一旦某趟沒有交換發生,即可提前結束排序。
假設有如下數據序列:
9,16,21,23,30, 49, 21,30
只需要經過如下幾趟排序。
第1趟:9,16,21,23,30,21,30,49
第2趟:9,16,21,23,21,30,30,49
第3趟:9,16,21,21,23,30,30,49
第4趟:9,16,21,21,23,30,30,49
從上面的排序過程可以看出,雖然該組數據包含8個元素,但采用冒泡排序只需要經過4趟比較。因為經過第3趟排序后,這組數據已經處于有序狀態,這樣,第4趟將不會發生交換,因此可以提前結束循環。
//冒泡排序
public static void bubbleSort(DataWrap[] data){
System.out.println("開始排序");
int arrayLength=data.length;
for(int i=0;i<arrayLength-1;i++){
boolean flag=false;
for(int j=0;j<arrayLength-1-i;j++){
if(data[j].compareTo(data[j+1])>0){
DataWrap tmp=data[j-1];
data[j+1]=data[j];
data[j]=tmp;
flag=true;
}
}
System.out.print(Arrays.toString(data)+"\n");
if(!flag){
break;
}
}
}
冒泡排序算法的時間效率是不確定的,在最好的情況下,初始數據序列已經處于有序狀態,執行1趟冒泡即可,做n-1次比較,無須進行任何交換;但在最壞的情況下,初始數據序列處于完全逆序狀態,算法要執行n-1趟冒泡,第i趟(1<i<n)做了n-i次比較,執行n-i-1次對象交換。此時的比較總次數為n(n-1)/2,記錄移動總次數為n(n-1)*3/2.
冒泡排序算法的空間效率很高,它只需要一個附加程序單元用于交換,其空間效率為O(1)。冒泡排序是穩定的。
快速排序
快速排序是一個速度非常快的交換排序方法,它的基本思路很簡單:從待排序的數據序列中任取一個數據(如第一個數據)作為分界值,所有比它小的數據元素一律放在左邊,所有比它大的數據元素一律放在右邊口經過這樣一趟下來,該序列形成左、右兩個子序列,左邊序列中數據元素的值都比分界值小,右邊序列中數據元素的值都比分界值大。
接下來對左、右兩個子序列進行遞歸,對兩個子序列重新選擇中心元素并依此規則調整,直到每個子序列的元素只剩一個,排序完成。
從上面的算法分析可以看出,實現快速排序的關鍵在于第一趟要做的事情,如下所示。
- 選出指定的分界值————這個容易完成
- 將所有比分界值小的數據元素放在左邊。
- 將所有比分界值大的數據元素放在右邊。
現在的問題是,如何實現上面的第2和3步?這時就要用到交換了,思路如下。
定義一個i變量,i變量從左邊第一個索引開始,找大于分界值的元素的索引,并用來記錄它。
定義一個j變量,j變量從右邊第一個索引開始,找小于分界值的元素的索弓卜并用j來記錄它。
如果i >j,則交換i, j兩個索引處的元素。
重復執行以上1~3步,直到i>=j,可以判斷j左邊的數據元素都小于分界值,j右邊的數據元素都大于分界值,最后將分界值和j索引處的元素交換即可。
下圖顯示了快速排序一趟操作的詳細過程。
從下圖可以看出,快速排序的速度確實很快,只要經過兩次交換,即可讓分界值左邊的數據都小于分界值,分界值右邊的數據都大于分界值。
//快速排序
public static void quickSort(DataWrap[] data){
subSort(data,0,data.length-1);
}
//對data數組中從start到end索引范圍的子序列進行處理
//使之滿足所有小于分界值的放在左邊,所有大于分界值的放在右邊
private static void subSort(DataWrap[] data,int start,int end){
//需要排序
if(start<end){
//以第一個元素作為分界值
DataWrap base=data[start];
//i從左邊開始搜索,搜索大于分界值的元索的索引
int i=start;
//j從右邊開始搜索,搜索小于分界值的元素的索引
int j=end+1;
while(true){
//找到大于分界值的元索的索引。或i已經到了end處
while(i<end&&data[++i].compareTo(base)<=0);
//找到小于分界值的元紊的索引,或j已經到了start處
while(j>start&&data[--j].compareTo(base)>=0);
if(i<j){
swap(data, i, j);
}else{
break;
}
}
swap(data, start, j);
//遞歸左邊子序列
subSort(data, start, j-1);
//遞歸右邊子序列
subSort(data, j+1, end);
}
}
private static void swap(DataWrap[] data,int i,int j){
DataWrap tmp=data[i];
data[i]=data[j];
data[j]=tmp;
}
快速排序的時間效率很好,因為它每趟能確定的元素呈指數增長。
快速排序需要使用遞歸,而遞歸使用棧,因此它的空間效率為O(log2n).
快速排序中包含跳躍式交換,因此是不穩定的排序算法。
插入排序
直接插入排序
直接插入排序的思路非常簡單:依次將待排序的數據元素按其關鍵字值的大小插入前面的有序序列。
細化來說,對于一個有n個元素的數據序列,排序需要進行n-1趟插入操作.如下所示。
第1趟插入:將第2個元素插入前面的有序子序列中,此時前面只有一個元素,當然是有序的。
第2趟插入:將第3個元素插入前面的有序子序列中,前面兩個元素是有序的。
......
第n-1趟插入:將第。個元素插入前面的有序子序列中,前面n-l個元素是有序的。掌握了上面的排序思路之后,如下程序實現了直接插入排序。
//直接插入排序
public static void insertSort(DataWrap[] data){
System.out.println("開始排序:\n");
int arrayLength=data.length;
for(int i=1;i<arrayLength;i++){
//當整體后移時,保證data [i]的值不會丟失
DataWrap tmp=data[i];
//i索引處的值己經比前面的所有值都大,表明己經有序,無須插入
//(i-1索引之前的教據己經有序,i-1素引處元紊的值就是最大值)
if(data[i].compareTo(data[i-1])<0){
int j=i-1;
for(;j>=0&&data[j].compareTo(tmp)>0;j--){
data[j+1]=data[j];
}
//最后將tmp的值插入合適位置
data[j+1]=tmp;
}
System.out.println(Arrays.toString(data));
}
}
直接插入排序的時間效率并不高,在最壞的情況下,所有元素的比較次數總和為(0+1+...+n-1)=O(nn);在其他情況下,也要考慮移動元素的次數,故時間復雜度為O(nn)。
直接插入排序的空間效率很好,它只需要一個緩存數據單元,也就是說,空間效率為O(1).
直接插入排序是穩定的。
折半插入排序
折半插入排序是對直接插入排序的簡單改進。對于直接插入排序而言,當第i-1趟需要將第i個元索插入前面的0i-1個元素序列中時,它總是從i-1個元素開始,逐個比較每個元素,直到找到它的位置。這顯然沒有利用前面0i-1個元素己經有序這個特點,而折半插入排序則改進了這一點。
對于折半插入排序而言,當第i-1趟需要將第i個元素插入前面的0i-1個元素序列中時,它不會直接從0i-1個元索開始逐個比較每個元素。折半插入排序的做法如下。
計算0~i-1索引的中間點,也就是用i索引處的元素和(0+i-1)/2索引處的元素進行
比較,如果i索引處的元素大,就直接在(0+i-1)/2i-1半個范圍內搜索;反之,就在0(0+i-1)/2半個范圍內搜索,這就是所謂的折半.在半個范圍內搜索時,再按第1步方法進行折半搜索.總是不斷地折半,這樣就可以將搜索范圍縮小到1/2,1/4,1/8,從而快速確定第i個元素的插入位置.
一旦確定了第i個元素的插入位置,剩下的事情就簡單了。程序將該位置以后的元素整體后移一位,然后將第i個元素放入該位置。
//折半插入排序
public static void binaryInsertSort(DataWrap[] data){
System.out.println("開始排序:\n");
int arrayLength=data.length;
for(int i=1;i<arrayLength;i++){
DataWrap tmp=data[i];
int low=0;
int high=i-1;
while(low<=high){
int mid=(low+high)/2;
if(tmp.compareTo(data[mid])>0){
low=mid+1;
}else{
high=mid-1;
}
}
for(int j=i;j>low;j--){
data[j]=data[j-1];
}
data[low]=tmp;
System.out.println(Arrays.toString(data));
}
}
上面程序中的粗體字代碼就是折半插入排序的關鍵代碼。程序會拿tmp的值和mid索引(就是中間索引)處的值進行比較,如果tmp大于mid索引處的元素,則將low(搜索范圍的下限)設置為mid+1,即表明在mid+1到原high范圍內搜索;反之,將high(搜索范圍的上限)設置為mid-1,即表明在原low至mid-l范圍內搜索。
上面程序的排序效果與直接插入排序的效果基本相同,只是更快一些,因為折半插入排序可以更快地確定第l個元素的插入位置。
Shell排序
對于直接插入排序而言,當插入排序執行到一半時,待插值左邊的所有數據都已經處于有序狀態,直接插入排序將待插值存儲在一個臨時變量里。然后,從待插值左邊第一個數擬單元開始,只要該數據單元的值大于待插值,該數據單元就右移一格,直到找到第一個小于待插值的數據單元。接下來,將臨時變量里的值放入小于待插值的數據單元之后(前面的所有數據都右移過一格,因此該數據單元有一個空格)。
從上面算法可以發現一個問題:如果一個很小的數據單元位于很靠近右端的位置上,為了把這個數據單元移動到左邊正確的位置上,中間所有的數據單元都需要向右移動一格。這個步驟對每一個數據項都執行了近n次的復制。雖然不是所有數據項都必須移動。個位置,但平均下來,每個數據項都會移動n/2格,總共是nn/2次復制。因此,插入排序的執行效率是O(nn)
Shell排序對直接插入排序進行了簡單改進:它通過加大插入排序中元素之間的間棲,井在這些有間隔的元素中進行插入排序,從而使數據項大跨度地移動。當這些數據項排過一趟序后,Shell排序算法減小數據項的間隔再進行排序,依此進行下去。這些進行排序的數據項之間的間隔被稱為增量,習慣上用h來表示這個增量。
下面以如下數據序列為例,進行說明。
9,-16,21,23,-30,-49,21,30,30
如果采用直接插入排序算法,第i趟插入會將第i+1個元素插入前面的有序序列中,將看到:
-16,9,21,23,-30,-49,21,30,30——第1趟,將第2個元素插入,前兩個元素有序
-16,,9,21,23,-30,-49,21,30,30——第2趟,將第3個元素插入,前三個元素有序。
......
Shell排序就不這樣了。假設本次She}1排序的h為4,其插入操作如下.
-30,-16,21,23,9,-49,21,30,30
-30,-49,21,23,9,-16,21,30,30
-30,-49,21,23,9,-16,21,30,30
-30,-49,21,23,9,-16,21,30,30
-30,-49,21,23,9,-16,21,30,30
注意上面排序過程中的粗體字數據。
當h增量為4時,第1趟將保證索引為0, 4, 8的數據元素己經有序。第1趟完成后,算
法向右移一步,對索引為1,5的數據元素進行排序。這個排序過程持續進行,直到所有的數據項都已經完成了以4為增量的排序。也就是說,所有間隔為4的數據項之間都己經排列有序。
當完成以4為增量的shell排序后,所有元素離它在最終有序序列中的位置相差不到兩個單元,這就是數組“基本有序”的含義,也正是Shell排序的奧秘所在。通過創建這種交錯的內部有序的數據項集合,就可以減少直接插入排序中數據項“整體體搬家”的工作量。
上面已經演示了以4為增量的"hell排序,接下來應該減少增量,直到完成以1為增量的Shell排序,此時數據序列將會變為有序序列。
從下面介紹可知,最終確定Shell排序算法的關鍵就在于確定h序列的值。常用的h序列由Knuth操出.該序列從1開始.誦討如下公式產生。
h=3*h+1
上面公式用于從1開始計算這個序列,可以看到h序列為1,4,13,40,......,反過來,
程序中還需要反向計算h序列,那應該使用如下公式。
h=(h-1/3)
上面公式從最大的h開始計算,假設h從40開始,可以看到h序列為40, 13, 4, 1。
Shell排序比插入排序快很多,因為當h值大的時候,數據項每一趟排序需要移動元素的個數很少,但數據項移動的距離很長,這是非常有效率的。當h減小時,每一趟排序需要移動的元素的個數增多,但是此時數據項己經接近于它們排序后最終的位置,這對于插入排序可以更有效率。正是這兩種情況的結合才使Shell排序效率這么高。
//Shell排序
public static void shellSort(DataWrap[] data){
System.out.println("開始排序:");
int arragLength=data.length;
int h=1;
while(h<=arragLength/3){
h=h*3+1;
}
while(h>0){
for(int i=h;i<arragLength;i++){
DataWrap tmp=data[i];
if(data[i].compareTo(data[i-h])<0){
int j=i-h;
for(;j>=0&&data[j].compareTo(tmp)>0;j-=h){
data[j+h]=data[j];
}
data[j+h]=tmp;
}
System.out.println(Arrays.toString(data));
}
h=(h-1)/3;
}
}
shell排序是直接插入排序的改進版,因此它也是穩定的,它的空間開銷也是O(1),時間開銷估計在O(n的(3/2)次方)~O(n的(7/6)次方)之間。
歸并排序
歸并的基本思想是將兩個(或以上〕有序的序列合并成一個新的有序序列。當然,此處介紹的歸并排序主要是將兩個有序的數據序列合并成一個新的有序序列。
細化來說,歸并排序先將長度為月的無序序列看成是n個長度為1的有序子序,首先做兩兩合并,得到n/2個長度為2的有序子序列,再做兩兩合并……不斷地重復這個過程,最終可以得到一個長度為n的有序序列。
假設有如下數據序列:
21,30,49,30*,97,62,72,08,37,16,54
程序對其不斷合并的過程如下:
從上圖可以看出,長度為16的數據序列,只需經過4次合并。也就是說,對于長度為n的數據序列,只需經過log2n次合并。
對于歸并排序而言,其算法關鍵就在于“合并”。那么,如何將兩個有序的數據序列合并成一個新的有序序列?合并算法的具體步驟如下。
- 定義變量i,i從0開始,依次等于A序列中每個元素的索引。
- 定義變量j,j從0開始,依次等于B序列中每個元素的索引
- 拿A序列中i索引處的元素和B序列中j索引處的元素進行比較,將較小的復制到一
個臨時數組中。 - 如果i索引處的元素小,則i++;如果j索引處的元素小,則j++.
不斷地重復上面四個步驟,即可將A、B兩個序列中的數據元素復制到臨時數組中,直到其中一個數組中的所有元素都被復制到臨時數組中.最后,將另一個數組中多出來的元素全部復制到臨時數組中,合并即完成,再將臨時數組中的數據復制回去即可。
下圖顯示了歸并排序算法合并操作的實現細節。
//歸并排序
public static void mergeSort(DataWrap[] data){
sort(data,0,data.length-1);
}
private static void sort(DataWrap[] data,int left,int right){
if(left<right){
int center=(left+right)/2;
sort(data, left,center);
sort(data, center+1, right);
merge(data, left, center, right);
}
}
private static void merge(DataWrap[] data,int left,int center,int right){
DataWrap[] tmpArr=new DataWrap[data.length];
int mid=center+1;
int third=left;
int tmp=left;
while(left<=center&&mid<=right){
if(data[left].compareTo(data[mid])<=0){
tmpArr[third++]=data[left++];
}else{
tmpArr[third++]=data[mid++];
}
}
while(mid<=right){
tmpArr[third++]=data[mid++];
}
while(left<=center){
tmpArr[third++]=data[left++];
}
while(tmp<right){
data[tmp]=tmpArr[tmp++];
}
}
從上面的算法實現可以看出,歸并算法需要遞歸地進行分解、合并,每進行一趟歸并排序需要調用merge()方法一次,每次執行merge()方法需要比較n次,因此歸并排序算法的時間復雜度為側O(n*log2N)
歸并排序算法的空間效率較差,它需要一個與原始序列同樣大小的輔助序列。
歸并排序算法是穩定的。
桶式排序
桶式排序不再是一種基于比較的排序方法,它是一種非常巧妙的排序方式,但這種排序方式需要待排序列滿足如下兩個特征。
- 待排序列的所有值處于一個可枚舉范圍內。
- 待排序列所在的這個可枚舉范圍不應該太大,否則排廳開銷太大。
下面介紹桶式排序的詳細過程,以如下待排序列為例。
5,4,2,4,1
這個待排序列處于0,1,2,3,4,5這個可枚舉范圍內,而且這個范圍很小,正是桶式
排序大派用場之時。
具體步驟如下:
- 對這個可枚舉范圍構建一個buckets數組,用于記錄“落入”每個桶中的元素的個數,于是可以得到如下圖所示的buckets數組。
- 按如下公式對上圖所示的buckets數組的元素進行重新計算。buckets[i] = buckets[i]+buckets[i-1](其中1 <=i<= buckets.length }
得到如下圖buckets數組
桶式排序的巧妙之處如上圖所示。重新計算后的buckets數組元素保存了“落入”當前桶和“落入”前面所有桶中元素的總數目,而且定義的桶本身就是從小到大排列的,也就是說,“落入”前面桶中的元素肯定小于“落入”當前桶中的元素。綜合上面兩點,得到了一個結論:每個buckets數組元素的值小于、等于“落入”當前桶中元素的個數。也就是說,“落入”當前桶中的元素在有序序列中應該排在buckets數組元素值所確定的位置。
上面理論還有點抽象。以待排序列中最后一個元索1為例,找到新buckets數組中元素對應桶的值,該值為1,這表明元素1就應該排在第1位:再以待排序列中倒數第2個元素4為例,找到新buckets數組中元素4對應桶的值,該值為4,這表明元素4就應該排在第4位....依此類推。
//桶式排序
public static void bucketSort(DataWrap[] data,int min,int max){
System.out.println("開始排序:");
//arrayLength記錄待排序數組的長度
int arrayLength=data.length;
DataWrap[] tmp=new DataWrap[arrayLength];
//buckets數組相當于定義了max一min個桶
//buckets數組用于記錄待排序元素的信息
int[] buckets=new int[max-min];
//計算每個元素在序列中出現的次數
for(int i=0;i<arrayLength;i++){
//buckets數組記錄了DataWrap出現的次數
buckets[data[i].data-min]++;
}
System.out.println(Arrays.toString(data));
//計算“落入”各桶內的元素在有序序列中的位置
for(int i=1;i<max-min;i++){
//前一個bucket的值+當前bucket的值一>當前bucket新的值
buckets[i]=buckets[i]+buckets[i-1];
}
//循環結束后,buckets數組元素記錄了“落入”前面所有捅和
//"落入"當前buckets中元素的總數
//也就是說,buckets數組元素的值代表了“落入”當前桶中的元紊在有序序列中的位置
System.out.print(Arrays.toString(buckets));
//將data數組中數據完全復制到tmp數組中級存起來
System.arraycopy(data,0, tmp, 0, arrayLength);
//根據buckets數組中的信息將待排序列的各元索放入相應的位置
for(int k=arrayLength-1;k>=0;k--){
data[--buckets[tmp[k].data-min]]=tmp[k];
}
}
基數排序
基數排序已經不再是一種常規的排序方法,它更多地像是一種排序方法的應用,基數排序必須依賴于另外的排序方法。基數排序的總體思路就是將待排數據拆分成多個關鍵字進行排序,也就是說,基數排序的實質是多關鍵字排序。
多關鍵字排序的思路是將待排數據里的排序關鍵字拆分成多個排序關鍵字:第1個子關堆字、第2個子關鍵字、第3個子關鍵字......然后,根據子關鍵字對待排數據進行排序。
在進行多關鍵字排序時有兩種解決方案。
- 最高位優先法MSD(Mast Significant Digit first}.
- 最低位優先法LSD(Least Significant Digit first ).
例如,對如下數據序列進行排序:
192,221,12,23
可以觀察到它的每個數據至多只有3位,因此可以將每個數據拆分成3個關鍵字:百位〔高位)、十位、個位(低位)。
如果按照習慣思維,會先比較百位,百位大的數據大:百位相同的再比較十位,十位大的數據大;最后再比較個位。人的習慣思維是最高位優先方式。
如果按照人的思維方式,計算機實現起來有一定困難,當開始比較十位時,程序還需要判斷它們的百位是否相同—這就人為地增加了難度。計算機通常會選擇最低位優先法,如下
所示。
- 第1輪先比較個位,對個位關鍵字排序后得到序列為:
221,192,13,23
- 第2輪再比較十位,對十位關鍵字排序后得到序列為:
13,23,221,192
- 第3輪再比較百位,對百位關鍵字排序后得到序列為:
13,23,192,22
從上面介紹可以看出,基數排序方法對任一個子關鍵字排序時必須借助于另一種排序方法,而且這種排序方法必須是穩定的。
如果這種排序算法不穩定,比如上面排序過程中,經過第2輪十位排序后,在第3輪百位排序時,如果該排序算法是穩定的,那么13依然位于23之前:如果該算法不穩定,那么可能l3跑到23之后,這將導致排序失敗。
現在的問題是,對子關鍵字排序時,到底選擇哪種排序方式更合適呢?答案是桶式排序。
回顧桶式排序的兩個要求:
- 待排序列的所有值處于一個可枚舉范圍內。
- 待排序列所在的這個可枚舉范圍不應該太大。
- 對于多關鍵字拆分出來的子關鍵字,它們一定位于0~9這個可枚舉范圍內,這個范圍也不大,因此用桶式排序效率非常高。
//基數排序
public static void radixSort(int[] data,int radix,int d){
System.out.println("開始排序:");
int arrayLength=data.length;
//需要一個臨時教組
int[] tmp=new int[arrayLength];
//buckets數組是捅式排序必書的buckets數組
int[] buckets=new int[radix];
//依次從高位的子關健字對待排戴據進行排序
//下面循環中rate用于保存當前計算的位(比如十位時rate-10)
for(int i=0,rate=1;i<d;i++){
//重置count數組,開始統計第二個關鍵字
Arrays.fill(buckets, 0);
//將data數組的元素復制到trnp數組中進行緩存
System.arraycopy(data, 0, tmp, 0, arrayLength);
//計算每個待排數據的子關鍵字
for(int j=0;j<arrayLength;j++){
//計算數據指定位上的子關鍵字
int subKey=(tmp[j]/rate)%radix;
buckets[subKey]++;
}
for(int j=1;j<radix;j++){
buckets[j]=buckets[j]+buckets[j-1];
}
//按子關鍵字對指定數據進行排序
for(int m=arrayLength-1;m>=0;m--){
int subKey=(tmp[m]/rate)%radix;
data[--buckets[subKey]]=tmp[m];
}
System.out.println("對"+rate+"位上子關鍵字排序:"+Arrays.toString(data));
rate*=radix;
}
}