十一. 排序1. 插入排序1.1 插入排序算法实现及分析算法优化1.2 希尔排序2. 交换排序2.1 冒泡排序2.2 快速排序3. 选择排序3.1 简单选择排序3.2 堆排序堆排序实现堆的插入堆的删除4. 归并排序5. 基数排序6. 外部排序6.1 外部排序实现6.2 外部排序优化6.3 败者树6.4 置换-选择排序6.5 最佳归并树
排序就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。
排序算法评价指标:
时间复杂度
空间复杂度
算法稳定性:指表中相同元素先后位置,经过排序后有没有相对的变动。
注意:稳定排序算法并不一定比不稳定的算法优秀,主要看实际需求。
排序算法分类:
主要分为:直接插入排序、折半插入排序、希尔排序
算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。
动画演示:
排序从第二个元素开始,默认当前排序元素左边已经有序。只要比当前元素大的都往后移动,直到碰到比当前元素小(相等)的。
代码实现:
1//直接插入排序
2void InsertSort(int A[],int n){
3 int i,j, temp;
4 for(i=1;i<n;i++) //将各元素插入已排好序的序列中
5 if(A[i]<A[i-1]){ //若A[i]关键字小于前驱
6 temp=A[i]; //用temp暂存A[i]
7 for(j=i-1;j>=0 && A[j]>temp;--j) //检查所有 前面已排好序的元素
8 A[j+1]=A[j]; //所有大于temp的元素都向后挪位
9 A[j+1]=temp; //复制到插入位置
10 }
11}
带哨兵实现方法:
代码实现:
xxxxxxxxxx
111//直接插入排序(带哨兵)
2void InsertSort(int A[],int n){
3 int i,j;
4 for(i=2;i<=n;i++) //依次将A[2]~A[n]插入到前面已排序序列
5 if(A[i]<A[i-1]){ //若A[i]关键码小于其前驱,将A[i]插入有序表
6 A[0]=A[i]; //复制为哨兵,A[0]不存放元素
7 for(j=i-1;A[0]<A[j];--j)//从后往前查找待插入位置
8 A[j+1]=A[j]; //向后挪位
9 A[j+1]=A[0]; //复制到插入位置
10 }
11}
带哨兵排序步骤:
这样做的优点是不用没轮循环都判断
算法效率分析:
空间复杂度:
时间复杂度:主要来自对比关键字、移动元素若有
最好情况(已经有序):共
最坏情况(逆序):每次都要移动元素。所以最坏时间复杂度
平均时间复杂度:
算法稳定性:相同两个元素,排序后相对位置不会改变,所以稳定。
适用性:可用于链表,也可用于顺序表。
思路:之前都是用顺序查找方式移动元素,实际可用折半查找找到应该插入的位置,再移动元素
假设下面
首先low=20;high=80
,则mid=(low+high)/2=4
,即mid
指向
由于low=mid+1;mid=(low+high)/2=6
,即mid
指向
而high=mid-1;mid=(low+high)/2=5
,即mid
指向
high=mid-1;
,此时low>high
,故折半查找停止,应将[low, i-1]
内的元素全部右移,并将A[0]
复制到low
所指位置。
后面元素mid
指向的元素和被查找元素相等时,不应该停止查找,为了算法稳定性,当元素相等时,我们应该在这个元素右边区间内查找。
代码实现:
xxxxxxxxxx
161//折半插入排序
2void InsertSort(int A[],int n){
3 int i,j,low,high,mid;
4 for(i=2;i<=n;i++){ //依次将A[2]~A[n]插入前面的已排序序列
5 A[0]=A[i]; //将A[i]暂存到A[0]
6 low=1;high=i-1; //设置折半查找的范围
7 while(low<=high){ //折半查找(默认递增有序)
8 mid=(low+high)/2; //取中间点
9 if(A[mid]>A[0]) high=mid-1; //查找左半子表
10 else low=mid+1; //查找右半子表
11 }
12 for(j=i-1;j>=high+1;--j)
13 A[j+1]=A[j]; //统一后移元素,空出插入位置
14 A[high+1]=A[0]; //插入操作
15 }
16}
算法效率:
比起"直接插入排序",比较关键字的次数减少了,但是移动元素的次数没变,整体来看时间复杂度依然是
直接插入排序可以用于链表的排序。对于链表来说移动元素的次数变少了,但是关键字对比的次数依然是
希尔排序是对插入排序的优化。对于插入排序来说,如果要排序的元素基本有序,那么排序效率会高很多。而希尔排序核心思想是:先追求表中的元素部分有序,再逐渐逼近全局有序。
实现大致步骤:
动画演示:
例如:将下列表中元素进行升序排列
步骤:
第一趟假设
对上面四个子表进行直接插入排序。
第二趟假设
对上面各个子表进行直接插入排序。
接着第三趟
整个表此时已呈现出基本有序,对整体再进行一次直接插入排序
上面例子中,每次将增量
代码实现:
xxxxxxxxxx
121//希尔排序
2void ShellSort(int A[] ,int n){
3 int d,i,j; //A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
4 for(d=n/2; d>=1; d=d/2) //步长变化
5 for(i=d+1; i<=n; ++i)
6 if(A[i]<A[i-d]){ //需将A[i]插入有序增量子表
7 A[0]=A[i]; //暂存在A[0]
8 for(j= i-d; j>0 && A[0]<A[j]; j-=d)
9 A[j+d]=A[j]; //记录后移,查找插入的位置
10 A[j+d]=A[0]; //插入
11 }//if
12}
上面内层第一个for循环中的
++i
,会让以为间隔的子表轮流切换排序。
算法性能分析:
空间复杂度:
时间复杂度:根据
最坏时间复杂度:当
当数据元素
稳定性:不稳定
适用性:仅适用于线性表,不能用于链表。
主要有:冒泡排序和快速排序
冒泡排序和快速排序一样属于交换排序的一种。
交换排序:根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。
代码实现:
xxxxxxxxxx
191//交换
2void swap(int &a,int &b){
3 int temp = a;
4 a=b;
5 b = temp;
6}
7//冒泡排序
8void BubbleSort(int A[],int n){
9 for(int i=0;i<n-1;i++){
10 bool flag=false; //表示本趟冒泡是否发生交换的标志
11 for(int j=n-1;j>i;j--) //一趟冒泡过程
12 if(A[j-1]>A[j]){ //若为逆序
13 swap(A[j-1],A[j]); //交换
14 flag=true;
15 }
16 if(flag==false)
17 return; //本趟遍历后没有发生交换,说明表已经有序
18 }
19}
注意:如果某一趟排序过程中未发生"交换"则算法可提前结束。
算法效率分析:
空间复杂度:
时间复杂度:
最好情况(原本有序):
最坏情况(逆序):
平均情况:
稳定性:稳定
适用性:链表,顺序表都可用
算法实现思路:
在待排序表中任取一个元素作为枢轴(通常取首元素)。通过一趟排序,将待排序表划分为独立的两个部分。这两部分中,左半部分所有元素都小于枢轴元素;右半部分都大于枢轴元素,则枢轴元素确定其最终元素位置。这个过程称为一次划分。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在最终位置上,完成排序。
动画演示如下:
代码如下:
x1//默认low=0,high=arr.length-1
2int partition(int arr[],int low,int high) {
3 int tempA=arr[low]; //枢轴元素
4 while(low<high) { //用low、high搜索枢轴的最终位置
5 while(low<high&&arr[high]>=tempA) high--;//从右开始,比枢轴小的元素移动到左端,即low所指位置
6 arr[low]=arr[high];
7 while(low<high&&arr[low]<=tempA) low++;//从左边开始,比枢轴大的元素移动到右端,即high所指位置
8 arr[high]=arr[low];
9 }
10 arr[low]=tempA; //确定枢轴元素存放到最终位置
11 return low; //返回存放枢轴的最终位置
12}
13
14void quickSort(int arr[],int low,int high) {
15 if(low<high) {
16 int p=partition(arr,low,high); //划分确定枢轴元素位置
17 quickSort(arr,low,p-1); //枢轴左边表进行递归确定位置
18 quickSort(arr,p+1,high); //右表确定位置
19 }
20}
效率分析:
通过上图可以看出每一层只需要处理剩下蓝色部分待排序元素,所以时间复杂度不超过
由于需要用到递归,层数越多用到空间越多,所以每一层空间复杂度是
可以看出对于快速排序时间与空间复杂度分析必须要研究递归层数:
可以得出结论:快速排序就是把
所以对于快速排序其最小递归层数
最好时间复杂度是:
最坏时间复杂度:
最好空间复杂度:
最坏空间复杂度:
快速排序是不稳定地算法。
注意:若每一次选中的枢轴元素将待排序序列划分为均匀的两个部分,则递归深度最小,算法效率最高。若初始序列有序或逆序,则快速排序的性能最差(因为每次选择的都是最靠边的元素)。
基于上面分析,快速排序算法优化思路:尽量选择可以把数据中分的枢轴元素。如:
在实际应用当中快速排序是所有内部排序算法中平均性能最优的排序算法。
主要有:简单选择排序和堆排序
选择排序:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列
选择排序分类:简单选择排序和堆排序。
算法思路:每一趟在待排序元素中选取关键字最小的元素加入有序子序列
动画演示:
实现代码:
xxxxxxxxxx
181//交换
2void swap(int &a,int &b){
3 a=a-b;
4 b=a+b;
5 a=b-a;
6
7}
8
9//简单选择排序
10void SelectSort(int A[],int n){
11 for(int i=0;i<n-1;i++){ //一共进行n-1趟
12 int min=i; //记录最小元素位置
13 for(int j=i+1;j<n;j++) //在A[i...n-1]中选择最小的元素
14 if(A[j]<A[min]) min=j; //更新最小元素位置
15 if(min!=i)
16 swap(A[i],A[min] ); //封装的swap()函数移动元素
17 }
18}
算法效率分析:
适用性:即可用于顺序表,也可以用于链表。
堆排序的实现需要用到堆这种数据结构。堆这种数据结构又可以进一步划分为大根堆和小根堆。
堆结构和二叉树的顺序存储类似:
二叉树顺序表中存储结构:
几个重要操作:
若完全二叉树中共有
堆结构也可以看作是一颗完全二叉树在顺序表存储结构:
上图左边是存储结构,右边是逻辑结构。可以观察到大根堆就是在完全二叉树中,所有的根结点
所以堆排序步骤:
首先将给定的随机序列表按照根结点
具体做法是检查二叉树中所有根结点是否满足根结点
由于大根堆表中第一个元素一定是最大元素,所以将其移动至表尾
接着对剩下的表中元素再次进行大根堆结构恢复操作。
依次循环上面三个步骤,直到所有最后表中只剩一个元素,排序结束
例子:给定以下序列表进行升序排序
排序步骤:
首先将序列表组成的二叉树转换为大根堆结构。在顺序表的完全二叉树中,非终端结点的编号为
对于
接着处理
处理
交换后发现以
此时,整个二叉树已经称为标准的大根堆结构,将序列表第一个元素换至表尾
接着将剩下元素重新恢复大根堆结构。恢复后结构如下:
此时已经完成第一趟处理,之后重复上述操作即可。经过
注意:基于大根堆的堆排序,得到是递增序列,而基于小根堆得到的是递减序列。
实现代码:
xxxxxxxxxx
291//将以k为根的子树调整为大根堆
2void HeadAdjust(int A[],int k,int len){
3 A[0]=A[k]; //A[0] 暂存子树的根结点
4 for(int i=2*k;i<=len;i*=2){ //沿key较大的子结点向下筛选
5 if(i<len&&A[i]<A[i+1])
6 i++; //取key较大的子结点的下标
7 if(A[0]>=A[i]) break; //筛选结束
8 else{
9 A[k]=A[i]; //将A[i]调整到双亲结点上
10 k=i; //修改K值,以便继续向下筛选
11 }
12 }
13 A[k]=A[0]; //被筛选结点的值放入最终位置
14}
15
16//建立大根堆
17void BuildMaxHeap(int A[],int len){
18 for(int i=len/2;i>0;i--) //从后往前调整所有非终端结点
19 HeadAdjust(A,i,len);
20}
21
22//堆排序的完整逻辑
23void HeapSort(int A[],int len){
24 BuildMaxHeap(A,len); //初始建堆
25 for(int i=len;i>1;i--){ //n-1趟的交换和建堆过程
26 swap(A[i],A[1]); //堆顶元素和堆底元素交换
27 HeadAdjust(A,1,i-1); //把剩余的待排序元素整理成堆
28 }
29}
算法效率分析:
由于所有操作都是基于HeadAdjust()
这个函数的,所以要分析该函数效率:
由于一个结点每下坠一层,最多只需要对比关键字两次。若树高为
第
所以,建堆的过程,关键字对比次数不超过
而HeapSort()
中for
循环排序过程中总共需要
假设以小根堆为例,对于小根堆,新元素放到表尾,与父节点对比,若新元素比父节点更小,则将二者互换。新元素就这样一路上升,直到无法继续上升(根结点比新元素小)为止。
例:将下列小根堆中插入新元素
首先将新元素
接着继续和父结点
接着和父结点
插入总共对比关键字
首先从序列表中将被删除元素删除,接着将表尾元素替换到被删除元素位置,再进行小根堆恢复:被删除的元素用堆底元素替代,然后让该元素不断下坠,直到无法下坠为止。
例:将下列小根堆中删除
首先删除
接着再和左右孩子对比,
最后小根堆恢复
删除总共对比关键字
归并:把两个或多个已经有序的序列合并成一个。
首先开辟一个能放入上面两个数组的更大的数组。
对比上面待排序两个数组,将
上面的归并称为二路归并,即每次选出一个子表中最小的元素插入新的排序序列中,这种每次选出一个元素需要对比
同样还有四路归并:
对比
结论:
归并排序模拟:给定以下序列,每个序列都是独立集合。
首先第一趟可以将初试序列,两两归并:
第二趟同样将第一趟归并后的序列再次两两归并:
第三趟归并,将第二趟序列做最后一次合并即可:
通过上面步骤可以得到归并排序核心操作:把数组内的两个有序序列归并为一个。
代码实现:
xxxxxxxxxx
251//辅助数组B
2int *B=(int *)malloc(n*sizeof(int));
3
4//A[low.mid]和A[mid+1...high]各自有序,将两个部分归并
5void Merge(int A[],int low,int mid,int high){
6 int i,j,k;
7 for( k=low; k<=high;k++)
8 B[k]=A[k]; //将A中所有元素 复制到B中
9 for(i=low, j=mid+1,k=i; i<=mid&&j<=high;k++){
10 if(B[i]<=B[j])
11 A[k]=B[i++]; //将较小值复制到A中
12 else
13 A[k]=B[j++];
14 }//for
15 while(i<=mid) A[k++]=B[i++];
16 while(j<=high) A[k++]=B[j++];
17}
18void MergeSort(int A[],int Low,int high){
19 if(low<high){
20 int mid=(low+high)/2; //从中间划分
21 MergeSort(A,low,mid); //对左半部分归并排序
22 MergeSort (A,mid+1,high); //对右半部分归并排序
23 Merge(A,low,mid,high); //归并
24 }//if
25}
算法效率分析:
可以看到上面MergeSort()
函数使用了递归进行排序。所有也可以认为二路归并是一棵倒立的二叉树。可以利用二叉树特性分析二路归并排序的算法效率。分治法递归树如下:
更详细图解:
二路归并总共需要
对上面算法例子进行归并分析:
时间复杂度:上面第二趟排序中,将两个序列归并时,所需要关键字对比次数
空间复杂度为
结论:
归并排序总结:
假设长度为
这里的
基数排序得到递减序列的过程如下:
初始化:设置
按照各个关键字位权重递增的次序(个
分配:顺序扫描各个元素,若当前处理的关键字位
收集:把
基数排序模拟:假设给定以下序列,要求得到关键字递减的有序序列:
上面数组中关键字基数为
第一趟处理:以"个位"进行分配,队列第一个关键字
之后进行收集工作:将各个队列中的关键字组织成一个统一的链表。由于这里要求得到递减的数列,所以从
第一趟"收集"结束:得到按"个位"递减排序的序列。
第二趟处理:基于第一趟结果,对"十位"进行分配。第一个关键字
收集结果如下:
第二趟"收集"结束:得到按"十位"递减排序的序列,"十位"相同的按"个位"递减排序。
第三趟处理:以"百位"进行分配。队列第一个关键字是
收集结果如下:
第三趟按"百位"分配、收集:得到一个按"百位"递减排列的序列,若"百位"相同则按"十位"递减排列,若"十位"还相同则按"个位"递减排列。
由此可以看出基数排序不是基于"比较"的排序算法。
基数排序实现:基数排序大都基于链式存储结构实现。
xxxxxxxxxx
91typedef struct LinkNode{
2 ElemType data;
3 struct LinkNode *next;
4}LinkNode,*LinkList;
5
6//队列是链式队列
7typedef struct{
8 LinkNode *front,*rear; //队列的队头和队尾指针
9}LinkQueue; //LinkQueue Q[10]
算法效率分析:
空间复杂度:需要
时间复杂度:一趟分配
xxxxxxxxxx
41//收集Q5队列
2p->next = Q[5].front;
3Q[5].front=NULL;
4Q[5].rear=NULL;
同时也可以看出基数排序具有稳定性。
基数排序应用:某学校有
上面例子中基数排序的时间复杂度为:
基数排序擅长解决的问题:
基数排序总结:
磁盘的读
外部排序是指数据元素太多,无法一次全部读入内存进行排序。可以使用归并排序的方法,最少只需在内存中分配
假如在内存中开辟以下三个缓冲区:
同时要排序以下磁盘中的没见,每块大小为
磁盘中分为
首先在归并排序开始之前,需要构造一些有序的子序列。首先将内存中前两块的内存读入输入缓冲器
首先将输入缓冲区
再通过输出缓冲区写回磁盘
之后将输入缓冲区
此时磁盘块前两个空间内的数据有序。这样的有序初试序列称为归并段。同样的将磁盘后面的块进行两两有序的内部排序。
通过这样方式可以得到
之后用上面的初始归并段进行排序,首先进行第一趟归并
将两个归并段中更小的归并段先放入缓冲区,之后进行归并排序,排序方法是依次将两个输入缓冲区中最小的放入输出缓冲区。
由于输出缓冲区与两个输入缓冲区大小一致,所以只能放入三个记录关键字,当记录关键字填满了输出缓冲区之后,输出缓冲区会将数据写回磁盘块。
接着继续对输入缓冲区中的记录关键字进行归并,当某一个输出缓冲区空时,需要将对应的归并段下一块补上。
这里将归并段
之后继续归并将关键字填入输出缓冲区,当缓冲区
之后继续进行归并,直到两个归并段合并
之后可以用与之上面类似方法对后面三组(每组两个)进行归并。归并后这一趟归并结束,结果如下:
在这一趟归并结束后,可以将
接着进行第二趟归并,将这四个归并段,分别进行两两归并。
之后用归并排序的方法,依次将两个输入缓冲区中更小的记录关键字写入输出缓冲区,同样的当输入缓冲区
最后如上图可以将这两个归并段归并为一个。两外一组进行归并方法一样,结果如下:
完成了这一趟的归并后得到两个归并段。
最后一趟归并将上面的两个归并段合并为一个。方法与上面一样,经过这一次归并就可以将这一片内存中的
注意:这里每趟归并会开辟一个与两个归并块大小之和的磁盘块,一趟排序结束后之前的磁盘块销毁。且每当输入缓冲区
外部排序时间开销分析如下:
通过上图不难发现外部排序时间开销
上面读写外村时间与读写磁盘次数成正比,上面例子首先初始化归并段读写
优化方法是使用多路归并。
这里以四路归并举例,如果是四路归并,就需要在内存中开辟四个输入缓冲区。之后将四个归并段中的内容读入缓冲区。
将四个归并段中第一块记录关键字块放入对应的输入缓冲区。
之后归并方法与上面一致。同样需要注意的是当一个输入缓冲区
这样在一趟归并完成后就得到两个归并段
之后再需要一趟二路归并即可。
效率分析:
这样采用
重要结论:采用多路归并可以减少归并趙数,从而减少磁盘
推导:
当然也不并不是
除了增加
外部排序总结:
注:按照本节介绍的方法生成的初始归并段,若共
这里补充
如上图仅仅是四路归并树,而不是四路平衡归并树。因为第一趟
上面外部排序优化可以得出增加
败者树可以视为一棵完全二叉树(根节点上面又多了一个根节点),
败者树在多路平衡归并中的应用:
有如上图所示的八个归并段。要从这八个归并段中每次选出最小的记录关键字。传统方式,每次都需要进行七次对比才能得到一个最小关键字,而采用败者树方式如下:
将每个归并段的第一个元素放入叶子结点。接着就需要构造一棵败者树。构造方法是:每次底部两个叶子结点进行对比,较小的晋级到父结点,但是这里晋级到父结点后并不是记录这个关键字的值,而是记录更小关键字来自哪个归并段。
第一趟对比如下:
结果是归并段
第二趟将归并段
之后
本轮关键字归并段
之后归并段
结论:对于
代码实现:
八路归并情况下只需要对应一个长度为
总结:败者树解决的问题是使用多路平衡归并可减少归并趟数,但是用老土方法从
败者树可视为一棵完全二叉树(多了一个头头)。
上图归并段有
上面败者树建立后,后序选出一个更小关键字最多只需要
之前讲过进行
实现原理:在用于内部排序的内存工作区中,可以容纳
设初始待排文件为
实现模拟:假设有以下待排序文件:
这里假设用于内部排序的内存工作区只能容纳
首先将待排序文件数据中三个记录关键字放入
选择最小的
之后依次将
直到当前记录关键字
之后继续置换,直到内存工作区
第二个归并段,恢复
此时
归并段
可以看到上个例子中得到的三个初始归并段可以超过内存工作区的限制。每个归并段所包含的关键字越多,
注意:这里的输出文件
上面的学习可以知道如果使用置换-选择排序构造初始归并段,这些初始的归并段长度可能各不相同。
下面有一个初始归并段:
这里
上面初始归并段中,如果
这颗归并树的读
可以得出一个重要结论:归并过程中的磁盘
最佳归并树
上面是二路归并情况,以下是多路归并:
上面初始归并段含义同上。假设现在要采用
这个归并树的
显然这不是最佳归并树,三路归并树构造和二路类似:每次选出三个权值最小的结点构造成树即可。上面出时段构造哈夫曼树如下:
归并树
但是如果减少一个归并段,即有
构造成
这棵树的
结论:对于
这里的虚段本质是,三个输入缓冲区中一个为
即代表虚段。
再接着深入分析:
另外根据
所以,判断初始段序列是否需要添加虚段方式:
最佳归并树总结: