排序大集锦(一):构建健壮的快速排序

文章标题虽然冠名为“构建健壮的快速排序”,但本文将由浅入深逐步介绍三种排序方法,分别为:插入排序、归并排序以及快速排序,最后我们将仔细分析各个排序的优缺点,汲取各种排序之精华,去其中之糟粕,构建出一个基于比较的健壮的快速排序,所构建出的算法若不考虑通用接口的设计,则将远远快于C中的qsort快排库函数。由于升序和降序的原理相同,故在整个排序系列中将按照升序的方式进行分析。在进入正文之前,首先需要解释的一个疑惑是:既然是全面介绍排序的,那为何不介绍三种基本排序的其他两种?(三种基础排序分别为冒泡、选择和插入)的确一提到排序很多人首先想到的就是冒泡、选择,准确的说这两者应该被舍弃了。在解释其原因之前,先来看一张表格:

高速缓存类型 相联度 块大小 组数 高速缓存大小
在芯片上的L1 i-cache 4 32B 128 16KB
在芯片上的L1 d-cache 4 32B 128 16KB
不再芯片上的L2统一高速缓存 4 32B 1024~16384 128KB~2MB
Intel Pentinum高速缓存结构

由于本文探讨的是排序而非高速缓存,故在此不多花笔墨介绍,详细介绍参见高速缓存。上表所用示例为Intel奔腾架构中的高速缓存相关信息,虽然表中所列的相关性能参数在如今计算机高速发展的时代已略显过时,但存储器层次结构整体的设计理念仍然未变,用其说明编码方面的一些问题仍具有代表意义。可以看到L2高速缓存在128KB~2MB之间,在进行排序时,中等输入规模(大约为1000000左右)的数据量不可能全部装入这一层级的高速缓存,对于L1 d-cache也是如此,因此在排序时,若不考虑OS调度的影响,数据将在L1、L2高速缓存以及内存之间产生加载和写回操作。认识到这一点之后来简单回顾一下冒泡、选择排序:

冒泡:形式化地说,考虑一个数组a[0..n-1],第一次迭代过程中,若任意相邻的两个元素a[i]与a[i+1]有a[i]>a[i+1]则进行一次swap(a[i],a[i+1])操作,因此一趟迭代结束之后a[n-1]为整个数组中最大值,第二次迭代过程中剔除最后一个元素之后重复第一次迭代的操作,因此在a[n-2]处为整个数组中第二个最大值,直至进行了n-1次迭代操作整个排序过程结束。该算法的运行时间与n的平方渐进等同。

选择:仍考虑数组a[0..n-1],第一次迭代之前对临时存储变量min进行初始化操作min←0,在迭代过程中对于任意一个元素a[i]有a[i]

这两种算法的运行时间都与输入规模n的平方渐进等同,若不对它们进行优化,则在基于比较的排序算法中的性能表现极差。(注意不是最差,后文将看到基于分治思想的stooge排序,我们发现尽管这种思想的立足点是使问题变得更加简单更容易解决,但也能产生某种算法的极差实现)同时根据之前的描述可知这两种算法在一次迭代过程中对数组中的元素为顺序引用方式,所以它们有极好的空间局部性。但在运行过程中,由于L1 d-cache无法完全装下整个数组(1000000个4字节整数≈4MB),因此当L1装满之后行替换策略将选择cache中的某一行用于存储算法所请求的新的数组元素引用值,由顺序引用可知当L1中装满待排序数组的部分元素时也就意味着这些元素在接下来的迭代过程中将不再被引用,所以它们有极差的时间局部性。因为有着极差的时间局部性表现,因此数据将在三个存储器层级(L1、L2、内存)之间产生强烈的存储抖动现象。另一方面,从简单可理解性入手,所有排序方法中选择排序无疑是最容易掌握的,但由于在该算法基础上极难进行优化操作,而冒泡排序则相对较难上手,并且若要进行优化则需借助额外的内存空间(在内循环中维护一个计数器,当内循环执行完成后每次都判断该值是否为0,若为0则说明数组已基本有序,此时可以直接退出主循环,因此在最好情况下的运行时间为O(n))。因为三种基本排序中冒泡和选择有着这样那样的缺点,所以它们也就无可避免的首先成为了一些优秀教材中在排序统计部分受打击的对象。

言归正传,接下来首先将要介绍的是插入排序算法,我们将简单分析该算法的性能表现。

插入排序:以数组a[0..n-1]为例,在第一次迭代过程中,将元素a[1]与a[0]进行比较,若a[0]>a[1]则执行swap(a[o],a[1])操作,否则a[0]与a[1]为有序,因此执行第二次迭代操作,从a[2]开始重复上述操作。故在第i次迭代过程中,我们将元素a[i]与有序子数组a[0..i-1]从后往前进行比较,选择适当的位置执行插入操作。不难发现,即便是顺序引用,但引用的元素需要与之前的元素进行比较从而构建有序子数组,因此其时间和空间局部性均表现良好。由此:

/*InsertionSort(A,length[A])*/
for i←1 to length[A]-1
  do temp←A[i]  //follow-up element maybe move forward
     for j←i-1 to 0
       do if temp

考虑最坏情况即待排序数组为降序排序,此时内循环的判断语句将执行1+2+…+(n-1)=O(n2),但在最好情况中——即待排序数组为升序排序时内循环的判断语句每次都只执行一次,故运行时间为1+1+…+1=n-1=O(n)。由于插入排序不需要像冒泡排序一样借助额外的空间即可在基本有序的特定输入下工作的极好,又因为其原地排序(sorted in place)的特性使得该算法的运行对存储器层次结构极为友好,所以该算法在本质上有许多值得借鉴的地方。

接着要介绍的就是归并排序,与其说是介绍一种排序的方法不如说是借鉴这种分治的思想。分治策略的基本模式是:将原问题划分成n个规模较小而结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果就得到原问题的解。因此分治模式在每一层递归上都有三个步骤:

  • 分解:将原问题递归地划分为一系列规模更小但结构相似的子问题。
  • 解决:在递归的每一层次上对这些子问题进行求解。
  • 合并:将求解之后的每个子问题自底向上不断进行归并即得到原问题的解。

遵循这种思路,我们考虑将输入规模为n的待排序数组进行不断地分解直至子数组中只有一个元素时达到递归的界,此时返回并在同一递归层次上与其他元素进行归并操作,因此在排序过程中本质上可以将分解和解决这两种操作合二为一。

/*MergeSort(A,l,u)*/
if lq-p  /*refresh the 'i' to copy the element if possible*/
  then i←j
for j←tmp to r
  do A[j]←t[i]
     i←i+1
/*delete the memory for temporary array*/

可以看到,在归并排序中我们对将要排序的数组的上界及下界作简单判断,当l≥u时函数不做任何处理便返回主调函数,该判断满足这一事实:即当数组中不存在或只存在一个元素时数组有序,因此可将其作为递归调用的界。当l

那么是否所有基于分治思想所给出的实现都能表现良好呢?要回答这个问题,首先来看一个名为stooge的排序算法。

/*StoogeSort(A,i,j)*/
if A[i]>A[j]
  then swap(A[i],A[j])
if i+1>=j
  then return
k←(j-i+1)/3
StoogeSort(A,i,j-k)
StoogeSort(A,i+k,j)
StoogeSort(A,i,j-k)

为了理解这段代码,我们根据实例对其进行研究。(假定待排序数组A[0..2]={2,3,1}),该算法图解如下:


由上图可知基于“分治思想”的stooge排序执行了多次比较操作。由于其采用递归操作,因此存在极大的函数调用开销,并且需要借助额外的栈空间保留每次递归之前主调函数的信息,当输入规模增长到一定量级,那么对需要进行比较的元素的引用将造成频繁的“存储抖动”,所以最终的结果是:基于“分治思想”的stooge排序是一个既有极差的运行时间表现,也有极差的存储空间表现,还对存储器层次结构极不友好的不折不扣的“烂”算法。在接着分析快速排序算法之前,我们还是计算一下该算法的运行时间到底如何:由上述算法描述可知,在i+1ba)=O(n3/23)≈O(n2.7),因此其运行时间渐进接近于n的立方!即便是任意两个元素均两两比较一次的算法的运行时间也才不过1+2+…+(n-1)=n×(n-1)/2=O(n2),换句话说,stooge算法中任意两个元素甚至两两比较多次,这对于基于比较的算法来说无疑是一场噩梦。

快速排序本质上基于分治思想:分解、解决以及合并。在正式描述该算法之前,首先考虑基于比较的算法如何才能做到快速?答案自然是不言而喻的,既然是基于比较的,那么比较的次数越少算法运行的越快。那么如何才能使比较的次数更少呢?我们发现如果存在一个值,可以确定数组的前半部分小于该值,而数组的后半部分大于或等于该值,那么数组中这两个部分的任意元素之间的比较都是多余的,因此可以省略的比较次数在最好情况下大致可以达到(n/2)×(n/2)=n2/4,这是一种巨大的节省,之后我们只需要对剩下的两个子数组分别递归即可,这种对原数组的划分让我们对问题的最终解决方案更近了一步,接下来所要考虑的是如何确定划分的主元(即根据何值划分),有两种方案:①采用数组内的某个元素;②采用数组外的某个元素。对于第②种方案,只有当我们得到关于原数组具体信息的情况下才有可能得以实施,比如原数组的中位数、均值等等。因此我们考虑第一种方案,此时可以简单的将数组两端的某个元素作为划分的主元,也可以采用随机抽取0..length[A]-1之间的某个值作为划分的主元,问题是哪种选择更好?

假设存在某种最坏情况——对一个已经有序的数组A[0..n-1]执行排序操作,若只是简单的将数组两端的其中一个元素作为划分的主元,那么原数组最终将被划分为A[0]与A[1..n-1]两个子数组,接着再次对子数组A[1..n-1]重复之前的划分操作,如此下去,最终的比较次数将达到1+2+…+(n-1)=O(n2)。而如果采用随机选择主元的方法,我们可以通过下述分析得到所期望的运行时间:

可以看到在上述分析中,快速排序所期望的运行时间为O(n㏒n),利用决策树模型可知基于比较的排序算法的运行时间为Ω(n㏒n),因此该算法的期望运行时间渐进最优

然而如果待排序数组中的所有元素全部相同时,随机选择主元的方式也无法得到所期望的运行时间。本质上我认为这是属于操作层面考虑的细节,考虑单向划分的情形,从左至右进行迭代从而我们有:

/*RandomSingDirePart(A, l, u)*/
temp←random(l,u)  //select random index between l & u
pivot←A[temp]
sepa←l
for iter←l to u
  do if A[iter]

图示如下:


既然有单向划分,那么相应地,自然也可以进行双向划分。通过简单思考我们得出:

/*RandomDoubDirePart(A, l, u)*/
temp←random(l,u)  //select random index between l & u
pivot←A[temp]  //set the pivot of partition operation
left←l-1
right←u+1
while TRUE
  do repeat left←left+1  //one iteration just compare n counts
       until A[left]>=pivot
     repeat right←right-1
       until A[right]<=pivot
     if right>left
       then swap(A[left], A[right])
       else return right

上述代码图解如下:


注意,双向划分情况中主循环的内循环其测试条件必须为≤和≥,不能使用<或>,原因是有可能导致数组的越界引用,而如果将等于归入测试条件内部,由于划分的主元是从数组中的元素随机选出的,因此最后left与right指针必然停在数组的某个位置处,从而我们说这种测试条件保证了对数组的引用是安全的。另外需要注意的一点是left与right指针所指向的各自区域中的元素都包含等于划分主元的值,因此当满足until测试条件时必然退出内循环,此时尽管有可能A[left]和A[right]这两个元素的值相同,但right>left条件成立仍将发生交换操作。接下来是我们的快排主函数:

/*RandomDoubDireSort(A, l, u)*/
if l

如果深入地观察,可以发现在主函数中,该函数连续两次递归调用自身,利用这种特性我们可以使用尾递归对其进行优化,因此有:

/*RandomDoubDireSort-variant(A, l, u)*/
while l

采用尾递归的优点是:使得本来将在下一层调用的划分函数在本层进行调用从而免去了函数调用开销,进而节省了运行时间。但很明显,其缺点是破坏了递归本身的调用语义。

最后在主函数中,我们可以看到无论子数组的规模如何,主函数都将调用其划分子过程将该数组一分为二。然而根据前文的分析,对于基本有序的子数组来说,调用插入排序可以得到接近于O(n)的运行时间,那么是否可以考虑在待处理的子数组规模较小时,直接退出快速排序并转而调用插入排序?遵循上述思路得到如下测试:

#include 
#include 
#include 
#include 
#include 
#include 
using std::cout;
using std::ios;
using std::string;
using std::endl;

typedef int Type;
typedef class CQSort{
private:	
#define LOW 0
#define HIGH 100000000
#define mintomillsec	(60*1000)
#define sectomillsec	1000
	typedef enum {Std_qSort, My_qSort} FuncName;
	static const size_t m_NumOfElem = 1000000;
	Type *m_Arr;
	Type *m_Temp;
	size_t TimeArray[100];

private:
	void ShowErr(const string str);
	inline void Swap(Type &a, Type &b)
	{Type m_Temp=a; a=b; b=m_Temp;}
	inline int RandElem(int m_Dist, int low);
	inline int GeneRand(int low,int high);
	void GeneRandIntoArr(int low, int high);
	size_t RandomDoubDirePart(Type Array[], size_t low,size_t high);
	void RandomDoubDireSort(Type Array[], size_t m_Apart,
										size_t low,size_t high);
	void InsertionSort(Type Array[], size_t m_Length);
	void CallWhichFun(FuncName fn);

public:
	CQSort();
	~CQSort();
	void Disp();
	void MyqSort();
	void STDqSort();
	size_t GetFuncRunTime(FuncName fn);
	void RecoverArr()
	{std::memcpy(m_Arr, m_Temp, m_NumOfElem*sizeof(Type) ); }
	void WriteMatFile(const string str, size_t fn);  /*provide file name*/
	void RunFuncAndGetTime();
} *pCQSort;

int _cdecl compare(const void *a, const void *b)
{
	return (*(int *)a - *(int *)b );
}

int main(int argc, char *argv[])
{
	CQSort c_qs;
	c_qs.RunFuncAndGetTime();
	system("pause");
}

CQSort::CQSort()
{
	m_Arr=new Type[m_NumOfElem];
	m_Temp= new Type[m_NumOfElem];
	std::memset(TimeArray,0,100*sizeof(Type) );
	std::memset(m_Arr,0,m_NumOfElem*sizeof(Type) );
	GeneRandIntoArr(LOW,HIGH);
	std::memcpy(m_Temp,m_Arr,m_NumOfElem*sizeof(Type) );
}

CQSort::~CQSort()
{
	if(m_Arr != NULL) delete [] m_Arr;
	if(m_Temp != NULL) delete [] m_Temp;
}

void CQSort::CallWhichFun(FuncName fn)
{
	switch(fn)
	{
	case Std_qSort:
		STDqSort();
		break;
	case My_qSort:
		MyqSort();
		break;
	default:
		ShowErr("Bad Function!");
	}
}

void CQSort::RunFuncAndGetTime()
{
	for(size_t f=0; f != 2; ++f)
	{
		string str;
		if(f == 0)
			str="STDqSort";
		else
			str="MyqSort";
		cout<<"Which qSort is used now:\t"<(f) );
			cout<high)
		ShowErr("the lower random must less than higher random");
	int m_Dist=high-low;
	srand(static_cast( time(NULL) ));
	for(size_t i=0; i != m_NumOfElem; ++i)
		m_Arr[i] = RandElem(m_Dist,low);
}

inline int CQSort::RandElem(int m_Dist, int low)
{
	return static_cast( rand()%m_Dist+low );
}

inline int CQSort::GeneRand(int low, int high)
{
	int m_Dist=high-low;
	return RandElem(m_Dist, low);
}

void CQSort::RandomDoubDireSort(Type Array[], size_t m_Apart,
																size_t low, size_t high)
{
	while(high-low >= m_Apart)
	{
		size_t m_Sepa=RandomDoubDirePart(m_Arr,low, high);
		RandomDoubDireSort(Array, m_Apart,low, m_Sepa);
		low=m_Sepa+1;
	}
}

size_t CQSort::RandomDoubDirePart(Type Array[],size_t low,size_t high)
{
	size_t m_Temp=GeneRand(low,high);  //select random index between low&high
	Type pivot= Array[m_Temp];
	int left=low-1 , right=high+1;
	while(TRUE)
	{
		do { left++; }
		while(Array[left] < pivot);
		do { right--; }
		while(Array[right] > pivot);
		if(right > left)
			Swap(Array[left], Array[right]);
		else
			return right;
	}
}

void CQSort::ShowErr(string str)
{
	cout<

执行所得文件及其图示分别为:

%clear the var in current working space;
clear;
clc;
ord_x=[...
1 2 3 4 5 6 7 8 9 10 ...
11 12 13 14 15 16 17 18 19 20 ...
21 22 23 24 25 26 27 28 29 30 ...
31 32 33 34 35 36 37 38 39 40 ...
41 42 43 44 45 46 47 48 49 50 ...
51 52 53 54 55 56 57 58 59 60 ...
61 62 63 64 65 66 67 68 69 70 ...
71 72 73 74 75 76 77 78 79 80 ...
81 82 83 84 85 86 87 88 89 90 ...
91 92 93 94 95 96 97 98 99 100 ...
];

STDqSort=[ ...
922 890 891 891 906 890 891 890 938 922 ...
890 890 875 937 922 906 907 891 890 907 ...
922 921 907 906 906 938 906 922 906 937 ...
907 938 922 907 922 984 938 921 891 891 ...
906 891 875 891 875 891 891 890 891 890 ...
891 875 891 891 890 922 922 906 922 907 ...
969 937 906 891 890 891 890 891 890 907 ...
875 875 891 891 890 891 890 938 906 891 ...
891 891 891 891 890 906 907 906 906 922 ...
891 906 890 890 907 906 906 906 922 906 ...
];

MyqSort=[...
641 516 484 438 406 406 407 390 391 375 ...
375 375 359 359 360 343 344 360 359 344 ...
359 344 344 343 344 344 344 328 329 328 ...
328 328 328 344 328 329 313 328 329 328 ...
328 313 328 328 329 313 328 329 328 328 ...
313 328 344 328 328 328 328 312 328 344 ...
328 328 328 344 343 328 328 329 343 344 ...
328 328 344 343 344 328 328 328 344 328 ...
344 343 344 344 344 344 344 344 344 344 ...
344 344 344 344 344 328 344 344 344 359 ...
];

title( ' performance of two sort algorithms ' );
xlabel( ' x=[1:100] ' );
ylabel( ' the time of sort using(millisecond) ' );
hold on;
plot(ord_x,STDqSort,'y*');
plot(ord_x,MyqSort,'r*');
legend('Std_qsort','My_qsort');

由上图我们发现,当子数组的规模大于5时,退出快排函数并调用插入排序,其性能提升逐渐趋缓,而当待排序子数组的规模从28开始时,My_qSort过程的性能趋于最优,当然选哪个值都无所谓,如果不介意的话就选我的学号49好了。因此最终的快排函数实现如下:

/*QuickSort(A)*/
RandomDoubDireSort(A, 0, length[A]-1)
InsertionSort(A,length[A])
//-------------------------------------------------------------------
/*RandomDoubDireSort(A, l, u)*/
while u-l>=49
  do q←RandomDoubDirePart(A, l, u)
     RandomDoubDireSort(A, l, q)
     l←q+1
//-------------------------------------------------------------------
/*RandomDoubDirePart(A, l, u)*/
temp←random(l,u)  //select random index between l & u
pivot←A[temp]  //be sure the pivot of partition operation
left←l-1
right←u+1
while TRUE
  do repeat left←left+1  //once iteration just compare n counts
       until A[left]>=pivot
     repeat right←right-1
       until A[right]<=pivot
     if right>left
       then swap(A[left], A[right])
       else return right
//--------------------------------------------------------------------
/*InsertionSort(A,length[A])*/
for i←1 to length[A]-1
  do temp←A[i]  //follow-up element maybe move forward
     for j←i-1 to 0
       do if temp

你可能感兴趣的:(算法)