排序是使数据有序化的操作。这里的数据包括关键字和其它信息项,关键字用来控制排序。排序使得数据有序化,实际上是使数据按关键字的某个定义明确的顺序规则排列。如果被排序的数据在内存中,那么这个排序方法就叫做内排序;如果数据来自磁盘则叫做外部排序。其中内部排序能很容易访问任何数据项,而外排序必须顺序地访问数据项。本章我们主要讨论内部排序。
对于内部排序,数据在内存中的存储方式分为数组和链表两种。本章我们主要讨论基于数组存储方式的算法,并简单介绍几种基于链表存储方式的数据的算法。对算法的性能评价包括时间开销、空间开销、稳定性等方面。时间和空间开销比较容易理解,所谓算法稳定性值得是:如果排序算法不改变关键字相同的记录的相对顺序,那它就是稳定的。通过本章的讨论,读者可以发现大部分简单排序算法是稳定的,然而部分复杂的算法是不稳定的。
排序通常有两种方式来访问数据项,存取关键字进行比较或者访问整个数据项进行移动。如果要排序的数据项内存空间较大,则通过间接排序来避免移动数据项。可以不对数据项本身而是对一个数据项的指针数组进行排序,其中数组的第一个元素指向最小的数据项,第二个指向次小的数据项。
首先介绍选择排序算法,并讨论排序算法中的基本操作。算法流程如:首先找数组中的最小元素,把它与第一个位置的元素进行交换;然后,找到第二个最小的元素,并将它与数组第二个位置的元素进行交换;循环下去直到整个数组排序完成。由于每次找到的都是数组中剩余元素中的最小的元素,所以这种方法称为选择排序。
以字符串"selectionsort"为例,
s |
e |
l |
e |
c |
t |
i |
o |
n |
s |
o |
r |
c |
e |
l |
e |
s |
t |
i |
o |
n |
s |
o |
r |
c |
e |
l |
e |
s |
t |
i |
o |
n |
s |
o |
r |
c |
e |
e |
l |
s |
t |
i |
o |
n |
s |
o |
r |
c |
e |
e |
i |
s |
t |
l |
o |
n |
s |
o |
r |
c |
e |
e |
i |
l |
t |
s |
o |
n |
s |
o |
r |
c |
e |
e |
i |
l |
n |
s |
o |
t |
s |
o |
r |
c |
e |
e |
i |
l |
n |
o |
s |
t |
s |
o |
r |
c |
e |
e |
i |
l |
n |
o |
o |
t |
s |
s |
r |
c |
e |
e |
i |
l |
n |
o |
o |
r |
s |
s |
t |
c |
e |
e |
i |
l |
n |
o |
o |
r |
s |
s |
t |
c |
e |
e |
i |
l |
n |
o |
o |
r |
s |
s |
t |
c |
e |
e |
i |
l |
n |
o |
o |
r |
s |
s |
t |
每次循环在表中灰色底纹的元素(a[i]~a[N-1])中找出最小的字符(记下下标位置min),黑体标出的字符为找出的最小元素项;然后将a[min]与a[i]进行交换。
以下是算法的实现:
算法内层循环选择剩余元素中最小的元素的下标min。循环外部做元素项的移动操作,通过exch函数实现。算法中包含两层循环,时间复杂度为O(n2)。
算法的时间消耗与数据的原始状态无关,每次寻找剩余数据中的最小元素时之前的遍历过程不为本次遍历提供任何信息。对于一个顺序由大到小排列的有序数组,调用选择排序时算法的时间消耗与对随机顺序的数组的排序的时间消耗几乎相同,这是本算法的最大缺点。
在算法中,我们可以发现,内存循环找到剩余数据中最小的元素后,在循环外对数据交换位置。与选择排序算法相比,没有任何其它算法能用更少的数据移动来完成排序。
在本章中,为了直观地理解算法过程,我们利用动画跟踪算法过程中数据的顺序变化。如图为一组随机数据,数据个数为200,范围为[0, 1),在下文称这组数据为DATASET1。利用选择排序对数据进行排序过程中数组中数据顺序变化如图(~)所示。
从图中数据顺序的变化可以看出,第i循环结束时,数组中前i个元素的将按照由小到大顺序排列。执行N次循环后数组中的所有元素将排序结束。
Java程序有两类,一类是我们通常编写的应用程序,另一类就是小应用程序(applet)。Applet程序编译后可以嵌入到页面中,由支持Java的浏览器(IE 或 Nescape)解释执行能够产生特殊效果的程序。它可以大大提高Web页面的交互能力和动态执行能力。
小应用程序除了可以由支持Java的网页浏览器运行之外,也可以通过Java开发工具的appletviewer来运行。幸运的是,在我们所使用的Eclipse集成开发环境中可以直接运行applet程序。
Applet小应用程序的实现主要依靠Applet类,它继承了java.awt.Panel。所以applet具有强大的可视化功能。
每个小应用程序都是Applet类的子类,在一般情况下有init()初始化函数,start()启动函数,stop()停止函数等。
l init()方法
这个方法主要是为Applet的正常运行做一些初始化工作。当一个Applet被系统调用时,系统首先调用的就是该方法。通常可以在该方法中完成从网页向Applet传递参数,添加用户界面的基本组件等操作。
l start()方法
系统在调用完init()方法之后,将自动调用start()方法。而且,每当用户离开包含该Applet的主页后又再返回时,系统又会再执行一遍start()方法。这就意味着start()方法可以被多次执行,而不像init()方法。因此,可把只希望执行一遍的代码放在init()方法中。可以在start()方法中开始一个线程,如继续一个动画、声音等。
l stop()方法
这个方法在用户离开Applet所在页面时执行,因此,它也是可以被多次执行的。它使你可以在用户并不注意Applet的时候,停止一些耗用系统资源的工作以免影响系统的运行速度,且并不需要人为地去调用该方法。如果Applet中不包含动画、声音等程序,通常也不必实现该方法。
这里我们借助apple类将每个数据以点的形式显示出来。这里为了显示方便,我们规定所有数据为double类型,范围为[0,1)。
可以在stat()方法中开启一个线程,并用于动态显示数据点,Java中实现多线程有两种途径:继承Thread类或者实现Runnable接口。
Runnable接口非常简单,定义一个方法run()即可。继承Runnable并实现这个方法就可以实现多线程了,但是这个run()方法不能自己调用,必须由系统或者客户程序来调用,否则就和普通的方法没有什么区别了。
基于Applet类和Runnable接口,可以实现算法动画类Animate。其实现如下:
其中init()方法将Applet窗口初始化为320×240。start()方法调用初始化方法,然后开启一个线程显示动画。run()为线程运行函数,在函数中首先从指定文件中读取指定个数的数据,并显示在窗口中,最后调用抽象函数sort对数据进行排序。
X(int)函数将给定的数组序号转换为显示窗口上的横坐标值,窗口的最左端对应数组的0下标,最右端对应数组的最后一个下标。Y(double)函数则将给定的数组中元素值转换为显示窗口上的纵坐标,最下端对应数组元素值为0,最上端对应的数组元素值为1,如图。
(i, X[i])→(320*i/N, 240 * (1-x[i]))
图 数组元素作图
dot函数在指定的窗口位置(x, y)画一个指定颜色的点。exch函数交换两个数据,并将更新这两个数据对应点的颜色。compExch函数比较数组中两个元素a[i]和a[j],如果a[i] > a[j]则调用exch函数交换这两个元素。cpyVal函数将元素项val赋值给数组中的第i项a[i],同时更新点(i, a[i])。
本类是一个抽象类,包含抽象函数sort。可以创建新的类继承本类并定义sort函数,sort函数可以用不同排序算法来实现。
我们对扑克牌的通常拿法是依次取一张牌,将它插入到已经排好序的牌中的适当位置,并维持手上扑克牌全部按顺序排列。在对数组中元素进行排序时可以借鉴这个过程:将数组中的元素依次作为新来的元素,插入到该元素之前的元素(子数组)中,不过这需要将较大元素依次向右移一个位置,为新元素腾出空间,最终将新元素插入到腾出的位置上。这个过程中每次将新元素插入到适当的位置,所以称为插入排序。
插入排序过程中,当前下标以左的元素是按顺序排列的,但是它们的位置并非最终的结果,这些位置在后来的元素插入时还可能会改变。当下标到达最右端,数组排序便结束。
以字符串"insertionsort"为例:
i |
n |
s |
e |
r |
t |
i |
n |
o |
s |
o |
r |
t |
i |
n |
s |
e |
r |
t |
i |
n |
o |
s |
o |
r |
t |
e |
i |
n |
s |
r |
t |
i |
n |
o |
s |
o |
r |
t |
e |
i |
n |
r |
s |
t |
i |
n |
o |
s |
o |
r |
t |
e |
i |
n |
r |
s |
t |
i |
n |
o |
s |
o |
r |
t |
e |
i |
i |
n |
r |
s |
t |
n |
o |
s |
o |
r |
t |
e |
i |
i |
n |
n |
r |
s |
t |
o |
s |
o |
r |
t |
e |
i |
i |
n |
n |
o |
r |
s |
t |
s |
o |
r |
t |
e |
i |
i |
n |
n |
o |
r |
s |
s |
t |
o |
r |
t |
e |
i |
i |
n |
n |
o |
o |
r |
s |
s |
t |
r |
t |
e |
i |
i |
n |
n |
o |
o |
r |
r |
s |
s |
t |
t |
e |
i |
i |
n |
n |
o |
o |
r |
r |
s |
s |
t |
t |
每次循环都确定了阴影部分的子数组中元素的顺序,但是此时这些元素的位置并非最终的位置。黑体字符表示新元素在之前子数组中插入的位置。
算法实现如下:
算法初始时首先确定最终数组最左端元素,即第l个元素的值,这可以通过一次数组遍历来实现。然后从第l+2个元素开始将新元素插入到之前已排序的子数组中,这里之所以从l+2个元素开始而不是第l+1个元素,因为在确保数组中第l个元素是所有元素中最小的前提下,数组中第l个和第l+1个元素肯定是按顺序排列的,则可以直接从第l+2个元素开始进行插入循环。
插入排序的算法复杂度依赖于输入文件中的关键字的最初顺序,一般直接插入排序的时间复杂度为O(n2),但是当数列基本有序时,如果按照有数列顺序排时,时间复杂度将改善到O(n)。
同样以DATASET1为例,算法过程中数组各位置上的元素的动态变化为:
从算法过程中数据顺序变化过程可以看出,第i次遍历后前i个元素项按顺序排列,其后的所有元素的顺序保持不变;但前i个位置上的元素在之后的遍历中又有所变化。
冒泡排序是是最常使用的一种简单排序,不断遍历数据,交换倒序的相邻元素,使得较小的数放在前面,较大的数据放在后面。
这里的数据遍历顺为从右向左,数据下标范围为[l, r]。首先比较第r-1个和第r个数,将小数放前,大数放后;然后比较第r-2个数和第r-1个数,将小数放前,大数放后,如此继续,直至比较最前面第0和第1个数,将小数放前,大数放后。一次遍历后数组中第0个位置上的数据将是最小的。循环调用上述过程,每次遍历都从第r个数开始,但是每次遍历的数据个数依次减1,N次遍历后数组中数据将排序完成。
以字符串"bubbleexamplesort"为例:
b |
u |
b |
b |
l |
e |
e |
x |
a |
m |
p |
l |
e |
s |
o |
r |
t |
a |
b |
u |
b |
b |
l |
e |
e |
x |
e |
m |
p |
l |
o |
s |
r |
t |
a |
b |
b |
u |
b |
e |
l |
e |
e |
x |
l |
m |
p |
o |
r |
s |
t |
a |
b |
b |
b |
u |
e |
e |
l |
e |
l |
x |
m |
o |
p |
r |
s |
t |
a |
b |
b |
b |
e |
u |
e |
e |
l |
l |
m |
x |
o |
p |
r |
s |
t |
a |
b |
b |
b |
e |
e |
u |
e |
l |
l |
m |
o |
x |
p |
r |
s |
t |
a |
b |
b |
b |
e |
e |
e |
u |
l |
l |
m |
o |
p |
x |
r |
s |
t |
a |
b |
b |
b |
e |
e |
e |
l |
u |
l |
m |
o |
p |
r |
x |
s |
t |
a |
b |
b |
b |
e |
e |
e |
l |
l |
u |
m |
o |
p |
r |
s |
x |
t |
a |
b |
b |
b |
e |
e |
e |
l |
l |
m |
u |
o |
p |
r |
s |
t |
x |
a |
b |
b |
b |
e |
e |
e |
l |
l |
m |
o |
u |
p |
r |
s |
t |
x |
a |
b |
b |
b |
e |
e |
e |
l |
l |
m |
o |
p |
u |
r |
s |
t |
x |
a |
b |
b |
b |
e |
e |
e |
l |
l |
m |
o |
p |
r |
u |
s |
t |
x |
a |
b |
b |
b |
e |
e |
e |
l |
l |
m |
o |
p |
r |
s |
u |
t |
x |
a |
b |
b |
b |
e |
e |
e |
l |
l |
m |
o |
p |
r |
s |
t |
u |
x |
a |
b |
b |
b |
e |
e |
e |
l |
l |
m |
o |
p |
r |
s |
t |
u |
x |
a |
b |
b |
b |
e |
e |
e |
l |
l |
m |
o |
p |
r |
s |
t |
u |
x |
冒泡排序算法与选择排序算法遍历次数相同,数值大小比较的次数也相同,计算法复杂度为O(n2);每次遍历后冒泡排序会粗略调整剩余元素的局部顺序,使得整体趋于有序,并找到剩余元素中最小的项,从这个意义上说冒泡排序是一种特殊的选择排序。但是冒泡排序需要执行元素移位操作,这导致算法执行效率较低。算法实现如下:
同样以DATASET1为例,算法过程中数组各位置上的元素的动态变化为:
从图可以看出,冒泡排序算法与选择排序过程中数据顺序变化很相似,只是冒泡排序除了找到剩余元素中最小元素之外还调整了剩余元素顺序的整体趋势。
在插入排序算法过程中我们提到,由于需要频繁进行元素移位操作,导致算法效率很低。例如,a[r-1]为数组中最小的元素项,则需要进行N次移位操作才能将其移到最终的正确位置。Sell排序时插入排序的改进:允许相隔很远的元素进行交换从而提高速度。
sell排序算法的思想是对一定间隔上的元素项进行排序,从而使得从任何一个元素起始,每间隔h个元素就产生一个已排序的文件。算法首先把数组中元素移到很远的位置,此时h值较大,这可以使得对小一点的h值排序更容易。这样的h一直取值到1,最终得到排好序的文件。
实现shell排序的一种方法是,对每个h,用插入排序在每个子文件上进行独立排序。可以调用第节中的插入排序,但是元素遍历扫描时的步进为h而不是1。
以字符串"shellexamplesort"为例,h值取4和1。h=4时,首先比较判断a[4]和a[0];然后a[5]和a[1]……;比较a[8],a[4]和a[0]……。h=1时,遍历过程与第节中的插入排序相同。过程如下:
h = 4
s |
h |
e |
l |
l |
e |
x |
a |
m |
p |
l |
e |
s |
o |
r |
t |
l |
h |
e |
l |
s |
e |
x |
a |
m |
p |
l |
e |
s |
o |
r |
t |
l |
e |
e |
l |
s |
h |
x |
a |
m |
p |
l |
e |
s |
o |
r |
t |
l |
e |
e |
l |
s |
h |
x |
a |
m |
p |
l |
e |
s |
o |
r |
t |
l |
e |
e |
a |
s |
h |
x |
l |
m |
p |
l |
e |
s |
o |
r |
t |
l |
e |
e |
a |
m |
h |
x |
l |
s |
p |
l |
e |
s |
o |
r |
t |
l |
e |
e |
a |
m |
h |
x |
l |
s |
p |
l |
e |
s |
o |
r |
t |
l |
e |
e |
a |
m |
h |
l |
l |
s |
p |
x |
e |
s |
o |
r |
t |
l |
e |
e |
a |
m |
h |
l |
e |
s |
p |
x |
l |
s |
o |
r |
t |
l |
e |
e |
a |
m |
h |
l |
e |
s |
p |
x |
l |
s |
o |
r |
t |
l |
e |
e |
a |
m |
h |
l |
e |
s |
o |
x |
l |
s |
p |
r |
t |
l |
e |
e |
a |
m |
h |
l |
e |
s |
o |
r |
l |
s |
p |
x |
t |
l |
e |
e |
a |
m |
h |
l |
e |
s |
o |
r |
l |
s |
p |
x |
t |
h = 1
l |
e |
e |
a |
m |
h |
l |
e |
s |
o |
r |
l |
s |
p |
x |
t |
e |
l |
e |
a |
m |
h |
l |
e |
s |
o |
r |
l |
s |
p |
x |
t |
e |
e |
l |
a |
m |
h |
l |
e |
s |
o |
r |
l |
s |
p |
x |
t |
a |
e |
e |
l |
m |
h |
l |
e |
s |
o |
r |
l |
s |
p |
x |
t |
a |
e |
e |
l |
m |
h |
l |
e |
s |
o |
r |
l |
s |
p |
x |
t |
a |
e |
e |
h |
l |
m |
l |
e |
s |
o |
r |
l |
s |
p |
x |
t |
a |
e |
e |
h |
l |
l |
m |
e |
s |
o |
r |
l |
s |
p |
x |
t |
a |
e |
e |
e |
h |
l |
l |
m |
s |
o |
r |
l |
s |
p |
x |
t |
a |
e |
e |
e |
h |
l |
l |
m |
s |
o |
r |
l |
s |
p |
x |
t |
a |
e |
e |
e |
h |
l |
l |
m |
o |
s |
r |
l |
s |
p |
x |
t |
a |
e |
e |
e |
h |
l |
l |
m |
o |
r |
s |
l |
s |
p |
x |
t |
a |
e |
e |
e |
h |
l |
l |
l |
m |
o |
r |
s |
s |
p |
x |
t |
a |
e |
e |
e |
h |
l |
l |
l |
m |
o |
r |
s |
s |
p |
x |
t |
a |
e |
e |
e |
h |
l |
l |
l |
m |
o |
p |
r |
s |
s |
x |
t |
a |
e |
e |
e |
h |
l |
l |
l |
m |
o |
p |
r |
s |
s |
x |
t |
a |
e |
e |
e |
h |
l |
l |
l |
m |
o |
p |
r |
s |
s |
t |
x |
图 shell排序算法示例
算法实现如下:
程序中增量h选择为1,4,13,40,121……增量之间的比值为1/3。事实上,增量序列并不能确定,无法证明某个序列可以给算法带来最好的性能。在实际中我们采用大致几何递减的增量序列,因此增量的数目与数组中元素个数成对数关系。例如,对一个有1010个元素的文件,如果每次增量大约前一次的1/2,则需要大概34个增量排序;如果此比率是1/4,则需要17个。
同样以DATASET1为例,算法过程中数组各位置上的元素的动态变化为:
从图中元素顺序的变化过程可以发现,元素是逐渐趋于有序化的,没变化一次h元素的有序性更高,这是一个由粗到细的过程。过程3,5,7,8分别对应的h大小为40,13,4,1。
shell排序的复杂度比前几节中讨论的算法复杂度稍低,但是依然是N的分数阶O(N1.5)。在下面几节中我们将讨论一些更高效的方法。
本节将介绍快速排序算法,该算法是实际应用中使用最广泛的算法。由于快速排序算法的实现比较容易,并且算法的资源消耗也相对较小,所以比较实用,是不少标准库中排序算法的实现方法。
对个数为N的数组进行排序,算法的时间消耗与NlogN成正比,但是在最坏的情况下的消耗为N2。
快速算法利用一种分治法,首先算法把输入数组A分成两部分,划分成两个子集As1和As2,划分点为a[p],此时满足A = As1∪{a[p]}∪As2,其中As1中所有数据项小于a[p],As2中所有数据大于a[p],即a[p]的位置被确定了;然后对两部分分别排序。数据的划分点取决于输入数组中元素的初始顺序。所以方法的关键点在于划分方法,上述过程使得数组满足以下3个条件:
l 对p,元素a[p]在数组中的位置即为最终位置;
l a[l], a[2], ..., a[p-1]中没有比a[p]大的元素;
l a[p+1], p[p + 1], ..., a[r]中没有比a[p]小的元素。
划分后再分别递归地对分得的两个子数组调用上述过程,这样就可以实现快速排序的递归过程。
在每次划分操作过程中,可以选择任意一个元素作为划分依据,在上面的过程中我们选择的是a[r]。我们从最左边扫描数组,直到找到一个比参照元素更大的元素,然后从右向左扫描直到找到第一个参照元素更小的元素。这两个元素的位置显然不符合上面的条件2和条件3,需要进行交换。继续以这种方式进行下去,我们就能确保参照元素左边没有比参照元素更大的,而参照元素右边没有比参照元素更小的,如下图:
图中v指的是划分元素的值,i指的是左下标,j指的是右下标。如图中,当参照元素左边的元素大于或等于参照元素值时,左扫描(向右扫描),停止;当参照元素右边的元素小于或等于参照元素是,就停止有扫描。当扫描交叉式,把a[r]与有子数组最左边元素进行交换。
以字符串"quicksortstring"为例,采用迭代的思想对其进行排序。首先以元素a[14]为参考,从下标0逐渐增大,首次发现a[0]比a[14]大,从下标13逐渐减小,首次发现a[3]比a[14]小,交换a[0]和a[3];继续从下标1逐渐增大,首次发现a[1]比a[14]大,从下标2逐渐减小,直到与下标1相遇,此时交换a[1]和a[14],可以看出,此时g位于位置1处,其左边的元素都小于g,其右边的元素都大于g,并且此时得到的划分点为1。
递归对{a[0]}和{a[2], ..., a[14]}分别调用快速排序。前者只有一个元素,无需排序;后面的子数组调用上面的过程,首先以a[14]为参照,从下标0逐渐增大,发现直到a[14]都没有发现比a[14]大的值,则不需要交换元素,并且返回的划分点为14。则对子数组{a[2], ..., a[13]}递归调用快速排序。
首先以元素a[13]为参考,从下标2逐渐增大,首次发现a[3]比a[13]大,从下标13逐渐减小,首次发现a[12]比a[13]小,交换a[3]和a[12];继续从下标4逐渐增大,首次发现a[5]比a[13]大,从下标11逐渐减小,直到与下标5相遇,此时交换a[5]和a[13],可以看出,此时n位于位置5处,其左边的元素都小于n,其右边的元素都大于n,并且此时得到的划分点为5。
然后分别对{a[2], ...,a[4]}{a[6], ..., a[13]}继续递归调用快速排序函数。
具体步骤如下表,其中l_r栏表示数组的子数组的左右边界,交换栏表示交换的两个元素所处的下标,与表格中阴影部分对应。标记为○的行,表示中间交换过程,标记为●的行为最终划分点的确定。
l_r |
交换 |
0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
○0_14 |
0↔3 |
q |
u |
i |
c |
k |
s |
o |
r |
t |
s |
t |
r |
i |
n |
g |
●0_14 |
1↔14 |
c |
u |
i |
q |
k |
s |
o |
r |
t |
s |
t |
r |
i |
n |
g |
●2_14 |
14↔14 |
c |
g |
i |
q |
k |
s |
o |
r |
t |
s |
t |
r |
i |
n |
u |
○2_13 |
3↔12 |
c |
g |
i |
q |
k |
s |
o |
r |
t |
s |
t |
r |
i |
n |
u |
●2_13 |
5↔13 |
c |
g |
i |
i |
k |
s |
o |
r |
t |
s |
t |
r |
q |
n |
u |
●2_4 |
4↔4 |
c |
g |
i |
i |
k |
n |
o |
r |
t |
s |
t |
r |
q |
s |
u |
●2_3 |
2↔3 |
c |
g |
i |
i |
k |
n |
o |
r |
t |
s |
t |
r |
q |
s |
u |
○6_13 |
8↔12 |
c |
g |
i |
i |
k |
n |
o |
r |
t |
s |
t |
r |
q |
s |
u |
○6_13 |
9↔11 |
c |
g |
i |
i |
k |
n |
o |
r |
q |
s |
t |
r |
t |
s |
u |
●6_13 |
10↔13 |
c |
g |
i |
i |
k |
n |
o |
r |
q |
r |
t |
s |
t |
s |
u |
○6_9 |
7↔8 |
c |
g |
i |
i |
k |
n |
o |
r |
q |
r |
s |
s |
t |
t |
u |
●6_9 |
8↔9 |
c |
g |
i |
i |
k |
n |
o |
q |
r |
r |
s |
s |
t |
t |
u |
●6_7 |
7↔7 |
c |
g |
i |
i |
k |
n |
o |
q |
r |
r |
s |
s |
t |
t |
u |
●11_13 |
12↔13 |
c |
g |
i |
i |
k |
n |
o |
q |
r |
r |
s |
s |
t |
t |
u |
根据上面的分析过程以及示意图,快速排序算法中的关键步骤,即划分函数的实现如下:
上面介绍的是快速排序的递归实现方式,具体实现如下:
同样以DATASET1为例,算法过程中数组各位置上的元素的动态变化为:
快速排序每次划分都至少确保一个位置上的元素的最终位置。从上图过程3开始,可以看出数组中元素呈现明显的“分簇”现象,其中分簇部分的元素就处于最终位置。从上图中我们还可以明显感知到快速排序算法是一种分支算法。在过程4以后的步骤中,算法分别对各个簇进行排序,即算法首先将整个数组分簇,然后再分别排序。
下面在观察一种特殊顺序的数组,并进一步认识快速排序的过程。DATASET2为整体降序排列的数组,如图。
从DATASET2的排序过程可以看出,每次划分都有元素的位置被最终确定,并且将元素分簇,并在之后的操作中对每个分簇做进一步的排序。
从程序实现过程可以看出,对长度为N的数组采用快速排序,在划分过程中,需要进行N+1次比较。下面讨论几种不同原始顺序的数组采用快速排序算法的复杂度。
如果原始元素严格升序排列,选择a[r]作为参照值时每次划分后所得的划分点都处于数组尾部,那么排序结束时总比较次数:
(N+1)+(N)+(N-1)+… +1= (N+2)(N+1)/2
如果原始元素严格降序排列,选择a[r]作为参照值是每次划分后所得的划分点都处于数组首部,同样需要约N2/2次比较。
快速排序最好的情况是每次划分都将所有元素分为两半,这种情况下快速排序的比较时间满足递归式:
CN = 2 CN/2 + N
其中2 CN表示对两个子数组进行排序的开销,N为划分过程中的比较次数,则可以得到递归解为:
CN = 2(2 CN/4 + N/2) + N = 22 CN/4 + 2N
= 22 (2 * CN/8 + N/4) + 2N = 23 CN/8 + 3N
= … = N/2 C2 + log2(N/2)N
其中C2=3,所以CN ≈Nlog2(N)
下面分析快速排序的平均比较次数。对于随机有序的不同元素进行排序时,所使用的比较此时可以用递归公式表示成:
CN = N + 1 + 1/N [∑1≤k≤N(Ck-1+CN-k)], N≥2
其中N+1表示比较次数;其余项表示子数组的平均开销,我们可以认为每个元素k可以称为划分元素的概率为1/N,按这个元素划分后,得到的两个随机文件大小为k-1和N-k。
对累加式∑1≤k≤N(Ck-1+CN-k),具有对称性,即C0+C1+…+CN-1 = CN-1+CN-2+…+C0。所以,我们得到
CN = N + 1 + 2/N [∑1≤k≤N(Ck-1)],
等式两端同时乘以N得到
N CN = N(N+1) + 2[∑1≤k≤N(Ck-1)],带入N-1得到:
(N - 1) CN-1 = (N - 1)N + 2[∑1≤k≤N-1(Ck-1)],
两式相减得到
N CN – (N + 1)CN-1 = 2 N,
即
CN/(N+1) = CN-1 / N + 2/(N+1) = CN-2 / (N - 1) + 2/N + 2/(N + 1)
= CN-3 / (N - 2) + 2/(N-1) + 2/N + 2/(N + 1)
= … = C2 / 3 + 2/(N+1) + 2/N + … + 2/4
≈ 2 lnN
所以CN ≈2(N+1)lnN ≈ 1.39Nlog2N.
从近似结果可以发现,平均比较次数约比最少比较次数多出39%。
对于可以使用递归实现的算法,一般也都可以借助堆栈结构用迭代的方法实现。对一个随机文件来说,栈的大小与logN成正比,但在退化的情况下,栈的大小与N成正比。
利用堆栈数据结构,首先将数组的左右边界压栈,在每次划分后,将划分后得到的两个子数组的边界分别压栈。这里首先将划分点p右边子数组的边界压栈,然后将划分点左边子数组的边界压栈。实现如下:
对于元素顺序整体趋于降序的DATASET2,堆栈大小变化如图。
对于顺序严格为降序的数组,则每次划分点依次为1,2,3,…其递归深度将逐渐加深,如图。
从上面三个例子可以发现,对不同顺序的输入文件排序将会导致不同的迭代深度,在退化的情况下,栈增长到的大小与N成正比。对于较大的输入文件,这样的开销上界可能会导致递归深度过深,甚至程序崩溃。
可以采用一种策略来减小堆栈大小的上界。在每次划分后检查两个子文件的大小,并把较大的子文件先压到栈中。这样处理时排序过程中元素处理的顺序与之前算法有所不同,但不会影响算法的时间开销。这种策略下,最坏情况下的栈的大小必须小于TN,满足递归式TN=T[N/2]+2(T1=T0=0).可以计算得栈中元素个数不会超过2log2N。
根据这一策略,代码实现如下:
对于大小为200的待排序文件,栈的最大深度不大于2log2200=15.2877,取值为16。对几个数据集进行测试,并验证这个结果。如下图,与上面的图比较可以发现,递归深度降低一半,并且都小于16。对于退化的情形,递归深度呈现周期性,范围在0~4之间。
对快速排序的改进的思路总是围绕如何使每次划分的位置位于所有数据的中间位置。有几种方法可以得到这样的效果。避免出现最坏情况的一种有效的算法是从数组中选择一个随机元素作为划分操作的参照元素。这样出现最坏情况的可能性将非常小。这是一种概率算法。不过这种方法需要设计简单又有效的随机数产生方法。
另一种找到一个更好的划分的方法是从文件中选出3个元素,然后选出3个元素的中值作为划分参考值。通过从数组的左边、中间和右边选出3个元素,对这3个元素进行排序,然后把中间元素与a[r-1]进行交换,然后随a[l+1],…,a[r-2]运行划分算法。这种改进方法称为三者取中法。
三者取中法在以下几个方面有助于快速排序的效率提高:最坏情况在实际排序中更不可能出现。对于要花N2时间的排序,对于所检查的3个元素,必须有2个是文件中最小元素或最大元素,并且在大多数划分中都必须如此。其次,三者取中方法与小文件截断法结合可以使快速排序算法的运行时间比单纯地递归实现改进20%左右。
首先三者取中法的实现如下:
本算法是三者取中法的迭代实现,并且其中设定了小文件截断。所谓小文件截断指的是在子文件大小小于设定值M时,则停止对小文件的继续迭代。这里通过语句
if(r - l <= M) continue;
来截断过深迭代。这里的M是一个参数,它的确切值取决于具体实现,可以通过尝试性试验来确定该值的大小,一般的范围为5~25之间。过大的M值会影响快速排序的优势,过小的M值会导致过深的迭代,从而也会影响算法的效率,如图。本程序中选择的M值为10。
三者去中过程由以下四行代码完成:
首先将数组中间位置上的元素交换至r-1位置,然后连续进行三次判断交换,最后满足一下条件a[l]≥a[r-1],a[l]≥a[r],a[r-1]≥a[r],最终满足关系:
a[l] ≥a[r-1] ≥a[r],
则此时a[r-1]为原数组中最左边、中间和最右边3个元素的中值。以a[r-1]作为划分参照值,调用函数partition(a, l + 1, r - 1)。
到这里位置算法还没有完成,因为子文件迭代截止的缘故,排序结果只是“整体有序”,如图。
对于整体有序的数组,只需要再做局部调整便可以完成最终排序,可以通过插入排序来完成。所以结合三者取中快速排序、子文件截取和插入排序从而形成混合排序法,其实现如下:
分别对DATASET1, DATASET2和退化的序列分别调用混合排序算法,其迭代深度分别为如图。在.6.2中我们采用了一定的策略使得递归堆栈大小控制在log2N量级,从DATASET1和DATASET2的排序结果可以看出迭代深度均值约为5,这里的混合算法的迭代深度约为4,可以发现迭代深度有一定的减小。
在前一节中我们研究了快速排序以及相关的改进方法。本节将介绍另一种基于归并(split-merge)过程的算法。归并算法运用了分治算法和自底向上方法的思想。
在快速排序法文件划分为两个子文件,其中划分点的位置被最终确定;而归并排序的过程与之相反,它是将两个分别排好序的算法合并成一个有序的文件。如果都用递归方式来描述这两个方法,它们的差异将很明显:
Partition into subfile1 and subfile2 Quicksort subfile1 Quicksort subfile2 |
Mergesort subfile1 Mergesort subfile2 Merge subfile1 and subfile2 |
归并排序有一个很大特性,其算法的时间始终与Nlog2N成正比,并且与输入的原始文件的顺序无关。另外,归并排序时稳定的排序,而快速排序和下一节将介绍的堆排序时间复杂度也与Nlog2N成正比,但是这两个算法都不是稳定的。
给定两个已经排好序的文件subfile1和subfile2,可以把他们合并成一个有序的输出文件file。扫描两个输入文件的首位元素,取出较小的元素输出到file,如图;这样不断循环直到其中至少一个文件中的元素都被取到file中为止,最后将剩余的元素一起放置到file的,如图。
以下为两个输入子文件,记为文件a和b,将这两个子文件进行归并,其结果保存到数组c中。
文件a: |
a |
c |
e |
g |
i |
k |
m |
o |
q |
s |
u |
w |
y |
文件b: |
b |
d |
f |
h |
j |
l |
m |
p |
r |
按照上面介绍的过程,对这两个文件进行归并,以下是归并过程中选择的输出元素的来源以及所在位置。可以看到,文件b先结束输出,然后将a中剩余的元素s, u, w, y直接添加到数组c中。
归并过程的代码实现如下:
代码中有一个辅助变量flag,主要用于决定向输出文件c中添加元素的方法:
l 如果flag为1,则直接通过赋值操作完成;
l 如果flag为0,则通过cpyVal函数完成,这样可以在图形界面上显示元素位置的动态变化情况。
另外,程序代码中没有对数组c的大小做判断,如果c的大小不够大,无法将输入的两个文件中的所有元素都存放进去,则程序会出错。所以在调用本函数时,用户需要保证数组c的容量足够大。cr表示的是c数组中的最右端位置。
这里函数的入参中输入文件为a和b,事实上,a和b可以为同一个数组A,al和ar表示数组A的前半部分,bl和br表示数组A的后半部分,在数组前半部分和后半部分的分别有序的情况,可以调用本函数
merge2ways(c, 0, A, al, ar, A, bl, br, flag),
从而将数组A的排序结果存放至数组c。
细心的的读者可能已经发现,程序中组要数组c来存放输出,这通常是比较大的空间开销。最好的方法是原地排序防范,在不适用大量额外空间、只通过元素之间的移位来完成排序。