408复习笔记——数据结构(八):排序

408笔记系列(八)(PS:本人使用的是王道四本书和王道视频)


前言

数据结构终于迎来了末章——排序,在学习了查找算法之后,我们发现数据结构真的是可以帮助我们在很大程度上提高我们的查找效率,但是我们在学习折半插入算法和分块查找算法时候会发现他们是需要我们的数据元素是有序的,但是我们在现实生活中得到的元素并不都是有序,很多时候他都是无序,那么我们应该怎样让他们变得有序呢?由此我们的排序算法便诞生了,他的任务很明确但是并不简单,那就是怎样才能让我们在最短的时间内把一个序列变得有序呢?


一、简介

在这里插入图片描述
从图中可以看到有很多种排序算法,并且每一种算法在面对不同类型数据时需要的时间是不一样的,如上图所示,那么一般我们会关注的排序算法的哪些指标呢?就像我们会看查找算法的平均查找长度ASL一样,在排序算法中,我们一般会关心算法的时间复杂度、空间复杂度还有算法的稳定性,算法的稳定性指的是经过排序后,能使关键字相同的元素保持原顺序中的相对位置不变即为稳定;那么我们便来看看会有哪些排序算法,他们又有怎样的特点呢?

二、主要内容

1. 插入排序

插入排序是一类统称,里面又包含着直接插入排序、折半插入排序和希尔排序;因为他们的排序策略,我们将他们称之为插入排序;

1.1 直接插入排序

直接插入排序的思想很简单,那我们来看一下算法的过程吧!
在这里插入图片描述
从第二个元素开始,往前面已经排好序的元素中插入新的元素,非常的简单,代码如下所示:

// 插入排序 稳定排序
void InsertSort(int  Arr[])
{
	int i, j;
	for (i = 2; i < N; i++)
	{
		if (Arr[i] < Arr[i - 1])
		{
			Arr[0] = Arr[i];
			for (j = i - 1; Arr[j] > Arr[0]; j--)
			{
				Arr[j + 1] = Arr[j];
			}
			Arr[j+1] = Arr[0];
		}
	}
}

这里需要注意的是我们将数组的第一个元素设置为空,作为哨兵,是不是很熟悉,我们在顺序查找那里也将数组的第一个元素空了出来,用于存放查找的关键字,这里的用法也是一样,但是时间复杂度并没有太大的改善,直接插入排序的时间复杂度为O(n2),最坏的情况大家可以看一下文章开头的那张动图,就是数组中的元素为逆序时,时间复杂度最高,而数组中元素为顺序时,时间复杂度最低;空间复杂度为O(1);

1.2 折半插入排序

折半插入排序其实是对直接插入排序的优化,过程和直接插入排序一样,但是大家看到折半是不是感觉很熟悉,我们当时在顺序查找时有一个折半查找算法,那他们之间有什么关系吗?其实是有原因的哦!当我们在直接插入排序每次插入一个元素进入前面已经有序的数组时,我们是不是需要查找新的元素需要插入的位置呢?因为前面的元素都已经是排好序的了,所以我们可以通过之前折半查找的方法去找到需要插入的位置使得插入后依然有序;代码如下所示:

// 折半插入排序
void BinInsertSort(int Arr[])
{
	int i, j;
	int low , high,mid;
	for (i = 2; i < N; i++)
	{
		Arr[0] = Arr[i];
		for (high = i - 1, low = 1; low < high||low == high;)
		{
			mid = (low + high) / 2;
			if (Arr[mid] > Arr[0])
			{
				high = mid - 1;
			}
			else
			{
				low = mid + 1;
			}
		}
		//找到合适的位置后,进行偏移
		for (j = i - 1; j > low - 1; j--)
		{
			Arr[j + 1] = Arr[j];
		}
		Arr[j+1] = Arr[0];
	}
}

这里需要注意的是,与折半查找算法不同,折半插入算法当在给新元素查找位置时,若找到与自己一样的元素时为了保证算法的稳定性,是按照大于关键字处理的;具体可以通过代码实践;

折半插入算法虽然说是对直接查找算法的优化,但是时间复杂度依然是O(n2);只是元素之间的比较次数可能变少了!

1.3 希尔排序

希尔排序也算是直接插入排序的优化版了,过程如图所示:

在这里插入图片描述
如图中所示,希尔排序会有一个gap,我们将他称之为增量,增量会把原来的元素分为几个组,然后在先在组中排序,然后增量越来越小直至为1,便成为了直接插入排序将整体排序;大家应该还记得我们刚刚提到直接插入排序最差的情况就是整体为逆序的时候,那么通过希尔排序,我们会发现通过之前几次增量分组的操作,最后一次直接插入排序我们得到的数其实基本上已经是有序的了!希尔排序的时间复杂度现在未被证明,但是一般情况会优于直接插入排序;

2.交换排序

交换排序是我们最熟悉的一类排序算法了,它指的是根据序列中关键字的比较结果来对换这两个关键字在序列中的位置,基于此的算法我们称之为交换排序;相信冒泡排序大家再熟悉不过啦!这就是典型的交换排序,因为这种排序的思维可以说是最容易想到的了;

2.1 冒泡排序

冒泡排序,直接看图:
在这里插入图片描述
相信大家从从图中可以看出冒泡排序的特点,两两比较,并且最后每一趟都会找出最值并放到乱序序列的尾部,之后再从剩下的序列号中两两比较直至一趟排序下来没有发生数据交换,说明序列已经有序;具体代码如下:

// 冒泡排序 升序
void BubbleSort(int  Arr[])
{
	for (int i = 1; i < N; i++)
	{
		bool is_swap = true;
		//需要注意这里的比较的开始应该把尾部已经排好序号的元素个数去掉
		for (int j = N -i; j > 0; j--)
		{
			if (Arr[j] < Arr[j - 1])
			{
				int temp = Arr[j];
				Arr[j] = Arr[j - 1];
				Arr[j - 1] = temp;
				is_swap = false;
			}
		}
		// 这里如果一轮下来都没有发生交换那么说明序列已经有序
		if (is_swap)
			break;
	}
}

冒泡排序和插入排序一样,当序列为逆序时,冒泡排序交换的次数是最多的,也是时间复杂度最坏的情况O(n2),但是冒泡排序的平均时间复杂度也是O(n2),每一次交换时都会发生数据交换,会发生三次移动,因此移动次数是比较次数的三倍;最后我们需要知道的是冒泡排序是稳定的

2.2 快速排序

快速排序,大家看他的名字就很嚣张,但是人家确实是有嚣张的资本,跟他的名字描述的那样,快速排序是所有内部排序算法中平均性能最优的排序算法,那么这么厉害算法是怎样工作的呢?我们先看一下快速排序的过程:
在这里插入图片描述

在快速排序中会先给一个pivot值,我们又称之为枢轴值,我们会根据这个枢轴值将我们的原序列划分为比枢轴值大的序列和比枢轴值小的序列,这也是分而治之思想的体现,并且这里也使用了递归的方法对每一个划分后的序列进行快速排序;具体代码如下所示

// 快速排序算法
void QuickSort(int Arr[],int low,int high)
{
	if (low < high)
	{
		// 进行划分,并返回枢轴值的位置,以此作为分界线
		int pivotpos = Paritition(Arr,low,high);
		// 对枢轴值右边的元素进行快速排序
		QuickSort(Arr, pivotpos + 1, high);
		// 对枢轴值左边的元素进行快速排序
		QuickSort(Arr, low, pivotpos - 1);
	}
}

// 进行快速排序的一趟划分
int Paritition(int Arr[], int low, int high)
{
	// 选取第一个元素作为枢轴值
	int pivot = Arr[low];
	while (low < high)
	{
		while (low < high && (Arr[high] > pivot||Arr[high] == pivot ))
		{
			high--;
		}
		Arr[low++] = Arr[high];
		while (low < high && (Arr[low] < pivot||Arr[low] == pivot) )
		{
			low++;
		}
		Arr[high--] = Arr[low];
	}
	Arr[low] = pivot;
	return low;
}

这里需要注意的是快速排序的时间复杂度是与递归的深度有关,而分而治之又是一棵二叉树,因为n个元素的二叉树的高度便是在O(log(n))到O(n)之间,n个元素的快速排序时间复杂度便在O(nlog(n))到O(n2),而快速排序的平均复杂度为O(nlog(n)),因此说快速排序是内部排序中性能最好的排序算法;那么我们来想一下在什么情况下快速排序的时间复杂度会是最低的呢?按照我们现在快速排序算法应该是序列为逆序时时间复杂度最高,有什么方法可以帮助我们避免最糟糕的情况呢?我们可以随机选取当前的枢轴值或者将前中后三个元素对比一个再选取一个中间值,这样便可以右效避免上述情况了;最后我们需要知道快速排序是一个不稳定的排序算法

3. 选择排序

选择排序的思想很纯粹,每一趟找一个最值,放入到有序序列中,再从剩下的无序序列中找最值,直至序列有序;

3.1 简单选择排序

简单选择排序之所以简单,因为他把选择排序的思想用最简单的方法实现了出来,只是每一趟遍历的比较,过程如图所示:
在这里插入图片描述
代码也十分简单,如下所示:

#include "func.h"
// 直接选择排序算法
void SelectSort(int Arr[])
{
	// 每次选择最大的元素放在序列尾部
	for (int i = 1; i < N; i++)
	{
		int maxpos = N - i;
		for (int j = N - i - 1; j > 0; j--)
		{
			if (Arr[maxpos] < Arr[j])
			{
				maxpos = j;
			}
		}
		int temp = Arr[N - i];
		Arr[N - i] = Arr[maxpos];
		Arr[maxpos] = temp;
	}
}

当然这么简单的算法时间复杂度也并不能给我们带来什么惊喜,简单选择排序的时间复杂度与序列的初始状态无关,但时间复杂度都是O(n2),而且令人遗憾的是简单选择排序也是一种不稳定的算法

3.2 堆排序

堆排序可以说是选择排序中的复杂选择排序了,因为相比较于其他排序算法,堆排序可以说是非常复杂的了,他使用了一种堆的数据结构来辅助寻找选择排序算法思想中每一次找最值的过程;堆分为大根堆和小根堆,如图所示:大根堆指的是根结点大于左右孩子,小根堆指的是根结点小于左右孩子;
在这里插入图片描述
我们从堆数据结构的定义可以知道堆的根结点一定是一个最值,而且因为堆又是一个完全二叉树,所以顺序存储结构中我们知道完全二叉树的孩子下标呵呵父亲下标是有关系可以直接查找的,在这个基本我们的堆排序便诞生啦!

堆排序要做的第一件事便是建堆,将现有元素序列建堆为一个堆,而后每次只需要将根结点元素拿出来即可,这里我们是将根结点元素每次与无序序列最后一个元素交换,然后重新调整堆,过程如图所示:
在这里插入图片描述
需要注意的是,大根堆最后得到的会是一个升序序列,而小根堆得到的则是降序序列;具体代码如下所示:

// 堆排序
void HeapSort(int Arr[], int length)
{
	BuildHeap(Arr, length);
	for (int i = length; i > 1; i--)
	{
		int temp = Arr[1];
		Arr[1] = Arr[i];
		Arr[i] = temp;
		HeapAdjust(Arr, 1, i-1);
	}
}

// 建堆
void BuildHeap(int Arr[], int length)
{
	for (int i = length / 2; i > 0; i--)
	{
		HeapAdjust(Arr, i, length);
	}
}

// 对大根堆进行调整
void HeapAdjust(int Arr[], int key, int length)
{
	// 先存下需要调整点的元素,防止丢失
	Arr[0] = Arr[key];
	for (int i = 2*key; i < length||i == length;i =i * 2)
	{
		// 观察有无左右孩子,若有右孩子则比较孩子的大小,找出较大者
		if (i < length&&Arr[i] <Arr[i + 1])
		{
			i++;
		}
		// 比较孩子中较大者与调整点的大小
		if (Arr[i] < Arr[0])
		{
			// 若调整点大于其孩子结点则该结点一定是满足大根堆要求
			break;
		}
		else
		{
			// 否则调换调整点和其较大孩子结点的位置
			Arr[key] = Arr[i];
			key = i;
		}
	}
	// 最后在调整后的位置插入关键点
	Arr[key] = Arr[0];

}

代码还是比较复杂的,但是大家还是非常有比较自己过一遍的,因为我们考试很多时候是需要我们知道代码的实现细节的,比如我们在建堆时是先比较的左右孩子再和根结点进行比较的;

除此之外,我们还需要知道堆的是插入和删除,插入时我们将插入的元素放在堆的末端,在对新结点进行向上调整,这里只需要和父结点比较即可;而删除操作时,我们会将指定元素删除,并将用末端元素替换指定元素的位置,进行向下调整,这里就需要我们在比较时先比较左右孩子结点的大小了;

堆排序算法这么复杂,但他的时间复杂度是非常好的,因为每次建堆的时间复杂为O(n),且每次调整的复杂度便是堆高O(log(n)),因此时间复杂度一直都是O(nlog(n));并且堆排序非常适合解决查找大量元素的前几个元素或者后几个元素的问题,只是堆排序和简单选择排序一样都是不稳定的

4. 归并排序

首先我们需要了解归并具体在做什么,归并的意思是指两个或多个已经有序序列合并成一个新的有序表,而我们在内部排序一般使用的都是二路归并排序算法,具体过程如图所示:
在这里插入图片描述

从图中我们可以看出这个 过程其实是可以递归实现的,首先将序列分为二路,然后再堆分好的两路进行划分,直至无法划分,再利用合并函数将分好的序列进行排序,直至序列完全有序;具体代码实现如下所示:

// 归并排序
void MergeSort(int Arr[], int low, int high)
{
	int mid;
	if (low < high )
	{
		mid = (low + high) / 2;
		MergeSort(Arr, low, mid);
		MergeSort(Arr, mid + 1, high);
		Merge(Arr, low, mid, high);
	}
}

// 对两个有序序列进行合并为一个有序序列
void Merge(int Arr[], int low, int mid, int high)
{
	int B[N];
	for (int i = low; i < high + 1; i++)
	{
		// 首先将需要进行合并的元素从原数组复制到新建的数组中
		B[i] = Arr[i];
	}
	int i = low, j = mid + 1, k = low;
	for (; i < mid + 1 && j < high + 1;)
	{
		if (B[i] < B[j]||B[i] == B[j])
		{
			Arr[k++] = B[i++];
		}
		else
		{
			Arr[k++] = B[j++];
		}
	}
	while (i < mid + 1)
	{
		Arr[k++] = B[i++];
	}
	while (j < high +1)
	{
		Arr[k++] = B[j++];
	}
}

其实归并的整个过程我们是可以得到一个归并树的,归并树的高度也决定了我们需要进行归并的趟数为O(log2(n)),而每一趟需要将两个有序序列合并的时间复杂度为O(n);因此平均时间复杂度为O(nlog2(n)),这里需要注意的是因为合并操作需要额外的空间存放现有的数组,因此归并排序的空间复杂度为O(n)且归并排序不受序列的初始状态影响;且归并排序是稳定的排序算法,相比较于其他两个时间复杂同样只有O(nlog2(n))的排序算法,堆排序和快速排序都是不稳定的;

5. 基数排序

基数排序是一种很清新脱俗的排序,因为基数排序不是一种基于比较的排序算法,这与我们之前学习的其他算法都不一样,那基数排序具体是怎样实现的呢?下面我们看一下基数排序的过程:
在这里插入图片描述

基数排序借助多关键字排序的思想,按照关键字的权重递增进行依次排序,最后实现对序列的排序,一般考试中不会考察基数排序的代码,但是我们需要知道基数排序的时间复杂度与一次排序需要的存储空间r以及基数排序需要进行的趟数有关d,时间复杂度为O(d*(n+r)),并且与序列的初始状态无关且算法稳定

至此所有的内部排序算法便已经全部结束了,下面是一个关于时间复杂度和稳定性的汇总图,当然大家也可以看文章一开头的那张图,更直观些
在这里插入图片描述
除了上图内容,我们还需要知道每个算法的实现细节,这里就拿比较常考的比较次数、交换次数、排序趟数,用直接插入算法、交换算法和简单选择算法进行说明:首先比较次数,这往往是与序列的初始状态是有直接关系的,因为简单选择排序与序列的初始状态无关,因此一般会是直接插入算法与交换算法比较,而我们知道当序列基本有序时,其实直接插入算法的比较次数会远小于交换算法;其次,针对交换次数,我们一般会看交换次数最少的,相信我,肯定不是交换算法,除非是初始序列状态非常好的时候,那这个时候直接插入排序和交换算法是有冲突的,所以针对一般情况而言,简单选择排序因为每一趟只需要交换一次最值的位置,往往是交换次数最少的那个;最后排序趟数,这个最简单了,因为通过代码我们知道直接插入排序和简单选择排序代码的排序趟数是固定的,只有交换排序会在一趟排序中没有交换时停下来;

内部排序结束后,便要开始我们的外部排序了;

6.外部排序

外部排序基本上就是通过归并排序来实现的了,而我们这里要做的就是怎么最大程度优化我们的归并排序,包括多路平衡归并、败者树、选择置换排序和最佳归并树;

6.1 多路平衡归并

我们在内部排序时候说的是二路归并排序,那这里就很容易理解啦!通过增加归并路数,用来减少归并趟数,从而减少磁盘I/O读写的次数;但是随着归并路数变多,我们会发现我们的内部归并需要比较的次数开始变多,原本二路归并一个数比较一次就OK了,但是对于K路归并而言,归并一个树,我们需要比较K-1次才行,为了避免此类情况,我们开始寻找新的解决方案,败者树就此诞生;

6.2 败者树

听着名字感觉很厉害的样子( •̀ ω •́ )y,但是这在我们生活中应用十分广泛,我们来看一下败者树到底是什么样子的吧!就像世界杯,哈哈哈!这让我想起来了最近意大利战胜英格兰夺得欧洲杯,抱歉,没能让足球回家,哈哈哈!!!
在这里插入图片描述
那将这应用到数字中便是这样:
在这里插入图片描述
我们只需将败者树建立起来,之后每一次比较最值只需要败者树的树高次数即可,这样便可以有效的减少我们归并时比较的次数;

除了增加归并路数,我们还可以减少初始归并段的数量,这样我们趟数便可以减少下来,接下来的置换-选择排序便可以帮我们很好的解决这个问题

6.3 置换-选择排序

置换—选择排序算法的具体操作过程为:

  1. 首先从初始文件中输入 6 个记录到内存工作区中;
  2. 从内存工作区中选出关键字最小的记录,将其记为 MINIMAX 记录;
  3. 然后将 MINIMAX 记录输出到归并段文件中;
  4. 此时内存工作区中还剩余 5 个记录,若初始文件不为空,则从初始文件中输入下一个记录到内存工作区中;
  5. 从内存工作区中的所有比 MINIMAX 值大的记录中选出值最小的关键字的记录,作为新的 MINIMAX 记录;
  6. 重复过程 3—5,直至在内存工作区中选不出新的 MINIMAX 记录为止,由此就得到了一个初始归并段;
  7. 重复 2—6,直至内存工作为空,由此就可以得到全部的初始归并段
    通过置换选择排序,最终得到长度不一的初始归并段,并且初始归并段数量也会减少;而当初始归并段长度不一样时,因为初始归并段的长度与我们读取文件的次数有关,因此对于k路归并,每一次选择哪几个归并段读取变成了一个问题;之后我们发现读取文件的数量的次数其实就是多路归并树的带权路径长度WPL,由此我们便想到了之前学习的哈夫曼树是可以达到最小带权路径长度的呀!没错,这就是最佳归并树的由来;

6.4 最佳归并树

如上所述,最佳归并树就是一棵哈夫曼树,只是这次的哈夫曼树是可以有多叉的;下图便是哈夫曼树作为三路归并树:
在这里插入图片描述

这时会出现一个问题,如果我们的初始归并段数量不满足我们搭建一棵严格的哈夫曼k路归并树呢?比如上面的图中最后少一个30这个元素呢?面对这种情况为了能够搭建严格的k路归并树,我们会给现有的初始归并段中添加新的“虚段”,也就是里面元素为空的归并段,如下图所示:
在这里插入图片描述
这样我们便可以搭建严格的哈夫曼k路归并树,当然这样也是带权路径长度WPL最小的方案,此时又会出现一个问题,这也是考试中非常常见的考题,就是给定初始归并段数量为n和多路归并树的路数K,问我们此时需要多少补充的虚段数量,此时便需要我们会议关于树的相关性质了,我们假设需要补充的虚段数量为u,因为我们知道初始归并段和补充的虚段都是度为0的结点,而哈夫曼树中只有度为0和度为K的结点,假设度为k的结点数为nk我们可以得到一个等式:K*nk + 1 = nk + n + u;将表达式化简,我们可以得到:nk = (n + u - 1) / (K - 1);而nk一定是整数,我们只需要得到一个合适的u值,让nk得到一个整数即可;

至此,外部排序的考点便也已经结束啦!排序到这里也就结束啦!数据结构到此便已经完结啦!


三、 常见题及易错题总结

查找的答案:A、B、C、A、C、D、
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

答案见下一章!(数据结构终于完结啦!!!马上准备开始计算机组成原理啦!!!冲!冲!冲!)

  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值