排序算法的C語言實現(上 比較類排序:插入排序、快速排序與歸並排序)
總述:排序是指將元素集合按規定的順序排列。通常有兩種排序方法:升序排列和降序排列。例如,如整數集{6,8,9,5}進行升序排列,結果為{5,6,8,9},對其進行降序排列結果為{9,8,6,5}。雖然排序的顯著目的是排列數據以顯示它,但它往往可以用來解決其他的問題,特別是作為某些成型算法的一部分。
總的來說,排序算法分為兩大類:比較排序 和 線性時間排序。
- 比較排序依賴於比較和交換來將元素移動到正確的位置上。它們的運行時間往往不可能小於O(nlgn)。
- 對於線性時間排序,它的運行時間往往與它處理的數據元素個數成正比,即為O(n)。線性排序的缺點是它需要依賴於數據集合中的某些特征,所以我們並不是在所有的場合都能夠使用它
某些算法只使用數據本身的存儲空間來處理和輸出數據(這些稱為就地排序或內部排序),
而有一些則需要額外的空間來處理和輸出數據(雖然可能最終結果還是會拷貝到原始內存空間中)(這些稱之為外部排序)。
一、插入排序
插入排序是最簡單的排序算法。正式表述為:插入排序每次從無序數據集中取出一個元素,掃描已排好序的數據集,並將它插入有序集合的合適位置上(像我們打撲克牌摸牌時的操作)。雖然乍一看插入排序需要獨立為有序和無序的元素預留足夠的存儲空間,但實際上它是不需要額外的存儲空間的。
插入排序是一種較為簡單的算法,但它在處理大型數據集時並不高效。因為在決定將元素插入哪個位置之前,需要將被插入元素和有序數據集中的其他元素進行比較,這會隨著的數據集的增大而增加額外的開銷。插入排序的優點是當將元素插入一個有序數據集中時,只需對有序數據集最多進行一次遍歷,而不需要完整的運行算法,這個特性使得插入排序在增量排序中非常高效
接口定義
issort
int issort(void *data, int size, int esize, int (*compare)(const void *key1, const void *key2));
返回值:如果排序成功返回0,否則返回-1。
描述:利用插入排序將數組data中的元素進行排序。data中的元素個數由size決定。而每個元素的大小由esize決定。
函數指針compare會指向一個用戶定義的函數來比較元素的大小。在遞增排序中,如果key1>key2,函數返回1;如果key1=key2,函數返回0;如果key1<key2,函數返回-1。在背叛排序中,返回值相反。當issort返回時,data包含已排好序的元素。
復雜度:O(n2),n為要排序的元素的個數。
插入排序的實現與分析
從根本上講,插入排序就是每次從未排序的數據集中取出一個元素,插入已經排好序的數據集中。在下面的實現中,兩個數據集都存放在data中,data是一塊連續的存儲區域。
最初,data包含size個無序元素,隨著issort的運行,data逐漸被有序數據集所取代,直到issort返回,此時data已經是一個有序數據集。雖然插入排序使用的是連續的存儲空間,但它仍能用鏈表來實現,並且效率也不差。
插入排序使用一個嵌套循環,外部循環使用標號j來控制元素,使元素從無序數據集中插入有序數據集中。由於待插入的元素總是在有序數據集的右邊,因此也可以認為j是data中分隔有序元素集和無序元素集的界線。對於每個處理位置j的元素,都會使用變量i來在有序數據集中向後查找元素將要放置的位置。當向後查找數據時,每個處於位置i的元素都要向右移動一位,以保證留出足夠的空間來插入新元素。一旦j到達無序數據集的尾部,data就是一個有序數據集了。
插入排序的時間復雜度關鍵在於它的嵌套循環部分。外部循環運行時間T(n)=n-1,乘以一段固定的時間,其中n為要排序元素的個數。考慮內部循環運行在最壞的情況,假設在插入元素之前必須從右到左遍歷完所有的元素。這樣的話,內部循環對於第一個元素叠代一次,對於第二個元素叠代兩次,以此類推。直到外部循環終止。嵌套循環的運行時間表示為1到n-1數據的和,即運行時間T(n)=n(n+1)/2 - n,乘以一段固定時間(這是由1到n的求和公式推導出的)。為O表示法可以簡化為O(n2)。當在遞增排序中使用插入排序時,其時間復雜度為O(n)。插入排序不需要額外的空間,因此它只使用無序數據集本身的空間即可。
示例:插入排序的實現
/*issort.c*/ #include <stdlib.h> #include <string.h> #include "sort.h" /*issort 插入排序*/ int issort(void *data, int size, int esize, int (*compare)(const void *key1, const void *key2)) { char *a = data; void *key; int i,j; /*為key元素分配一塊空間*/ if((key =(char *)malloc(esize)) == NULL) return -1; /*將元素循環插入到已排序的數據集中*/ for(j=1; j < size; j++) {
/*取無序數據集中的第j個元素,復制到key中*/ memcpy(key, &a[j*esize], esize);
/*設i為j緊鄰的前一個元素*/ i = j - 1; /*從i開始循環查找可以插入key的正確位置*/
/*key和第i個元素對比,如果小於第i個元素就復制i元素到i+1的位置;i遞減循環對比*/ while(i >= 0 && compare(&a[i*esize],key)>0) { memcpy(&a[(i+1)*esize],&a[i*esize],esize); i--; }
/*將key元素的值(也就是要插入的值)復制到while循環後i+1的位置,也就是要插入的位置*/ memcpy(&a[(i+1)*esize],key,esize); } /*釋放key的空間*/ free(key); return 0; }
二、快速排序
快速排序是一種分治算法。
廣泛地認為它是解決一般問題的最佳算法。同插入排序一樣,快速排序也屬於比較排序的一種,而且不需要額外的存儲空間。在處理中到大型數據集時,快速排序是一個比較好的選擇。
我們來看一個人工對一堆作廢的支票進行排序的例子,可以將未排序的支票分為兩堆。其中一堆專門用來放小於或等於某個編號的支票,而另一堆用來放大於這個編號的支票(假設這個支票大概是所有支票編號的中間值)。當以這種方式得到兩堆支票後,又可以以同樣的方式將它們分為四堆,不斷的重復這個過程直到每個堆中只放有一張支票。這時,所有的支票就已經排好序了。
由於快速排序屬於分治算法的一種,我們用分治的思想將排序分為三個步驟:
1、分:設定一個分割值,將數據分為兩部分;
2、治:分別在兩個部分用遞歸的方式繼續使用快速排序法;
3、合:對分割部分排序直至完成。
快速排序最壞情況下的性能不會比插入排序的最壞情況好。通過一點點修改可以大大改善快速排序最懷情況的效率,使其表現得與其平均情況相當。如何做到這一點,關鍵在於如何選擇分割值。
所選的分割值需要盡可能的將元素平均分開。如果分割值會將大部分的元素放到其中一堆中,那麽此時快速排序的性能會非常差。例如:如果用10作為數據值{15,20,18,51,36,10,77,43}的分割值,其結果為{10}和{15,20,18,51,36,77,43},明顯不平衡。如果將分割值選為36,其結果為{36,51,77,43}和{15,20,18,10},就比較平衡。
選擇分割值的一種有效的方法是通過 隨機選擇法 來選取。隨機選擇法能夠有效的防止被分割的數據極度不平衡。同時,還可以改進這種隨機選擇法,方法是:首先隨機選擇三個元素,然後選擇三個元素中的中間值。這就是所謂的中位數方法,可以保證平均情況下的性能。由於這種分割方法依賴隨機數的統計特性,從而保證快速排序的整體性能,因此快速排序也是隨機算法的一個好例子。
快速排序的接口定義
qksort
int qksort(void *data, int size, int esize, int i, int k, int (*compare)(const void *key1, const void *key2);
返回值:如果排序成功,返回0;否則返回-1。
描述: 利用快速排序將數組data中的元素進行排序。數組中的元素個數由size決定。而每個元素的大小由esize決定。參數i和k定義當前進行排序的兩個部分,其值分別初始為0和size-1。函數指針compare會指向一個用戶定義的函數來比較元素大小,其函數功能與issort中描述的一樣。當qksort返回時,data包含已經排好序的元素。
復雜度: O(n lg n),n為要被排序的元素的個數。
快速排序的實現與分析
快速排序本質上就是不斷地將無序元素集遞歸分割,直到所有的分區都只包含單個元素。
在以下的實現方法中,data包含size個無序元素,並存放在單塊連續的存儲空間中,快速排序不需要額外的存儲空間,所以所有分割過程都在data中完成。當qksort返回時,data就是一個有序的數據集了。
快速排序的關鍵部分是如何分割數據。這部分工作由函數partition完成。函數分割data中處於i和k之間的元素(i小於k)。
首先,用前面提到的中位數法選取和個分割值。一旦選定分割值,就將k往data的左邊移動,直到找到一個小於或等於分割值的元素。這個元素屬於左邊分區。接下來,將i往右邊移動,直到找到一個大於或等於分割值的元素。這個元素屬於右邊分區。一旦找到的兩個元素處於錯誤的位置,就交換它們的位置。重復這個過程,直到i和k重合。一旦i和k重合,那麽所有處於左邊的元素將小於等於它,所有處於右邊的元素將大於等於它。
qksort中處理遞歸的過程:在初次調用qksort時,i設置為0,k設置為size-1。首先調用partition將data中處於i和k之間的元素分區。當partition返回時,把j賦於分割點的元素。接下來,遞歸調用qksort來處理左邊的分區(從i到j)。左邊的分區繼續遞歸,直到傳入qksort的一個分區只包含單個元素。此時i不會比k小,所以遞歸調用終止。同樣,分區的右邊也在進行遞歸處理,處理的區間是從j+1至k。總的來說,以這種遞歸的方式繼續運行,直到首次達到qksort終止的條件,此時,數據就完全排好了。
圍繞其平均情況下的性能分析是快速排序的重點,因為一致認為平均情況是它復雜度的度量。雖然在最壞情況下,其運行時間O(n2)並不比插入排序好,但快速排序的性能一般能比較有保障地接近其平均性能O(nlgn),其中n為要排序的元素個數。
快速排序在平均情況下的時間復雜度取決於均勻分布的情況,即數據是否分割為平衡或不平衡的分區。如果使用中位數法,那麽此平衡分區將有保障。在這種情況下,當不斷分割數組,在圖3中用樹(高度為(lgn)+1)的方式直觀地表示出來。由於頂部為lgn層的樹,因此必須遍歷所有n個元素,以形成新的分區,這樣快速排序的運行時間為O(nlgn)。快速排序不需要額外的存儲空間,因此它只使用無序數據本身的存儲空間即可。
/*qksort.c*/ #include <stdlib.h> #include <string.h> #include "sort.h" /*compare_int 比較函數*/ static int compare_int(const void *int1, const void *int2) { /*對比兩個整數的大小(用於中位數分區)*/ if(*(const int *)int1 > *(const int *)int2) return 1; else if(*(const int *)int1 < *(const int *)int2) return -1; else return 0; } /*partition 分割函數*/ static int partition(void *data, int esize, int i, int k, int (*compare)(const void *key1, const void *key2)) { char *a=data; void *pval, *temp; int r[3]; /*為分割值和交換值變量分配空間*/ if((pval = malloc(esize)) == NULL) return -1; if((temp = malloc(esize)) == NULL) { /*如果為交換變量分配空間失敗,則將分割變量的空間一起釋放掉*/ free(pval); return -1; } /*用中位數法找到分割值*/ r[0] = (rand()%(k-i+1))+i; r[1] = (rand()%(k-i+1))+i; r[2] = (rand()%(k-i+1))+i; /*調用插入排序函數對三個隨機數排序*/ issort(r,3,sizeof(int),compare_int); /*把排好序的三個數的中間值復制給分割值*/ memcpy(pval,&a[r[1]*esize,esize); /*圍繞分割值把數據分割成兩個分區*/ /*準備變量範圍,使i和k分割超出數組邊界*/ i--; k++; while(1) { /*k向左移動,直到找到一個小於或等於分割值的元素,這個元素處於錯誤的位置*/ do { k--; } while(compare(&a[k*esize],pval)>0); /*i向右移動,直到找到一個大於或等於分割值的元素,這個元素處於錯誤的位置*/ do { i++; } while(compare(&a[i*esize],pval)<0); /*直到i和k重合,跳出分區,否則交換處於錯誤位置的元素*/ if(i >= k) { break; } else { memcpy(temp, &a[i*esize], esize); memcpy(&a[i*esize], &a[k*esize], esize); memcpy(&a[k*esize], temp, esize); } } /*釋放動態分配的空間*/ free(pval); free(temp); /*返回兩個分區中間的分割值*/ return k; } /*qksort 快速排序函數*/ int qksort(void *data, int size, int esize, int i, int k, int(*compare)(const void *key1, const void *key2)) { int j; /*遞歸地繼續分區,直到不能進一步分區*/ while(i < k) { /*決定從何處開始分區*/ if((j = partition(data,esize,i,k,compare))<0) return -1; /*遞歸排序左半部分*/ if(qksort(data,size,esize,i,j,compare) < 0) return -1; /*遞歸排序右半部分*/ i=j+1; } return 0; }
快速排序的例子:目錄列表
在一個層次結構的文件系統中,文件通常分目錄進行組織。在任何一個目錄中,我們會看到此目錄包含的文件列表和子目錄。例如,在UNIX系統中,可以通過命令ls來顯示目錄。在windows的命令行中,通過dir來顯示目錄。
本節展示一個函數directls,它能實現與ls同樣的功能。它調用系統函數readdir來創建path路徑中指定的目錄列表。directls默認將文件按照名字排序,這一點與ls一樣。由於在建立列表時調用了realloc來分配空間,因此一旦不再使用列表時,也需要用free來釋放空間。
directls的時間復雜度為O(nlgn),其中n為目錄中要列舉的條目數。這是因為調用qksort來對條目進行排序是一個O(nlgn)級的操作,所以總的來說,遍歷n個目錄條目是一個O(n)級別的操作。
示例:獲取目錄列表的頭文件
/*directls.h*/ #ifndef DIRECTLS_H #define DIRECTLS_H #include <dirent.h> /*為目錄列表創建一個數據結構*/ typedef struct Directory_ { char name[MAXNAMLEN+1]; }Directory; /*函數接口定義*/ int directls(const char *path,Directory **dir); #endif // DIRECTLS_H
示例:獲取目錄列表的實現
/*directls.c*/ #include <dirent.h> /*是POSIX.1標準定義的unix類目錄操作的頭文件,包含了許多UNIX系統服務的函數原型,例如opendir函數、readdir函數. */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include "directls.h" #include "sort.h" /*compare_dir 目錄比較*/ static int compare_dir(const void *key1,const void key2) { int retval; if((retval = strcmp(((const Directort *)key1)->name,((const Directory *)key2)->name))>0) return 1; else if (retval < 0) return -1; else return 0; } /*directls*/ int directls(const char *path,Directory **dir) { DIR *dirptr; Directory *temp; struct dirent *curdir; int count,i; /*打開目錄*/ if((dirptr = opendir(path)) == NULL ) return -1; /*獲取目錄列表*/ *dir = NULL; count =0; while((curdir = readdir(dirptr)) != NULL /*readdir()返回參數dir目錄流的下個目錄進入點*/ ) { count ++; if((temp = (Directory*)realloc(*dir,count*sizeof(Directory))) == NULL) { free(*dir); return -1; } else { *dir = temp; } strcpy(((*dir)[count - 1]).name, curdir->d_name); } closedir(dirptr); /*將目錄列表按名稱排序*/ if(qksort(*dir,count,sizeof(Directory),0,count-1,compare_dir) != 0) return -1; /*返回目錄列表的數目*/ return count; }
三、歸並排序
歸並排序也是一種運用分治法排序的算法。與快速排序一樣,它依賴於元素之間的比較來排序。但是歸並排序需要額外的存儲空間來完成排序過程。
我們還是以支票排序的例子說明。首先,將一堆未排序的支票對半分為兩堆。接著,分別又將兩堆支票對半分為兩堆,以此類推,重復此過程,直到每一堆支票只包含一張支票。然後,開始將堆兩兩合並,這樣每個合並出來的堆就是兩個有序的合集,也是有序的。這個合並過程一直持續下去,直到一堆新的支票生成。此時這堆支票就是有序的。
由於歸並排序也是一種分治算法,因此可以使用分治的思想把排序分為三個步驟:
1、分:將數據集等分為兩半;
2、治:分別在兩個部分用遞歸的方式繼續使用歸並排序法;
3、合:將分開的兩個部分合並成一個有序的數據集。
歸並排序與其他排序最大的不同在於它的歸並過程。這個過程就是將兩個有序的數據集合並成一個有序的數據集。合並兩個有序數據的過程是高效的,因為我們只需要遍歷一次即可。根據以上事實,再加上該算法是按照可預期的方式來劃分數據的,這使得歸並排序在所有的情況下都能達到快速排序的平均性能。
歸並排序的缺點是它需要額外的存儲空間來運行。因為合並過程不能在無序數據集本身中進行,所以必須要有兩倍於無序數據集的空間來運行算法。這點不足極大的降低了歸並排序在實際中的使用頻率,因為通常可以使用不需要額外存儲空間的快速排序來代替它。
然而,歸並排序對於處理海量數據處理還是非常有價值的,因為它能夠按預期將數據集分開。這使得我們能夠將數據集分割為更加可管理的數據,接著用歸並排序法處理數據,然後不斷的合並數據,在這個過程中並不需要一次存儲所有的數據。
歸並排序的接口定義
mgsort
int mgsort(void *data, int size, int esize, int i, int k, int (*compare)(const void *key1,const void key2));
返回值:如果排序成功,返回0;否則,返回-1。
描述: 利用歸並排序將數組data中的元素進行排序。數據中的元素個數由size決定。每個元素的大小由esize決定。i和k定義當前排序的兩個部分,其值分別初始化為0和size-1。函數指針compare指向一個用戶定義的函數來比較元素的大小。其函數功能同issort中描述的一樣。當mgsort返回時,data中包含已經排好序的元素。
復雜度:O(n lg n),n為要排序的元素個數。
歸並排序的實現與分析
歸並排序本質上是將一個無序數據集分割成許多個只包含一個元素的集,然後不斷地將這些小集合並,直到一個新的大有序集生成。在以下介紹的實現方法中,data最初包含size個無序元素,並放在單塊連續的存儲空間中。因為歸並過程需要額外的存儲空間,所以函數要為合並過程分配足夠的內存。在函數返回後,最終通過合並得到的有序數據集將會拷貝回data。
歸並排序最關鍵的部分是如何將兩個有序集合並成一個有序集。這部分工作交由函數merge完成。它將data中i到j之間的數據集與j+1到k之間的數據集合並成一個i到k的有序數據集。
最初,ipos和jpos指向每個有序集的頭部。只要數據集中還有元素存在,合並過程就將持續下去。如果數據集中沒有元素,進行如下操作:如果一個集合沒有要合並的元素,那麽將另外一個集合中要合並的元素全部放到合並集合中。否則,首先比較兩個集合中的首元素,判斷哪個元素要放到合並集合中,然後將它放進去,接著根據元素來自的集合移動ipos或jpos的位置(如圖4),依此類推。
現在我們來看看mgsort中如何來處理遞歸。在初次調用mgsort時,i設置為0,k設置為size-1。首先,分割data,此時j處於數據中間元素的位置。然後,調用mgsort來處理左邊分區(從i到j)。左邊的分區繼續遞歸分割,直到傳入mgsort的一個分區只包含單個元素。在此過程中,i不再小於k,因此調用過程終止。在前一個mgsort的過程中,在分區的右邊也在調用mgsort,處理的分區從j+1到k。一旦調用過程終止,就開始歸並兩個數據集。總的來說,以這種遞歸方式繼續,直到最後一次歸並過程完成,此時數據就完全排好序了。
將數據集不斷地對半分割,在分到每個集合只有一個元素前,需要lgn級分割(n為要排序的元素個數)。對於兩個分別包含q和p個元素的有序集來說,歸並耗費的時長為O(p+q),因為產生了一個合並的集,必須遍歷兩個集的每個元素。由於對應每個lgn級的分割,都需要遍歷n個元素合並該集,因此歸並排序的時間復雜度為O(nlgn)。又因為歸並排序需要額外的存儲空間,所以必須要有兩倍於要排序數據的空間來處理此算法。
示例:歸並排序的實現
/*mgsort.c*/ #include <stdlib.h> #include <string.h> #include "sort.h" /*merge 合並兩個有序數據集*/ static int merge(void *data, int esize, int i, int j, int k, int (*compare)(const void *key1,const void *key2)) { char *a = data, *m; int ipos ,jpos,mpos; /*初始化用於合並過程中的計數器*/ ipos = i; jpos = j+1; mpos = 0; /*首先,為要合並的元素集分配空間*/ if((m = (char *)malloc(esize * ((k-i)+1))) == NULL) return -1; /*接著,只要任一有序集有元素需要合並,就執行合並操作*/ while(ipos <= j || jpos <=k) { if(ipos > j) { /*左集中沒有元素要合並,就將右集中的元素放入目標集(合並集)*/ while(jpos <= k) { memcpy(&m[mpos * esize],&a[jpos * esize],esize); jpos++; mpos++; } continue; } else if(jpos > k) { /*右集沒有要合並的元素,就將左集中的元素放入目標集(合並集)*/ while(ipos <= j) { memcpy(&m[mpos * esize],&a[ipos *esize],esize); ipos++; mpos++; } continue; } /*追加下一個有序元素到合並集中*/ if(compare(&a[ipos * esize],*a[jpos *esize])<0) { memcpy(&m[mpos * esize],&a[ipos * esize],esize); ipos++; mpos++; } else { memccpy(&m[mpos * esize],&a[jpos * esize],esize); jpos++; mpos++; } } /*將已經排序的數據集拷貝到原數組中*/
memcpy(&a[i * esize],m,esize * ((k-i)+1));
/*釋放為排序分配的存儲空間*/
free(m);
return 0; }
/*mgsort 歸並排序(遞歸調用)*/
int mgsort(void *data, int size, int esize, int i, int k, int(*compare)(const void *key1,const void *key2))
{
int j;
/*遞歸調用mgsort持續分割,直到沒有可以再分割的數據集*/
if(i < k)
{
/*計算對半分割的位置下標*/
j = (int)(((i+k-1)) / 2);
/*遞歸排序兩邊的集合*/
if(mgsort(data, size, esize, i, j, compare) < 0)
return -1;
if(mgsort(data, size, esize, j+1, k, compare) <0)
return -1;
/*將兩個有序數據集合並成一個有序數據集*/
if(meger(data, esize, i, j, k compare) < 0)
return -1;
}
return 0;
}
排序算法的C語言實現(上 比較類排序:插入排序、快速排序與歸並排序)