数据结构复习(1):内部排序--各类排序算法(C程序)

这篇博客回顾了七种内部排序算法:直接插入排序、希尔排序、简单选择排序、堆排序、冒泡排序、快速排序和归并排序。作者通过C语言代码展示了每种排序算法的工作原理,并分析了它们的时间复杂度和空间复杂度,揭示了它们在不同场景下的优劣。
摘要由CSDN通过智能技术生成

国庆七天乐,一下子就过去了两天,第一天把电脑重装了下系统,看了好多集美剧,第二天开始把九月份参加的一些笔试题整理了一下,数据结构这块感觉还是要再复习复习,打算分几个章节把数据结构和一些基本的算法好好整理整理,充实一下,迎接十月份的各种招聘~

之前看的《大话数据结构》对各种排序算法有个非常好的分类图:

其中冒泡排序、直接插入排序、以及简单选择排序的时复杂度都不能突破O(n^2);希尔排序的发明,使得我们终于突破了慢速排序的时代(超越O(n^2)),之后,更为高效的堆排序以及标准化的快速排序等也相继出现。

进一步将这7种算法的各种指标进行对比,如下表所示:

其中需要用到的顺序表结构和swap()交换函数:

//element_operator.h
#ifndef _ELEMENT_OPERATOR_H
#define _ELEMENT_OPERATOR_H
#define MAXSIZE 10 /*用于要排序数组个数的最大值,可根据需要改变*/

typedef struct MyStruct
{
	int r[MAXSIZE+1]; /*用于存储要排序数组,r[0]用作哨兵或临时变量*/
	int length; /*用于记录顺序表的长度*/
}sqList;

void swap(sqList * L,int i,int j);
#endif


//element_operator.cpp
#include"element_operator.h"
void swap(sqList *L,int i,int j){

int temp=L->r[i];
L->r[i]=L->r[j];
L->r[j]=temp;
return;
}


先来看看插入排序类:

1、直接插入排序(Straight Insertion Sort) :这有点类似于玩扑克牌的时候,我们按照点数,从小到大顺序理牌。具体的C代码如下:
//Straight_Insertion_Sort.h
#ifndef _STRAIGHT_INSERTION_SORT_H
#define _STRAIGHT_INSERTION_SORT_H
#include"element_operator.h"
void InsertSort(sqList *L);
#endif


//Straight_Insertion_Sort.cpp
#include"Straight_Insertion_Sort.h"
void InsertSort(sqList *L){
//直接插入排序
	int i,j;
	for(i=1;i<L->length;i++){
		
		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];
	}	  

}

采用如下测试函数:


//测试函数
void main(){
	int i;
	sqList L={{0,5,8,3,4,6,2},7};
	InsertSort(&L);
	for(i=1;i<L.length;i++)
		printf("%d\t",L.r[i]);
	getchar();
}


运行结果如下:

算法的复杂度分析:

从空间上来看,需要一个记录的负责空间(设置哨兵);从时间上看,最好的情况是要排序的表本身就有序,因此我们在每个记录处执行了一次比较,无移动的记录(哨兵与记录固有交换),因此时间复杂度为O(n);最坏的情况是待排序的表为逆序表,比较的次数就为O(n^2);

2、希尔排序(shell sort):

对于直接插入排序来说:它的效率在某些时候是很高的,比如记录本事是基本有序的,我们只需要少量的插入操作,就可以完成整个记录集的排序工作;还有就是记录数比较少时,直接插入的优势也比较明显。

但实际的问题是,现实中记录少或者基本有序都属于特殊情况。希尔排序就是针对这种限制的改进版的直接插入排序:将原本有大量记录数的记录集进行分组。分割成若干子序列,然后先在这些子序列内分别进行直接插入排序,当整个序列都基本有序时,再对全体记录进行一次直接插入排序。

那么如何分组以达到基本有序呢?这里采用跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序的而不是局部有序!!!

具体的C程序代码如下:

//Shell_Sort.h
#ifndef _SHELL_SORT_H
#define _SHELL_SORT_H
#include"element_operator.h"
void ShellSort(sqList *L);
#endif

 

//ShellSort.cpp
#include"Shell_Sort.h"

void ShellSort(sqList *L){
//分成子序列,子序列内基本有序后,对全体记录做直接插入排序
	int i,j,flag;//flag是分组标记
	flag=L->length;
do{
	flag=flag/3+1;
	for(i=flag;i<L->length;i++){
		
		L->r[0]=L->r[i]; //设置哨兵
		for(j=i-flag;L->r[j]>L->r[0];j-=flag)
				L->r[j+flag]=L->r[j];
		L->r[j+flag]=L->r[0];
	}	  
	
}while(flag>1); //注意此次采用do...while结构可以避免陷入死循环
}


采用如下的测试函数:

#include<stdlib.h>
#include<stdio.h>
//测试函数
void main(){
	int i;
	sqList L={{0,9,1,5,8,3,7,4,6,2},10};
	ShellSort(&L);
	for(i=1;i<L.length;i++)
		printf("%d\t",L.r[i]);
	getchar();
}


运行得到如下结果:

需要注意的是采用while循环结构会因为分组间隔减到1后陷入死循环!

希尔排序复杂度分析:希尔排序成功的把时间复杂度推进到O(n^(3/2)),需要注意的是究竟采用什么样的增量才是最好,目前还是个数学难题,只是增量序列是的最后一个增量值必须等于1才行!

再来看看选择排序类:

3、简单选择排序(Simple Selection Sort):

基本思想:每一趟在n-i+1(i=1,2,...,n)个记录中选取关键字最小的记录作为有序序列的第i个记录

详细的C代码也是比较简单的:

//Simple_Selection_Sort.h
#ifndef _SIMPLE_SELECTION_SORT_H
#define _SIMPLE_SELECTION_SORT_H
#include"element_operator.h"
void SelectSort(sqList *L);
#endif


 

//Simple_Selection_Sort.cpp
#include"Simple_Selection_Sort.h"
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;
//if(i!=min) 书上补充的一行
swap(L,i,min);
}

}


采用如下的测试函数:

#include<stdlib.h>
#include<stdio.h>
//测试函数
void main(){
	int i;
	sqList L={{0,9,1,5,8,3,7,4,6,2},10};
	SelectSort(&L);
	for(i=1;i<L.length;i++)
		printf("%d\t",L.r[i]);
	getchar();
}


 

运行的结果得:

简单选择排序算法的复杂度分析:简单选择排序的最大特点就是,交换移动数据次数相当少,节约了相应的时间。但分析发现,无论最好最差情况,其比较次数都是一样多的O(n^2)。其交换次数,最好的时候是0次,最差的时候,也就是初始逆序的时候,交换次数为n-1,但总的时间复杂度仍为O(n^2) !

 

4、堆排序

相比较而言,简单选择排序并没有把每一趟的比较结果保存下来,在后一趟的比较中,有许多比较在前一趟中已经做过了,但由于前一趟排序时伟保存这些比较结果,所以后一趟排序时又重复执行了这些比较操作,因而记录的比较次数过多!如果可以砸做到每次在选择最小记录的同时,根据比较结果对其他记录做出相应的调整,那么排序的整体效率就会大大提高。实际上,堆排序(Heap Sort)就是对简单选择排序的一种改进

堆排序的精髓是要用到一种所谓的”堆“结构,如下图所示的大顶堆和小顶堆:

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

注意我们需要知道关于完全二叉树的一个性质:

其中,将上图中的大顶堆或小顶堆按层序存入数组,则也一定满足上面的性质5。

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

明白了堆排序的基本思想之后,实现它,我们需要解决如下的两个问题:

1)如何由一个无序序列构建成一个堆?

2)如何在输出堆顶元素之后,调整剩余元素成为一个新的堆?

针对上述两个问题,设计一个将无序序列调整成为大顶堆的函数:

void HeapAdjust(sqList *L,int low,int len);


调用上述函数先将给定的无序序列调整成为一个大顶堆;然后再循环将根结点和最后一个元素交换并继续调整成大顶堆,即可完成堆排序过程!

//Heap_Sort.h
#ifndef HEAP_SORT_H
#define HEAP_SORT_H
#include"element_operator.h"
void HeapAdjust(sqList *L,int low,int len);
void HeapSort(sqList *L);
#endif


 

//Heap_Sort.cpp
#include"Heap_Sort.h"
void HeapSort(sqList *L){
int i;

HeapAdjust(L,1,L->length-1);

	for(i=L->length-1;i>1;i--){
		swap(L,1,i);
	HeapAdjust(L,1,i-1);
	}
}

 

这里我们不考虑交换大顶堆的根和最后一个元素后,其他结点之间序不变的特点,设计如下的HeapAdjust()函数:

void HeapAdjust(sqList *L,int low,int len){

int max,j;
// if low>len/2,则直接返回~
	for(j=len/2;j>=low;j--){
	  max=j;
	  if(2*j<=len && L->r[2*j]>L->r[max]) max=2*j;
	  if(2*j+1<=len && L->r[2*j+1]>L->r[max]) max=2*j+1;
          if(max!=j) swap(L,max,j);
         }
}


采用如下的测试函数验证:

#include<stdlib.h>
#include<stdio.h>
//测试函数
void main(){
	int i;
	sqList L={{0,90,10,50,80,30,70,40,60,20},10};
	HeapSort(&L);
	for(i=1;i<L.length;i++)
		printf("%d\t",L.r[i]);
	getchar();
}


 

得到如下的运行结果:

而事实上,如果我们进一步深究,可以对HeapAdjust函数进一步的改造,提高效率,观察如下三幅图的调整过程:

因此,针对上述动态调整大顶堆的过程,可以观察到,将原大顶堆的根和最后一个元素交换之后,原大顶堆的除根结点之外的其他2度结点仍满足大顶堆的要求,进一步改进HeapAdjust()函数:

void HeapAdjust(sqList *L,int low,int len){
	int temp,j;
	temp=low;
	for(j=2*low;j<=len;j*=2){
	if(j<len &&L->r[j]<L->r[j+1])
		++j;
	if(L->r[temp]>=L->r[j])
		break;
	swap(L,temp,j);
		temp=j;
	}
}

相应的也要对HeapSort()函数进行相应的调整:

void HeapSort(sqList *L){
int i;
for(i=(L->length-1)/2;i>0;i--) //修改部分,采用一个循环过程调整成为一个大顶堆
	HeapAdjust(L,i,L->length-1);

for(i=L->length-1;i>1;i--){
	swap(L,1,i);
	HeapAdjust(L,1,i-1);
}
}

 运行同样的测试函数,得到相同的结果:

堆排序复杂度分析:堆排序的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。
建堆过程调用不超过n/2次HeapAdjust过程时,总共进行的关键字比较次数不超过4n次(详见《数据结构》P282页推导);在正式排序时,总共调整重建堆n-1次,又因为堆的深度不超过logn+1次,则重建堆的时间复杂度为O(nlogn)。总体来讲,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此,它无论是最好,最坏和平均时间复杂度均为O(nlogn)。这在性能上显然远远好于冒泡、简单选择、直接插入的O(n^2)的时间复杂度。另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况!

交换排序类:

5、冒泡排序:无论你学习哪种编程语言,在学到循环和数组时,通常都会介绍一种非常简单的算法来作为例子,就是大家所熟知的冒泡排序!

冒泡排序(Bubble Sort)的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。

一种比较粗犷的C算法程序如下:

void SimpleBubbleSort(sqList *L){
int i,j;
for(i=L->length-1;i>0;i--)
	for(j=L->length-1;j>L->length-i;j--)
		if(L->r[j]<L->r[j-1]) swap(L,j,j-1);
}

采用同样的测试函数:

#include<stdlib.h>
#include<stdio.h>
//测试函数
void main(){
	int i;
	sqList L={{0,9,1,5,8,3,7,4,6,2},10};
	SimpleBubbleSort(&L);
	for(i=1;i<L.length;i++)
		printf("%d\t",L.r[i]);
	getchar();
}

运行得到如下结果:

除了上述对冒泡排序的优化使得较小的数字如同气泡般慢慢上浮,还可以进行进一步的优化,对于基本有序的序列,可以通过缩减无谓的比较过程使得效率进一步的提高:

void BubbleSort(sqList *L){
int i,j,flag;
for(i=1;i<L->length;i++){  //共L->length-1次循环,通过flag来缩减
	flag=0;
	for(j=L->length-1;j>i;j--)
		if(L->r[j]<L->r[j-1]) {
			flag=1; //发生实际的交换过程就置flag为1!
			 swap(L,j,j-1);
		}
if(flag==0) break; //flag若为0说明前一趟“冒泡”之后,再无实质交换过程
}
}


运行可以得到相同的结果。

冒泡排序的复杂度分析: 当最好的情况,也就是要排序的表本身就是有序的,那么我们只需经过n-1次比较,没有数据的交换,时间复杂度为O(n)。当最坏的情况,即待排序表示逆序时,此时要比较(n-1)+(n-2)+...+2+1共O(n^2)量级的比较,并作等数量级的记录移动,因此时间复杂度为O(n^2)。

6、快速排序

高手终于压轴登场了(最后上归并排序,呵呵),事实上,不论是C++ STL,java SDK或者 .NET等程序库中的源代码中都能找到它的某种实现版本!

快速排序实际上就是我们认为最慢的冒泡排序升级版,即它也是通过不断比较和移动交换来实现排序的,只不过它的实现, 增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,较小的记录从后面直接移动到前面,从而减少了总的比较次数和移动交换次数!

快速排序(quick sort)的基本思想是:通过一趟排序将待排记录分割成独立的两个部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

//Quick_Sort.cpp
#include"Quick_Sort.h"

/*对顺序表L做快速排序*/
void QuickSort(sqList *L){
	QSort(L,1,L->length-1);
}


其中,由于需要递归调用,因此我们封装了一个函数Qsort,其实现如下:

/*对顺序表L中的子序列L->r[low..high]做快速排序*/
void QSort(sqList *L,int low,int high){
int pivot; //定义枢轴
if(low<high){
	pivot=Partition(L,low,high); //将L->r[low..high]一分为二,算出枢轴值pivot
	QSort(L,low,pivot-1); //对低子表递归排序
	QSort(L,pivot+1,high);//对高子表递归排序
}
}


上面这段代码的核心是:

pivot=Partition(L,low,high); //将L->r[low..high]一分为二,算出枢轴值pivot

调用的Partition()函数要做的就是先选取当中的一个关键字,然后想尽办法将它放到一个位置,使得它左边的值都比它小,右边的值比它大,这其中的关键字叫做枢轴(pivot).
下面就是快速排序算法最关键的Partition函数的实现:

 

/*交换顺序表L中子表的记录,使枢轴记录到位,并返回其所在位置*/
int Parition(sqList *L,int low,int high)
{
int pivotkey;
pivotkey=L->r[low]; //用子表的第一个记录作枢轴记录
while(low<high){    //从表的两端交替向中间扫描
	while(low<high && L->r[high]>=pivotkey)
		   --high;
	swap(L,low,high);  //将比枢轴记录小的记录交换到低端
	while(low<high && L->r[low]<=pivotkey)
		 ++low;
	swap(L,low,high); //将比枢轴记录大的记录交换到高端
}

return low;  //返回枢轴所在的位置
}

采用如下测试函数:

#include<stdlib.h>
#include<stdio.h>
//测试函数
void main(){
	int i;
	sqList L={{0,90,10,50,80,30,70,40,60,20},10};
	QuickSort(&L);
	for(i=1;i<L.length;i++)
		printf("%d\t",L.r[i]);
	getchar();
}


得到如下运行结果:

快速排序算法复杂度分析:快速排序算法的时间性能取决于快速排序递归的深度。在最优的情况下,Partition每次划分的很均匀,如果排序n个关键字,其递归树的深度不超过logn+1,需要时间为T(n)的话,容易得到如下不等式:

T(n)<=2T(n/2)+n,T(1)=0

容易求出在最优的情况下,快速排序算法的时间复杂度为O(nlogn)! 那在最坏的情况下呢?待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,(如果递归树画出来就是一颗斜树),需要经过n-1次递归,时间复杂度易得为O(n^2);平均情况下,由数学归纳法经过特殊证明可得为O(nlogn)

就空间复杂度来说,主要是递归造成的栈空间的使用,最好的情况,递归树的深度为logn,其空间复杂度也就为O(logn),最坏情况为O(n^2),平均情况下为O(logn)。

最后来看看一个特殊的归并排序类:

7、归并排序(Merging Sort)

归并排序的基本思想就是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到n/2+1个长度为2或者1的有序子序列;再两两归并,...,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。

实现该算法的相关头文件如下:

//Merging_Sort.h
#ifndef MERGING_SORT_H
#define MERGING_SORT_H
#include"element_operator.h"
void Merge(int SR[],int TR[],int i,int m,int n);
void MSort(int SR[],int TR1[],int s,int t);
void MergeSort(sqList *L);

#endif

其中的归并排序算法包装如下:

/*对顺序表L做归并排序*/
void MergeSort(sqList *L){
	MSort(L->r,L->r,1,L->length-1);
}


其中调用的MSort()执行真正的归并排序工作:

/*将SR[s..t]归并排序为TR1[s..t]*/
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..m]
}

}

最后看看Merge代码是如何工作的:

/*将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n]*/
void Merge(int SR[],int TR[],int i,int m,int n){
int j,k,t;
	for(j=t=i,k=m+1;j<=m && k<=n;t++){
		if(SR[j]<=SR[k])	TR[t]=SR[j++];
		else        TR[t]=SR[k++];
	}
	if(k<=n){            //将剩余的SR[k..n]复制到TR
		while(t<=n)
			TR[t++]=SR[k++];
	}
	if(j<=m){           //将剩余的SR[j..m]复制到TR
		while(t<=n)
			TR[t++]=SR[j++];
	}
}


采用如下测试函数:

#include<stdlib.h>
#include<stdio.h>
//测试函数
void main(){
 int i;
 sqList L={{0,90,10,50,80,30,70,40,60,20},10};
 MergeSort(&L);
 for(i=1;i<L.length;i++)
  printf("%d\t",L.r[i]);
 getchar();
}


得到运行结果截图:

归并排序的复杂度分析:一趟归并排序的操作是,调用n/2h+1次算法Merge()将SR[1..n]中前后相邻且长度为h的有序段进行两两归并,得到前后相邻、长度为2h的有序段,并放在TR[1..n]中,整个归并排序需要进行不超过logn+1趟,可见,实现归并排序需和带排记录等数量的辅助空间,即空间复杂度为O(n),时间复杂度为O(nlogn)。归并算法是一种稳定高效但占用内存也比较高的算法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值