8 数据结构-排序

463 阅读34分钟

考纲要求 💕

知识点(考纲要求)

1、一般概念

2、插入排序、选择排序、归并排序、基数排序。

3、有关内部排序方法的讨论。

考核要求

1、掌握各种排序的基本思想及其特点,熟悉各种排序方法的排序过程。

2、掌握各种排序方法的优缺点。


▶️ 1. 排序基本概念


图片.png


1.1 ✨ 排序定义

排序(Sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。

输入:n个记录R1 , R2 ,..., Rn,对应的关键字为k1, k2 ,..., kn 。
输出:输入序列的一个重排R1 ʹ, R2ʹ,..., Rn ʹ,使得有k1ʹ≤k2 ʹ≤...≤kn ʹ(也可递减)

图片.png


1.2 ✨ 排序算法的评价指标

时间复杂度与空间复杂度

还需要关注 稳定性


算法的稳定性: 若待排序表中有两个元素Ri和Rj,其对应的关键字相同即key i = key j,且在排序 前Ri在Rj的前面,若使用某一排序算法排序后,Ri仍然在Rj的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。

举例:

图片.png


1.3 ✨ 排序算法的分类

图片.png

内部排序:程序数据放入内存中。
外部排序:数据太多,数据放入外存,磁盘读写。

外存内存读写速度比较:

图片.png


1.4 可视化网站

参考:可视化网站


▶️ 2. 插入排序

图片.png


2.1 ✨ 插入排序算法思路

❗ 2.1.1 思路流程

算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。

举例:

动画1.gif


❗ 2.1.2 代码实现

直接插入排序

//直接插入排序
void InsertSort(int a[],int n){
    int i,j,temp;
    for(i=1;i<n;i++){ //将各元素插入已排好序的序列中
       if(a[i]<a[i-1]){ //a[i]关键字小于前驱
         temp=a[i];  //用temp暂存a[i]
         
        for(j=i-1;j>=0 && a[j]>temp ; --j) //检查前面已经排好序的元素
        {
            a[j+1] = a[j]; //所有大于temp的元素向后挪位
        }
        a[j+1] = temp; //复制到插入位置
    }
}

代码流程如下举例:

  • i=1,指向第二个元素。然后与前驱第一个元素比较。这里是前面是49,小于,所以存入temp=a[i];

图片.png

  • 进入for循环,j=i-1。j=0;检查i前面排好序的元素。把所有大于temp即a[i]的元素向后移动一位。

图片.png

这时候j--,j=0-1=-1。然后最后 a[j+1] = temp; 把原来小的a[i]放入前面的位置0。

图片.png

这就是第一轮循环,然后后面i++,i=2,指向2,65元素。重复for循环。


直接插入排序(哨兵)

//0相当于哨兵
void InsertSort(int a[],int n){
     int i,j;
    for(i=2;i<=n;i++)  //依次将a[2]~a[n]插入到前面已排序序列
        if(a[i]<a[i-1]){ //若a[i]关键码小于其前驱,将a[i]插入有序表
        a[0]=a[i];  //复制为哨兵,a[0]不存放元素
        for(j=i-1;a[0]<a[j];--j) //从后往前查找待插入位置
            a[j+1] = a[j]; //向后挪位
            
              a[j+1] = a[0];//复制到插入位置
     }
}
  • 第一次for循环,i=2,指向38。如果a[i]小于前驱,a[0]=a[i]赋值给他。然后for循环,j=i-1=1;这时候a[0]是小于a[1]的,for循环执行,向后挪位。a[2]=49

图片.png

  • 此时--j; j=0, 这时候ij指向如下

图片.png

  • 这时候a[0]=a[j],可以跳出for循环。执行a[j+1] = a[0];//复制到插入位置

图片.png

  • 优点是每轮循环不用判断j大于等于0

图片.png


❗ 2.1.3 效率分析

空间复杂度都是O(1)

看下时间复杂度:

  • 最好情况:原本就有序

共n-1趟处理,每一趟只需要对比关键字1次,不用移动元素,也就是只用if比较。

图片.png

  • 最好情况:时间复杂度为O(N)

  • 最坏情况:原本为逆序,都要处理。

图片.png

最坏情况:
第1趟:对比关键字2次,移动元素3次
第2趟:对比关键字3次,移动元素4次
...
第 i 趟:对比关键字 i+1次,移动元素 i+2 次
...

  • 最坏情况:时间复杂度为O(N^2)

  • 平均时间复杂度:时间复杂度为O(N^2)

总结:稳定排序算法

图片.png


▶️ 3. 折半插入排序

对上面的插入排序进行优化。

3.1 ✨ 折半插入排序算法思路

❗ 3.1.1 思路流程

  • 思路:先用折半查找找到应该插入的位置,再移动元素

  • 流程如下:先看i=8,55时候的第一次排序。

动画1.gif

  • 再看i+1=9后,对60的处理:

动画1.gif

  • 这时候查找到mid=5=60相同时候。继续往右边查找

当 A[mid]==A[0] 时,为了保证算法的“稳定性”,应继续在 mid 所指位置右边寻找插入位置

动画1.gif

  • 再看i+1=10后,对90的处理。也跟上面一样

图片.png

当 low>high 时折半查找停止,应将 [low, i-1] 内的元素全部右移,并将 A[0] 复制到 low 所指位置

这时low>i-1 。 区间不成立,移动不了。直接下一个元素。i+1=11,对10处理

动画1.gif


❗ 3.1.2 代码实现

void InsertSort(int A[],int n){ //n是数组长度
	int i,j,low ,high,mid;
	for(i =2;i<=n;i++){           //i是从待排序序列中拿出来的数值。依次将A[2]~A[n]插入前面的已排序序列(注意这里是从2开始,这里是用哨兵的方法)
		A[0]=A[i];             //A[0]处暂时存放A[i]
		low=1;high=i-1;         //设值折半查找的范围(默认递增有序)
		//用一个while循坏,用折半查找方法查找插入位置
		while(low<=high){  //折半查找默认递增有序
			mid=(low+high)/2; //取中间点
			if(A[0]<A[mid]) high=mid-1; //查找左半子表(记忆:先左后右)
			else low=mid+1; //查找右半子表。
		} //重点:最终,high指向了小于等于A[0]的位置。
		//把high后的元素统一后移
		for(j = i-1;j>=high+1;--j){
			A[j+1]=A[j];//统一后移元素,空出插入位置
		}
		//把A[0]插入high+1处
		A[high+1]=A[0];	
	}
}

❗ 3.1.3 效率分析

  • 空间复杂度:和直接插入排序一样,仅使用了常数个辅助单元,空间复杂度为 O ( 1 ) 。

  • 时间复杂度:相对于直接插入排序,折半插入排序仅仅减少了比较元素的次数,没有减少移动的次数,所以时间复杂度仍为 O(n²)

  • 稳定性:和折半插入一样,在移动的过程中, 只有前面一个数大于temp,才会移动,所以如果遇到相等的情况,就不会移动。所以折半插入排序也是一种稳定的排序方法。

  • 适用性: 和直接插入排序不一样,折半插入只适用于顺序表,不能用于链表因为链表不支持随机查找,它不能随意定位到low mid high这些点处的数值。


▶️ 4. 希尔排序

✨ 4.1 思路流程

希尔排序:先追求表中元素部分有序,再逐渐逼近全局有序

希尔排序:先将待排序表分割成若干形如 L[i, i + d, i + 2d,..., i + kd] 的“特殊”子表,对各个子表分别进行直接插入排序。缩小 增量d,重复上述过程,直到d=1为止。


希尔排序的过程如下:

  • 第一步:取d=n/2;举例:d1=n/2=4

图片.png

  • 第二步:待排序表分割成若干形如[ i , i + d , i + 2 d , ⋯   , i + k d ] ;

这里举例:相隔4个的组成子表

图片.png

  • 第三步:在各个组内进行直接插入排序;

比如第一个子表,49,76本来就是有序递增的,直接插入。不改变。

第二个子表,38,13。 需要改变位置。13放在前面。

图片.png

处理后表为:

图片.png

  • 第四步:缩小d,即让d=d/2;重复二、三的步骤;

图片.png

图片.png

图片.png

  • 第五步: 不断缩小d,直到d= 1. 即所有记录已放在同一组中,再进行一次直接插入排序。

第三趟:d3=d2/2=1

图片.png

图片.png

图片.png

由于此时已经具有了较好的局部有序性,故可以很快得到最终结果。


✨ 4.2 代码实现

void ShellSort(int A[],int n){ //n是数组长度
	int i ,j ,temp,d;
	 
	for(d=n/2;d>=1;d=d/2){  //每次最外的循环计算了一个d,d会不断变小。
		for(i=d+1;i<=n;i++){  //根据某一个d,进行分组排序
			
			if(A[i-d]>A[i]){  //在子表中,如果前面的数值大于后面的数值
				temp=A[i];  //用temp暂存后面的数值
				//移动前面的元素
				for(j=i-d;j>=0 && temp<A[j];j-=d){
					A[j+d]=A[j];}  //记录后移,查找插入的位置
				//插入
				A[j+d]=temp;
			}
		}
	}	
}

流程如下:

  • 第一趟。d1=8/2=4,i从5开始排序。

动画1.gif

  • 第二趟。d2=4/2=2。i从3开始。

动画.gif

  • 第二趟。d3=1。i从2开始。直接遍历一遍。排序一遍

图片.png

图片.png


✨ 4.3 效率分析

  • 空间复杂度: 仅使用了常数个辅助单元,空间复杂度为O ( 1 )

  • 时间复杂度:当n在某个特定范围时,希尔排序的平均时间复杂度约为O(n^{1.3}) 。在最坏情况下,希尔排序的时间复杂度为O(n²) 。不过总体而言,这个算法还是比较优化的。

  • 稳定性:当相同关键字的记录被划分到不同子表时,可能会改变它们之间的相对次序,因此希尔排序是一种不稳定的排序方法。

  • 适用性: 希尔排序对较大规模的排序都可以达到很高的效率。
    仅适用于顺序存储的线性表。因为我们需要用增量d快速找到与之相邻的、从属于同一个子表的各元素,所以必须要有随机访问的个性。


▶️ 5. 冒泡排序


图片.png


✨ 5.1 思路流程

从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]),则交换它们,直到序 列比较完。称这样过程为“一趟”冒泡排序

举个例子: 按递增序列来

  • 第一趟结束: 第一趟排序使关键字值最小的一个元素“冒”到最前面

动画1.gif

  • 第二趟: 跟第一趟类似,但是第二趟不用与第一个元素比较,因为已经第一趟比较是最小的

动画.gif

第2趟结束后,最小的两个元素会“冒”到最前边

  • 后面的与上面类似

  • 到第5趟排序时候:

动画1.gif

  • 所以最后结束条件 : 若某一趟排序没有发生“交换”,说明此时已经整体有序

图片.png


✨ 5.2 代码实现

需要写一个交换的函数swap。
冒泡排序算法代码如下

//交换函数
void swap(int &a, int &b){
int temp=a;
a=b;
b=temp;}

//冒泡排序
void MaopaoSort(int A[],int len){
	int i , j;
	for(i=0;i<=len-1;i++){ //i每增加一次,说明一趟冒泡结束
		bool flag=false;   //表示本趟冒泡是否发生交换的标志
		for(j=len-1;j>i;j--){ //j是要处理的数。在这一趟冒泡里,是从后面往前冒泡,所以是j=len-1。
			//如果两个数,如果前面一个比后面一个大,就对调
			if(A[j-1]>A[j]){     
				//对调
                                swap(A[j-1],A[j]);
				flag=true;  //只要有交换就改成true。说明算法还没有结束。
			}
		}
		if(flag==false) return; //算法结束的标志是false。意思是一趟结束后,flag都没有变成true。说明算法结束,表已经有序
	}
}

✨ 5.3 效率分析

  • 空间复杂度:仅使用了常数个辅助单元,空间复杂度为O ( 1 )

  • 时间复杂度

最好情况:都是排序好的,不需要交换。比较次数=n-1;交换次数=0 。

最好时间复杂度=O(n)

图片.png

最坏情况:(逆序) 都需要二二交换。 第一趟n-1次,第二趟n-2次.....

比较次数=(n-1)+(n-2)+...+1 = n(n − 1)/2=交换次数。

最坏时间复杂度=O(n^2)

图片.png

平均时间复杂度=O(n^2)


这里注意下每次交换元素swap:

//交换函数
void swap(int &a, int &b){
int temp=a;
a=b;
b=temp;}

需要移动元素3次。

也就是 最坏情况下比较次数=n(n-1)/2
最坏情况下每次比较都要交换。然后 移动元素次数=n(n-1)/2 乘以 3


  • 稳定性:冒泡排序时一种稳定的排序方法。
    如果把代码中判断是否逆序的条件由“>”改为“≥”,则算法变得不稳定。

  • 是否可以用于链表:可以

举例:假设递增

动画1.gif


▶️ 6. 快速排序

✨ 6.1 思路流程

快速排序的基本思想是基于分治法的:在待排序表中选取一个元素,称为枢轴(或称基准,常取首元素)。

通过一趟排序,将待排序表分成两部分,一部分中所有元素均小于枢轴,另一部分元素均大于枢轴,两部分分别位于枢轴元素的两侧,这个过程称为一趟快速排序(或一次划分)。

然后递归地分别对两个子表重复上述过程,直到每部分只有一个元素或空为止,此时所有元素都放在了最终位置。

举例说明流程:

  • 原图: 指定low ,high 指针

图片.png

  • 以low,high作为枢轴。也就是low的左边是小于low的,higt右边是大于等于high的

图片.png

  • low此时所指的元素为空,先让high移动。high指向6位置27,此时27<49,所有小于49的应该要放到low左边。所以先把27移动到low位置也就是0位置。

图片.png

  • 此时hig指针位置空出来了,low指针指向的27<49,右移。此时38<49不需要动。

图片.png

  • low指向下一个。此时65>49,要放到high右边。high此时为空,放入high位置。

图片.png

  • 此时low指针空了,让high指针移动。65>49不移动,指向下一个5位置13.13<49 ,移动到low位置。

图片.png

  • 继续

图片.png

  • 最后high,low在一个位置

图片.png

  • 此时把枢轴元素放入这个位置

图片.png

  • 也就是用第一个元素把序列划分为二个部分,左边更小,右边更大。

这里注意下最后划分后,左右子表就剩一个元素就不需要再排了。

  • 后续不用管49了,以49为中心分成左子表,右子表。重复上面。

左子表:

动画1.gif

右子表:

动画1.gif


✨ 6.2 代码实现

一趟快速排序是一个交替搜索和交换的过程,算法如下

//用第一个元素将待排序列划分为左右二个部分 这是一趟
int partition(int A[], int low, int high){
	int pivot = A[low];  //用第一个元素作为枢轴
	while(low < high){  
		while(low < high && A[high] >= pivot) //把high指针不断往前移动,找到小于枢轴的元素
			high--;               
		A[low] = A[high]; //把小于枢轴的元素放到左端
		
		while(low < high && A[low] <= pivot) //把low指针不断向后移动,找到大于枢轴的元素
			low++;
		A[high] = A[low]; //把大于枢轴的元素放到右端
	}
	A[low] = pivot;  //将枢轴元素置入交替搜索后留出的空位中。
	return low;  //返回枢轴位置
}

//快速排序
void quickSort(int A[], int low, int high){
	if(low < high){  //low和high初始的意义是指向待排序的数组的两头
		//一趟快排,将表划分为两个子表,返回枢轴位置
		int pivotpos = partition(A, low, high); //调用这个函数一次,就相当于把基准放到最终的位置。
		quickSort(A, low, pivotpos-1);  //对左子表进行递归
		quickSort(A, pivotpos+1, high);  //对右子表进行递归
	}
}

✨ 6.3 效率分析

  • 空间复杂度

快排是递归地,需要借助一个递归工作栈来保持每层递归调用的必要信息(变量、地址),容量与递归调用的最大深度一致。

图片.png

所以需要分情况讨论:

空间复杂度=O(递归层数)。递归层数最大是n,最小是log2nlog_2n

可以把递归的过程写成二叉树,这样使用二叉树的特性就能求出递归层数:

图片.png


  • 时间复杂度

时间复杂度也是如此:需要处理几次排序,每次排序遍历n

图片.png

时间复杂度=O(n*递归层数)。递归层数最大是n,最小是log2nlog_2n


  • 稳定性

某一趟中,两个关键字相同的元素,从一个区间被交换到另一个区间的过程中,相对位置会发生变化。快速排序是一种不稳定的排序方法。

开始是这样的:

图片.png

排序后:

图片.png

原本下划线2的位置发生改变,不稳定的算法


✨ 6.4 效率优化

  • 优化

根据二叉树的特性,如果数值刚好顺序或者逆序,那么时间复杂度就是O(n²)。这是最大的情况。

图片.png

如上图:每次划分完,左右两边差的太多,需要遍历的次数多。

为了提高性能,在选取枢轴的时候, 最好能将序列划分成为均匀 的两部分。

图片.png

所以,在选取枢轴的时候:

  • 可以选头、尾、中间值,再比较一下,然后采用数值大小居中的元素作为枢轴

  • 也可以采用 随机选择 的办法,这样就可以避免刚好顺序或者逆序


优化后的效率分析:

图片.png

快排一般的时间复杂度接近平均时间复杂度,因此快排是所有排序算法中平均性能最好的算法。

图片.png


▶️ 7. 简单选择排序


图片.png


✨ 7.1 思路流程

思路:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序序列

注意:相同的元素先取最近的,也就是最前面的。

动画1.gif


✨ 7.2 代码实现

//交换函数
void swap(int &a, int &b){
int temp=a;
a=b;
b=temp;}

//简单选择排序算法
void SelectSort(int A[],int n){ //n是数组长度
for(int i =0;i<n-1;i++){ //遍历,一共进行n-1趟
	int min =i;      //记录最小元素位置
	for(int j =i+1;j<n;j++){ //在A[i,n-1]中选择最小的元素     
	if(A[j]<A[min])    //更新最小元素位置
        min = j;}
         
	if(min!=i){      //如果最小元素位置不等于i,更新元素
	swap(A[i],A[min]); //封装的swap函数移动元素3次
		}
	}

✨ 7.3 效率分析

  • 空间复杂度:仅使用常数个辅助单元,空间效率为O(1)

  • 时间复杂度:这个算法,因为是全盘扫描找最小值,所以不管初始状态是什么(不管你是顺序还是逆序),它的时间复杂度始终是 O(n^2)

图片.png

这里的元素交换次数是指每一次对比都需要swap移动3个元素的次数。最多n-1次交换。

  • 稳定性:在第 i 趟把最小元素和第 i 个元素进行交换时,可能导致第 i 个元素与其后含有相同关键字元素的相对位置发生变化。简单选择排序是不稳定的

图片.png

  • 适用性:简单选择排序的思想是全部扫描,没有用到随机查找,所以 顺序表和链表都适用

▶️ 8. 堆排序


图片.png


✨ 8.1 什么是“ 堆(Heap) ”

若n个关键字序列L[1...n] 满足下面某一条性质,则称为堆(Heap):

① 若满足:L(i)≥L(2i)且L(i)≥L(2i+1) (1 ≤ i ≤n/2 )—— 大根堆(大顶堆)
② 若满足:L(i)≤L(2i)且L(i)≤L(2i+1) (1 ≤ i ≤n/2 )—— 小根堆(小顶堆)

举例:

图片.png


理解:

可以把他看做一个二叉树:

图片.png

图片.png

所以45,78是i=1,87 的孩子结点。

所以上面的定义可以简化成这样:

大根堆(大顶堆) :完全二叉树中,根≥左、右
小根堆(小顶堆) :完全二叉树中,根≤左、右


✨ 8.2 如何基于“堆”进行排序?

❗ 8.2.1 建立大根堆

思路:把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整

检查当前结点是否满足 根≥左、右
若不满足,将当前结点与更大的一个孩子互换:

图片.png


然后根据完全二叉树的性质:在顺序存储的完全二叉树中,非终端结点编号 i≤[n/2]

也就是说,i≤[n/2]的都是非终端节点,也就是分支节点+根结点。

图片.png


也就是处理前四个元素。这里从后往前处理:

  • 先对09进行处理:i与2i,2i+1比较也就是和左右孩子节点比较。

图片.png

与32互换:

图片.png

  • 继续下一个:

动画1.gif

如果二个孩子节点都不符合,也就是都大于它,找最大的代替

动画1.gif

最后一个:

动画1.gif

这时候会出现一个问题:若元素互换破坏了下一级的堆。(小元素不断“下坠”)则采用相同的方法继续往下调整

也就是对53进行处理

动画1.gif

图片.png

然后不断往下调整,直到小元素无法继续下坠了,则符合了。


❗ 8.2.2 大根堆代码实现

建立大根堆的代码实现

//建立大根堆
void buildMaxHeap(int A[], int len){
    for(int i = len/2; i > 0; i--)  // i指向要调整的非叶节点。从最小非叶结点开始,反复调整堆
        headAdjust(A, i, len);  //调用函数,调整为大根堆
}
 
//将以k为根的子树调整为大根堆
void headAdjust(int A[], int k, int len){  //k是要调整的非叶节点。len是参与调整的界限
    A[0] = A[k];  // 暂存子树的根节点
    
    //沿着key较大的子节点往下筛选
    for(int i = 2*k; i <= len; i *= 2){ // i指向左孩子。i *=2的意思是沿key值较大的结点往下,即小元素下坠。 i <= len是向下调整的终止条件
        if(i<len && A[i+1]>A[i])  // i<len的作用是保证k有右孩子
            i++;  // 这时i 指向左右孩子中较大的节点。
        if(A[0] >= A[i])  // 再拿较大的孩子和根节点对比,如果根节点更大,就结束这次循环
            break;  
            
        A[k] = A[i];  // 否则就交换结点,把较大的孩子节点和根节点互换
        k = i;  // k指向了没有交换前i的位置,然后让i *=2,也就是进入第二次循环,以此来向下检查,让小元素不断下坠。
    }
    A[k] = A[0];//小元素下坠到最后,k指向了小元素最终下坠的位置,这步也是将被筛选的节点的值放入最终位置
}

流程图演示:

动画.gif

最后i=7x2=14>len,结束此次调整(for循环)。


❗ 8.2.3 基于大根堆进行排序

大根堆:大的数据排在前面,这样选择排序更容易实现了,直接取堆顶元素。

堆排序:每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换)


举例流程:

  • 第一趟:先取堆顶元素87,与最后一个元素交换,也就是最大与最小。这样就不需要再排序最后一个元素了。

图片.png

去除87,把上面的树重新进行大根堆排序。恢复成大根堆。

动画.gif

  • 继续跟上面步骤差不多,第一个元素交换,然后去除,剩余部分恢复成大根堆

动画.gif

动画1.gif


❗ 8.2.4 基于大根堆排序代码

//建立大根堆
void buildMaxHeap(int A[], int len)
 
//将以k为根的子树调整为大根堆
void headAdjust(int A[], int k, int len)


//堆排序的完整逻辑
void HeapSort(int A[],int len){
	buildMaxHeap(A,len);  //初始建堆(建堆函数中就有调整为大根堆)
	for(int i =len;i>1;i--){ //n-1趟将堆顶和堆底互换
		swap(A[i],A[1]);
		headAdjust(A,1,i-1);//互换结束后,要调整。为什么是i-1,因为i不参与调整了。
	}
}

✨ 8.3 算法效率分析

  • 空间复杂度: O(1)。没定义数组变量之类的。

  • 时间复杂度: O(nlog2nnlog_2n)

一个结点下坠一次需要比较2次:

图片.png

如果下方只有一个子树,则只用下坠一次,只需要比较关键字一次。

图片.png

结论:一个结点,每“下坠”一层,最多只需对比关键字2次

若树高为h,某结点在第 i 层,则将这个结点向下调整最多只需要“下坠” h-i 层,关键字对比次数不超过 2 * (h-i)

n个结点的完全二叉树高h=log2n+1log_2n+1


对于完全二叉树来说,第i层最多有2^(i-1) 个结点。而只有第 1 ~ (h-1) 层的结点才有可能需要“下坠”调整

从这二个式子得出:关键字比较次数=2(h-1)+2(h-2)+...+2

公式得出:

图片.png


因此得出:

  • (1)在建堆时,需要调用”调整函数”关键字的比较总次数不超过4n建堆的时间复杂度为O(n)

  • (2)在排序时,根节点最多“下坠” h-1 层。
    而每“下坠”一层,最多只需对比关键字2次,因为二叉树h=log2nlog_2n+1。因此每一趟排序复杂度不超过 O(h) = O(log2nlog_2n)。

共n-1 趟,排序总的时间复杂度 = O(nlog2nnlog_2n)


  • 最终得出:堆排序总的时间复杂度 = O(n)+O(nlog2nnlog_2n)=O(nlog2nnlog_2n)

图片.png


  • 稳定性

原图:

图片.png

调整为大根堆,堆排序后流程:

动画1.gif

最终:

图片.png

下划线2相对位置改变,不稳定的算法


▶️ 9. 堆的插入删除

注意:第八章以大根堆为例,本章以小根堆为例

✨ 9.1 堆的插入

对于小根堆,将新元素放在堆尾,与父节点相比,如果比父节点更小,则两者互换。新元素就这样一直上升,直到无法继续上升为止。(常考知识点:上升一次,关键字只对比1次

  • 举例:插入13

动画1.gif

总共比较了3次。

  • 举例:插入46,此时插入46,与父节点比较符合小根堆,不需要上升。

动画1.gif

总共比较了1次。


✨ 9.2 堆的删除

堆的删除通常在根节点处,在删除的位置用堆尾元素替代,然后让它不断调用“调整函数”来调整,让堆恢复成小根堆的性质。(常考知识点:下坠一次,关键字要对比2次

  • 举例:删除13,用堆尾元素代替。

图片.png

图片.png

此时需要调整,他此时不是小根堆,需要变成小根堆。也就是下坠。

动画1.gif

总共比较了4次。

因为下坠跟上升不一样,需要2次对比(2个孩子结点时候)

图片.png


▶️ 10. 归并排序

✨ 10.1 什么是Merge(归并/合并)?

归并:把两个或者多个有序的序列合并称为一个有序序列。

“二路归并”就是把2个有序合并成一个有序;“多路归并”就是把4个有序序列合并成一个有序序列。

举例:一个二路归并:

动画1.gif

每选出一个元素,关键字需要对比一次。

举例:一个四路归并:

图片.png


得出结论:m路归并,每选出一个元素需要对比关键字m-1次


✨ 10.2 归并算法流程

在内部排序中一般使用2路归并,二二归并。二部分二部分合并。

图片.png

核心操作:把数组内的二个有序序列归并为一个。


✨ 10.3 代码实现

代码如下:

//建立辅助数组B
int *B=(int *)malloc(n*sizeof(int));
 
//归并函数
//A[low...mid]和A[mid+1...high]各自有序,将二部分归并
void Merge(int A[],int low, int mid, int high){
	int i ,j ,k; //三个指针,i j是B上的,K是A上的
	//把A复制到B
	for(k=low;k<=high;k++)
		B[k]=A[k];
	//比较i j的key值,把小的放入A[k]
	for(i=low,j=mid+1,k=i;i<=mid&&j<=high;k++){
		if (B[i]<=B[j])
			A[k]=B[i++];
		else
			A[k]=B[j++];
	}
	//把没有扫描完的直接复制进入A
	while(i<=mid) A[k++]=B[i++];
	while(j<=high) A[k++]=B[j++];
}


//归并排序主体函数
void MergeSort(int A[],int low,int high){
	if(low<high){
		int mid =(low+high)/2;//从中间划分
		MergeSort( A,low mid); //对左半部分进行归并排序
		MergeSort( A,mid+1,high); //对右半部分进行归并排序
		Merge(A,low,mid,high); //调用归并函数
	}
}

解释:

  • 这里我们会先定义个辅助数组B

图片.png

  • 把low,high区域的复制到B

图片.png

  • 第二个for循环,进行归并,i指向low,即第一个序列的低于个元素16。j指向mid+1,即第二个序列的第一个元素21。

图片.png

  • 比较i,j指向的元素的大小,将较小的值复制到A中。也就是A[k]=b[]

这里16小,赋值到a[k],i++,k++

图片.png

  • 继续for循环

动画11.gif

相同优先前面的,保证其稳定性。

然后此时j=10,j>hig,不满足for循环条件,跳出循环。

但是排完,使用while(i<=mid);while(j<=high);看看是否有剩余没排的,直接插入A[K]

动画11.gif


添加这个

//归并排序主体函数
void MergeSort(int A[],int low,int high){
	if(low<high){
		int mid =(low+high)/2;//从中间划分
		MergeSort( A,low mid); //对左半部分进行归并排序
		MergeSort( A,mid+1,high); //对右半部分进行归并排序
		Merge(A,low,mid,high); //调用归并函数
	}
}

是把原始的A序列划分。

图片.png

  • 经过对左右二个子序列分别归并排序后

图片.png

  • 对二部分进行归并

图片.png


这里演示下左右部分排序,再划分二部分:

动画11.gif

  • 当子序列只含有一个元素时候,进入归并

大致流程就是这样。分治思想。


✨ 10.4 算法效率分析

  • 时间复杂度

这里可以把2路归并的归并树看成一个倒立的二叉树:

图片.png

根据二叉树的性质:

二叉树的第h层最多有2^(h-1)个结点。

若树高为h,则应该满足n个元素<=2^(h-1)

即h-1=[log2nlog_2n]

得到结论:

n个元素进行2路归并排序,归并趟数=[log2nlog_2n]

每趟归并的时间复杂度为O ( n ) :因为是用i j 扫描,一共要进行n-1次关键字对比。

算法的时间复杂度为O(nlog2nlog_2n)


  • 空间复杂度: 空间的开销来自构建的辅助数组B,B和A元素个数相同,都是n,所以是O(n).

  • 稳定性

merge()操作的时候,如果i j 的key值相等,优先让靠左边的元素放入A,所以并不会改变相同关键字记录的相对次序,所以归并排序的算法是稳定的。

从单个记录起进行两两归并并不值得提倡,通常将它和直接插入排序结合。改进后的归并排序仍是稳定的。


▶️ 11. 基数排序

✨ 11.1 基数排序思路流程

基数排序是一种很特别的排序方法,它不基于比较和移动进行排序,而是基于**关键字位(个十百位)**的大小进行排序。

举例:以排序为递减为例,初始长度为n的线性表

第一趟:以个位进行分配:

图片.png

图片.png

  • 以递减为顺序,那么就从高位开始到低位:

图片.png


  • 第二趟:以十位进行分配

这里需要注意一点:相同的入队后,可以比较低一级的个位,个位越大的越先入队。

图片.png

图片.png

  • 第二趟收集工作:按十位递减,十位相同的按个位递减。

图片.png


  • 第三趟:以百位进行分配

图片.png

  • 第三趟收集:

图片.png


总结流程:

(1)首先把每个元素想成由d元组组成,其中的关键字大于等于0,小于等于r-1,这个r称为“基数

d决定几趟,d是几位的代表,比如下面3元组,d=3,也就是个十百。

图片.png

(2)初始化:设值r个空队列,起名为r-1,r-2...0    。然后按照关键字位权重递增的顺序(个、十、百),对关键字位分别做“分配”和“收集”

(3)分配:顺序扫描各个元素,若当前处理的关键字位=x,则将元素插入Qx队尾;

(4)收集:把各个队列中的结点依次出队列并连接。(最终要想得到递减序列,就先收集队列较大的;如果想得到递增序列,就先收集队列较小的)


图片.png


✨ 11.2 算法效率分析

  • 空间复杂度

一趟排序需要辅助空间为 r(r个队列,r个队头指针和队尾指针)。空间复杂度为O ( r )


  • 时间复杂度

基数排序需要进行 d 趟分配和收集,一趟分配需要O ( n ) ,一趟收集需要O ( r )。所以基数排序的时间复杂度为O ( d *( n + r ) )

每个元素拆成d部分,n是元素个数,r是基数(每个部分可能取得r个值)。其与序列的初始状态无关。


  • 稳定性:
    基数排序是稳定的。

举例:第一趟个位分配:

图片.png

无论中间隔了多少,队列中的:

图片.png

出队后下划线12相对位置不变

图片.png


✨ 11.3 基数排序应用

举例:某学校学生信息按年龄递减排序

将年龄拆分三d组。

图片.png


根据上面总结的时间复杂度为O ( d *( n + r ) )。d=3,n=10000,r=31;时间复杂度约等于3万

跟以前学习的比较,若采用时间复杂度为O(n^2)的排序,时间复杂度约等于10^8
若采用时间复杂度为O(nlog2nlog_2n)的排序,时间复杂度约等于140000

比较下来,基数排序的时间复杂度较小,效率高。


所以基数排序适用于:

图片.png


▶️ 12. 外部排序


图片.png


✨ 12.1 外存、内存之间的数据交换

操作系统以“块”为单位对磁盘存储空间进行管理,如:每块大小1KB。各个磁盘块内存放着各种各样的数据

举例:磁盘读写 块

  • 读磁盘:

图片.png

  • 写磁盘:

图片.png


✨ 12.2 外部排序思路流程

外部排序:数据元素太多,无法一次全部读入内存 进行排序。

使用归并排序”的方法,最少只需在内存中分配3块大小的缓冲区即可对任意一个大文件进行排序

图片.png


这里我们使用归并排序的方法实现16个块递增排序:

  • 先把磁盘的二个块写入二个内存输入缓冲区

图片.png

  • 对块进行内部排序,递增排序

图片.png

  • 通过输出缓冲区,写入磁盘原来对应的块位置。

图片.png

  • 二个都输出写入后,前2块是一个有序的归并段。

图片.png

  • 后续的大致流程如上相同:

动画.gif

  • 最后得到16/2=8个归并段,需要16次读和16次写。然后可以使用这8个归并段进行归并排序。

接下来使用归并排序排序归并段:

  • 因为是递增,这里先把二个归并段中最小的块读入输入缓冲区

图片.png

  • 继续内部的归并排序:第一趟归并

动画.gif

  • 这里需要注意下,此时输入缓冲区1为空,就要立即用归并段的下一块补上。继续

动画.gif

  • 最终归并段1和归并段2在磁盘中被归并成一个段

图片.png


  • 跟上面一样,把后面的归并段也给归并了

图片.png

  • 这样第一趟归并结束了,二二归并合成1,原来8块/2=4块现在。

  • 第二趟归并,也跟上面流程一样

动画.gif

  • 最终:4块/2=2块

图片.png


  • 第三趟归并,也跟上面流程一样,最终得到2块/2=1块

图片.png


排序总体流程:

图片.png


✨ 12.3 时间效率分析

整个过程如下图:

图片.png


外部排序时间开销=读写外存的时间+内部排序所需时间+内部归并所需时间


  • 读写外存时间:每次读写各16次。读写磁盘次数=(文件总块数*2)+(文件总块数*2)*归并趟数

读写磁盘次数=32+32*3=128次。设每次读写的时间为tms,则需要时间128tms


✨ 12.4 优化

12.4.1 多路归并

    1. 减少读写外存时间:采用多路归并可以减少归并趟数,从而减少磁盘|/O(读写)次数

2路变4路:

图片.png

归并思路是一样的:

图片.png

图片.png

这样归并后只需要2趟就完成了:

图片.png

缩短了读写次数和所需时间:

读写磁盘次数=32+32*2=96次。设每次读写的时间为tms,则需要时间96tms


12.4.2 减少初始归并段数量r

    1. 减少初始归并段数量r

图片.png


但是多路归并不是越多越好,太大会有负面影响:

  • ①k路归并时,需要开辟k个输入缓冲区,内存开销增加。

  • ②每挑选一个关键字需要对比关键字(k-1)次,内部归并所需时间增加。


12.4.3 增加初始归并段长度

其实还是用到了第二个结论,若能增加初始归并段的长度,则可减少初始归并段数量r


附加: k路平衡归并

图片.png

举例:

图片.png


▶️ 13. 败者树


图片.png


13.1 解决问题

前面说的多路归并,归并路数k增加,归并趟数s减小,读写磁盘总次数减少。来减少读写外存的时间。

但是使用k路平衡归并策略,选出一个最小元素需要对比关键字(k-1) 次,导致内部归并所需时间增加

举例: 8路平衡段中选出一个最小元素需要对比关键字7次

图片.png

所以需要败者树来解决多路归并导致内部排序对比次数增加,时间增加。


✨ 13.2 什么是败者树

败者树可视为一棵完全二叉树 (多了一个头头)。

k个叶结点分别是当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”而让胜者往上 继续进行比较,一直到根结点。 最上面那个头头就是最终胜利者。

举例:比武大会。胜者天津饭。

图片.png


13.3 构造败者树

假设冠军跑了,不需要重新比较。但是新增加个选手。

图片.png

  • 插入的是左边,那么右边的又不变。所以只用重新比较左边的。

图片.png

  • 基于已经构造好的败者树。选出新的胜者只需要进行3场比赛。

✨ 13.4 败者树在多路平衡归并应用

运用败者树进行多路平衡树构造:

图片.png

这里的归并段已经内部排好序了


  • 先取最小的,放入叶子结点:

图片.png

  • 构造败者树

图片.png

  • 在失败结点中记录的是失败败者来自第几个归并段。成功也是

图片.png

  • 这样我们已经选出了最小的一个元素1。对于k路归并,第一次构造需要对比关键字k-1次

  • 选出下一个最小的元素。成功的元素1去除,归并段3重新上传一个元素来

图片.png

  • 重新对比,构造败者树。

图片.png

  • 最后只用对比3次,重新选出最小元素2,归并段5

图片.png


结论:

有了败者树后,以后进行对比选择时候,只需要对比关键字[log2klog_2k]次,其实跟树的分支结点的层数相同。

图片.png


✨ 13.5 败者树的实现思路

k路归并的败者树只需要定义一个长度为k的数组即可。

举例:8路归并树,数组对应分支结点和成功结点

图片.png


▶️ 14. 置换-选择排序

图片.png


14.1 解决问题

解决问题: 外部排序进行s趟k路归并,s=[logkrlog_kr]。 优化时候减少初始归并段数量r。可以减少趟数。

使用置换-选择排序来实现。


原来我们使用的初始归并段排序流程:

动画.gif

初始归并段中包含6个记录,原先的2个归并段。二个输入缓冲区的内存工作区只能容纳6个记录。

图片.png

用于内部排序的内存工作区WA可容纳l个记录,则每个初始归并段也只能包含l个记录,若文件共有n个记录则初始归并段的数量r= n/l

所以增加初始归并段记录大小l可以减少归并段数量r


现在改进使用置换-选择排序


✨ 14.2 置换选择排序

  • 初始化归并段输出文件FO:递增为例

图片.png

  • 把内存工作区中最小的置换出去,minimax记录下来

图片.png

  • 从待排序文件FO中读取一个到内存工作区补上。这时候找到里面6最小。置换出去,minimax更新。

图片.png

  • 后续

动画.gif

这里读入10后,是内存工作区最小的,但是minimax=13,不可能放到归并段1的13后面。

图片.png

不管它,查看下一个。到能够放后面为止。

图片.png

  • 继续:

动画.gif

  • 当内存工作区里面关键字都比记录的minimax要小,不能插入归并段1后面,则此归并段结束。新开归并段2。

图片.png

  • 取最小的,记录minimax,重复上述

动画.gif

  • 归并段3

动画.gif

  • 最后得出长度不一样的初始归并段,也就是说,这样归并段的数量更少。

图片.png


总结流程:

图片.png


▶️ 15. 最佳归并树


图片.png


✨ 15.1 最佳归并树概念

举例:5个初始归并段,所占块数不同,进行二路归并

图片.png

  • 比如归并R2,R3,总共读入需要5+1=6次,但是写入也需要5+1=6次

图片.png

  • 最终如图:

图片.png

总共需要:(6+8+14+16)写入,(6+8+14+16)读出


这个初始归并段可以看成树的叶子结点,归并段的长度作为结点权值,则 上面这颗归并树的带权路径长度WPL=2*1+(5+1+6+2)*3=44=读磁盘的次数=写磁盘的次数.

得出结论:归并过程中的磁盘I/O次数(磁盘读写次数)=归并树的WPL*2


所以要让磁盘I/O次数最小,就是求归并树的ASL最小。--哈夫曼树


✨ 15.2 哈夫曼树构造

2路归并

举例:构造2路归并的最佳归并树

图片.png

最佳归并树带权路径长度WPLmin=(1+2) * 4+2 * 3+5 * 2+6=34

所以读磁盘次数=写磁盘次数=34; 总的磁盘I/O次数=68次

多路归并

  • 以3路归并为例子:

跟哈夫曼树一样,选出最小三个组成一个,再选出3个最小的,新组成的也在备选方案里面,反正使值最小。

流程如下:

动画.gif

  • 得出:

图片.png


多路归并无法构成k叉归并树

比如上个例子中,如果减去30,那么最终得到:

图片.png

最后的是二路归并,多路归并无法构成k叉归并树。


  • 解决方法:需要补充几个长度为0的“虚段”,再进行k叉哈夫曼树的构造。

例子中缺少一个,补充一个0虚段:

图片.png

最终得到:

图片.png


补充“虚段”数量

  • 那么要补充几个呢?

  • k叉的最佳归并树一定是一棵严格的k叉树,即树中只包含度为k、度为0的结点。

比如上例中的3路归并树,树中只包含度为3(分支为3个)和度为0的结点。

  • 设度为k的结点有nkn_k个,度为0的结点有n0n_0个,归并树总结点数=n

则:

  • 初始归并段数量+虚段数量=n0n_0

推论:

图片.png

得出判断需要添加虚段数量的结论

图片.png


举例说明运算:

  • 8路归并,初始归并数量19

(19-1)%(8-1)=4; 说明需要添加虚段(8-1)-4=3个

那么初始度为0的结点n0n_0=初始归并段数量+虚段数量=19+3=22个


  • 附注:解释下 k nkn_k=n-1

比如:

图片.png

nkn_k是指度为k的结点,也就是蓝色的结点+根节点,有2个。
n0n_0是度为0的结点,也就是绿色的结点,有5个。

总共n=相加=2+5=7个。

k nkn_k 相当于3 * 2=6分叉,也就是相当于这个树根节点下面的分支数目。
也就是等于总结点-1,也就是总的结点数目减去一个根节点


▶️ 16 排序算法比较

算法比较表

排序类型平均情况最好情况最坏情况辅助空间稳定性
冒泡排序O(n²)O(n)O(n²)O(1)稳定
选择排序O(n²)O(n²)O(n²)O(1)不稳定
直接插入排序O(n²)O(n)O(n²)O(1)稳定
折半插入排序O(n²)O(n)O(n²)O(1)稳定
希尔排序O(n^1.3)O(nlogn)O(n²)O(1)不稳定
归并排序O(nlog₂n)O(nlog₂n)O(nlog₂n)O(n)稳定
快速排序O(nlog₂n)O(nlog₂n)O(n²)O(nlog₂n)不稳定
堆排序O(nlog₂n)O(nlog₂n)O(nlog₂n)O(1)不稳定
基数排序O(d(n+k))O(d(n+k))O(d(n+kd))O(n+kd)稳定

图片.png 图片来自网络,侵删


稳定性

不稳定的算法口诀:“快些选堆”。快速排序、希尔排序、简单选择排序、堆排序


时间复杂度

时间较快的算法的口诀:“快些归队” 。快速排序、希尔排序、归并排序、堆排序

在实际应用中,快速排序往往可以优于其他算法,被认为是目前基于比较的内部排序中最好的方法。


空间复杂度

大部分的算法的空间复杂度都是常量O(1)。空间复杂度的定义是用到的额外的辅助空间,不包含自己的空间的。


适用性(n大小)

  • 若 n 较小,可以采用直接插入排序或简单选择排序;
  • 若 n 较大,则应采用时间复杂度为O(log2nlog_2n)的排序方法: “快些归队” 。快速排序、希尔排序、归并排序、堆排序;

其他

常考知识点
(1)当关键字随机分布时,快速排序平均时间最短
(2)堆排序所需的辅助空间少于快速排序,且不会出现快速排序可能出现的最坏情况;
(3)冒泡排序和堆排序每趟处理后都能产生当前的最大值或最小值
(4)快速排序一趟处理就能确定一个元素的最终位置。
(5)当记录本身信息量较大时,为避免耗费大量时间移动记录,可以采用链表作为存储结构

(6)每一趟都至少能确定一个元素的最终的位置的算法有:“快选一堆帽子”。快速排序、简单选择排序、堆排序、冒泡排序。


▶️ 参考

求审核通过,不知道哪里错了。