基本概念

排序:是将无序的队列重新排列成有序序列的过程。每一项既可能是数据元素也可能是记录(由多个数据元素组成)

稳定性:当排序序列中有两个或者两个以上关键字的时候,排序前和排序后这些关键字的相对位置如果没有发生变化就是稳定的,否则就是不稳定的。
如果关键字不能重复,则排序结果是唯一的,那么选择的排序算法稳定与否就无关紧要;如果关键字可以重复,则在选择排序算法时,就要根据具体的需求来考虑选择稳定的还是不稳定的排序算法。

排序算法的分类

  • 插入类的排序:在一个排好序的序列中插入一个新的关键字。有直接插入排序、折半插入排序、希尔排序
  • 交换类的排序:每一趟排序都通过一系列的交换,让一个关键字排到最终的位置上去。有冒泡排序、快速排序。
  • 选择累的排序:每一趟排序都选出一个最小(最大)的关键字,吧它和序列中第一个(最后一个)关键字交换。有简单选择排序、堆排序。
  • 归并类的排序:将两个或者两个以上的有序队列合并成一个新的有序序列,采用分治归并的思想实现排序。有二路归并排序。
  • 基数类的排序:基于多关键字排序的思想,把一个逻辑关键字拆分成多个关键字。

插入类排序

直接插入排序

void InsertSort(int R[],int n){
	int i,j;
	int temp;
	for(int i=1;i<=n;i++){
		temp=R[i];
		j=i-1;
		while(j>=0&&temp<R[j]){//找到第一个比temp小的位置 
			R[j+1]=R[j];
			--j;
		}
		R[j+1]=temp;
	}
} 

算法性能分析
时间复杂度:

  • 最好:原来的序列本来有序,此时内层循环为常数级,则时间复杂度\(O(n)\)
  • 最坏:原来的序列本来无序,此时内存循环为\(1~n\),总的时间复杂度为\(O(n^2)\)

空间复杂度:全部都是常量,空间复杂度\(O(1)\)

折半插入排序

在寻找当前元素位置的时候使用二分,使得搜寻时间变成\(O(log_2n)\)
算法性能分析
时间复杂度:最好情况下由于使用二分,时间复杂度为\(O(nlog_2n)\),最坏情况下不变
空间复杂度:\(O(1)\)

希尔排序

算法介绍:又叫做最小增量排序,本质上还是插入排序,只不过将待排序的的序列按照某种规则分成几个子序列,分别对这几个序列进行直接插入排序。这个规则的体现就是增量的提取,如果增量为1,那么就是直接插入排序。
直接插入排序使用于序列基本有序的情况,希尔排序的每趟排序都会使得整个序列变得更加有序,等整个序列变得有序了,再来一趟直接插入排序,这样会使得排序效率更高。
排序过程:

  1. 首先进行5分割序列,得到若干个子序列,对子序列进行直接插入排序,之后将得到的子序列合并
  2. 再执行3分割排序,对子序列进行同样操作,这时得到的子序列已经基本有序了
  3. 最后执行一次直接插入排序

希尔排序选取增量的注意点:

  1. 增量序列最后一个值一定是1
  2. 增量序列的值应该尽量没有除了1之外的公因子

希尔排序不是一种稳定排序,也就是说值相同的关键字相对位置会发生变化。

算法性能分析:
时间复杂度:希尔排序的时间复杂度和增量选取有关,规则有很多,常用的规则有以下两个

  1. 希尔自己提出的:
    $ \lfloor n/2 \rfloor、\lfloor n/4 \rfloor、……\lfloor n/{2^k} \rfloor、……、2、1 $,此时复杂度为 $ O(n^2) $ 。
  2. \(2^k+1、……、65、33、17、……、1\),此时时间复杂度为\(O(n^{1.5})\)

空间复杂度:一样为\(O(1)\)

交换类排序

冒泡排序

算法介绍:每次选取当前长度中最大的元素放到最后去,注意终止条件是一趟排序过程中没有发生关键字交换

void BubbleSort(int R[],int n){
	int i,j,temp,flag;
	for(i=n;i>=1;i--){
		flag=0;
		for(j=1;j<=i;j++){
			if(R[j]>R[i]){
				temp=R[j];
				R[j]=R[i];
				R[i]=temp;
				flag=1;
			}
		
		}
		if(flag==0) break; 
	}
}

算法性能分析:时间复杂度最好为\(O(n)\),此时没有交换发生,最坏为\(O(n^2)\)

快速排序

算法介绍:从当前子序列中选取一个关键字(通常是第一个)作为标志,把比标志大的元素放到标志右端,比标志小的元素放到左端,之后递归处理左端和右端。

void QuickSort(int R[],int low,int high){
	int i=low,j=high,temp;
	if(low<high){
		//当前[low~high]最左边的元素作为目标值
		temp=R[low];
		while(i<j){
			//每一次循环中,当首先是从右往左边搜素比temp小的第一个元素,然后从从左边搜索大于等于temp的第一个元素 
			while(j>i&&R[j]>=temp) --j;
			if(i<j){
				R[i]=R[j];
				++i;
			}
			while(i<j&&R[i]<temp) ++i;
			if(i<j){
				R[j]=R[i];
				j--;
			}
		}
		R[i]=temp;
		QuickSort(R,low,i-1);
		QuickSort(R,i+1,high);
	}
}

算法性能分析:
时间复杂度:

  • 最好情况:当序列越接近无序时,算法效率越高,最好复杂度为\(O(nlog_2n)\)
  • 最坏情况:当序列越接近有序是,复杂度接近\(O(n^2)\)
    快速排序虽然不够稳定,但是在\(log_2n\)级别的排序算法中常数是最小的,所以叫快速排序
    空间复杂度:空间复杂度为\(O(log_2n)\)。快速排序是递归进行的,递归需要栈的辅助,所以空间要求大。

选择类排序

简单选择排序

算法介绍:从头至尾扫描序列,找到最小的关键字,和第一个关键字进行交换,接着从剩下的关键字中进行选择和交换,最终使得序列有序

void SelectSort(int R[],int n){
	int i,j,k,temp;
	for(i=1;i<=n;i++){
		k=i;
		for(j=i+1;j<=n;j++){
			if(R[k]>R[j]) k=j;
		}
		temp=R[i];
		R[i]=R[k];
		R[k]=temp;
	}
} 

算法性能分析:和冒泡思路做法都差不多,就是少了很多交换,不再多赘述

堆排序

算法介绍:使用了堆这种数据结构,把堆看成完全二叉树,这个二叉树满足:任何一个非叶结点的值都不大于(小于)其左、右孩子结点的值。若父亲大儿子小,叫做大顶堆,否则叫小顶堆。

void Sift(int R[],int low,int high){
	int i=low,j=2*i;
	int temp=R[i];
	while(j<=high){
		//有两个孩子,且右孩子比较大,所以指向右孩子 
		if(j<high&&R[j]<R[j+1]) ++j;
		if(temp<R[j]){
			R[i]=R[j];
			i=j;
			j=2*i;
		}
		else break;
	}
	R[i]=temp;
}

void heapSort(int R[],int n){
	int i,temp;
	//先建立好初始堆,使得R[1]是数组当中的最大值 
	for(int i=n/2;i>=1;i--) Sift(R,i,n);
	for(int i=n;i>=2;i--){
		temp=R[i];
		R[1]=R[i];
		R[i]=temp;
		//将堆顶删除之后,把最后元素丢过来,然后再次建立大跟堆 
		Sift(R,1,i-1);
	} 
}

算法性能分析:
时间复杂度:第一个循环的操作数为\(O(log_2n)*n/2\),第二个循环的操作次数为\(O(log_2n)*(n-1)\),化简之后得到的复杂度为\(O(nlog_2n)\)
空间复杂度:辅助储存空间不随待排序列规模的变化而变化,因此复杂度为\(O(1)\)

二路归并排序

算法介绍:简称归并排序,本质上就是将原来的序列看成很多个小序列,然后对小序列排序完之后归并。和快速排序一样,使用了分治的思想,但是比快速排序要稳定,毕竟它的区间划分是固定的。

int a[maxSize];
void merge(int R[],int low,int mid,int high){
	int i=low,j=mid+1,id=low;
	while(i<=mid&&j<=high){
		if(R[i]>=R[j]) a[id++]=R[j++];
		else a[id++]=R[j++];
	} 
	while(i<=mid) a[id++]=R[i++];
	while(j<=high) a[id++]=R[i++];
	for(i=low;i<=high;i++) R[i]=a[i]; 
}
void mergeSort(int R[],int low,int high){
	if(low<high){
		int mid=(low+high)/2;
		mergeSort(R,low,mid);
		mergeSort(R,mid+1,high);
		merge(R,low,mid,high);
	}
}

算法性能分析:
时间复杂度:归并排序的执行层数为\(log_2n\)次,每层执行\(O(n)\)次基本操作,所以总的复杂度为\(O(nlog_2n)\)
空间复杂度:在进行区间归并的时候要使用额外数组,因此空间复杂度为\(O(n)\)

基数排序

算法介绍:核心思想是“多关键字排序”,有两种实现方式:

  • 最高位优先:先按最高位排成若干子序列,在对每个子序列按次高位排序,这样子全部位都走一遍之后可以直接得到目标序列,也就是符合常人直观的比较,比如三位数之间的比较,都是先看高位再比低位。
  • 最低位优先:这种方式不需要分出子序列,每次排序全部的关键字都参与。最低位可以优先进行,不通过比较,而是通过“分配”和“收集”。先将不同的元素按照关键字分配到对应的桶中,然后在按照桶的次序进行收集,这样子桶的顺序就代表了元素的顺序。
    每个桶都是一个队列,按照先进先出的原则进行操作,如此来保证排序的合理性。

算法性能分析:
时间复杂度:平均和最坏情况下都是\(O(d(n+r_d)\)
空间复杂度:\(O(r_d)\)
其中\(n\)为序列中关键字数,d为关键字的关键字位数,如三位数的关键字位数为3;\(r_d\)为关键字基的个数,这里的基为构成关键字的符号,比如十进制下每位数的个数就是10。

外部排序

概念和流程

基本概念:所谓外部排序,即对外存的记录进行排序(相对于内部排序而言)。
为什么需要外部排序?
因为外存中记录规模太大,内存存不下……
外部排序可以总结为一句话:将内存作为工作空间来辅助外存数据的排序。
流程解析:
外部排序最常用的算法是归并排序。之所以常用,是因为归并排序并不需要将全部记录都读入内存中即可完成排序。因此可以结局由于内存空间不足导致的无法进行大规模排序的问题?啥意思

假设要对外存中一组大规模无序记录进行归并排序,步骤如下

  1. 将这组记录假设为n个,分为m个规模较小的记录段(长度不一定相等),并对这些小的记录段排序。一般情况下这些记录段都足够小,可以整段读入内存并选择合适的排序算法对其进行排序。
  2. 将这m个有序记录段每k段分为一个小组,得到\(\lceil m/k \rceil\)组记录段。取其中一组,每行一段,将每段段首的记录读入内存中
  3. 用某种算法从读入内存的这组记录中选出最小的
  4. 将上一步选出的最小值写回外存,并将其所在记录段的次小值读入内存补上空位置
  5. 重复以上过程
  6. 当此组记录全部导出之后,就会在外存中得到一个较长的有序记录段。以此方法将剩下的所有组记录段全部都归并称比较长的记录段,就得到了\(\lceil m/k \rceil\)个较长有效记录段。按照同样的方法分组、归并,重复此过程,最终完成对n个记录的排序。

整个过程中只占用了k个内存空间,达到了用较小的内存空间完成较大规模记录排序的目的。

重要子算法

  1. 置换-选择排序:步骤1中的m个有序记录段称为初始归并段。如果被划分的每个小记录段规模不够小,仍然无法读入内存,则无法使用内排序的到初始归并段,因此需要一种适用于初始归并段规模的、高效的且不受内存空间限制的排序算法
  2. 最佳归并树:将当前的m组记录归并为有m个有效记录段的过程称为一趟归并,可见每个记录在每趟归并中需要进行两次I/O操作。读写操作时非常耗时的,可见减少归并次数可以提高效率。为了使得归并次数最小,需用到最佳归并树。
  3. 败者树:归并排序算法中有一次多个出现的步骤是从当前k个值中选出最值,可见提高选择最值的效率也是整个归并排序算法效率提高的关键,这就需要用到败者树。

置换-选择排序

采用置换-选择排序算法构造初始归并段的过程:根据缓冲区大小,由外存读入记录,当记录充满缓冲区后,选择最小的(假设升序排列)写回外存,其空缺位置由下一个读入记录来取代,输出的记录为当前初始归并段的一部分,如果新输入的记录不能成为当前生成的归并段的一部分,即它比生成的当前归并段中最大记录要小,则它将成为生成其他初始归并段的选择。反复进行上述操作,直到缓冲区中的所有记录逗比当前初始归并段最大的记录小时,就生成了一个新的初始归并段。用同样的方法继续生成下一个初始归并段,直到全部记录都被处理完毕为止,

通过本方法得到的m个初始归并段长度可能是不同的。不同的归并策略可能导致归并次数的不同,即意味着需要的I/O操作次数不同,因此需要找出一种归并次数最少的归并车略来减少I/O操作次数,以提高排序效率。

最佳归并树

归并过程可以用一棵树来形象地描述,这颗树称为归并树,书中结点代表当前归并段长度。初始记录经过置换-选择排序之后,得到的是长度不一的初始归并段,归并策略不同,所得到的的归并树也不相同,树的带权路径长度也不同。为了优化归并树的带权路径长度,可以将之前讲过的huffman树运用过来,对于k路归并算法,可以通过构造k叉huffman树的方法来构造最佳归并树

败者树

在k路归并中,若不使用败者树,则每次对读入的k个值需要进行k-1次比较才可以得到最值。引入败者树可以在\(log_2k\)次中完成最值得选取。
败者树中有两种不同的结点

  1. 叶子结点,其值为从当前归并的归并段中读入的段首记录。叶子结点的个数为当前参与归并的归并段数,即k路归并叶子数为k。
  2. 非叶子结点,都是度为2的结点,其值为叶子结点的序号,同时也指示了当前参与选择的记录所在的归并段。

建立败者树

因树中有两种类型的结点,因此对其的处理方式稍有不同,建树方法如下

  1. 对当前读入的k个结点,构造k个叶子结点,任意两个结点为一组,建立二叉树,如果结点数不是偶数,则多余的那个结点放到下一轮处理
  2. 如果当前参与建树的是两个叶子结点,则以败者(值较大者)所在的归并段的序号构造新节点作为其父节点(T),胜者(值小)所在归并段的序号作为T的父节点建一棵二叉树
  3. 如果当前参与建树的两个结点是非叶根结点,则以败者(胜负关系通过结点中序号所致归并段的值大小判断)为这两个根节点子树的新根节点T,胜者为T的父节点
  4. 如果当前参与建树的两个结点类型不同,则有
  • 如果叶子结点是胜者,则叶子结点挂在非叶根结点的空分支上,并以叶子结点记录值所在归并段序号构造新节点作为非叶根结点的父节点建立二叉树
  • 如果叶子结点是败者,则叶子结点所在序号构造新节点作为父节点T,非叶根结点作为T的父节点构建二叉树

调整败者树

最小值所在序号的归并段取出最小值记录,将后面的一条记录压入原来最小值所在的位置,然后和上面的部分进行比较完成调整

实际上我们可以发现,这个就是一个建立堆的过程==

时间和空间复杂度相关问题

时间复杂度:涉及到很多方面,只考流程中的具体细节

  1. m个归并段进行k路归并,归并的躺树为\(\lceil log_km \rceil\)
  2. 每一次归并,所有记录都要进行两次I/O操作。
  3. 置换-选择排序这一步中,所有记录都要进行两次I/O操作
  4. 置换-选择排序中,选最值得那一部分时间复杂度根据考试选定的算法而定。
  5. k路归并的败者树的高度为\(\lceil log_2k \rceil +1\),因此利用败者树从k个记录中选出最值需要进行\(\lceil log_2k \rceil\)次比较,时间复杂度为\(O(log_2k)\)
  6. k路归并败者树的建树时间复杂度为\(O(klog_2k)\)

空间复杂度:所有步骤的空间复杂度都是常量,\(O(1)\)

知识小结

复杂度总结

时间复杂度:
平均情况下,快排,希尔,归并和堆排序的时间复杂度均为\(O(nlog_2n)\),基数排序为\(O(d(n+r_d))\),其余都是\(O(n^2)\)
最坏情况下,快排时间复杂度是\(O(n^2)\),当序列是个有序序列的情况下,其他都平均相同
空间复杂度:
快排\(O(log_2n)\),归并\(O(n)\),基数\(O(r_d)\)

稳定性总结

快速排序,希尔排序,简单选择排序,堆排序是不稳定的

其他细节

  1. 经过一趟排序,能够保证一个关键字到达最终位置,这样的排序是交换类或者选择类
  2. 排序算法的比较次数和原始序列无关——简单选择排序和折半插入排序
  3. 排序算法的排序躺树和原始序列有关——交换类的排序