【从浅到深的算法技巧】排序算法的复杂度,快速排序

5.4 排序算法的复杂度

学习归并排序的一个重要原因是它是证明计算复杂性领域的一个重要结论的基础,而计算复杂性能够帮助我们理解排序自身固有的难易程度。计算复杂性在算法设计中扮演着非常重要的角色,而这个结论正是和排序算法的设计直接相关的。

研究复杂度的第一步是建立 一个计算模型。一 般来说, 研究者会尽量寻找一个和问题相关的最简单的模型。对排序来说,我们的研究对象是基于比较的算法,它们对数组元素的操作方式是由主键的比较决定的。一个基于比较的算法在两次比较之间可能会进行任意规模的计算,但它只能通过主键之间的比较得到关于某个主键的信息。因为我们局限于实现了Comparable接口的对象。

命题1:没有任何基于比较的算法能够保证使用少于lg(N) ~ MgN次比较将长度为N的数组排序。

证明:首先,假设没有重复的主键,因为任何排序算法都必须能够处理这种情况。我们使用二叉树来表示所有的比较。树中的结点要么是一片叶子…D.表示排序完成且原输入的排列顺序是a[io], a[iJ,a[iw]…,排列顺序是aiJati],alirJ,要么是一个内部结点0,表示a[i]和a[]之间的一次时进行的其他比较。从根结点到叶子结点每一条路径都对应着算法在建立叶子结点所示的顺序时进行的所有比较。

从比较树观察得到的第一个重要结论是这棵树应该至少有N!个叶子结点,因为N个不同的主键会有N!种不同的排列。如果叶子结点少于N!I,那肯定有一些排列顺序被遗漏了。算法对于那些被遗漏的输入肯定会失败。

从根结点到叶子结点的一条路径上的内部结点的数量即是某种输入下算法进行比较的次数。我们感兴趣的是这种路径能有多长(也就是树的高度),因为这也就是算法比较次内最坏情况。三叉树的一个基本的组合学性质就是高度为h的树最多只可能有2“个叶子结点,拥有2个结点的树是完美平衡的,或称为完全树。

命题H表明归并排序在最坏情况下的比较次数为~NgN。这是其他排序算法复杂度的上限,也就是说更好的算法需要 保证使用的比较次数更少。命题I说明没有任何排序算法能够用少于-NgN次比较将数组排序,这是其他排序算法复杂度的下限。也就是说,即使是最好的算法在最坏的情况下也至少需要这么多次比较。将两者结合起来也就意味着:

命题J:归并排序是一种渐进最优的基于比较排序的算法。

证明:更准确地说,这句话的意思是,归并排序在最坏情况下的比较次数和任意基于比较的排序算法所衢的最少比较次数都是-MgN。命题H和命题I证明了这些结论。

需要强调的是,和计算模型一样, 我们需要精确地定义最优算法。例如,我们可以严格地认为仅仅只需要IgN!次比较的算法才是最优的排序算法。我们不这么做的原因是,即使对于很大的N,这种算法和(比如说)归并排序之间的差异也并不明显。或者我们也可以放宽最优的定义,使之包含任意在最坏情况下的比较次数都在MgN的某个常数因子范围之内的排序算法。我们不这么做的原因是对于很大的N,这种算法和归并排序之间的差距还是很明显的。

计算复杂度的概念可能会让人觉得很抽象,但解决可计算问题内在困难的基础性研究则不管怎么说都是非常必要的。而且,在适用的情况下,关键在于计算复杂度会影响优秀软件的开发。首先,准确的上界为软件工程师保证性能提供了空间。很多例子表明,平方级别排序的性能低于线性排序。其次,准确的下界可以为我们节省很多时间,避免因不可能的性能改进而投入资源。

但归并排序的最优性并不是结束,也不代表在实际应用中我们不会考虑其他的方法了,因为本节中的理论还是有许多局限性的,例如:

1.归并排序的空间复杂度不是最优的;

2.在实践中不一-定会遇到最坏情况;

3.除了比较,算法的其他操作(例如访问数组)也可能很重要;

4.不进行比较也能将某些数据排序。

5.5 快速排序

快速排序,它可能是应用最广泛的排序算法了。快速排序流行的原因是它实现简单、适用于各种不同的输入数据且在一般应用中比其他排序算法都要快得多。快速排序引人注目的特点包括它是原地排序(只需要一个很小的辅助栈),且将长度为N的数组排序所需的时间和MgN成正比。我们已经学习过的排序算法都无法将这两个优点结合起来。另外,快速排序的内循环比大多数排序算法都要短小,这意味着它无论是在理论上还是在实际中都要更快。它的主要缺点是非常脆弱,在实现时要非常小心才能避免低劣的性能。已经有无数例子显示许多种错误都能致使它在实际中的性能只有平方级别。幸好我们将会看到,由这些错误中学到的教训也大大改进了快速排序算法,使它的应

5.5.1 基本算法

快速排序是一种分治的排序算法。它将个数组分成两个子数组,将两部分独立地排序。快速排序和归并排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序:而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了。

一种情况中,递归调用发生在处理整个数组之前:在第二种情况中,递归调用发生在处理整个数组之后。在归并排序中,一个数组被等分为两半:在快速排序中,切分( prtition )的位置取决于数组的内容。

算法 快速排序
public class Quick{

	public static void sort(Comparable[] a){

		StdRandom. shuffle(a);  //消除对输入的依赖

		sort(a, 0, a.length - 1);

	}

	private static void sort(Comparable[ a, int lo, int hi){

		if(hi <= lo)   return;

		int j = partition(a, lo, hi); //切分(请见“快速排序的切分”)

		sort(a,lo,j-1);  //将左半部分a[lo.. j-1]排序

		sort(a j+1, hi);  //将右半部分a[j+1.. hi]排序

	}

}

快速排序递归地将子数组a[lo. .hi]排序,先用partitionO方法将a[j]放到一个合适位置,然后再用递归调用将其他位置的元素排序。

该方法的关键在于切分,这个过程使得数组满足下面三个条件:

1.对于某个j,a[j] 已经排定;

2.a[lo]到a[j-1]中的所有元素都不大于a[j];

3.a[j+1]到a[hi]中的所有元素都不小于a[j]。

我们就是通过递归地调用切分来排序的。

因为切分过程总是能排定一个元素,用归纳法不难证明递归能够正确地将数组排序:如果左子数组和右子数组都是有序的,那么由左子数组(有序且没有任何元素大于切分元素)、切分元素和右子数组(有序且没有任何元素小于切分元素)组成的结果数组也-定是有序的。上面的算法就是实现了这个思路的一个递归程序。它是一个随机化的算法,因为它在将数组排序之前会将其随机打乱。我们这么做的原因是希望能够预测(并依赖)该算法的性能特性。

要完成这个实现,需要实现切分方法。一般策略是先随意地取a[lo]作为切分元素,即那个将会被排定的元素,切分前 [然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个切分中sv小于等于它的元素。这两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针i的左侧元素都不大于切分元素,右指针j的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素a[]o]和左子数组最右侧的元素(a[j] )交换然后返回j即可。

快速排序的切分
private static int partition(Comparable[] a, int lo, int hi){ 

	//将数组切分为a[1o..i-1], a[i], a[i+1..hi]

	int i = lo, j = hi+1;  //左右扫描指针

	Comparable v = a[lo];  //切分元素

	wgile(true){ //扫描左右,检查扫描是否结束并交换元素

		while (less(a[++i],)  if (i == hi) break;

		while (less(v, a[--j]))  if (j == lo) break;

		if (i > j)  break;

		exch(a, i, j);

	}

	exch(a, lo, j);  //将v= a[j]放入正确的位置

	return j;  // a[lo..j-1] < a[j] < a[j+1..hi] 达成

}

这段代码按照a[lo]的值v进行切分。当指针i和j相遇时主循环退出。在循环中,a[i] 小于v时我们增大i,a[j]大于v时我们减小j,然后交换a[i]和a[j]来保证i左侧的元素都不大于v, j右侧的元素都不小于v。当指针相遇时交换a[lo]和a[j],切分结束(这样切分值就留在a[j]中了)。

5.5.2 原地切分

如果使用一个辅助数组,我们可以很容易实现切分,但将切分后的数组复制回去的开销也许会使我们得不偿失。一个初级Java程序员甚至可能会将空数组创建在递归的切分方法中,这会大大降低排序的速度。

5.5.2.1 别越界

如果切分元素是数组中最小或最大的那个元索,我们就要小心别让扫描指针跑出数组的边界。partition( 实现可进行明确的检测来预防这种情况。测试条件(j = lo)是冗余的,因为切分元素就是a[1o],它不可能比自己小。数组右端也有相同的情况,它们都是可以去掉的。

5.5.2.2 保持随机性

数组元素的顺序是被打乱过的。因为上面的算法对所有的子数组都一视同仁, 它的所有子数组也都是随机排序的。这对于预测算法的运行时间很重要。保持随机性的另一种方法是在partition()中随机选择一个切分元素。

5.5.2.3 终止循环

正确地检测指针是否越界需要一点技巧, 并不像看上去那么容易。一个最常见的错误是没有考虑到数组中可能包含和切分元素的值相同的其他元素。

5.5.2.4 处理切分元素值有重 复的情况

如上面的算法所示,左侧扫描最好是在遇到大于等于切分元素值的元素时停下,右侧扫描则是遇到小于等于切分元素值的元素时停下。尽管这样可能会不必要地将一些等值的元素交换, 但在某些典型应用中,它能够避免算法的运行时间变为平方级别。

5.5.2.5 终止递归

例如,实现快速排序时一个常见的错误就是不能保证将切分元素放入正确的位置,从而导致程序在切分元素正好是子数组的最大或是最小元素时陷入了无限的递归循环之中。

5.5.3 性能特点

数学上已经对快速排序进行了详尽的分析,因此我们能够精确地说明它的性能。大量经验也证明了这些分析,它们是算法调优时的重要工具。

快速排序切分方法的内循环会用一个递增的索引将数组元素和一个定值比较。这种简洁性也是快速排序的一个优点,很难想象排序算法中还能有比这更短小的内循环了。例如,归并排序和希尔排序一般都比快速排序慢,其原因就是它们还在内循环中移动数据。

快速排序另一个速度优势在于它的比较次数很少。排序效率最终还是依赖切分数组的效果,而这依赖于切分元素的值。切分将一个较 大的随机数组分成两个随机子数组,而实际上这种分制可能发生在数组的任意位置(对于元素不重复的数组而言)。下面我们来分析这个算法,看看这种方法和理想方法之间的差距。

快速排序的最好情况是每次都 正好能将数组对半分。在这种情况下快速排序所用的比较次数正好满足分治递归的C =2Cn2+N公式。2Cn2表示将两个子数组排序的成本,N表示用切分元素和所有数组元素进行比较的成本。由归并排序的命题F的证明可知,这个递归公式的解CrMgwo尽管事情并不总会这么顺利,但平均而言切分元素都能落在数组的中间。将每个切分位置的概率都考虑进去只会使递归更加复杂、更难解决,但最终结果还是类似的。我们对快速排序的信心来自于这个结论的证明。

命题K:将长度为N的无重复数组排序,快速排序平均需要~2MInN次比较(以及1/6的交换)。证明。令C,为将N个不同元素排序平均所需的比较次数。显然C=C=0,对于N>1,由递归程序可以得到以下归纳关系:

​ C=N+1+(C+C+…+Cxs+C )N+(u+Cx+CGVIN

第一项是切分的成本(总是N41),第二项是将左子数组(长度可能是0到N-1)排序的平均成本,第三项是将右子数组(长度和左子数组相同)排序的平均成本。将等式左右两边乘以N并整理各项得到

​ NCx=N(N+1)+2(G+C+…+Cu.2+Cx.)

将该等式减去N-1时的相同等式可得:

​ NCr-(N-I)Cx=2N+2Cxu

整理等式并将两边除以N(N+1)可得:

​ C/(N+1)=Cx/N+2/(N+1)

归纳法推导可得:

​ Cr-2(N+1)13+14+.+ 1/(N+1))

括号内的量是曲线2/x下从3到N的离散近似面积加一,积分得到Cr 2MnN。注意到2MnN≈1.39MgN,也就是说平均比较次数只比最好情况多39%。

在实际应用中,当数组元素可能重复时,精确的分析会相当复杂,但不难证明即使存在重复的元素,平均比较次数也不会大于CN,尽管快速排序有很多优点,它的基本实现仍有一个潜在的缺点: 在切分不平衡时这个程序可能会极为低效。例如,如果第一次从最小的元素切分,第二次从第二小的元素切分,如此这般,每次调用只会移除个元素。 这会导致一个大子数组需要切分很多次。我们要在快速排序前将数组随机排序的主要原因就是要避免这种情况。它能够使产生糟糕的切分的可能性降到极低,我们就无需为此担心了。

命题L:快速排序最多需要约N*N/2次比较,但随机打乱数组能够预防这种情况。

证明:根据刚才的证明,在每次切分后两个子数组之一总是空的情况下, 比较次数为:

​ N+(N-1)+(N- 2+…+2+1+(N+1)N/2

这不仅说明算法所需的时间是平方级别的,也显示了算法所需的空间是线性的,而这对于大数组来说是不可接受的。但是(经过一些复杂的工作)通过扩展对一般情况的分析我们可以得到比较次数的标准差约为0.6SN。因此,随着N的增大,运行时间会趋近于平均数,且不可能与平均数偏差太大。例如,对于一个有100万个元素的数组,由Chebyshev不等式可以粗略地估计出运行时间是平均所需时间的10倍的概率小于0000(且真实的概率还要小得多)。对于大数组,运行时间是平方级别的概率小到可以忽略不计。例如,快速排序所用的比较次数和插入排序或者选择排序一样多的概率比你的电脑在排序时被闪电击中的概率都要小得多!

总的来说,可以肯定的是对于大小为N的数组,上面的算法的运行时间在1.39MgN的某个常数因子的范围之内。归并排序也能做到这一点, 但是快速排序一般会更快(尽管它的比较次数多39%),因为它移动数据的次数更少。这些保证都来自于数学概率,你完全可以相信它。

5.4.4 算法改进

如果你的排序代码会被执行很多次或者会被用在大型数组上(特别是如果它会被发布成一个库函数,排序的对象数组的特性是未知的),那么下面所讨论的这些改进意见值得你参考。需要注意的是,你需要通过实验来确定改进的效果并为实现选择最佳的参数。一般来说它们能将性能提升20% ~ 30%。

5.4.4.1 切换到插入排序

和大多数递归排序算法一样, 改进快速排序性能的一个简 单办法基于以下两点:

1.对于小数组,快速排序比插人排序慢;

2.因为递归,快速排序的sort(方法在小数组中也会调用自己。

因此,在排序小数组时应该切换到插入排序。简单地改动上面的算法就可以做到这一点:将sort()中的语句

if (hi <= lo) return;

替换成下面这条语句来对小数组使用插入排序:

if (hi <= lo+ M) { Insertion.sort(a, lo, hi); return; }

转换参数M的最佳值是和系统相关的,但是5 ~ 15之间的任意值在大多数情况下都能令人满意。

5.4.4.2 三取样切分

改进快速排序性能的第二个办法是使用子数组的一小部分元素的中位数来切分数组。这样做得到的切分更好,但代价是需要计算中位数。人们发现将取样大小设为3并用大小居中的元素切分的效果最好。我们还可以将取样元素放在数组末尾作为“哨兵"来去掉partition()中的数组边界测试。

5.4.4.3 熵最优的排序

实际应用中经常会出现含有大量重复元素的数组,例如我们可能需要将大量人员资料按照生日排序,或是按照性别区分开来。在这些情况下,我们实现的快速排序的性能尚可,但还有巨大的改进空间。例如,一个元素全部重复的子数组就不需要继续排序了,但我们的算法还会继续将它切分为更小的数组。在有大量重复元素的情况下,快速排序的递归性会使元素全部重复的子数组经常出现,这就有很大的改进潜力,将当前实现的线性对数级的性能提高到线性级别。

一个简单的想法是将数组切分为三部分,分别对应小于、等于和大于切分元素的数组元素。这种切分实现起来比我们目前使用的二分法更复杂,人们为解决它想出了许多不同的办法。这也是E. w. Dijkstra的荷兰国旗问题引发的一道经典的编程练习,因为这就好像用三种可能的主键值将数组排序样,这三种主键值对应着荷兰国旗上的三种颜色。

Dijkstra 的解法如“三向切分的快速排序”中极为简洁的切分代码所示。它从左到右遍历数组次,维护个指针lt使得a[0…lt-1]中的元素都小于V,一个指针gt使得al[gt+1…hi]中的元素都大于v,一个指针i使得a[t…i-1]中的元素都等于v,a[i…gt]中的元索都还未确定。开始i和1o相等,我们使用Comparable接口(而非less() )对a[i]进行三向比较来直接处理以下情况:

1.a[i]小于V,将a[1t]和a[i]交换,将1t和i加一;

2.a[1]大于v,将a[gt]和a[i]交换,将gt减一;

3.a[i]等于V,将i加。

这些操作都会保证数组元素不变且缩小gt-i的值(这样循环才会结束)。另外,除非和切分元素相等,其他元素都会被交换。

三向切分的快速排序
public class Quick3way{

	private static int partition(Comparable[] a, int lo, int hi){ 

		//调用此方法的公有方法sort()请见上面的算法

		if (hi <= 10)  return;

		int lt =10, l =lo+1, gt = hi;

		Comparable V = a[lo];

		while (i < gt){

			int cmp = a[i].compareTo(v);

			if(cmp < 0) exch(a, lt++, i++);

			eIse if(cmp > 0)  exch(a, i, gt--);

			else i++;

		}//现在a[lo..lt-1] 

		sort(a, lo,lt-1);

		sort(a, gt + 1, hi);

	}

}

这段排序代码的切分能够将和切分元素相等的元素归位,这样它们就不会被包含在递归调用处理的子数组之中对于存在大量重发无素的数组, 这种方法比标准的快速排序的效率高得多。

例如,对于只有若干不同主键的随机数组,归并排序的时间复杂度是线性对数的,而三向切分快速排序则是线性的。从上面的可视轨迹就可以看出,主键值数量的N倍是运行时间的一个保守的上界。

这些准确的结论来自于对主键概率分布的分析。给定包含k个不同值的N个主键,对于从1到k的每个i,定义f为第i个主键值出现的次数,p为f/N,即为随机抽取一个数组元素时第i个主键值出现的概率。那么所有主键的香农信息量(对信息含量的一种标准的度量方法 )可以定义为:

H= (plgp,+ pleg+…+ plgp)

给定任意一个待排序的数组,通过统计每个主键值出现的频率就可以计算出它包含的信息量。值得一提的是, 可以通过这个信息量得出三向切分的快速排序所需要的比较次数的上下界。

命题M:不存在任何基于比较的排序算法能够保证在NH-N次比较之内将N个元素排序,其中H为由主键值出现频率定义的香农信息量。

命题N:对于大小为N的数组,三向切分的快速排序需要-(2In2)NH次比较。其中H为由主键值出现频率定义的香农信息量。

略证:将命题K中快速排序的普通情况的分析(相对困难地)通用化即可证明该结论。在所有主键都不重复的情况下,它比最优解所霄比较多39% (但仍在常数因子的范围之内)。

请注意,当所有的主键值均不重复时有H=lgN (所有主键的概率均为1/N),这和2.2节的命题1以及命题K是- -致的。 三向切分的最坏情况正是所有主键均不相同。当存在重复主键时,它的性能就会比归并排序好得多。更重要的是,这两个性质- -起说明 了三向切分是信息量最优的,即对于任意分布的输人,最优的基于比较的算法平均所需的比较次数和;向切分的快速排序平均所需的比较次数相互处于常数因子范围之内。

对于标准的快速排序,随着数组规模的增大其运行时间会趋于平均运行时间,大幅偏离的情况非常罕见,因此可以肯定三向切分的快速排序的运行时间和输人的信息量的N倍是成正比的。在实际应用中这个性质很重要,因为对于包含大量重复元素的数组,它将排序时间从线性对数级降低到了线性级别。这和元素的排列顺序没有关系,因为算法会在排序之前将其打乱以避免最坏情况。元素的概率分布决定了信息量的大小,没有基于比较的排序算法能够用少于信息量决定的比较次数完成排序。这种对重复元素的适应性使得三向切分的快速排序成为排序库函数的最佳算法选择一需要将包含大量重复元素的数组排序的用例很常见。

经过精心调优的快速排序在绝大多数计算机上的绝大多数应用中都会比其他基于比较的排序算法更快。快速排序在今天的计算机业界中的广泛应用正是因为我们讨论过的数学模型说明了它在实际应用中比其他方法的性能更好,而近几十年的大量实验和经验也证明了这个结论。

你可能感兴趣的:(从浅到深的算法技巧,算法,排序算法)