排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)

        前言:好的排序方法可以提高程序运行的效率,了解常用的排序算法,对提升编程思想、程序优化都有帮助。


一、背景知识

        稳定性:假设ki=kj (1≤in,1jn,i≠j),且在排序前的序列中ri领先于rj (即i

        内排序与外排序:根据在排序过程中待排序的记录是否全部被放置在内存中, 排序分为:内排序和外排序。内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。 外排序是由于排序的记录个数太多, 不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。对于内排序来说,排序算法的性能主要是受3个方面影响:1、时间性能;2、辅助空间;3、算法的复杂性(不是算法的时间复杂度)。

                                                      排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第1张图片

        大顶堆和小顶堆:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆(如下左图所示);或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆(如下右图所示)

排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第2张图片

        如果按照层序遍历的方式给结点从1开始编号,则结点之间满足如下式关系:(一棵完全二叉树,如果i=1,则结点i是二叉树的根,无双亲;如果i>1, 则其双亲是结点。那么对于有n个结点的二叉树而言,它的i 值自然就是小于等于了。)


        如果将上图中的大小顶堆用层序遍历存入数组,则必满足上式。

排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第3张图片

二、各排序算法介绍

       

        排序用到的结构与函数:提供一个用于排序用的顺序表结构,此结构也将用于之后所有排序算法。


#define MAXSIZE 10      //用于要排序数组个数最大值,可根据需要修改
typedef struct
{
    int r[MAXSIZE+1];   //用于存储要排序数组,r[0]用作哨兵或临时变量
    int length;         //用于记录顺序表的长度
}SqList;

          由于排序最最常用到的操作是数组两元素的交换,可写成函数,后面会直接用到。


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

1、直接插入排序


(1)简介:直接插入排序,从字面意思可以看出,直接插入数据完成排序。像是扑克牌抓牌时候的理牌方法。

(2)基本思想:将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。

(3)例如:

                              排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第4张图片

(4)实现:

/* 对顺序表L作直接插入排序 */
void InsertSort(SqList *L)
{ 
	int i,j;
	for(i=2;i<=L->length;i++)
	{
		if (L->r[i]r[i-1]) /* 需将L->r[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]; /* 插入到正确位置 */
		}
	}
}

(5)复杂度分析

          从空间上来看,它只需要一个记录的辅助空间,因此关键是看时间复杂度

          最好的情况,也就是要排序的表本身就是有序的,比如纸牌拿到后就是{2.3.4.5.6}时,那么我们比较次数,其实就是代码中每个L.r[i]与L.r[i-1]的比较,共比较了n-1()次,由于每次都是L.r[i]>L.r[i-1],因此没有移动的记录,时间复杂度为O(n)。

          当最坏的情况,即待排序表是逆序的情况,比如{6.5.4.3.2},此时需要比较次,而记录的移动次数也达到最大值次。
          如果排序记录是随机的,那么根据概率相同的原则,平均比较和移动次数约为(n^2)/4次。因此,我们得出直接插入排序法的时间复杂度为O(n^2)。 从这里也看出,同样的O(n^2)时间复杂度,直接插入排序法比冒泡和简单选择排序的性能要好一些。


2、希尔排序(Shell排序)

(1)简介: 希尔排序又称为缩小增量排序,是对直接插入排序方法的改进

(2)基本思想:将整个序列分成多个子序列,然后分别进行直接插入排序,直到整个序列中的所有数基本有序时,再对整体进行一次直接插入排序。

(3)例如:

                             排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第5张图片

(4)实现

/* 对顺序表L作希尔排序 */
void ShellSort(SqList *L)
{
	int i,j,k=0;
	int increment=L->length;
	do
	{
		increment=increment/3+1;/* 增量序列 */
		for(i=increment+1;i<=L->length;i++)
		{
			if (L->r[i]r[i-increment])/*  需将L->r[i]插入有序增量子表 */ 
			{ 
				L->r[0]=L->r[i]; /*  暂存在L->r[0] */
				for(j=i-increment;j>0 && L->r[0]r[j];j-=increment)
					L->r[j+increment]=L->r[j]; /*  记录后移,查找插入位置 */
				L->r[j+increment]=L->r[0]; /*  插入 */
			}
		}
		printf("	第%d趟排序结果: ",++k);
		print(*L);
	}
	while(increment>1);
}

        注意,增量的选择是难点,增量序列的最后一个增量值必须等于1才行。另外由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法

(5)复杂度分析

        希尔排序是将相隔某个“增量” 的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。当增量序列为时,可以获得不错的效率, 其时间复杂度为O(n^(3/2)),要好于直接排序的O(n^2)。


3、简单选择排序

(1)简介:下文中冒泡排序的思想是不断地在交换,通过交换完成最终的排序,而简单选择排序是在排序时找到合适的关键字再做交换,并且只移动一次就完成相应关键字的排序定位工作。

(2)基本思想:通过n-i次关键字间的比较,从n-j+1个记录中选出关键字最小的记录,并和第i (1≤i≤n) 个记录交换之。

(3)例如:

                          排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第6张图片

(4)实现:

/* 对顺序表L作简单选择排序 */
void SelectSort(SqList *L)
{
	int i,j,min;
	for(i=1;ilength;i++)
	{ 
		min = i;						/* 将当前下标定义为最小值下标 */
		for (j = i+1;j<=L->length;j++)/* 循环之后的数据 */
        {
			if (L->r[min]>L->r[j])	/* 如果有小于当前最小值的关键字 */
                min = j;				/* 将此关键字的下标赋值给min */
        }
		if(i!=min)						/* 若min不等于i,说明找到最小值,交换 */
			swap(L,i,min);				/* 交换L->r[i]与L->r[min]的值 */
	}
}

(5)复杂度分析

         从简单选择排序的过程来看,它最大的特点就是交换移动数据次数相当少, 这样也就节约了相应的时间。分析它的时间复杂度发现,无论最好最差的情况,其比较次数都是一样的多,第i 趟排序需要进行n-i次关键字的比较,此时需要比较n-1+n-2+……+1=n(n-1)/2次。 而对于交换次数而言,当最好的时候,交换为0次,最差的时候,也就初始降序时,交换次数为n-1次,基于最终的排序时间是比较与交换的次数总和, 因此,总的时间复杂度依然为O(n^2)

        应该说,尽管与冒泡排序同为O(n^2), 但简单选择排序的性能上还是要略优于冒泡排序


4、堆排序


(1)简介:堆排序是一个相当有用的排序技术,特别适用于对大量的记录进行排序。同时,堆排序也是对简单选择排序的改进注意:堆树必须是一颗完全二叉树。

(2)基本思想:利用堆积树这种数据结构所设计的一种排序,可以利用数组的特点快速的定位指定索引的元素。假设利用大顶堆,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1 个序列重新构造成一个堆,这样就会得到n个元素中的次小值。如此反复执行, 便能得到一个有序序列了。

(3)例如:

将序列{20,60,26,30,36,10}调整为递增序列。

①首先将数据建立完全二叉树,填充规则是按层次遍历将数据一一填入,最后构建最小堆;

                         排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第7张图片

②提取堆顶并调整删除队顶后的元素为新堆;

③重复第2步,直到堆空;

                        排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第8张图片

④每次提取的堆顶依次排序即为递增序列。

                     排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第9张图片

(4)实现

/* 已知L->r[s..m]中记录的关键字除L->r[s]之外均满足堆的定义, */
/* 本函数调整L->r[s]的关键字,使L->r[s..m]成为一个大顶堆 */
void HeapAdjust(SqList *L,int s,int m)
{ 
	int temp,j;
	temp=L->r[s];
	for(j=2*s;j<=m;j*=2) /* 沿关键字较大的孩子结点向下筛选 */
	{
		if(jr[j]r[j+1])
			++j; /* j为关键字中较大的记录的下标 */
		if(temp>=L->r[j])
			break; /* rc应插入在位置s上 */
		L->r[s]=L->r[j];
		s=j;
	}
	L->r[s]=temp; /* 插入 */
}

/*  对顺序表L进行堆排序 */
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]重新调整为大根堆 */
	}
}

(5)复杂度分析

        运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)。

            在正式排序时,第i 次取堆顶记录重建堆需要用O(logi)的时间(完全二叉树的某个结点到根结点的距离为,并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)。

        所以总体来说,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、 直接插入的O(n^2)的时间复杂度了。

        空间复杂度上,它只有一个用来交换的暂存单元。不过由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。


5、冒泡排序

(1)简介:冒泡排序,就跟水里的物体一样,小的往上浮,大的往下沉。是一种交换排序。

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

(3)例如:

             假设我们待排序的关键字序列是{9,1,5,8,3,7,4,6,2}。当i=1 时,变量j 由8反向循环到1,逐个比较,将较小值交换到前面,直到最后找到最小值放置在了第1 的位置。如图所示,依次类似循环排序。

排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第10张图片

(4)实现

/* 对顺序表L作冒泡排序 */
void BubbleSort(SqList *L)
{ 
	int i,j;
	for(i=1;ilength;i++)
	{
		for(j=L->length-1;j>=i;j--)  /* 注意j是从后往前循环 */
		{
			if(L->r[j]>L->r[j+1]) /* 若前者大于后者(注意这里与上一算法的差异)*/
			{
				 swap(L,j,j+1);/* 交换L->r[j]与L->r[j+1]的值 */
			}
		}
	}
}

(5)改进

        如果我们待排序的序列是{2,1,3,4,5,6,7,8,9}, 也就是说,除了第一和第二的关键字需要交换外,别的都已经是正常的顺序。当i=1时,交换了2 和1,此时序列已经有序,但是算法仍然不依不饶地将i=2 到9 以及每个循环中的j 循环都执行了一遍,尽管并没有交换数据,但是之后的大量比较还是大大地多余了。这里可以增加一个flag来实现,避免已经有序的情况下无意义循环判断。

        比如{2,1,3,4,5,6,7,8,9}中,i=2时,我们已经对9与8,8与7……3与2作了比较,没有任何数据交换,说明序列已经有序,不需再后续循环判断了。

/* 对顺序表L作改进冒泡算法 */
void BubbleSort2(SqList *L)
{ 
	int i,j;
	Status flag=TRUE;			/* flag用来作为标记 */
	for(i=1;ilength && 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);	/* 交换L->r[j]与L->r[j+1]的值 */
				 flag=TRUE;		/* 如果有数据交换,则flag为true */
			}
		}
	}
}

(6)复杂度分析

        当最好的情况,也就是要排序的表本身就是有序的,那么我们比较次数,根据最后改进的代码,可以推断出是n-1 次的比较,没有数据交换,时间复杂度为O(n)。当最坏的情况,即待排序表是逆序的情况,此时需要比较1+2+3+……+(n-1)=n(n-1)/2次,并作等数量级的记录移动。因此,冒泡排序总的时间复杂度为O(n^2)


6、快速排序


(1)简介:快速排序是目前内部排序中速度最快的一种排序算法。

(2)基本思想:选取一个数据(通常是数组的第一个数)作为关键数据,然后将所有比它小的数都放在它前面,所有比它大的数都放在它后面,这个过程称为一趟快速排序,再从分开的部分选取基准数,进行分组划分,重复执行,直到完成。

(3)例如:

                        排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第11张图片

(4)实现

/* 快速排序******************************** */
 
/* 交换顺序表L中子表的记录,使枢轴记录到位,并返回其所在位置 */
/* 此时在它之前(后)的记录均不大(小)于它。 */
int Partition(SqList *L,int low,int high)
{ 
	int pivotkey;

	pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
	while(lowr[high]>=pivotkey)
			high--;
		 swap(L,low,high);/* 将比枢轴记录小的记录交换到低端 */
		 while(lowr[low]<=pivotkey)
			low++;
		 swap(L,low,high);/* 将比枢轴记录大的记录交换到高端 */
	}
	return low; /* 返回枢轴所在位置 */
}

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

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

        优化:

/* 快速排序优化算法 */
int Partition1(SqList *L,int low,int high)
{ 
	int pivotkey;

	int m = low + (high - low) / 2; /* 计算数组中间的元素的下标 */  
	if (L->r[low]>L->r[high])			
		swap(L,low,high);	/* 交换左端与右端数据,保证左端较小 */
	if (L->r[m]>L->r[high])
		swap(L,high,m);		/* 交换中间与右端数据,保证中间较小 */
	if (L->r[m]>L->r[low])
		swap(L,m,low);		/* 交换中间与左端数据,保证左端较小 */
	
	pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
	L->r[0]=pivotkey;  /* 将枢轴关键字备份到L->r[0] */
	while(lowr[high]>=pivotkey)
			high--;
		 L->r[low]=L->r[high];
		 while(lowr[low]<=pivotkey)
			low++;
		 L->r[high]=L->r[low];
	}
	L->r[low]=L->r[0];
	return low; /* 返回枢轴所在位置 */
}

void QSort1(SqList *L,int low,int high)
{ 
	int pivot;
	if((high-low)>MAX_LENGTH_INSERT_SORT)
	{
		while(lowr[low..high]一分为二,算出枢轴值pivot */
			QSort1(L,low,pivot-1);		/*  对低子表递归排序 */
			/* QSort(L,pivot+1,high);		/*  对高子表递归排序 */
			low=pivot+1;	/* 尾递归 */
		}
	}
	else
		InsertSort(L);
}

/* 对顺序表L作快速排序 */
void QuickSort1(SqList *L)
{ 
	QSort1(L,1,L->length);
}

(5)复杂度分析

        快速排序的时间性能取决于快速排序递归的深度,可以用递归树来描述递归算法的执行情况。在最优的情况下,快速排序算法的时间复杂度为O(nlogn)。在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。 如果递归树画出来,它就是一棵斜树。此时需要执行(n-1)次递归调用,且第i 次划分需要经过n-i次关键字的比较才能找到第i 个记录,也就是枢轴的位置, 因此比较次数为,最终其时间复杂度为O(n^2),平均时间复杂度是O(nlogn)

      就空间复杂度来说,主要是递归造成的栈空间的使用,最好情况,递归树的深度为logn,其空间复杂度也就为O(logn),最坏情况,需要进行n-1递归调用,其空间复杂度为O(n),平均情况,空间复杂度也为O(nlgn)
      由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法

(6)优化

        a.优化选取枢轴:三数取中法,即取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数,也可以随机选。

        b.优化不必要的交换:变交换为替换。将pivotkey 备份到L.r[0]中,然后在之前是swap时, 只作替换的工作,最终当Iow与high会合,即找到了枢轴的位置时,再将L.r[0]的数值赋值回L.r[Iow]。(交换会开辟临时存储空间)

        c.优化小数组时的排序方案:增加了一个判断,当high-low不大于某个常数时(有资料认为7 比较合适,也有认为50更合理,实际应用可适当调整),就用直接插入排序,这样就能保证最大化地利用两种排序的优势来完成排序工作。

        d.优化递归操作:利用尾递归(什么是尾递归?),来减少递归次数,采用迭代而不是递归的方法可以缩减堆栈深度,从而提高整体性能。也就是将每次调用递归后的结果保留到递归函数的参数值,以减少不必要的回归。


7、归并排序 


(1)简介:使用合并操作完成排序。

(2)基本思想:将两个或两个以上的有序表合并成一个新的有序表,最后将所有的有序表合成一个整体有序表。

(3)例如:

          ①递归法

排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第12张图片

         ②非递归法

a.排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第13张图片

b.排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第14张图片

c.排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第15张图片

(4)实现

         ①递归法

/* 将有序的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,l;
	for(j=m+1,k=i;i<=m && j<=n;k++)	/* 将SR中记录由小到大地并入TR */
	{
		if (SR[i]r,L->r,1,L->length);
}

                其中Merge()的运行示意图如下:

       a. 排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第16张图片  b.排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第17张图片

c.排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第18张图片         d.排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第19张图片

        ②非递归法

/* 非递归法 */
/* 将SR[]中相邻长度为s的子序列两两归并到TR[] */
void MergePass(int SR[],int TR[],int s,int n)
{
	int i=1;
	int j;
	while(i <= n-2*s+1)
	{/* 两两归并 */
		Merge(SR,TR,i,i+s-1,i+2*s-1);
		i=i+2*s;        
	}
	if(ilength * sizeof(int));/* 申请额外空间 */
    int k=1;
	while(klength)
	{
		MergePass(L->r,TR,k,L->length);
		k=2*k;/* 子序列长度加倍 */
		MergePass(TR,L->r,k,L->length);
		k=2*k;/* 子序列长度加倍 */       
	}
}

(5)复杂度分析

        ①递归法

        我们来分析一下归并排序的时间复杂度,一趟归并需要将SR[1]-SR[n]中相邻的长度为h的有序序列进行两两归并。并将结果放到TR1[1]-TR1[n]中,这需要将待排序序列中的所有记录扫描一遍,因此耗费O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行次,因此,总的时间复杂度为O(nlogn), 而且这是归并排序算法中最好、最坏、平均的时间性能。

        由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为Iogn的栈空间,因此空间复杂度为O(n+Iogn)。

        另外,对代码进行仔细研究,发现Merge 函数中有if (SR[i]归并排序是一种稳定的排序算法。

        也就是说,归并排序是一种比较占用内存,但却效率高且稳定的算法

        ②非递归法

        非递归的迭代方法,避免了递归时深度为logn的栈空间,空间只是用到申请归并临时用的TR数组,因此空间复杂度为O(n),并且避免递归也在时间性能上有一定的提升,应该说,使用归并排序时,尽量考虑用非递归方法


8、基数排序


(1)简介:前面介绍的排序方法都是对元素进行的,基数排序是对元组进行的。

(2)基本思想:从低位到高位依次对待排序的数进行分配和收集,经过d趟分配和收集,就可以得到一个有序序列。

(3)例如:

                     排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第20张图片


排序算法对比

排序算法总结(插入、Shell、选择、堆排序、冒泡、快速、归并、基数)_第21张图片



参考:

http://blog.csdn.net/jiuqiyuliang/article/details/25304009

《大话数据结构》

你可能感兴趣的:(【数据结构和算法】)