浅入浅出数据结构(20)——快速排序

  正如上一篇博文所说,今天我们来讨论一下所谓的“高级排序”——快速排序。首先声明,快速排序是一个典型而又“简单”的分治的递归算法。

  递归的威力我们在介绍插入排序时相比已经见识过了:只要我前面的队伍是有序的,我就可以通过向前插队来完成“我的排序”,至于前面的队伍怎么有序……递归实现,我不管。

  递归就是如此“简单”的想法:我不管需要的条件怎么来的,反正条件的实现交给“递归的小弟们”去做,只要有基准情形并且向着基准情形“递”去,就可以保证“归”回我一个需要的条件。

  不过插入排序虽然体现出了递归的想法,却没有解释什么叫“分治”,其实分治就是分而治之的意思。如果要举个例子的话,恐怕汉诺塔是最为合适的,毕竟大家学习C语言递归时应该都接触过。

  汉诺塔的递归解法用大白话来说就是:作为老和尚,我希望自己只用做一件事,就是把最底下的盘子移到C柱去,至于上面的盘子怎么移到B柱,交给小和尚A去做,而我把最底下的盘子移到C柱后,B柱的盘子们怎么移到C柱来,交给小和尚B去做。

  

  上述汉诺塔的解法就是一种“分治”:把上层盘子移到B柱的任务“分给”A去“治”,而把B柱的盘子们移到C柱的任务又“分给”B去“治”,我只要把底下的盘子移到C柱就行了。需要注意的是,分治不需要什么基准情形,其本质就是将一个大的任务分成两个或多个小的子任务,在子任务完成的情况下去更简单地解决大任务。一般来说分治总是与递归同时出现,即“分治之后递归完成”。

    

 

  那么,快速排序又是怎样的一个分治、递归呢?我们就用大白话来说说快速排序的想法:

  对于数组a,我随便选一个元素a[x]作为枢纽,所有小于枢纽的元素都“站左边去”,所有大于枢纽的元素都“站右边去”(分治),小于枢纽的元素们给我“自行”排好队,大于枢纽的元素们也给我“自行”排好队(递归)。(若小于a[x]的元素共有i个,则它们放在a[0]到a[i-1],而后将a[x]放在a[i]处,大于a[x]的放于a[i+1]到a[size-1]处,i如何确定暂时不管)

  既然有递归,那么就必须有基准情形,那么快速排序的基准情形会是什么呢?显然是当数组被“递”得只有3个元素的时候,此时对该数组进行“分治”就会直接完成排序。

  我们先来试着给出快速排序的伪代码,调用者调用方式为QuickSort(a,0,size-1):

void QuickSort(int *a, unsigned int left,unsigned int right)
{//若a[left]到a[right]元素个数大于2,则继续分治、递归if (left < right&&left != right - 1){/*伪代码:随机选一个a[x],要求x>=left&&x<=right,作为枢纽*//*伪代码:将a[left]到a[right]中所有小于median的元素置于a[left]到a[i-1]处(i未知)*//*伪代码:将a[left]到a[right]中所有大于median的元素置于a[i+1]到a[right]处*/
/*伪代码:将a[x]放在a[i]处*/
QuickSort(a, left, i - 1);QuickSort(a, i + 1, right);}//若a[left]到a[right]元素个数恰为2,则直接排序else if (left == right - 1){if(a[left]>a[right])swap(&a[left], &a[right]);}//若left==right,则说明只有一个元素,已为“有序” }

  虽然快速排序的想法看似比较简单,但其实现还是有“坑”与“捷径”的,接下来我们就一步一步实现快速排序,看看都有什么“坑”与“捷径”。

 

  回顾快速排序的想法和伪代码,可以看出其第一步就是“随便选一个a[x]”,这看似简单的第一步其实是一个“坑”,因为虽说是随便选,但“选一个a[x]”还是要写出具体代码来的,所以随便选一个枢纽的代码该如何写,就有了三种做法:

  1.既然是随便选,那就直接选a[left]好了。

  这个做法是最不可取的,原因非常简单,假设数组已经接近有序,那么选取a[left]作为枢纽就很容易导致分治变得“无效”,因为a[left]很可能就是最小的元素。

  2.既然要随便选,那就选a[rand()%(right-left+1)]好了

  这个做法可取,但是问题出在计算随机数上,计算随机数多少需要一点代价,而且计算随机数对于排序这件事并没有直接帮助。

  3.三数中值法

  很显然,这就是我们的主角了。相比于方法1,本方法要更加安全,而相比于方法2,本方法要更加廉价并且可以帮助到排序本身。那么三数中值法究竟是怎样的呢?其实就是:令center=(left+right)/2,然后选a[left]、a[center]、a[right]三者的中值作为枢纽。不过在选取中值的同时,我们也获取了这三者的大小信息,因此可以顺便将这三者“放好位置”,所以说相比于方法2,本方法对于排序要更有帮助一些。

 

  三数中值法我们一般用一个单独的函数来实现(其返回值即枢纽的值):

int MedianOf3(int *a, unsigned int left, unsigned int right)
{unsigned int center = (left + right) / 2;//前两个if保证a[left]存储着三数最小值//最后一个if保证a[center]为三数中值,a[right]为三数最大值//三个if不仅选出了枢纽,同时将另外两元素的分治工作完成if (a[left] > a[center])swap(&a[left],&a[center]);if (a[left] > a[right])swap(&a[left],&a[right]);if (a[center] > a[right])swap(&a[center],&a[right]);//最后,将枢纽与a[right-1]交换,即“将枢纽放在a[right-1]处”swap(&a[center],&a[right-1]);return a[right-1];
}

  在三数中值法的代码中,最后我们将枢纽放在了a[right-1]处,这是为什么呢?接下来的讲解可以解释这个做法的原因。

 

  实现了枢纽的选取后,接下来要实现的就是分治的“分”,在上述快速排序的想法中我们说过,我们将小于枢纽的元素们放在a[left]至a[i-1]处,枢纽放在a[i]处,大于枢纽的元素们放在a[i+1]至a[right]处。但是问题来了,怎么确定i的值呢?其实可以肯定的是,在开始分治(与枢纽的比较)前,i是绝对不可能知道的。只有所有元素都与枢纽比较完了才知道i到底是多少。

  不过,既然肯定了必须比完才知道,那我们就“比完再知道”呗。具体想法就是:

  1.先令枢纽与a[right-1]交换,即将枢纽暂且放在a[right-1]处(在三数中值代码中已经完成此步骤)

  2.设变量l_pos从left+1开始递增,直观地说就是“让l_pos从数组左侧开始向右逐个扫描元素”(a[left]已经在三数中值时分治完毕,不需要再扫描)

  若l_pos扫描到a[l_pos]>枢纽,则l_pos暂停扫描

  3.设变量r_pos从right-2开始递减,直观地说就是“让r_pos从数组右侧开始向左逐个扫描元素”(a[right]已分治,a[right-1]为枢纽)

  若r_pos扫描到a[r_pos]<枢纽,则r_pos暂停扫描

  4.当l_pos与r_pos均停止时(若元素互异,必然存在此情况),若l_pos<r_pos(直观地说就是它们“尚未碰头”),则交换a[l_pos]与a[r_pos],然后l_pos继续向右扫描,r_pos继续向左扫描。若l_pos>r_pos,则它们“已经碰头”,此时应有l_pos=r_pos+1,即l_pos就在r_pos右边,于是我们彻底停止两者的扫描,并确定了i的值为此时的l_pos。

 

  画图三张,以兹参考

  图1,表示一种初始状态:

  

 

  图2,表示当a[l_pos]>枢纽,a[r_pos]<枢纽,且尚未结束时

  

 

  图3,表示结束情况

  

 

 

  对应的分治部分代码就是这样:

//初始化l_pos与r_pos
unsigned int l_pos = left + 1, r_pos = right - 2;
//根据三数中值法得出枢纽同时完成两个元素的分配
int median = MedianOf3(a, left, right);//l_pos与r_pos不断向中间扫描
while (1)
{while (a[l_pos] < median)l_pos++;while (a[r_pos] > median)r_pos++;//l_pos与r_pos均暂停扫描的两种情况if (l_pos < r_pos)swap(&a[l_pos], &a[r_pos]);elsebreak;
}
//最后记得将枢纽交换至正确位置
swap(&a[l_pos], &a[right - 1]);

 

  解决了选取枢纽与分治,快速排序就算是完成了,从之前所给的快速排序伪代码就可以看出这一点,伪代码中没有解决的就是这两个地方:

void QuickSort(int *a, unsigned int left,unsigned int right)
{if (left < right&&left != right - 1){//选枢纽:
//随机选一个a[x],要求x>=left&&x<=right,作为枢纽median//分治:
//将a[left]到a[right]中所有小于median的元素置于a[left]到a[i-1]处//将a[left]到a[right]中所有大于median的元素置于a[i+1]到a[right]处//将a[x]放在a[i]处

//递归
QuickSort(a, left, i - 1);QuickSort(a, i + 1, right);}else if (left == right - 1){if(a[left]>a[right])swap(&a[left], &a[right]);} }

  将伪代码中未完成部分填上,就有了如下快速排序:

void QuickSort(int *a, unsigned int left, unsigned int right)
{//若a[left]到a[right]元素个数大于2,则继续分治、递归if (left < right&&left != right - 1){/*——————选枢纽——————*/int median = MedianOf3(a, left, right);//根据三数中值法得出枢纽同时完成两个元素的分配/*——————分治——————*/unsigned int l_pos = left + 1, r_pos = right - 2;//初始化l_pos与r_pos//l_pos与r_pos不断向中间扫描while (1){while (a[l_pos] < median)l_pos++;while (a[r_pos] > median)r_pos++;//l_pos与r_pos均暂停扫描的两种情况if (l_pos < r_pos)swap(&a[l_pos], &a[r_pos]);elsebreak;}//最后记得将枢纽交换至正确位置swap(&a[l_pos], &a[right - 1]);/*——————递归——————*/QuickSort(a, left, l_pos - 1);QuickSort(a, l_pos + 1, right);}//若a[left]到a[right]元素个数恰为2,则直接排序else if (left == right - 1){if (a[left]>a[right])swap(&a[left], &a[right]);}//若left==right,则说明只有一个元素,已为“有序”
}

 

  但是请注意!上述代码是有问题的,这是不容易察觉的第二个坑!坑在何处呢?让我们揪出上述代码的一部分:

        while (1){while (a[l_pos] < median)l_pos++;while (a[r_pos] > median)r_pos++;//l_pos与r_pos均暂停扫描的两种情况if (l_pos < r_pos)swap(&a[l_pos], &a[r_pos]);elsebreak;}

  如果数组的元素一定互异,那么这一部分代码没有问题,但是如果数组元素存在相同,那么这部分代码就可能出现问题。

  假设l_pos暂停了扫描,原因是a[l_pos]==median,而且r_pos也暂停了扫描并且也是因为a[r_pos]==median,那么单纯交换a[l_pos]与a[r_pos]只会使循环陷入死循环,因为两个子循环的判断条件将永远为false,从而l_pos与r_pos一直不变。

  那么该如何解决这个问题呢?最直接的办法就是增加新的判断,判断a[l_pos]与a[r_pos]是否都与median相等,如果是则不交换两者,改为令l_pos++和r_pos--。

  但是实际上我们存在一个解决此问题的捷径,这个捷径的思路解说起来稍显麻烦:既然问题是出在交换后l_pos与r_pos不会变化(递增与递减),那就在子循环处改为先变化再比较不就好了:

        while (1){while (a[++l_pos] < median)/*Do nothing*/;while (a[--r_pos] > median)/*Do nothing*/;if (l_pos < r_pos)swap(&a[l_pos], &a[r_pos]);elsebreak;}

  同时,因为l_pos与r_pos都变成了“先变化再比较”,所以两者的初始值也要改变为:

unsigned int l_pos = left, r_pos = right - 1;

 

 

  于是,完整的快速排序就实现好了,代码如下:

void QSort(int *a, unsigned int left, unsigned int right)
{if (left < right&&left != right - 1){unsigned int l_pos = left, r_pos = right - 1;int median = MedianOf3(a, left, right);int temp;while (1){while (a[++l_pos] < median);while (a[--r_pos] > median);if (l_pos < r_pos){temp = a[l_pos];a[l_pos] = a[r_pos];a[r_pos] = temp;}elsebreak;}temp = a[l_pos];a[l_pos] = a[right - 1];a[right - 1] = temp;QSort(a, left, l_pos - 1);QSort(a, l_pos + 1, right);}else if (left == right - 1 && a[left] > a[right]){int temp = a[left];a[left] = a[right];a[right] = temp;}
}

  为了方便调用者,我们可以实现一个简单的“接口”:

void QuickSort(int *a, unsigned int size)
{return QSort(a, 0, size - 1);
}

 

 

 

  对于快速排序,还要注意的一点是当数组的大小N很小时,快速排序是不如插入排序的,并且需要注意的是由于快速排序的递归,必然会出现“小数组”。因此实际实现快速排序时往往选择对小数组执行一个插入排序。即:

void QSort(int *a, unsigned int left, unsigned int right)
{if (left < right-N){//此部分代码略
    }else{InsertionSort(a,right-left+1);}
}

 

  至此,快速排序实现完毕。

 

  接下来我们试着分析一下快速排序的时间复杂度。这一部分我们将分为两个小部分:快速排序的最坏情况,快速排序的最好情况。

 

  首先,为了方便分析,我们假设快速排序的枢纽选择是完全随机的。对于大小为N的数组,快速排序耗时设为T(N),则T(1)=1。于是,T(N)=T(i)+T(N-i-1)+c*N,其中i为小于枢纽的元素个数,c为未知常数,c*N代表分治阶段耗费的线性时间。

  那么,快速排序的最坏情况就是每一次选取的枢纽都是最小的元素,此时i=0,上述公式变为:

  T(N)=T(N-1)+c*N,递推此公式可得

  T(N-1)=T(N-2)+c*(N-1)

  T(N-2)=T(N-3)+c*(N-2)

  ……

  T(2)=T(1)+c*2

  将上述公式左侧与右侧均全部相加,得:

  T(N)+T(N-1)+T(N-2)+……+T(2) = T(N-1)+T(N-2)+……T(2)+T(1)+c*(2+3+4+5+……+N)

  化简可得:

  T(N)=T(1)+c*(2+3+4+……+N)=1+c*(2+3+4+……+N)=O(N2)

  也就是我们在上一篇博文提到的,快速排序最坏情况为O(N2)

  不难看出,选择枢纽时不论是完全随机还是三数中值,快速排序都不容易出现这样的最坏情况。

 

  接下来我们看看快速排序的最好情况。快速排序的最好情况显然就是每次枢纽都选择了整个剩余数组的中间值,为了简化推导,我们假定递归时将枢纽本身也带进去,并且N为2的幂。从而

  T(N)=2*T(N/2)+c*N

  两边同时除以N,得:

  T(N)/N=T(N/2)/(N/2)+c,递推此公式可得:

  T(N/2)/(N/2)=T(N/4)/(N/4)+c

  T(N/4)/(N/4)=T(N/8)/T(N/8)+c

  ……

  T(2)/2=T(1)/1+c

  将上述公式左侧与右侧均全部相加, 得:

  T(N)/N+T(N/2)/(N/2)+……+T(2)/2=T(N/2)/(N/2)+……T(1)/1+c*logN

  化简,得:

  T(N)/N=T(1)/1+c*logN,即T(N)=N*c*logN=O(N*logN)。所以快速排序的最好情况就是O(N*logN)

 

  快速排序的平均情况分析的公式繁杂,且化简需要高深的数学,此处只给出基本思路:既然枢纽是随机的,那么小于枢纽的元素个数i就也是随机位于[0,N-1],那么i的平均值就应该是(0+1+2+……+(N-1))/N,同理大于枢纽的元素个数平均值为(0+1+2+……+(N-1))/N,基于这两点,T(N)=2*(0+1+2+……+(N-1))/N+c*N,依据此公式进行递推、相加、化简,可以得出平均时间为O(N*logN)

 

  对于快速排序的分析并不是只有本文所提,比如在l_pos于r_pos扫描的过程中,我们为什么选择在a[l_pos]或a[r_pos]等于median时停下,而不是继续扫描呢?这是有原因的,简述就是:防止极端情况下l_pos及r_pos越界,同时使分得的两个子数组大小更加平衡。但是更多的分析本文就不做介绍了,时间有限╮(╯_╰)╭

 

 

  最后,对于大小为20万的随机整数数组,我们提过的“主流”排序算法的简单比较结果如下(仅供参考):

   

转载于:https://www.cnblogs.com/mm93/p/7569529.html

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/399741.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

结对第一次作业

同学A : 031502630 - 吴松青 同学B : 031502644 - 邹星 第一次结对作业 本次作业的要求是设计一个方便部门纳新与学生选择部门的app&#xff0c;当然只是原型......刚开始怕要求实现的我们畏首畏尾&#xff0c;总得考虑到后期的实现的困难。最后老师提醒我们不需要实现后&#…

仿美团实现地域选择和城市列表

介绍 在开发O2O相关应用的时候&#xff0c;肯定会有定位&#xff0c;选择所在城市&#xff0c;选择地域&#xff0c;然后再向服务器请求该地区的相关数据&#xff0c;这时就需要我们提供一个导向让用户选择所在区域。 看来看去&#xff0c;最终还是选择模仿美团&#xff0c;感觉…

Ubuntu16.04中php如何切换版本

其实就是一条Linux命令,如下: sudo update-alternatives --config php 会出现下面选项: There are 2 choices for the alternative php (providing /usr/bin/php).Selection Path Priority Status -------------------------------------------------------…

MAC下面maven如何设置让其实下载源码

2019独角兽企业重金招聘Python工程师标准>>> Eclipse--->偏好设置&#xff0d;&#xff0d;&#xff0d;&#xff0d; >Maven--->download artifact source 转载于:https://my.oschina.net/u/2422498/blog/500292

EventBus使用详解(一)——初步使用EventBus

前言&#xff1a;EventBus是上周项目中用到的&#xff0c;网上的文章大都一样&#xff0c;或者过时&#xff0c;有用的没几篇&#xff0c;经过琢磨&#xff0c;请教他人&#xff0c;也终于弄清楚点眉目&#xff0c;记录下来分享给大家。 相关文章&#xff1a; 1、《EventBus使用…

Android应用程序打包时,出现错误:XXX is not translated in af (Afrikaans), am (Amharic), ar (Arabic).....(...

转自&#xff1a;http://blog.163.com/shexinyang126/blog/static/136739312201492144928812/ 问题&#xff1a;当我们开发完成一个Android应用程序后&#xff0c;在发布该应用程序之前必须要经过的一步时打包应用程序。 至于从打包程序到发布的完整过程可以参考&#xff1a; A…

如何拿到阿里算法校招offer

好多同学有问过怎么能拿到阿里算法类校招的offer&#xff0c;刚好看到这篇文章分享给大家&#xff0c;详情可以看原文链接&#xff0c;原文链接中有视频讲解。 师兄师姐的建议&#xff1a; 之前初学算法的时候上过的公开课和看过的书 1. Coursera&#xff1a;《Machine Learnin…

通用软件/工具手册

为什么80%的码农都做不了架构师&#xff1f;>>> #sublime text ##Settings - User {"font_size": 14.0,"tab_size": 2,"scroll_past_end": true,"translate_tabs_to_spaces": true,"trim_trailing_white_space_on_sa…

优秀的SharePoint 2013开发工具有哪些(二)

SharePoint 2013 Search Tool 搜索功能是SharePoint2013的一大亮点。SharePoint 2013 Search Tool可以让我们学习和了解查询如何被格式化&#xff0c;并让我们轻松地配置一个Search REST Query。使用SharePoint 2013 Search Tool来创建你的查询&#xff0c;就可以对它们进行分…

使用jquery解析xml

使用Jquery解析XML&#xff1a;$.ajax({ url: ajax/test.xml, dataType : xml, cache: false, success: function(xml) { $("AUTHOR", xml).each(function(id) { AUTHOR $("AUTHOR", xml).get(id); …

cv1159 最大全0子矩阵(极大子矩阵)

题目描述 Description 在一个01方阵中找出其中最大的全0子矩阵&#xff0c;所谓最大是指0的个数最多。 输入描述 Input Description 输入文件第一行为整数N&#xff0c;其中1<N<2000&#xff0c;为方阵的大小&#xff0c;紧接着N行每行均有N个0或1&#xff0c;相邻两数…

Docker认识基础

版权声明&#xff1a;本文为博主chszs的原创文章&#xff0c;未经博主允许不得转载。 https://blog.csdn.net/chszs/article/details/48212081 Docker认识基础 作者&#xff1a;chszs&#xff0c;版权所有&#xff0c;未经同意&#xff0c;不得转载。博主主页&#xff1a;http:…

信管 - 挣值 - 资料收集

信息系统项目管理师计算题之挣值分析、完工预测知识与习题 挣值分析&#xff1a;早期只需要记住三个参数&#xff0c;4个指标以及公式即可。PV、EV、AC、CV、SV、CPI、SPI。但现在没这么简单了&#xff0c;深入考核PV、EV、AC的理解&#xff0c;从一段文字描述中计算出PV、EV、…

hdu5424 Rikka with Graph II

给一个n个节点n条边的无向图G&#xff0c;试判断图中是否存在哈密顿路径。 若G中存在哈密顿路径l&#xff0c;则路径端点度数不小于1&#xff0c;其余点度数不小于2。 则G存在哈密顿路径的必要条件&#xff1a; 1&#xff09;G连通&#xff1b; 2&#xff09;G中度数为1的点不超…

VisualStudio中的代码段

VS很强大&#xff0c;在这里就不过多说了&#xff0c;在平时码代码时应用代码段会提高我们的编写速度。 举个例子&#xff1a; 比如输入Console.WriteLine (); 传统方法就是一个字母一个字母的输入进去。 如果大家掌握了代码段&#xff0c;就变得非常简单了。只需要输入cw按两次…

tcp和udp的区别和三次 四次挥握手 http://www.cnblogs.com/bizhu/archive/2012/05/12/2497493.html...

小结TCP与UDP的区别&#xff1a;1.基于连接与无连接&#xff1b;2.对系统资源的要求&#xff08;TCP较多&#xff0c;UDP少&#xff09;&#xff1b;3.UDP程序结构较简单&#xff1b;4.流模式与数据报模式 &#xff1b;5.TCP保证数据正确性&#xff0c;UDP可能丢包&#xff0c;…

java concurrent包介绍及使用

2019独角兽企业重金招聘Python工程师标准>>> 说一说java的concurrent包1-concurrent包简介 前面一个系列的文章都在围绕hash展开&#xff0c;今天准备先说下concurrent包&#xff0c;这个系列可能会以使用场景说明为主&#xff0c;concurrent包本身的代码分析可能比…

Codeforces 864E Fire(背包DP)

背包DP&#xff0c;决策的时候记一下 jc[i][j]1 表示第i个物品容量为j的时候要选&#xff0c;输出方案的时候倒推就好了 #include<iostream> #include<cstdlib> #include<cstdio> #include<cstring> #include<algorithm> using namespace std; c…

EF里查看/修改实体的当前值、原始值和数据库值以及重写SaveChanges方法记录实体状态...

EF里查看/修改实体的当前值、原始值和数据库值以及重写SaveChanges方法记录实体状态 原文:EF里查看/修改实体的当前值、原始值和数据库值以及重写SaveChanges方法记录实体状态本文目录 查看实体当前、原始和数据库值&#xff1a;DbEntityEntry查看实体的某个属性值&#xff1a;…

Linux命令与shell

为什么80%的码农都做不了架构师&#xff1f;>>> 资料来自&#xff1a;《http://blog.chinaunix.net/uid-14880649-id-2954340.html》 所谓shell就是命令解释程序。它提供了程序设计接口&#xff0c;可以使用程序来编程。学习shell对于Linux初学者理解Linux系统是非…