转自:http://blog.chinaunix.net/uid-23069658-id-4221767.html
常见的排序算法有选择排序、冒泡排序、插入排序、希尔排序、归并排序、堆排序、快速排序这些都是以前教科书上教给我们的。科技在发展,人类在进步,在前人们不懈努力下新的排序算法总是层出不穷,特别是大数据时代关于海量数据的处理方面显得尤为重要,所以出现了诸如计数排序(couting sort)、桶排序(bucket sort)、基数排序(radix sort)。这些暂不属于我们的讨论范围。
选择排序
所谓的“选择”就是在待排序列里,找出一个最大(小)的元素,然后将它放在序列某个位置,这就完成了一次选择过程。如果将这样的选择循环继续下去,就是我们所说的选择排序。这也是选择排序的精髓。
注:本文所讨论的都是升序排序,降序也一样,没啥本质区别。
假如,有一个无须序列A={6,3,1,9,2,5,8,7,4},选择排序的过程应该如下:
第一趟:选择最小的元素,然后将其放置在数组的第一个位置A[0],将A[0]=6和A[2]=1进行交换,此时A={1,3,6,9,2,5,8,7,4};
第二趟:由于A[0]位置上已经是最小的元素了,所以这次从A[1]开始,在剩下的序列里再选择一个最小的元素将其与A[1]进行交换。即这趟选择过程找到了最小元素A[4]=2,然后与A[1]=3进行交换,此时A={1,2,6,9,3,5,8,7,4};
第三趟:由于A[0]、A[1]已经有序,所以在A[2]~A[8]里再选择一个最小元素与A[2]进行交换,然后将这个过程一直循环下去直到A里所有的元素都排好序为止。这就是选择排序的精髓。因此,我们很容易写出选择排序的核心代码部分,即选择的过程,就是不断的比较、交换的过程。
整个选择的过程如下图所示:
其中,黄色表示即将要选择的位置,即该位置上要安放从该位置往后开始最小的那个元素;桔色表示在无须序列里最小元素所在的位置,经过交换后的结果如右边箭头所示。而所有的灰色块,表示那些位置上的元素已经排好序了。所以,整个选择排序算法的核心代码如下:
- int min,tmp,i,j;
- for(i=0;i<len-1;i++){
- min = i; //在本趟选择过程中,我们要将最小的元素放在a[i]的位置上
- for(j=i+1;j<len;j++) /××××××××××××××××××××××××××××××××××××××××××××××××××××××××
- if(a[min]>a[j]) ×在剩下的len-i个元素里选择一个最小,然后用min记住其下标
- min = j; ××××××××××××××××××××××××××××××××××××××××××××××××××××××××/
-
- /× 如果a[i]本身就已经是最小的元素,则不要交换,这样可以提高一点算法的效率×/
- if(min != i){
- swap(a[min],a[i]) //交换两个数
- }
- }
我们可以看到,不论待排序列是否有序,选择排序算法都要进行大量的比较、交换。第一趟选择过程中,需要比较n-1次,第二趟需要比较n-2次,第i次就需要n-i次比较,所以最后第n-1个元素只需要和第n个元素比较一次就可以了。如果我们将交换两个元素所耗费的时间认为是个常数忽略不计,每次比较所耗费的时间又是固定单位时间,那么整个选择排序所耗费的时间就是:
1+2+3+4+5+... ...+(n-2)+(n-1)=n*(n-1)/2,其时间复杂度是O(n^2)。任何情况下,无论待排序列是否有序,选择排序所耗费的比较时间都是O(n^2)。唯一的区别在于,如果待排序列已经是升序的,那么最好的情况就是不需要任何交换;如果待排序列是逆序的,则最坏的情况就是总共需要交换n/2次。
所以,选择排序算法,最好、最坏和平均时间复杂度都是O(n^2)。
冒泡排序
冒泡排序算法的核心是每次冒泡过程中,比较相邻的两个元素,如果A[i]大于A[i+1],则将其交换,然后
A[i+1
]和A[i+2]再进行比较,将大的元素往后放。这样一趟下来,最大元素就被逐次“冒”到序列的末尾了。继续以前面的序列A为例:我们将序列“竖起来”,这样看冒泡的效果会更好。
第五趟没有画出来,实际上第五趟冒泡完了之后,整个序列就已经升序排列好了。上图中,在每一趟冒泡过程中,绿色都表示本趟、本次相邻两个元素比较后值较大者,而紫红色则是较小者。同样滴,我们也很容易就写出冒泡排序的核心算法:
- int i;
- while(len--){
- for(i=0;i<len;i++){
- if(a[i]>a[i+1]){
- swap(a[i],a[i+1]); //交换两个数
- }
- }
- }
因为a[i]要和a[i+1]进行比较,所以得确保i+1的值不会造成数组访问越界的情况。第一趟比较时序号i+1最大可以指向序列最后一个元素,即i+1=len-1,i此时的值即为i=len-2。注意理解上述代码第2行的while(len--)在冒泡排序算法上的用意。
关于冒泡排序的时间复杂度,如果序列的长度为n,第一趟的冒泡需要的比较次数是n-1,第二趟冒泡比较时最后一个元素已经脱颖而出,所以第二趟冒泡的总比较次数是n-2,以此类推,第i次冒泡的比较次数是n-i。同样地,和选择排序一样,冒泡排序在最坏和最好的情况下,其时间复杂度都是O(n^2)。
让我们把冒泡排序第五趟及剩下的过程再分析一下:
虽然我们看到第五趟冒泡完成后,整个序列都已经有序了,但接下来的第六、第七、第八和第九趟冒泡过程依然要做相邻元素比较的无用功。基于此,有人提出了改进的冒泡排序算法。算法主体和传统的冒泡排序一致,改进之处在于将上述这种无用的比较操作给滤掉了。其核心思想是,在冒泡排序过程中,如果有一趟冒泡过程中没有发生交换操作,则待排序的序列一定已经有序了。这个结论可以用数学归纳法来证明,有兴趣的朋友可以去研究一下。反应到程序层面就是我们需要用一个标记变量来记录某趟排序是否有交换操作发生,如果有则继续冒泡的过程;如果没有则立即停止冒泡,此时待排序的序列一定已经有序了。代码的改动也很简单:
- int i,goon=1;
- while(goon && len--){
- goon=0;
- for(i=0;i<len;i++){
- if(a[i]>a[i+1]){
- swap(a[i],a[i+1]); //交换两个数
- goon = 1;
- }
- }
- }
改进之后的冒泡排序,时间复杂度最好的情况可以达到O(n),最坏的情况依然是O(n^2)。
上一篇我们回顾了选择和冒泡排序、以及改进的冒泡排序两种算法,今天我们来看一下插入排序和希尔排序。
插入排序
插入排序的本质是将待排序序列分成有序和无序两部分,通常情况下我们都认为序列的第一元素是有序的,所以插入排序一般是从序列的第二个元素(下标是1的位置)开始。插入排序的的思想是:从无序序列里取出一个元素,我们将这个元素叫做哨岗,然后用一个额外的存储单元将其值保存下来,然后再在有序序列里,从后向前逐次比较它们和哨岗的大小。如果哨岗比有序序列的值还小,则向后移动有序序列里的数据,直到找到第一个比哨岗小的元素为止。还是以前面用到的序列A={6,3,1,9,2,5,8,7,4}为例,看一下算法的执行过程:
上述插入排序过程,灰色表示有序部分,白色表示无序部分,黄色表示无序序列里当前所选择的哨岗,而橙色表示最后在有序序列为哨岗所找到的合适位置,绿色的圈表示本趟插入时的执行步骤。简单分析一下第八趟的插入过程:
第八趟插入排序开始前,无序序列里就剩下一个元素了。将元素4标记为哨岗,用一个额外的存空间temp记录下。然后从有序序列最后一个元素9开始,与元素4进行比较。因为9比4大,所以元素9向后移动,然后是有序序列里的元素8,也比4大,同样地,8也往后移动,一直到元素5也比4大,所以继续往后移动。到元素3的时候,它比4小,所以元素4应该安放在元素3后面紧挨着的位置,即元素5腾出来的地方,最后将哨岗的值4设置到那里就OK了。根据上述思路,我们就可以很容易地写出插入排序的核心代码了:
- int i,j,tmp;
- for(i=1;i<len;i++){ //下标i用于从无须序列里取元素,因为第一个元素a[0]已经有序,所以下标从1开始
- tmp = a[i]; //tmp代表哨岗,每从无序序列里取出一个元素a[i],就用哨岗tmp将其值保存下来
- for(j=i;j>0;j--){ //下标j用于在有序序列里为哨岗找一个合适的插入位置,j需要在有序序列从后向前遍历
- if(tmp<a[j-1])
- a[j] = a[j-1]; //将有序序列里的元素向后移动
- else
- break; //找到了合适的位置则退出
- }
- a[j] = tmp; //将哨岗的值安放在合适的位置上
- }
上述代码的核心逻辑已经很清楚明了了,方便理解插入排序的核心思想。这段代码还可以写得更漂亮点:
- int i,j,tmp;
- for(i=1;i<len;i++){
- for(j=i,tmp=a[i];j>0 && tmp;j--){
- a[j] = a[j-1];
- }
- a[j] = tmp;
- }
上面两段代码,在gcc标准编译环境下,后者生成的机器指令代码确实比前者要少几条,但在O2、O3优化级别下,两段程序最终生成的机器指令数量是一模一样的。备注:我GCC的版本是4.4.7。
接下来分析一下插入排序算法的效率。如果待排序序列长度为n,则初始时无序序列长度为n-1,因为第一个元素是有序的。在最好的情况下,比较的次数是n-1次,移动元素0次;最坏的情况是,当序列为逆序时:
无须序列第一个元素(即整个待排序序列的第二个元素),比较的次数1,移动1次;
第二个元素,比较次数2,移动次数2;
第三个元素,比较次数3,移动次数3;
... ...
第n-1个元素的比较次数为n-1,移动的次数也是n-1;
所以,插入排序最坏的情况下,其时间复杂度n*(n-1)/2,即O(n^2)。
这里我们介绍的是传统的插入排序,又叫直接插入排序。和现有查找算法进行结合后,产生了新的插入排序算法,例如折半插入排序、表插入排序、希尔插入排序等,这些衍生出来的算法,核心思想和直接插入排序是一致的,变化的部分主要是在有序序列里找哨岗的位置上进行优化做文章,不像传统的直接插入排序算法是在有序序列从后向前逐次查找的过程。新算法充分借助了二分查找,或者希尔查找算法的优势,可以提高插入排序算法的时间效率。感兴趣的朋友可以自己去写写代码。
希尔排序
希尔排序又叫递减增量排序算法,它是直接插入排序的一种改进算法。当增量为1时,希尔排序就是直接插入排序。如果待排序序列长度为n,执行第一次希尔排序时,增量一般是n/2;第二次一般是n/4,以此类推,直到增量递减为1,再执行一次直接插入排序。继续看希尔排序的过程,
A={
6,3,1,9,2,5,8,7,4
}:
上述希尔排序过程中,前两趟里颜色相同的元素属于一个分组,我们对每个这样的分组使用直接插入排序算法。第三趟时,增量已经为1了,即进行直接插入排序。所以,基于直接插入排序,我们可以很容易写出希尔排序的核心算法:
- int i,j,tmp,d=len;
- while((d/=2)>0){ //增量依次递减到1
- for(i=d;i<len;i++){ //无须序列下标从d开始,且哨岗tmp=a[d]
- for(j=i,tmp=a[i];j>=d && tmp<a[j-d];j-=d){
- a[j] = a[j-d];
- }
- a[j] = tmp;
- }
- }
我们可以看到,希尔排序是插入排序更普遍的情况。希尔排序算法的重点就在于
增量的选择方式上,如果增量选择不合适将会大大影响算法的整体效率,这也是的希尔排序的时间复杂分析起来比较困难的主要原因。如果按照通常情况下,待排序列长度折半的方式选取增量的话,其时间复杂度最坏的情况是O(n^2)。
这是排序算法的最后一篇了,剩下归并排序、堆排序和快速排序了,关于各种排序算法的效率、适用场合和性能的分析留到下一篇再讲。
归并排序
归并排序是基于归并操作的,归并操作的思想很简单,针对两个已经排好序的有序序列:
1、申请一块额外的存储空间,空间的大小等于两个待操作序列长度之和;
2、设定两个指针,初始位置分别指向两个有序序列的开始处;
3、比较两个指针所指元素的值,较小者放到合并空间里,同时指针向后移动;
4、重复步骤3直到某个指针达到序列末尾;
5、如果另外一个序列的指针为到达末尾,则将该序列里剩下的元素拷贝到合并空间的尾部。
这便是归并操作的核心思想,其归并过程如下:
我们看到一趟归并操作,要求参与归并的序列必须有序,如果参与归并操作的两个序列(即我们常见的两路归并)长度分别是M和N,则还需要额外M+N的辅助存储空间。归并排序的基本操作就是归并操作,所以归并排序其实是“分治策略”的第一个典型应用。将待排序序列拆分成两个等长序列,然后将每个子序列继续拆分,直到最后的子序列长度为1为止。然后对拆分后的子序列两两执行归并操作,最后就达到了归并排序的目的。所以说归并排序算法分两步:第一步是拆分,第二步是归并。还是以前面见到过的测试序列A={6,3,1,9,2,5,8,7,4}为例,先看一下其拆分过程:
待排序最终被拆分成长度为1的子序列,然后对这些子序列两两执行归并操作。根据上述归并操作的思想,我们可以很容易写出归并操作的核心代码:
- int merge_ops(int a[],int alen,int b[],int blen){
- int i,j,k,len=alen+blen;
- int ele_size = sizeof(int);
- int *tmp = (int*)malloc(ele_size*len); //为归并后的结果申请临时存储空间
- if(NULL == tmp){
- perror("Error!can't alloc mem!");
- return -1;
- }
- memset(tmp,0,ele_size*len); //清空临时缓冲区
- i=j=k=0;
- while(i<alen && j<blen){
- tmp[k++] = ((a[i]<b[j]) ? a[i++]:b[j++]); //执行归并过程
- }
- //将某一序列的剩余元素追加到归并后的存储空间里(如果有的话)
- if(i>=alen && j<blen){
- memcpy(tmp+k,b+j,ele_size*(blen-j));
- }
- if(j>=blen && i<alen){
- memcpy(tmp+k,a+i,ele_size*(alen-i));
- }
- memcpy(a,tmp,ele_size*len);
- free(tmp);
- tmp = NULL;
- return 0;
- }
可以看到,上述归并操作的代码逻辑并不复杂,接下来我们需要完成归并排序的另一项工作,那就是拆分。我们要将待排序序列不断拆分,最终变成一个个长度为1的子序列,用于实现这个拆分过程最好的办法就是递归。因此,拆分的递归代码如下:
- int merge(int a[],int len){
- if(len == 1){ //当子序列长度为1时则停止拆分
- return 0;
- }
- //拆分的过程
- if(merge(a,len/2)<0)
- return -1;
- if(merge(a+len/2,len-len/2)<0)
- return -1;
- //归并的过程
- return merge_ops(a,len/2,a+len/2,len-len/2);
- }
归并排序的效率还是蛮高的,它包含了“拆分”和“归并”两个过程。对于长度为n的待排序序列而言,将其拆分成长度是1的子序列时,如果拆分每一个元素所需要的时间为固定值C,那么拆分n个元素的时间总和就是Cn。在归并过程中,我们是在深度为log(n)+1的二叉树上执行的,二叉树每一层上的元素个数都是相同的,同样地,如果归并一个元素需要的时间为P,那么归并n个元素需要的时间总和就是Pn,即每一层都需要Pn的时间,所以归并log(n)+1层就需要Pn(log(n)+1)的时间。最后归并排序总耗费的时间:
T(n)=Cn+Pn(log(n)+1)=Pn*log(n)+(C+P)n,取C和P中较大者,假如记作M,则T(n)=Mn*log(n)+Mn。相比于nlog(n)来说,常数M和Mn可以忽略不计,所以可以得到归并排序的时间复杂度为O(nlog(n))。
堆排序
排序算法里的堆和计算机进程地址空间里的堆并不是一个概念。堆又分二叉堆和N叉堆,其中二叉堆比较常见(目前我们只讨论这种,后面我们提到堆时默认情况下都指的是二叉堆),是一个类似于完全二叉树的数据结构。堆排序主要是借助堆这种数据结构来实现的排序算法。堆的主要特性就是:
父节点的值总是大于(或者小于
)等于任意一个子节点的值。
我们将父节点的值总是
大于等于
任意一个子节点的值的堆叫做
大堆(或者
大根堆);同样地,父节点的值总是
小于等于
任意一个子节点的值的堆叫做
小堆(或者
小根堆)。
通常情况下,堆都是用一维数组进行存储。编号为i的节点,其左子节点在数组中的下标为2×i+1,右子节点的下标为2×i+2;编号为i的子节点,其父节点的标号为(i-1)/2取整。例如:
A={6,3,1,9,2,5,8,7,4
}
所以我们看到,堆排序的核心点是如何建立一个堆,这方面在STL里已经有很好的封装和实现了。当然,这里我们不是拿来主义,如果自己不对算法、原理本身进行研究,总感觉雾里看花似的。因为我是进行升序排列,所以需要建大根堆(小根堆原理一样)。可能有人会觉得应该建立小根堆才对,因为我们的堆是借助数组存储的,不使用树形结构,所以当堆调整成大根堆之后,将堆的根部元素依次和后面的叶子节点进行交换。然后再把堆调整成大根堆,依次循环下去,直到堆里仅剩一个元素为止。这样堆排序就完成了,且是原地排序,空间复杂度为O(1)。以上面的序列A为例,先看一下将其调整成大根堆的过程:
如果序列的长度为n则我们需要从编号为(n/2)-1的元素开始调整,直到编号为0。因为对于编号为i(注意数组小标是i从0开始编号)的节点,其子节点的编号需要满足2*i+1
第一步时,index=3,所以其左右子节点的编号分别为7和8,在左右子节点a[7]和a[8]里找一个较大者,然后和a[3]进行比较。发现a[3]已经是三个参与比较的值中最大的了,因此这一步不用调整,index减1,后执行第二步,如下:
如上所示,第二步时index=2,在根节点和两个子节点之间进行比较,将最大值交换到根节点上,如右所示;
如上所示,第三步时index=1,最大值其左子节点a[3]=9,调整后的结果如右所示。节点a[1]=3的值“下沉”后我们发现它打破了原来左下角那个二叉树的平衡,所以接下来需要对其重新调整,使它满足大根堆的特性,过程如第四步所示:
第四步调整时,index的值并没有改变,而是对index的左子节点所表示的二叉树进行了调整。当然,如果左子节点的任意一个子节点还是一棵二叉树,则需要一直递归调整下去,直到不需要调整或者遇到叶子节点为止。
第五步时,index=0,调整后的结果如右所示。同样地,我们发现,根节点的左子节点“下沉”后也破坏原有二叉树的平衡性,因此需要继续调整:
到此为止,我们的大根堆就算建立好,即任意一个节点如果有子节点,则根节点的值大于等于其左右子节点的值。
根据上述建堆的过程,我们可以用递归算法来实现,但递归不方便理解,所以先看一个非递归的建堆过程:
- //取得节点编号为i的左子节点的编号
- int leftChildIndex(int i){
- return (2*i+1);
- }
- //取得节点编号为i的右子节点的编号
- int rightChildIndex(int i){
- return (2*i+2);
- }
- //交换两个数
- void swap(int *a,int *b){
- int t;
- t = *a;
- *a = *b;
- *b = t;
- }
- /××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××
- 功能描述:AdjustHeap用于在堆里对编号为i的节点进行调整,使其满足大根堆的特性
- 输入参数:
- a :待排序的序列;
- len:待排序序列长度;
- i :要调整的节点编号
- 输出参数:无
- 返 回 值:无
- ××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××××/
- void AdjustHeap(int a[],int len,int i){
- int left,right,bigger;
- left = leftChildIndex(i);
- right = rightChildIndex(i);
- //如果i节点左右子节点编号均未越界才执行此循环
- while(left<len || right<len){
- //如果右子节点编号未越界,则左子节点的下标也一定不会越界
- if(right<len){
- bigger = ((a[left]>a[right])?left:right); //在左右子节点里找出较大者,将其编号用bigger标记
- }else if(left<len){
- bigger = left; //如果右子节点越界,但左子节点未越界,则直接将左子节点标记为较大者
- }else{
- break; //否则的话,说明节点i是叶子节点,直接退出
- }
- //如果编号为i的根节点的值,小于从其左右子节点里选出来的较大的值,则进行交换
- if(a[bigger]>a[i]){
- swap(&a[i],&a[bigger]);
- i = bigger; /*
- 交换完成后,用i标记新的较大者,即将i记为新的根节点,然后取其左右子节点重复迭代调整
- 如果把这个步骤改成递归算法,其实也不复杂
- */
- left = leftChildIndex(i);
- right = rightChildIndex(i);
- }else
- break;
- }
- }
- /*******************************************************
- 功能描述:BuildHeap对长度为len的无序列a,将其构建成一个大根堆
- 输入参数:
- a :待排序的无序序列首地址
- len:无序序列的长度
- 输出参数:无
- 返 回 值:无
- *******************************************************/
- void BuildHeap(int a[],int len){
- int i;
- for(i=len/2-1;i>=0;i--){ //注意前面分析的为什么要从编号为n/2-1的元素开始依次递减进行调整
- AdjustHeap(a,len,i);
- }
- }
当建好堆之后,堆排序的第一步就完成了。第二步就是不断的将堆的根元素交换到数组的末尾,然后重新再将堆调整成大根堆,并继续将堆的根元素继续和一维数组倒数第二个位的元素进行交换。一直这样下去,直到堆里剩下一个元素为止。还是看过程,下面是我们通过调用BuildHeap()将序列A建成的大根堆:
我们将堆根元素和最后一个叶子元素4进行交换:
因为此时,数组中的最后一个元素即堆的最末尾一个叶子元素已经被固定了,就相当于已经排好序了,所以在调整剩下来的大根堆时,序列的总长度要减1,即len=len-1,然后从堆的根元素开始调整,重新将其调整成大根堆,过程如下:
然后再继续调整大根堆剩下来的部分,和上面的调整、排序流程是一模一样地:
根据以上这个执行流程,现在我们就可以写出堆排序的核心代码了,非常简单、明了:
- void heap_sort(int a[],int len){
- int i;
- BuildHeap(a,len); //建堆
- while(--len > 0){ //如果堆里仅剩下一个元素则停止排序
- swap(&a[0],&a[len]); //从后向前,将堆的根节点和数组尾部依次执行交换
- AdjustHeap(a,len,0); //交换完成后,再以堆的根节点作为参照,将堆重新调整成大根堆
- }
- }
堆排序的时间主要包含两部分:“建堆的时间”+“排序时调整堆的之间”。建堆和调整堆时我们都调用AdjustHeap()函数,这个函数会将根节点向下沉,且节点下沉的深度不会超过二叉树的深度,即log(n)+1,因此AdjustHeap()函数的时间复杂度为O(log(n))。在建堆的时候,我们一共执行了n/2-1次,也就是说调用了n/2-1次AdjustHeap()函数,还不明白这个n/2-1是如何来的童鞋强烈建议从头再认真、仔细看一遍。最后我们可以得出建堆的时间是(n/2-1)×log(n),即建堆的时间复杂度为O(nlog(n))。
那么堆排序的时候,根元素依次和叶子节点进行交换的时间为O(1),主要的时间开销还在交换完成后重新调整堆上面。堆排序时一共需要交换n-1,也就是所执行过n-1次AdjustHeap(),所以在进行堆排序时所耗费的时间为(n-1)×log(n),即排序时的时间复杂度同样为O(nlog(n))。因此,堆排序总的时间复杂度为O(nlog(n))。
快速排序
快速排序本质上其实是对冒泡排序的一种改进,它和归并排序同样都是“分治策略”的一种典型应用。快速排序也分两个步骤:第一步,现将待排序序列依次拆分成子序列,然后在每个子序列上执行下述操作:
1、两个指针分别指向序列的头和尾,且定义一个关键参考值=头指针所指向的元素的值;
2、如果尾指针所指的元素值比头指针所指的元素值大,则尾指针向前移动,头指针不动;
3、继续步骤2,如果尾指针所指的元素小于头指针所指的元素,且头、尾指针没有相遇,则将尾指针指向的元素值赋值给头指针所指向的元素值,同时头指针开始向头移动,尾指针不动;
4、继续执行步骤3,如果头、尾指针相遇则程序停止,否则如果满足条件2则只习惯步骤2,否则继续执行步骤3.
这样一趟快速排序下来后,所有比较关键参考值小得元素都在其左边,所有比它大的元素都在其右边了。然后在以关键参考值所在的位置为分界线,在分别对其左边和右边两个子序列重复执行这个过程,直到最后所有子序列的长度为1则停止。
还是看一下序列A={6,3,1,9,2,5,8,7,4}的一趟快速排序过程:
初始时我们选择key=a[low]=6作为关键参考元素,low指向a的头部,hight指向数组a的尾部,从后向前开始。
第1步时,由于key>a[high],所以a[low]=a[high],然后low++;
第2步时,由于a[low]
第3步和第2步一样;
第4步时,a[low]>key,则执行a[high]=a[low],然后high--;
... ...
这样一直下去直到第11步,当low和high相遇就停止,最后将a[low]=key,同时以low(或者以high也可以)为分界线对它的左右两个子序列重复执行刚才的操作,直到序列长度为1则停止。
所以,我们现在可以写出一次快速排序的核心代码了:
- int partoff(int a[],int low,int high)
- {
- int key = a[low]; //取a[low]为关键参考元素
- while(low<high)
- {
- while(low<high&&key<=a[high]) //从后向前找第一个比key小的元素
- high--;
- if(low<high)
- a[low++] = a[high]; //找到后执行一次赋值,然后开始从前向后找
- while(low<high && key >= a[low]) //从前先后找第一个比key大的元素
- low++;
- if(low<high)
- a[high--] = a[low]; //找到后也执行一次赋值,然后再从后向前找
- }
- //如果low和high相遇,则退出上面的while循环,将key安放在low所指定的位置上,并返回low用于下一次拆分子序列时当参考定标
- a[low] = key;
- return low;
- }
整个快速排序的过程就是不断调用上述函数的过程,那么采用递归的算法就很容易实现整个快速排序的过程了:
- void quick_sort(int a[],int low,int high)
- {
- int index=0;
- if(low<high)
- {
- index = partoff(a,low,high);
- quick_sort(a,low,index-1);
- quick_sort(a,index+1,high);
- }
- }
快速排序是目前已知的内部(后面会解释内部排序)排序中效率比较高的排序算法。待排序序序列最终被拆分成深度为log(n)+1的二叉树,和前面的堆排序一样。
第一次拆分时,总共的执行时间是Cn(C为固定的单位时间常数);第二次拆分时,每个子序列的执行时间为Cn/2,一共两个子序列,则总的执行时间是Cn/2+Cn/2=Cn;第三次拆分时,总时间时Cn/4+
Cn/4+Cn/4+Cn/4+=Cn,以此类推,总共拆分了log(n)次,所以快速排序最终的时间损耗为Cn×log(n),也就是说快速排序的时间复杂度为O(nlog(n))。
前面三篇博文我们分别回顾了冒泡排序、选择排序、插入排序、希尔排序、归并排序、堆排序和快速排序。关于排序算法有几种分类标准,稳定与非稳定、内部与外部。
所谓稳定的排序算法,意思是如果待排序序列有相同元素,经过排序算法处理后他们的相对顺序和排序前在序列里的相对顺序一样,这样我们就称该排序算法是稳定;否则就是非稳定的。
所谓内部排序算法,意思是待排序序列数据量规模较小,排序直接在内存里就可以完成的排序算法;而外部排序是针对数据量特别大,不能一次性将所有数据调入内存来,在排序过程中要不断地访问外部存储设备的排序算法。我们这里介绍的七种排序算法,还有一个没有介绍的基数排序,它们都是内部排序算法。
下面我们用实际数据来测试一下这几种算法的性能。通过前面几篇博文的复习,我已经将这七种排序算法写成了一个单独的工程:
头文件innersort.h:
- /**********************************************
- filename: innersort.h
- **********************************************/
- #include <stdlib.h>
- #include <string.h>
- #include <stdio.h>
- void bubble_sort(int a[],int len);
- void select_sort(int a[],int len);
- void insert_sort(int a[],int len);
- void shell_sort(int a[],int len);
- void merge_sort(int a[],int len);
- void heap_sort(int a[],int len);
- void quick_sort(int a[],int low,int high);
源文件innersort.c:
- /******************************************
- filename:innersort.c
- ******************************************/
- #include "innersort.h"
- //交换两个数
- void swap(int *a,int *b)
- {
- int t;
- t = *a;
- *a = *b;
- *b = t;
- }
- //冒泡排序
- void bubble_sort(int a[],int len)
- {
- int i,goon;
- goon = 1;
- while(goon && len--){
- goon = 0;
- for(i=0;i<len;i++){
- if(a[i]>a[i+1]){
- swap(&a[i],&a[i+1]);
- goon =1;
- }
- }
- }
- }
- //选择排序
- void select_sort(int a[],int len)
- {
- int i,j,min;
- for(i=0;i<len-1;i++){
- min = i;
- for(j=i+1;j<len;j++)
- if(a[min]>a[j])
- min = j;
- if(min != i){
- swap(&a[i],&a[min]);
- }
- }
- }
- //插入排序
- void insert_sort(int a[],int len)
- {
- int i,j,tmp;
- for(i=1;i<len;i++){
- for(j=i,tmp=a[i];j>0 && tmp < a[j-1];j--){
- a[j] = a[j-1];
- }
- a[j] = tmp;
- }
- }
- //希尔排序
- void shell_sort(int a[],int len)
- {
- int i,j,tmp,d=len;
- while((d/=2)>0){
- for(i=d;i<len;i++){
- for(j=i,tmp=a[i];j>=d && tmp < a[j-d];j-=d){
- a[j] = a[j-d];
- }
- a[j] = tmp;
- }
- }
- }
- //归并操作,被归并排序使用
- inline void merge_ops(int a[],int alen,int b[],int blen)
- {
- int i,j,k,len=alen+blen;
- int *tmp = (int*)malloc(sizeof(int)*len);
- i=j=k=0;
- while(i<alen && j<blen){
- tmp[k++] = ((a[i]<b[j]) ? a[i++]:b[j++]);
- }
- if(i>=alen && j<blen){
- memcpy(tmp+k,b+j,sizeof(int)*(blen-j));
- }
- if(j>=blen && i<alen){
- memcpy(tmp+k,a+i,sizeof(int)*(alen-i));
- }
- memcpy(a,tmp,sizeof(int)*len);
- free(tmp);
- }
- //归并排序
- void merge_sort(int a[],int len)
- {
- if(len == 1){
- return;
- }
- merge_sort(a,len/2);
- merge_sort(a+len/2,len-len/2);
- merge_ops(a,len/2,a+len/2,len-len/2);
- }
- //用于堆排序,计算节点i的左子节点
- inline int leftChildIndex(int i)
- {
- return (2*i+1);
- }
- //用于堆排序,计算节点i的右子节点
- inline int rightChildIndex(int i)
- {
- return (2*i+2);
- }
- //将堆调整成大根堆的元操作函数
- inline void adjustHeap(int a[],int len,int i)
- {
- int l,r,bigger;
- l = leftChildIndex(i);
- r = rightChildIndex(i);
- while(l<len || r<len){
- if(r<len){
- bigger = ((a[l]>a[r])?l:r);
- }else if(l<len){
- bigger = l;
- }else{
- break;
- }
- if(a[bigger]>a[i]){
- swap(&a[i],&a[bigger]);
- i = bigger;
- l = leftChildIndex(i);
- r = rightChildIndex(i);
- }else
- break;
- }
- }
- //建立大根堆
- inline void buildHeap(int a[],int len)
- {
- int i;
- for(i=len/2-1;i>=0;i--){
- adjustHeap(a,len,i);
- }
- }
- //堆排序
- void heap_sort(int a[],int len)
- {
- int i;
- buildHeap(a,len);
- while(--len > 0){
- swap(&a[0],&a[len]);
- adjustHeap(a,len,0);
- }
- }
- //快速排序中用于拆分子序列的操作接口
- inline int partoff(int a[],int low,int high)
- {
- int key = a[low];
- while(low<high)
- {
- while(low<high&&key<=a[high])
- high--;
- if(low<high)
- a[low++] = a[high];
- while(low<high && key >= a[low])
- low++;
- if(low<high)
- a[high--] = a[low];
- }
- a[low] = key;
- return low;
- }
- //快速排序
- void quick_sort(int a[],int low,int high)
- {
- int index=0;
- if(low<high)
- {
- index = partoff(a,low,high);
- quick_sort(a,low,index-1);
- quick_sort(a,index+1,high);
- }
- }
关于测量函数执行时间有很多方式,clock(), times(), gettimeofday(), getrusage()等,还有通过编译程序时,打开gcc的-pg选项,然后用gprof来测量,下面是我在网上找到的一个计算函数执行时间的版本,非常感谢博客园的“ 静心尽力”朋友,稍加改造一下,我们就可以通过编译时给Makefile传递不同的宏选项,打开不同的时间测量方式:
- /*****************************************************
- filename: common.h
- 如果定义了TEST_BY_CLOCK,则采用clock()方式计量函数的执行时间;
- 如果定义了TEST_BY_TIMES,则采用times()方式计量函数的执行时间;
- 如果定义了TEST_BY_GETTIMEOFDAY,则采用gettimeofday()方式计量函数的执行时间;
- 如果定义了TEST_BY_GETRUSAGE,则采用getrusage()方式计量函数的执行时间;
- *****************************************************/
- #include <sys/time.h>
- #include <sys/resource.h>
- #include <unistd.h>
- #include <stdio.h>
- #include <time.h>
- #include <stdlib.h>
- #include <string.h>
- //用于生成随机待排序序列
- #define random(x) (rand()%x)
- static clock_t clockT1, clockT2;
- static double doubleT1, doubleT2;
- //非快速排序的统一回调测试接口
- typedef void (*sfun)(int a[],int len);
- //快速排序的测试接口
- typedef void (*sfun2)(int a[],int low,int high);
- /***************************************************
- 功能说明:生成随机待排序序列
- 输入参数:len-随机序列长度,range-随机序列里元素的取值范围
- 输出参数:无
- 返 回 值:随机序列首地址
- ***************************************************/
- int *genArray(int len,int range)
- {
- int i = 0;
- int *p = (int*)malloc(sizeof(int)*len);
- if(NULL == p)
- return NULL;
- srand((int)time(0));
- for(i=0;i<len;i++){
- p[i] = random(range);
- }
- return p;
- }
- /***************************************************
- 功能说明:逐次打印给定序列里的每一个元素
- 输入参数:title-提示符,a-序列首地址,len-序列长度
- 输出参数:无
- 返 回 值:无
- ***************************************************/
- void printforeach(char *title,int a[],int len)
- {
- int i = 0;
- printf("%s: ",title);
- for(i=0;i<len;i++){
- printf("%d ",a[i]);
- }
- printf("\n");
- }
-
- double getTimeval()
- {
- struct rusage stRusage;
- struct timeval stTimeval;
- #ifdef TEST_BY_GETTIMEOFDAY
- gettimeofday(&stTimeval, NULL);
- #endif
- #ifdef TEST_BY_GETRUSAGE
- getrusage(RUSAGE_SELF, &stRusage);
- stTimeval = stRusage.ru_utime;
- #endif
- return stTimeval.tv_sec + (double)stTimeval.tv_usec*1E-6;
- }
-
- void start_check(){
- #ifdef TEST_BY_CLOCK
- clockT1 = clock();
- #endif
- #ifdef TEST_BY_TIMES
- times(&clockT1);
- #endif
- #ifdef TEST_BY_GETTIMEOFDAY
- doubleT1 = getTimeval();
- #endif
- #ifdef TEST_BY_GETRUSAGE
- doubleT1 = getTimeval();
- #endif
- }
- void end_check(){
- #ifdef TEST_BY_CLOCK
- clockT2 = clock();
- printf("Time result tested by clock = %10.30f\n",
- (double)(clockT2 - clockT1)/CLOCKS_PER_SEC);
- #endif
- #ifdef TEST_BY_TIMES
- times(&clockT2);
- printf("Time result tested by times = %10.30f\n",
- (double)(clockT2 - clockT1)/sysconf(_SC_CLK_TCK));
- #endif
- #ifdef TEST_BY_GETTIMEOFDAY
- doubleT2 = getTimeval();
- printf("Time result tested by gettimeofday = %10.30f\n",
- (double)(doubleT2 - doubleT1));
- #endif
- #ifdef TEST_BY_GETRUSAGE
- doubleT2 = getTimeval();
- printf("Time result tested by getrusage = %10.70f\n",
- (double)(doubleT2 - doubleT1));
- #endif
- }
- void do_test(sfun fun_ptr,int a[],int len){
- start_check();
- (*fun_ptr)(a,len);
- end_check();
- }
- void do_test2(sfun2 fun_ptr,int a[],int low,int high){
- start_check();
- (*fun_ptr)(a,low,high);
- end_check();
- }
最终的测试代码如下:
- #include "common.h"
- #include "innersort.h"
- #ifdef NOECHO
- #define printforeach(...) {}
- #endif
- int main(int argc,char** argv){
- if(3 != argc){
- printf("Usage: %s total range \n",argv[0]);
- return 0;
- }
- int len = atoi(argv[1]);
- int range = atoi(argv[2]);
- int *p = genArray(len,range);
- int *data = (int*)malloc(sizeof(int)*len);
- memcpy(data,p,4*len);
- printforeach("Pop before",data,len);
- do_test(bubble_sort,data,len);
- printforeach("Pop after ",data,len);
- memcpy(data,p,4*len);
- printforeach("select before",data,len);
- do_test(select_sort,data,len);
- printforeach("select after ",data,len);
- memcpy(data,p,4*len);
- printforeach("Insert before",data,len);
- do_test(insert_sort,data,len);
- printforeach("Insert after ",data,len);
- memcpy(data,p,4*len);
- printforeach("Shell before",data,len);
- do_test(shell_sort,data,len);
- printforeach("Shell after ",data,len);
- memcpy(data,p,4*len);
- printforeach("merge before",data,len);
- do_test(merge_sort,data,len);
- printforeach("merge after ",data,len);
- memcpy(data,p,4*len);
- printforeach("heap before",data,len);
- do_test(heap_sort,data,len);
- printforeach("heap after ",data,len);
- memcpy(data,p,4*len);
- printforeach("quick before",data,len);
- do_test2(quick_sort,data,0,len-1);
- printforeach("quick after ",data,len);
- free(p);
- free(data);
- return 0;
- }
Makefile文件的长相如下:
- TARGET = test
- SRC = test.c innersort.c
- OBJS = $(SRC:.c=.o)
- CC = gcc
- DEBUG += -pg
- INCLUDE = -I.
- all:$(TARGET)
- $(TARGET):$(OBJS)
- $(CC) $(INCLUDE) $(DEBUG) $(CFLAGS) $(OBJS) -o $(TARGET)
- %.o : %.c
- $(CC) $(INCLUDE) $(DEBUG) $(CFLAGS) -c $<
- clean:
- rm -fr $(TARGET) *.out $(OBJS)
最终,测试工程文件夹下的文件结构列表:
如果要关闭排序前后序列的输出信息,则执行“make CFLAGS+="-DNOECHO"”,需要采用gettimeofday()来计量函数的实行时间,则执行“make CFLAGS+="-DTIME_BY_GETTIMEOFDAY
-DNOECHO"”;同理需要用clock()来计量,则将TIME_BY_GETTIMEOFDAY替换成TEST_BY_CLOCK。一次测试结果如下:
在数据量很小的情况下希尔排序的性能要比快速排序稍微好一点点,但是当数据上量级别后,在七种内部排序算法里,经过100次测试后发现,快速排序的性能绝对是最优的:
(测试环境:CPU-AMD 速龙双核2.1GHz,内存-2G,操作系统-Fedora 17,内核版本-3.3.4)
在下面的对比图里我们可以看到,当数据量上10万后,冒泡排序算法明显力不从心了,选择排序和插入排序性能相当,但也有点不可接受。但是当数据量达到百万后前三种算法已经跑不出结果了,但快速排序和归并排序算法排列一百万条数只需不到1秒钟的时间。当数据量达到一千万时,快速排序也只需3.8秒左右。所以,结论已经很明显了。
当然,上述是我用gettimeofday()测量出的算法性能,感兴趣的朋友还可以用其它几种方式,或者再对比一下gprof的统计结果,看看快速排序到底是不是真汉子