【数据结构从0到1】第五篇:排序(上)

文章目录

  • 引言
  • 一、排序的概念及其运用
    • 1.1排序的概念
    • 1.2排序运用
    • 1.3 常见的排序算法
  • 二、2.常见排序算法的实现
    • 2.1 插入排序
      • 2.1.1基本思想
      • 2.1.2直接插入排序
      • 2.1.3 希尔排序( 缩小增量排序 )
    • 2.2 选择排序
      • 2.2.1基本思想
      • 2.2.2 直接选择排序
      • 2.2.3 堆排序

引言

本篇介绍的是排序算法,重点探讨前四种排序算法: 直接插入排序、希尔排序、直接选择排序和堆排序,关于冒泡排序、快排和归并排序我们下章再重点讨论。话不多收,我们直接开始。


一、排序的概念及其运用

1.1排序的概念

  • 排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
  • 稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排
    序算法是稳定的;否则称为不稳定的。
  • 内部排序:数据元素全部放在内存中的排序。
  • 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

1.2排序运用

排序在我们生活中随处可见,比如在我们购物的时候:
【数据结构从0到1】第五篇:排序(上)_第1张图片
我们想看大学排名,也是要用到排序:
【数据结构从0到1】第五篇:排序(上)_第2张图片

1.3 常见的排序算法

【数据结构从0到1】第五篇:排序(上)_第3张图片
因为上面这8种排序算法在我们在校招中常被考到,所以我们重点讨论这8种,而对于其他的排序算法在我们校招中基本用不到,所以就不作为重点。

二、2.常见排序算法的实现

2.1 插入排序

2.1.1基本思想

直接插入排序是一种简单的插入排序法,其基本思想是:

  • 把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列

实际中我们玩扑克牌时,就用了插入排序的思想:
【数据结构从0到1】第五篇:排序(上)_第4张图片
这张图片大家是不是很熟悉呢?这就是插入排序。

2.1.2直接插入排序

当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
这个理解起来可能有点费脑:我们假设现在有一个升序数组:

int a[7] = {
     3, 5, 6, 7, 9 ,18}; 

当我们在插入一个数据x = 2时,我们就依次从数组的最后一个数往前面比较,当找到比x小的数了,我们就停下
【数据结构从0到1】第五篇:排序(上)_第5张图片

代码如下:

while (end >= 0)
{
     
	if (a[end] > x)
	{
     
		a[end + 1] = a[end];
		end--;
	}
	else
	{
     
		break;
	}
    //两个结束条件:
	//1.end < 0 or break;
	a[end + 1] = x;
}

这里是插入一个数据,而当数组不可能已从开始就是有序的,所以我们从依次从0开始插入。
比如现在有一个无序数组:

int a[] = {
     20, 0, 9, 5, 2, 7, 3, 8, 7, 3};

现在用图片表示排序过程:
【数据结构从0到1】第五篇:排序(上)_第6张图片

代码如下:

//直接插入排序
//时间复杂度:最好O(N),最坏O(N^2)
void InsertSort(int* a, int n)
{
     
	//多趟排序
	int i = 0;
	//将后一个数插入前面的数中,所以i的区间是[0,n-1]
	for(int i = 0; i < n - 1; i++)
	{
     
		//单趟排序:从最后一个数往前挪
		int end = i;
		int x = a[i + 1];
		while (end >= 0)
		{
     
			if (a[end] > x)
			{
     
				a[end + 1] = a[end];
				end--;
			}
			else
			{
     
				break;
			}
		//两个结束条件:
	    //1.end < 0 or break;
		a[end + 1] = x;
		}
	}
}

那么我们来调用下这个直接插入排序函数,看是否是正确的。
【数据结构从0到1】第五篇:排序(上)_第7张图片
直接插入排序的特性总结:

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1),它是一种稳定的排序算法
  4. 稳定性:稳定

问题?假设我们现在要排的数组是升序,而现在给我们的数组却是降序,那么越大的数就越在数组的前面,那么我们插入数据每次都要和x比,这样的话时间复杂度肯定是O(N^2),那么我们能不能将像这样的数组优化一下呢?答案是:希尔排序

2.1.3 希尔排序( 缩小增量排序 )

希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
用图片表示就是:
【数据结构从0到1】第五篇:排序(上)_第8张图片
相信大家看完这张图片也还是很懵的,我们一步一步来:
此时假设有一个数组:

int a[7] = {
     20, 9, 5, 2, 7, 3, 8, 7, 3}; 

我们看到偏向前面的数据都比较大,而偏向后面的的数据都比较小。那么我们首先就先从gap = 4来排序:
第一步:假设现在还是先插入一个数据x
【数据结构从0到1】第五篇:排序(上)_第9张图片

//单趟排序
			while (end >= 0)
			{
     
				if (a[end] > x)
				{
     
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
     
					break;
				}
			}
			//两个结束条件:
			/*1.end < 0 or break;*/
			a[end + gap] = x;

此时我们插入一个数据后就是这样的图:
【数据结构从0到1】第五篇:排序(上)_第10张图片
第二步:我们对其中的一组数据进行排序
【数据结构从0到1】第五篇:排序(上)_第11张图片

代码如下:

        for (int i = 0; i < n - gap; i += gap)
		{
     
			int end = i;
			int x = a[end + gap];
			while (end >= 0)
			{
     
				if (a[end] > x)
				{
     
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
     
					break;
				}
			}

			a[end + gap] = x;
		}

第三步,我们进行预排序,警数组排成接近有序
【数据结构从0到1】第五篇:排序(上)_第12张图片
代码如下:

    for (int j = 0; j < gap; ++j)
	{
     
		for (int i = j; i < n - gap; i += gap)
		{
     
			int end = i;
			int x = a[end + gap];
			while (end >= 0)
			{
     
				if (a[end] > x)
				{
     
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
     
					break;
				}
			}

			a[end + gap] = x;
		}
	}

此时我们已经将数组排成为了接近有序,而已经嵌套了3层循环。结构相对复杂,我们还有一种"一锅炖"的方法,先看代码:

//预排序:一锅炖,进行多组排序
		for (int i = 0; i < n - gap; i++)
		{
     
			int end = i;
			int x = a[i + gap];
			//单趟排序
			while (end >= 0)
			{
     
				if (a[end] > x)
				{
     
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
     
					break;
				}
			}
			//两个结束条件:
			/*1.end < 0 or break;*/
			a[end + gap] = x;
		}

不知道大家能不能看懂,我们还是用一张图片来表示:
【数据结构从0到1】第五篇:排序(上)_第13张图片
第四步:多次预排序,将数组排序成有序数组! !

//多次预排序(gap > 1) +直接插入 (gap == 1)
	while (gap > 1)
	{
     
		gap = gap / 2;
		//gap = gap / 3 + 1;
		//预排序:一锅炖,进行多组排序
		for (int i = 0; i < n - gap; i++)
		{
     
			int end = i;
			int x = a[i + gap];
			//单趟排序
			while (end >= 0)
			{
     
				if (a[end] > x)
				{
     
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
     
					break;
				}
			}
			//两个结束条件:
			/*1.end < 0 or break;*/
			a[end + gap] = x;
		}
	}

此时我们在看最开始的那张流程图片,相信大家都能看懂了吧!

希尔排序的特性总结:

  1. 希尔排序是对直接插入排序的优化。
  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定
  4. 稳定性:不稳定

《数据结构(C语言版)》— 严蔚敏
【数据结构从0到1】第五篇:排序(上)_第14张图片
《数据结构-用面相对象方法与C++描述》— 殷人昆
【数据结构从0到1】第五篇:排序(上)_第15张图片
有很多人肯定会想,我感觉这个希尔排序也不怎么优啊!那么我们来实验一下,大家都能感受到希尔排序的优势了。
我们先测试10000个数据,排序要多久:

void Test1OP()
{
     
	srand((int)time(0));
	const int N = 10000;
	//开辟空间
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	for (int i = 0; i < N; ++i)
	{
     
		a1[i] = rand();
		a2[i] = a1[i];
	}
	//算出排序的时间
	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();
	//打印
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);

	free(a1);
	free(a2);
}

【数据结构从0到1】第五篇:排序(上)_第16张图片
这里看起差距也不大。
我们不妨将数据改大一点:
【数据结构从0到1】第五篇:排序(上)_第17张图片
【数据结构从0到1】第五篇:排序(上)_第18张图片
此时我们看到希尔排序的优势所在了吧!

2.2 选择排序

2.2.1基本思想

每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。

2.2.2 直接选择排序

  • 在元素集合array[i]–array[n-1]中选择关键码最大(小)的数据元素
  • 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
  • 在剩余的array[i]–array[n-2](array[i+1]–array[n-1])集合中,重复上述步骤,直到集合剩余1个元素

每次选出一个数效率太低了,我们现在优化一下这个算法,我们一次选出两个数,一个最大数和一个最小的数。
假设现在排升序,现在还是有一个数组

	int a[] = {
     8, 9, 5, 2, 7, 3, 8, 7, 3};

现在从这个数组中选出最小的数,放在开始位置,选出最大的数放在最后的位置
【数据结构从0到1】第五篇:排序(上)_第19张图片

代码如下:

        int mini = begin;
		int maxi = end;
		for (int i = begin; i <= end; i++)
		{
     
			if (a[i] < a[mini])
			{
     
				mini = i;
			}
			if (a[i] > a[maxi])
			{
     
				maxi = i;
			}
		}
		//将最小值交换到最前面,将最大值换到最后
		Swap(&a[begin], &a[mini]);
		if (begin == maxi)
		{
     
			maxi = mini;
		}
		Swap(&a[end], &a[maxi]);

此时我们就找一趟中找出了数组中的最小值和最大值,那么我们想要找出次小值和次大值就需要跑多躺,这必须就要控制我们的区间:
【数据结构从0到1】第五篇:排序(上)_第20张图片
代码如下:

    int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
     
		//查找begin到end区间内的最小值和最大值的下标
		int mini = begin;
		int maxi = end;
		for (int i = begin; i <= end; i++)
		{
     
			if (a[i] < a[mini])
			{
     
				mini = i;
			}
			if (a[i] > a[maxi])
			{
     
				maxi = i;
			}
		}
		//将最小值交换到最前面,将最大值换到最后
		Swap(&a[begin], &a[mini]);
		Swap(&a[end], &a[maxi]);
		begin++;
		end--;
	}

我们还是来检验一下结果是否正确:
【数据结构从0到1】第五篇:排序(上)_第21张图片
结果正确。那么就这也太简单了吧!我们换一个数据试下:

int a[] = {
      20, 9, 5, 2, 7, 3, 8, 7, 3 };

【数据结构从0到1】第五篇:排序(上)_第22张图片
怎么换一组数据就不行了?我们还是画图来好检验:
【数据结构从0到1】第五篇:排序(上)_第23张图片
通过画图我们很显然很快就发现了错误!那么我们该怎么改正呢?这里介绍一种方法:

//选择排序
//时间复杂度:最好和最坏都是O(N^2)
void SelectSort(int* a, int n)
{
     
	assert(a);
	//定义两个下标begin和end控制区间
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
     
		//查找begin到end区间内的最小值和最大值的下标
		int mini = begin;
		int maxi = end;
		for (int i = begin; i <= end; i++)
		{
     
			if (a[i] < a[mini])
			{
     
				mini = i;
			}
			if (a[i] > a[maxi])
			{
     
				maxi = i;
			}
		}
		//将最小值交换到最前面,将最大值换到最后
		Swap(&a[begin], &a[mini]);
		if (begin == maxi)
		{
     
			maxi = mini;
		}
		Swap(&a[end], &a[maxi]);
		begin++;
		end--;
	}
}

那我们还是画图来看改正是否正确:
【数据结构从0到1】第五篇:排序(上)_第24张图片
很显然画图是正确的,我们编译程序再看一下:
【数据结构从0到1】第五篇:排序(上)_第25张图片
直接选择排序的特性总结

  1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定

这里时间复杂度为什么会是O(N^2)呢?有人会想如果是有序那不就是O(1)吗?千万不能这样想,如果你这样想的话不妨问一下如果每个排序排的都是升序,那我们还学这么多排序干嘛。这里的选择排序不管是最好的情况和最坏的情况都输 O(N^2),因为就算排的是升序,还是要从前往后一个一个的遍历比较大小,选出最大的数和最小的数。而之前的插入排序最好的情况可能是O(N),是因为如果排的是升序的话我们只需要和最后(下标为end)的数相比较,然后直接在最后插入,就不需要在往前面比较了。

此时我们也同样排10000个数据看一下:

void Test1OP()
{
     
	srand((int)time(0));
	const int N = 10000;
	//开辟空间
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	int* a3 = (int*)malloc(sizeof(int) * N);
	for (int i = 0; i < N; ++i)
	{
     
		a1[i] = rand();
		a2[i] = a1[i];
		a3[i] = a1[i];
	}
	//算出排序的时间
	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();
	int begin3 = clock();
	SelectSort(a3, N);
	int end3 = clock();
	//打印
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);

	free(a1);
	free(a2);
	free(a3);
}
int main()
{
     
	Test1OP();
	/*TestSort();*/
	/*TestOP();*/
	return 0;
}

【数据结构从0到1】第五篇:排序(上)_第26张图片
我们看尽选择排序跑的时间是最长的,但是跑的时间也不长,我们也同样将数据增加到100000:
【数据结构从0到1】第五篇:排序(上)_第27张图片
这样看起来是不是这个排序就更差了,没错,这个排序算是在这些排序当中最差的一个排序了。

2.2.3 堆排序

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
我们先用一张图片来表示整个堆排序的过程:
【数据结构从0到1】第五篇:排序(上)_第28张图片
相信大家看完这张图片可能有些也不能理解,我们还是一步一步分析:
首先我们先建立一个数组:

int a[] = {
      5, 17, 16, 4, 20, 3 };

这是以个无序的数组。假设现在我们要排的是升序。
第一步,将这个无序的数组先建成大堆
有人会问,排升序我们不是应该建小堆吗?将小的数放在最前面,那么我们先来试一下,先建一个小堆看一下。
【数据结构从0到1】第五篇:排序(上)_第29张图片
如图,现在是这个堆的逻辑结构,我们先把它建成小堆:
从下标为1的位置开始往下调:
【数据结构从0到1】第五篇:排序(上)_第30张图片

代码实现:

void AdjustUp(HPDataType* a, int child)//插入时向上调整数据
{
     
	assert(a);
	int parent = (child - 1) / 2;
	while (child > 0)
	{
     
		if (a[child] < a[parent])
		{
     
			HeapSwap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
     
			break;
		}
	}
}

void HeapSort(int* a, int n)//堆排序
{
     
	
	//方法一,从1开始往下调
	for (int i = 1; i < n; i++)
	{
     
		AdjustUp(a, i);
	}
}

【数据结构从0到1】第五篇:排序(上)_第31张图片
此时我们已经建成小堆了,
【数据结构从0到1】第五篇:排序(上)_第32张图片

但是我们现在应该怎么选出次小的数呢?难道是将第二个数看成堆顶,然后再来重新建一个小堆来选出次小的数吗?这样选数不但会打乱原本堆的顺序,而且会导致时间复杂度为O(N^2)了,效率太低了。所以我们选择建大堆。

建大堆:
第一种方法:从下标为1的位置开始往下调
【数据结构从0到1】第五篇:排序(上)_第33张图片

代码实现:

void AdjustUp(HPDataType* a, int child)//插入时向上调整数据
{
     
	assert(a);
	int parent = (child - 1) / 2;
	while (child > 0)
	{
     
		if (a[child] > a[parent])
		{
     
			HeapSwap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
     
			break;
		}
	}
}

void HeapSort(int* a, int n)//堆排序
{
     
	
	//方法一,从1开始往下调
	for (int i = 1; i < n; i++)
	{
     
		AdjustUp(a, i);
	}
}

第二种方法:从倒数第一个不为叶子节点的数开始向上调:
【数据结构从0到1】第五篇:排序(上)_第34张图片

代码实现:

//向下调整算法
void AdjustDown(int* a, int n, int parent)
{
     
	int child = parent * 2 + 1;
	while (child < n)
	{
     
		if (child  + 1 < n && a[child + 1] > a[child])
		{
     
			child++;
		}
		if (a[child] > a[parent])
		{
     
			Swap(&a[child], &a[parent]);
			parent = child;
			child = child * 2 + 1;
		}
		else
		{
     
			break;
		}
	}
}
void HeapSort(int* a, int n)//堆排序
{
     
	
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
     
		AdjustDown(a, n, i);
	}
}

【数据结构从0到1】第五篇:排序(上)_第35张图片

通常我们选择第二种建堆操作,因为时间复杂度更低。而且在跟建好大堆之后选数我们要用的调整算法是一样的都要用到void AdjustDown(int* a, int n, int parent) ; 而此时我们也就建成了大堆,有的人可能会想,我怎么感觉这样建堆的时间复杂度也不低了,很多人以为我们建堆的时间复杂度是O(NlogN),但其实是O(N),为什么呢?我们排n个数,每个数向下调整是logN,这样不是NlogN吗?难道不是这样算的吗?千万不要这样理解,我们推理来证明:
【数据结构从0到1】第五篇:排序(上)_第36张图片
所以我们建堆操作的时间复杂度是O(N)。
第二步:将堆顶最大的数交换到最后,然后再重新排成大堆,选出次大的数最为堆顶,将次大的数和倒数第二个数交换,依次循环直到排升序。
【数据结构从0到1】第五篇:排序(上)_第37张图片
代码实现:

//向下调整算法
void AdjustDown(int* a, int n, int parent)
{
     
	int child = parent * 2 + 1;
	while (child < n)
	{
     
		if (child  + 1 < n && a[child + 1] > a[child])
		{
     
			child++;
		}
		if (a[child] > a[parent])
		{
     
			Swap(&a[child], &a[parent]);
			parent = child;
			child = child * 2 + 1;
		}
		else
		{
     
			break;
		}
	}
}



//堆排序
void HeapSort(int* a, int n)
{
     
	assert(a);
	//建堆
	//排升序,建大堆;排降序,建小堆
	//从倒数第一个不为叶子节点开始建
	//建堆的时间复杂度是O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
     
		AdjustDown(a, n, i);
	}

	/*排序
	时间复杂度是O(N*logN)*/
	for (int end = n - 1; end > 0; end--)
	{
     
		//将最大的数和最后一个数交换
		Swap(&a[end], &a[0]);
		//向下调整选出次大的数
		AdjustDown(a, end, 0);
	}
}

此时选数的时间复杂度是O(NlogN),加起来是N+NlogN,所以此时堆排序的时间复杂度就为O(N*logN)。
此时我们来测试我们结果是否正确:
【数据结构从0到1】第五篇:排序(上)_第38张图片
那么我们也来根前面的几种排序来比较下,看堆排序的效率怎么样:

void Test1OP()
{
     
	srand((int)time(0));
	const int N = 10000;
	//开辟空间
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	int* a3 = (int*)malloc(sizeof(int) * N);
	int* a4 = (int*)malloc(sizeof(int) * N);
	for (int i = 0; i < N; ++i)
	{
     
		a1[i] = rand();
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
	}
	//算出排序的时间
	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();
	int begin3 = clock();
	SelectSort(a3, N);
	int end3 = clock();
	int begin4 = clock();
	HeapSort(a4, N);
	int end4 = clock();
	//打印
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);

	free(a1);
	free(a2);
	free(a3);
	free(a4);
}

【数据结构从0到1】第五篇:排序(上)_第39张图片
我们排10000个数很显然效率根希尔排都差不多的!我们在该为100000个数据呢?
【数据结构从0到1】第五篇:排序(上)_第40张图片
堆排序还是很快!所以在排序当中,堆排序的效率还是挺高的!


你可能感兴趣的:(数据结构,数据结构,算法,排序)