算法:基本排序算法

来源:互联网 发布:免费的录制软件 编辑:程序博客网 时间:2024/05/17 20:02

Intro:

  哥怒了,虽然是最基本的排序算法,手头查到的书啊,帖子啊,文章啊,该死的居然出现N多种不同的版本,就连Wiki上的代码范例也都还有点小瑕疵。于是,这篇文章按照Thomas H. Cormen的Introduction to Algorithms Second Edition的内容整理出来最基本且常用的四种基本的排序算法类型:选择、插入、交换以及归并。这四种的分类是按照wiki上的分类归类的。

 四种常用的入门排序算法类型

 

  • 快速排序
  • 归并排序
  • 插入排序
  • 选择排序
  • 堆排序

 


 

快速排序

  快速排序隶属于选择类的排序之中。使用的是分治法的思想:分解->解决->合并.这里假定输入的数组是A[p...r]即下标是用p表示到r的

  • 分解:把数组A[p..r] 划分成两个(允许空)的子数组A[p..q-1] 和A[q+1..r],前者的每个元素都小于等于A[q],后者的每个元素都大于等于A[q]。
  • 解决:递归调用快速排序算法解决两个子数组的排序问题
  • 合并:因为两个子数组是就地(in place)排序的,所以无须合并

  以上便是快速排序的顶层伪代码,所以,最重要的问题是partition这个函数。它应该对A[p..r]进行就地重排

  我们分析一下这个函数,对于输入的数组A和头尾值p, r, 把A[r]当作一个pivot,然后从头开始用ij两个游标来把比A[r]小的元素分别partition出来。这本书的做法和我们的教科书不同,和wiki上乱七八糟的算法也略有一些出入。需要注意的是。i <- p-1,没错,从下面这个实例过程中你就能发现原因了。小于等于i的值的位置的元素都保存着小于等于pivot的元素。所以起始值自然是p-1.而j游标是整个查找的主游标,这个算法里面,其实是在A[j]大于游标的时候是直接进行下一轮循环的,因而,整个过程总能保持下图的状态。

  而最后那个exchange正是用来把游标值最终放到中间。

 

下图是Wiki上的这个算法的动态演示:

 Wiki上的快速排序动态演示

 

以下就是我自己写的C/C++的实现:

 

性能分析:

  快速排序的性能和它的划分是有关的,我们可以把它每次的划分想象成一个树,先假设对n个元素(假设为偶数吧)每次都2等分,那么,这颗递归树的深度就是lgn,而每次划分都是一个O(n)的搜索过程,所以这样的一个划分结果就是O(n lgn)时间复杂度的工作。这也是所谓的最佳情况。

  那最坏的情况自然就是每次只有一个元素在树的一侧,这样这个畸形的递归树深度就是n了。于是就变成了O(n*n)=O(n^2)

  下面来看平均情况,我们假设每次的划分都是按照一个常数比例,算法导论上用的是9:1.于是,运行时间的递归式就是

  T(n) <= T(9n/10)+T(n/10)+ cn

  这个c是在每层调用上的常数。但是只要是按比例划分的,树的深度就是Θ(lgn)级别的,比如这里就是log(10/9)为底的n,所以,总的运行时间还是O(nlgn).

  纯数学的分析还是自己看算法导论吧,打公式太麻烦了。。

  为了减少最坏情况导致的潜在问题,实际上我们可以使用随机化的版本,即取pivot的时候不是像上面那样固定取一个,而是随机的取出一个。这样能够保证排序的期望运行时间是O(nlgn).

  总之,快速排序还是相当不错的。不过虽然快排比较猛,但是常数还是可能比较大的。从我自己测试的结果来看和归并以及堆排序比起来稍微弱点。

 


归并排序:

  Merge Sort是典型的分治法的体现,它在每一个递归层次上都实现了三个步骤:Divide-Conquer-Combine,具体来说,每次把n个元素分解为各含n/2个元素的子序列,用归并排序法对子序列进行排序,合并两个子序列的排序结果以得到排序结果。

  • 分解:归并排序的分解相当简单,直接把上一级数组2分或者更多分。也可以按照某种规律来分,总之对之后的排序没有逻辑上的影响
  • 解决:递归的调用归并排序对子数组进行排序。
  • 合并:Merge(A, p, q, r)其中p<= q < r 分别是数组A的划分,其中A[p...q]和A[q+1...r]都是已经排序过的数组了。这个函数把两个子数组排序合并好成一个新的子数组。

  对于归并排序的合并过程来说,它的时间只有Θ(n),具体做法是每次从两个子数组中从左往右(已经排好序的了)各取一个数,然后把这两个中最大的一个数拷贝到新的数组中,如果一个先结束了就把另外一个数组屁股背后的剩余的数全都拷贝到新数组的后面。作为算法上的优化,还是用了一个sentinel来减少比较的次数。

 

 

  同样,我们分析一下这个程序,n1和n2分别表示两个子数组的长度和,之后创建两个数组分别长度比n1,n2大1(保存sentinel),剩下的不言自明了。此外,废话一句,如果你的排序算法想要使用归并排序的话,又可能需要排序大量数据的话,最好在每次Merge里面都释放内存,否则,内存泄漏是可怕的。

下图是Wiki上的这个算法的动态演示:

Wiki上的归并排序的动态演示

以下是我写的归并排序算法C++版:

 

 性能分析:

  由于归并排序是个递归的分治法算法,在合并的步骤,这个东西是一个Θ(n)复杂度的东西,而分治的步骤是两个T(n/2)的运算.于是对n>1来说

  T(n)=2T(n/2)+ Θ(n)

  由主定理即得Θ(nlgn)因为都是基本对等的划分,所以这个算法的效率总是Θ(nlgn),归并排序唯一麻烦的就是需要额外的n+2个元素的空间来保存临时数据.不过,归并排序也是这篇文章里面介绍的排序算法中实际最快的一种了.

 


插入排序

 

 插入排序应该可以说是每本算法书籍的序章必说算法了。这种排序法对少量元素进行排序还行,但是元素量一大就恶心了。它的排序原理就是每次从未排序的元素中拿出一个元素,移动到已排好序的数组中正确的位置,插入排序的关注点在于,对于未排序的元素都是直接拿出来,比较的用途是找到正确的插入位置。这个算法非常简单,没有什么好多说的。

 

 性能分析:

  这个没啥好分析的了,总共比较n(n-1)/2次,交换次数也是同个数量级的,所以就是O(n^2)了,不过如果原来已经是升序排列.就是O(n)了...


选择排序

  选择排序和插入排序一样,都是基本的入门式排序算法,选择排序的重点正好和插入排序相反,选择排序不关心已经拍好序的序列,它直接从未排序的序列中挑出最小的和未排序序列的第一个元素交换。由于算法导论直观的目录里面没有选择排序。所以就没有写伪代码了,我直接贴我的C++实现

下图是Wiki上的这个算法的动态演示:

Wiki上的选择排序动态演示

 

性能分析:

  选择排序所需要交换的次数只有n次,但是比较的次数总是n(n-1)/2次,虽然速度是O(n^2)但是具体表现来说,似乎比插入慢一个常数倍数,比如我下面的那个测试里,就越相差3.5倍。


堆排序

  堆排序使用了一个堆结构来进行排序,其实这个堆可以理解为数组表示的完全二叉树(算法导论上的定义是除最后一层外其他层都是满的),而这个二叉树的每一层都满足子节点的值小于等于根节点。于是,这个二叉树的关系在数组上表示就非常简单了。

堆的存储表示

这个图就表示了在堆上和数组上的对应关系。由所以,对任意节点在A[i],它爸的下标是i/2⌋,它左儿子的下表是2i,它右儿子的下标是2i+1。

  有了表示以后就需要想个办法把这个堆调整好,所谓调整好就是让每个节点都小于等于它的父节点。为了实现这个目的,我们要分解这个问题,根据二叉树的性质我们就可以知道,比较好的分法就是看一个只有爸节点和左右子节点的情况,从三个里面找出最大的,保证它在根上。

  有了这个,只需要从倒数第二层开始从屁股往前做这件事就可以建好一个最大堆了。

上面的准备工作做完之后才是真正的堆排序。一个最大堆在数组上来看它最大的元素总是在A[0]的位置。所以,我们只需要每次把A[0]和最后一个元素交换,然后重新把剩下的A[0...n-1]个元素调成最大堆,这样一直循环到第2个元素(根的左儿子)为止,就得到了这个结果。

下图是Wiki上的这个算法的动态演示:

 

Wiki上堆排序的动态演示

 

下面是我自己写的C++版本

 

性能分析:

  在堆排序的这个算法里面,第一个过程是Build-Max-Heap,这个过程可以在O(n)之内完成。之后n-1次Max-Heapify的时间代价都是O(lgn),所以最后的复杂度还是O(nlgn)


 

 

最后,这是我在一台电脑上对上面这些算法进行测试的结果:

其中,元素的个数是按1.05的150次方到380次方生成的,而排序时间是线性的。这里我们可以清楚的看到以上几个算法之间的比较。另外,排序用的元素皆取自一次rand()生成的一个125000000个元素的数组,每次排序都从数组里面重新取出数据。因此结果相对来说应该比较接近平均结果。

文中提到的集中排序算法的运行时间比较

从图中我们也基本可以看出,在20000以下个元素的排序中,几种算法之间的性能差别几乎可以忽略不计。实际上最慢的选择排序,在拍20000个元素耗时也不过0.36秒,而第二慢的插入排序也仅仅0.07秒。

原创粉丝点击