天天看点

数据结构之常见排序算法汇总

最近校招又陆续开始啦!为了能在笔试中取得好成绩,从而赢取面试机会,并最终进入理想的公司,同学们又得开始好好备战啦!学计算机的同学都知道,数据结构是一门很重要的课程,笔试面试也经常考到问到,特别是那些排序算法,更是经常考。我在找实习时,也重新温习过数据结构,不过平时用的不是很多,也忘的差不多了。这次为了备战校招,同时也为了方便大家复习,我整理了所有常见的排序算法,跟大家一起重新温故一下。有不对的地方非常欢迎大家指出纠正。

注:本文参考书籍《大话数据结构-程杰》、《数据结构C语言版-吴伟民》

这里我将会较详细地总结以下几种常见的排序算法:

冒泡、选择、直接插入、希尔、堆、归并以及快速排序,对于树的相关知识我打算在下一篇再跟大家细讲。

进入主题吧,什么是排序算法、算法具有哪些特征,什么是时间复杂度、空间复杂度,这些问题我就不说啦,忘记的同学可以翻书或者百度、谷歌,我的目的不是跟大家一起学习什么是数据结构,而是跟大家一起温习数据结构,否则恐怕没那么多时间,请见谅啦!

1、冒泡排序。

冒泡排序的算法思路很简单,比较容易理解,其基本思想是:通过两两比较相邻记录的关键字,如果反序就交换,直到没有反序的记录为止。

初级版的冒泡排序算法是这样的:

void BubbleSort(Sqlist *L)//Sqlist是一个结构体,我下面会标明出来
{
	int i,j;
	for(i=1;i<L->length;i++){
		for(j=i+1;j<L->length;j++){
			if(L->r[i]>L->r[j]){//前者大于后者,即反序
				swap(L,i,j);//交换值
			}
		}
	}
}

/**顺序表结构体*/
#define MAXSIZE 10
typedef struct {
	int r[MAXSIZE+1];//加1是为了让第一个元素的值从数组下标1开始算起。也就是说把r[0]用作临时变量
	int length;
}Sqlist;

/**交换L中数组r的下标为i和j的元素*/
void swap(Sqlist *L,int i,int j){
	int temp = L->r[i];
	L->r[i] = L->r[j];
	L->r[j] = temp;
}
           

上面的就是初级版的冒泡排序,然而严格地说,它并不是标准的冒泡排序,因为它没有满足我们说的“两两比较相邻记录”的思想。举一个例子,{8,1,6,3,5,9,4,7,2},当i=1时,8与1交换后,在第一位置的值变成了1,与后面的关键字比较,很明显1最小,所以1就为最小值,第一趟比较了n-1,即8次,但只有在第一次比较时才发生了值交换,即8与1的交换,

{1,8,6,3,5,9,4,7,2};当i=2时,8与6交换,然后6与3交换,最后3与2交换,最终经过第二趟的排序后,序列为{1,2,8,6,5,9,4,7,3},3反而被放到最后去了,也就是说,这个算法除了没有满足“两两比较相邻记录”的思想外,其效率也是非常低的。

下面看看改进后的算法:

void BubbleSort(Sqlist *L){
	int i,j;
	for(i=1;i<L->length;i++){//控制比较次数
		for(j=L->length-1;j>=i;j--){//j从后往前循环
			if(L->r[j]>L->r[j+1])//前者大于后者
			{
				swap(L,j,j+1);
			}
		}
	}
}
           

这个算法较前面的有进步,大家可以自己模拟一下,还是拿上面的例子,{8,1,6,3,5,9,4,7,2},经过第一趟,序列为{1,8,2,6,3,5,9,4,7},第二趟过后,序列为{1,2,8,3,6,4,5,9,7},以此类推,你会发现数字就像是气泡一样慢慢浮到上面,所以这个算法才叫冒泡算法。

然而上面的算法还不算是最有算法,比如,有待排序列{2,1,3,4,5,6,7,8,9},很明显,除了第一和第二的关键字需要交换外,其他的都已经是有序了,此时根据我们上面的算法,每次循环还是会继续从尾到头进行比较,这些比较都是多余的,所以我们再优化这个算法,增加一个标志,来判断待排序列是否已经是有序了。

#define FALSE = 0
#define TRUE = 1
void BubbleSort(Sqlist *L){
	int i,j;
	int flag = TRUE;//c语言文明用整形来表示,也可使用bool,C99增加的
	for(i=1;i<L->length&&flag;i++){//若flag为true,则说明待排序列已经有序
		flag = FALSE;//初始化为false
		for(j=L->length-1;j>=i;j--){
			if(L->r[j]>L->r[j+1]){
				swap(L,j,j+1);
				flag = TRUE;//有数据交换则为true
			}
		}
	}
}
           

冒泡的算法复杂度分两种极端情况,最好的时候,即待排序列有序,可以推断比较次数为n-1次,时间复杂度为O(n),最坏的情况下,即待排序列为逆序,

需比较1+2+3+...+(n-1)=n(n-1)/2次,即所需时间复杂度为O(n^2)。

2、简单选择排序

简单选择排序的思想也很简单,就是第i次循环时,先取第i个关键字,假设它为最小值,比如min=i,然后依次与后面的n-i个关键字比较,若有比它更小的关键字,

则把最小值指向更小的关键字,比如min=k(k!=i),最后进行记录的交换(这是我个人的表述,来看看较官方的表述)

官方表述:通过n-i次关键字的比较,从n-i+1个记录中选出关键字最小的记录,并与第i个记录进行交换。

看代码比较容易理解:

void SelectSort(Sqlist *L){
	int i,j,min;
	for(i=1;i<L->length;i++){
		min = i;	//将当前下标定义为最小值小标
		for(j=i+1;j<=L->length;j++){
			if(L->r[min] > L->r[j]){
				min = j;//将此关键字的下标赋值给min
			}
		}
		//内循环结束后,即一趟比较结束后,判断min是否等于i,否则说明最小值有改动,需交换
		if(min != i){
			swap(L,i,min);
		}
	}
	
}
           

代码不难理解,我也就不举例子了,很明显它的最大特点就是交换移动数据次数相当少,看下简单选择排序算法的时间复杂度,无论情况好坏,其比较次数都是一样的,大家可以自己模拟下,第i趟排序需要进行n-i次关键字的比较,共需要比较 n-1 + n-2 +...+1 = n(n-1)/2,

相对于交换次数而言,最好情况下,交换次数为0,因为有序序列不需要交换了,对于最坏的情况,即是初始降序,比如{9,8,7,6,5,4,3,2,1},交换次数为n-1次,

但总的时间复杂度依然为O(n^2),虽然时间复杂度与冒泡相同,但简单选择排序性能上还是要优略于冒泡排序。

3、直接插入排序

直接插入排序是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表,我们来看下代码就理解了,

void InsertSort(Sqlist *L){
	int i,j;
	for(i=2;i<=L->length;i++){
		if(L->r[i] < L->r[i-1]){//后者比前者小,需将其插入有序子表
			L->r[0] = L->r[i];//设置哨兵
			for(j=i-1;L->r[j] > L->r[0];j--){
				L->r[j+1] = L->r[j];//记录后移
			}
			L->r[j+1] = L->r[0];//插入到正确位置
		}
	}
}
           

以待排序列{0,5,3,4,6,2}为例,其length=6,r[0]=0,r[0]起哨兵作用,即实际上我们要排序的是{5,3,4,6,2}。我们来模拟一次算法,当i=2时,r[2]=3,比r[i-1]即r[1]=5小,所以我们将r[0]=r[2]=3,此时执行第二个for

循环,j=i-1=2-1=1,r[1]=5比r[0]=3大,所以r[j+1]=r[1+1]=r[2]赋值为r[1]的值,即r[2]=5,跳出循环后执行下一条语句,L->r[j+1] = L->r[0],此时j因为减量1,所以为0,所以

r[0+1] = r[1] = r[0] = 3,经过第一趟后的序列为{3,3,5,4,6,2},r[0]我们不去关注,只是作为哨兵作用的,我们只看后面5个数。

接下来第二趟同样,i=3,即r[3]=4,肯定比r[2]=5小,所以又执行if语句的for循环,一趟过后序列为{4,3,4,5,6,2},剩下的同理,我就不一一说了。分析直接插入的时间复杂度,最好的情况下,即待排序列表本身就是有序的,所以我们比较的次数就是if语句的比较,一共n-1次,因为没有记录移动,所以时间复杂度为o(n).最坏的情况下,即逆序,需要比较2+3+...+n

=(n+2)(n-1)/2次,记录移动次数也达到最大值,我这里直接给出结果,因为要打数字符号麻烦,请谅解嘿,结果是(n-4)(n-1)/2次。

如果假设排序记录是随机的,那根据概率相同的原则,平均比较次数和移动次数约为n^2/4次。因此直接插入排序的时间复杂度也为O(n^2),不过从这里我们可以看出,同样的时间

复杂度,直接插入排序算法比冒泡和简单选择排序的性能要好,但是他们都没有突破O(n^2)的时间复杂度,下面我们来看看希尔排序,它是第一批突破O(n^2)时间复杂度的算法之一。

4、希尔排序

复习希尔排序之前,大家要弄明白什么是基本有序,所谓的基本有序,就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间,比如像{2,1,3,6,4,7,5,8,9}这样就可以称为基本有序了。

直接看代码:

void ShellSort(Sqlist *L){
	int i,j;
	int increment = L->length;//增量
	do{
		increment = increment/3+1;//增量序列
		for(i=increment+1;i<L->length;i++){
			//进行比较
			if(L->r[i]<L->r[i-increment]){
				L->r[0]=L->r[i];//暂存在L->r[0]
				for(j=i-increment;j>0&&L->r[0]<L->r[j];j-=increment){//这里有些类似于直接插入排序
					L->r[j+increment]=L->r[j];//记录后移
				}
				L->r[j+increment]=L->r[0];
			}
		}
	}while(increment > 1)//终止条件是increment不大于1,即增量为1时就停止循环
}
           

假设待排序列{0,9,1,5,8,3,7,4,6,2},第一趟时,increment计算得4,i从4+1=5开始到9结束。进入if语句,r[5]=3比r[1]=9小,所以把r[4]暂存于r[0],进入第二个for循环,j=4-1=1,

r[0]<r[1],所以r[j+increment]=r[1+4]=r[5] = r[1] = 9,然后j-=increment=1-4=-3<0,跳出循环,执行最后一句,r[-3+4]=r[0]=3,此时的序列为{3,3,1,5,8,9,7,4,6,2},r[0]此时变为3了,接下来i++,继续下面的循环,我偷懒就不再继续下去了。

希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个字序列,实现跳跃式的移动,从而使得排序效率提高。这里“增量”的选取非常关键,我们代码中的increment=increment/3+1的方式选取,并不是最好的,目前为止还没有人找到一种最好的增量序列。不过大量的研究表明,当增量序列为dlta[k] = 2^(t-k+1)-1

 (大家可以百度下,我这里粗略举一下例子,取值范围也没写出来,数字符号蛋疼,请见谅),其时间复杂度为O(n^(3/2)),要好于直接插入的O(n^2)。

 5、堆排序

 堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子的值,称为小顶堆。

 堆排序的思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造一个堆,这样就会得到n个元素中的次小值。如此反复执行,就能得到一个有序序列了。

 如果有不明白的,请翻书或者百度,我这里是复习哦。

//堆排序
 void HeapSort(Sqlist *L){
 	int i;
 	for(i=L->length/2;i>0;i--)//把L中的r构建成一个大顶堆
 	{
 		HeapAdjust(L,i,L->length);//调整为大顶堆
 	}

 	for(i=L->length;i>1;i--){
 		swap(L,1,i);//将堆顶记录和当前未经过排序的子序列的最后一个记录交换
 		HeapAdjust(L,1,i-1);//将L->r[1...i-1]重新调整为大顶堆
 	}
 }
           

整个排序过程分为两个for循环。第一个循环要完成的就是将现在的待排序序列构建成一个大顶堆。第二个循环要完成的就是逐步将每个最大值的根结点与末尾元素交换,并且再调整使其为大顶堆。

看看HeapAdjust的实现.

void HeapAdjust(Sqlist *L ,int s,int m){
	int temp,j;
	temp = L->r[s];
	for(j=2*s;j<=m;j*=2){//	沿关键字较大的孩子结点向下筛选,忘了二叉树性质的要回去看一下,不然你需要较多时间理解这段代码
		if(j<m && L->r[j]<L->r[j+1])
			++j;		//j为关键字中较大的记录下标
		if(temp>=L->r[j])
			break;
		L->r[s]=L->r[j];
		s=j;
	}
	L->r[s] = temp;//插入
}
           

举一个例子吧,r[10]={0,50,10,90,30,70,40,80,60,20},length=9,算法第一次被调用时,s=4,m=9,看下图:

数据结构之常见排序算法汇总

堆排序的时间复杂度,主要是消耗在初始构建堆和在重建堆时的反复筛选上。整个构建堆的时间复杂度为O(n),在正式排序时,第i次取堆顶记录重建堆需要用O(logi)的时间,并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)。总体来说,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度都为O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、直接插入的O(n^2)的时间复杂度了。空间复杂度值只需要一个用于交换的暂存单元。由于堆排序的比较与交换是跳跃式进行的,因此堆排序也是一种不稳定的排序方法,跟希尔排序同属于不稳定算法。

注:由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。

6、快速排序

快速排序请参见我的另外两篇博客《浅谈快速排序》、《快速排序优化分析》,这里我就不多说了。

7、归并排序

归并排序的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到n/2(向上取整)个长度为2或1的有序字序列;再两两归并,

......,这样重复下去,直到得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。

看代码吧,

void MergeSort(SqList *L){
	MSort(L->r,L->r,1,L->length);
}

void MSort(int SR[],int TR1[],int s,int t){
	int m;
	int TR2[MAXSIZE+1];
	if(s == t) 
		TR1[s] = SR[s];
	else{
		m = (s+t)/2;	//	将SR[s...t]平分为SR[s...m]和SR[m+1...t]
		MSort(SR,TR2,s,m);	//递归将SR[s...m]归并为有序的TR2[s...m]
		MSort(SR,TR2,m+1,t);//递归将SR[m+1...t]归并为有序的TR2[m+1...t]
		Merge(TR2,TR1,s,m,t);//将TR2[s...m]和TR2[m+1...t]归并到TR1[s...t]
}
}

void Merge(int SR[],int TR[],int i,int m,int n){
	int j,k,l;
	for(j=m+1,k=i;i<=m&&j<=n;k++){	//将SR中记录有小到大归并入TR

		if(SR[i]<SR[j]) 
			TR[k] = SR[i++];
			else 
			TR[k] = SR[j++];
}
	if(i<=m)
		for(l=0;l<=m-i;l++)//将剩余的SR[i...m]复制到TR
		TR[k+l] = SR[i+l];
	if(j<=n)
		for(l=0;l<=n-j;l++)//将剩余的SR[j...n]复制到TR
		TR[k+l] = SR[j+l];
}
           

时间复杂度分析:归并排序一趟需要将待排序列中的所有记录扫描一遍,需要耗费O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行log2^n(向上取整)次,因此总的时间按复杂度为O(nlogn),而且这是归并排序算法中最好、最坏和平均的时间性能。然而归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及

递归时深度为log2^n的栈空间,因此空间复杂度为O(n+logn)。归并排序属于稳定排序,因为它需要两两比较,从代码if(SR[i]<SR[j]) 就可以看出。

换句话说,归并排序是一种比较占用内存,但却高效且稳定的算法。

好吧,先暂时写到这里,说的不对的地方非常欢迎指出,大家都在学习和进步,欢迎交流!晚点再跟大家一起复习关于图的知识点。

继续阅读