【数据结构】排序算法(中篇)·处理大数据的精妙

前引:在进入本篇文章之前,我们经常在使用某个应用时,会出现【商品名称、最受欢迎、购买量】等等这些榜单,这里面就运用了我们的排序算法,作为刚学习数据结构的初学者,小编为各位完善了以下几种排序算法,包含了思路拆解,如何一步步实现,包含了优缺点分析、复杂度来历,种类很全,下面我们开始穿梭算法排序的迷雾,寻求真相!

堆排序

说到堆排序,我们又需要重温树的神奇了,大家先别畏惧,区区堆排序小编将带着大家解构它,让它毫无保留的展现给大家!在学习之前,我们需要知道两种思想结构:物理结构、逻辑结构

堆实现的逻辑结构就是一颗完全二叉树:

但是咱们是用数组实现的,只是借助逻辑结构去完成物理结构,大家对比两幅图,就明白了!

算法思路:

在看算法思路之前,我们需要知道什么是大顶堆,什么是小顶堆

大顶堆:每个节点的值都大于或等于其子节点的值,堆顶为最大值

小顶堆:每个节点的值都小于或等于其子节点的值,堆顶为最小值

咱们的堆本质是一颗用数组实现的完全二叉树,下面我以数组下标从1开始存储为例,如需更多的参考以及深入学习堆,可以参考小编主页的【堆】哦!下面是各节点下标换算的关系:

左子节点:2 * i 右子节点:2 * i + 1 父节点:i(若已知子节点 k ,则父节点为 k / 2)

堆的算法思路就是:利用堆的性质,来实现对数据的排序

(白话文理解:利用堆结构关系,对数据按照最大堆/最小堆提取堆顶元素来实现排序,因为堆顶是这组数组的最值,咱每次提取出来,不就是达到了排序效果吗!)

实现步骤:

咱们堆排序以实现堆为主,因此需要先手搓一个堆,堆的实现包含以下步骤:(实现有详细说明)

初始化 存储 上浮调整 移除堆尾元素 下沉调整

复杂度分析:

堆分为建堆、排序堆两个阶段,因此需要分别说明,参考如下理解

建堆阶段:从最后一个非叶子节点开始调整,时间复杂度为O(n)

排序堆阶段:每次调整堆的时间复杂度为O(logn),共需要调整n-1次,因此总时间复杂度为O(nlogn)

综合起来就是O(nlogn)

空间复杂度为O(1)。因为堆排序是原地排序算法,仅仅需要常数级的额外空间

代码实现:
建堆:

咱们按照堆排序的步骤,第一步需要先建立堆,咱们先建立一个结构体,参考如下代码:

cpp 复制代码
#define MAX 10

typedef struct Pile
{
	//存储的数组空间
	int* space;
	//当前堆存储数量
	int size;
	//当前最大存储量
	int max;
}Pile;

下面我们对堆进行初始化操作,开辟空间、设置初始变量,参考以下代码:

cpp 复制代码
//初始化堆
void Preliminary(Pile* pilestruct)
{
	assert(pilestruct);

	pilestruct->size = 0;
	pilestruct->max = MAX;
	//开辟空间
	pilestruct->space = (int*)malloc(sizeof(int) * MAX);
	if (pilestruct->space == NULL)
	{
		perror("malloc");
		return;
	}
}

接下来咱们进行数据存储进堆,来实现存储的代码:

cpp 复制代码
//存储
void Storage(Pile* pilestruct,int data)
{
	assert(pilestruct);

	//先判断是否存满,是则扩容
	if ((pilestruct->size+1) == pilestruct->max)
	{
		int* pc = (int*)realloc(pilestruct->space, sizeof(int) * (pilestruct->max) + MAX);
		if (pc == NULL)
		{
			perror("realloc");
			return;
		}
		pilestruct->space = pc;
		//更新max
		pilestruct->max += MAX;
	}
	//存储数据
	pilestruct->size++;
	pilestruct->space[pilestruct->size] = data;
	//上浮调整
	Adjust(pilestruct->space, pilestruct->size);
}

存储之后需要数据满足最大堆的性质,所以咱们还需要一个调整函数,其思想如下:

将此数据与其父节点进行比较,如果这个数据较大,交换父节点与其位置,反之不交换,代码如下

cpp 复制代码
//上浮调整
void Adjust(int * space, int child)
{
	//父节点下标
	int parent = child / 2;

	while (child > 1)
	{
		if (space[child] > space[parent])
		{
			Exchange(&space[child], &space[parent]);
			//更新child,parent
			child = parent;
			parent = (child ) / 2;
		}
		else
			break;
	}
}

现在咱们的建堆就写完了,我存储了几个数字,大家可以看看效果:

排序堆:

将堆顶元素(最大值)与堆的最后一个元素交换,然后size--,这样我们就去除了堆顶元素(最大值),注意:这里的去除只是将堆元素个数减一,并不是删除。然后我们就一直重复这样的操作,直到将所有元素调整完,我们堆的元素原来是9 8 6 1 0 2,调整之后是:0 1 2 5 6 8 9

核心思想(重点):利用多次下沉调整每次将最大值放到末尾,这样就达到了有序数据

cpp 复制代码
void Pilesort(Pile* pilestruct)
{
	assert(pilestruct);

	//parent下标
	int parent = 1;
	//child下标
	int child = 2*parent;
	
	//最后一个节点和根节点换位
	Exchange(&pilestruct->space[pilestruct->size], &pilestruct->space[1]);
	//移除根节点
	pilestruct->size--;

	//下沉调整

	while (child <= pilestruct->size)//       注意点1
	{
		//找到左右孩子节点的最大值
		//                                    注意点2
		if (child < pilestruct->size && pilestruct->space[child] < pilestruct->space[child+1])
		{
			child++;
		}
		//看是否交换其与父节点
		//                                    注意点3
		if (child<=pilestruct->size && pilestruct->space[child] > pilestruct->space[parent])
		{
			Exchange(&pilestruct->space[child], &pilestruct->space[parent]);
			//更新父、孩子节点下标
			parent = child;
			child = 2 * parent;
		}
		else
			break;
	}
}

在上面的代码中,有3个需要注意的地方**(上图标出来了)**,为哈3个判断条件需要注意?

注意点1:当堆最后只有2个元素的时候,还要发生一次交换(0与1),因此可以取等号

注意点2:这个 if 条件需要注意,当最后两个元素0与1时,1在根节点,0在子节点,虽然满足中国 判断条件,但是所以这两个数字不能发生交换,所以不能取等号

注意点3:还是跟上个判断条件一样,0在根节点,1在子节点,满足判断条件,需要交换

**总结:**堆的实现需要两次调整。一次是存储数据时需要满足最大堆的性质,需要做上浮调整。而 每次交换根节点元素 和 尾节点元素之后,还是需要保持大顶堆的性质,因此需要下沉调整

下面我们已经将这组数据利用下沉调整完全实现了有序,只需要再依次拿出来就行了

优缺点分析:

堆排序属于原地排序,对大规模数据排序效率较高,并且不会因为数据量增大导致性能急剧下降,但是堆排序同时也是非稳定排序,相同元素的相对顺序可能会改变,最大的问题就需要手动写一个堆,需要对指针、数组之间的逻辑、物理关系的相互转化

冒泡排序

"冒泡"排序的实现思路很简单,咱们先看一个图:

当各种小泡泡从鱼嘴里面吐出来到达最上面的水层,泡泡是最大的,因此咱们冒泡排序的实现也是,每次排序将最大值向后面靠,话不多说,进入正题!

算法思路:

通过重复遍历列表,比较相邻元素并看是否进行交换,将最大的元素逐渐"冒泡"到列表末尾

实现步骤:

记住咱们的排序先从单趟写,由里向外。

单趟的实现:通过 for 循环将第一次遍历的最大值通过比较移到末尾即可

单趟实现完后,咱们已经排序完了一个元素,那么只需要将剩余的 n-1 个元素排序完成就行了

复杂度分析:

最好最坏都是遍历(可以参看"代码实现"),所以时间复杂度都是O(n^2)

空间复杂度:冒泡属于原地排序,所以为O(1)

代码实现:

按照实现步骤,咱们先实现单趟:(对比左右两幅图,我们已经将 9 排序到了最后)

cpp 复制代码
//单趟
for (int i = 0; i < size - 1; i++)
{
	//如果前一个元素比后一个元素大
	if (arr[i] > arr[i + 1])
	{
		Exchange(&arr[i], &arr[i + 1]);
	}
}

我们已经排序完了一个元素,下面我们再套个循环,排序剩余的 n-1 个元素:

cpp 复制代码
for (int j = 1; j < size - 1; j++)
{
	//单趟
	for (int i = 0; i < size - 1; i++)
	{
		//如果前一个元素比后一个元素大
		if (arr[i] > arr[i + 1])
		{
			Exchange(&arr[i], &arr[i + 1]);
		}
	}
}
代码优化:

我们在优化之前不管后面的元素是否已经有序,都需要遍历,导致重复了无用的次数,优化如下

如果我们开始假设整组元素都是有序的,也就是 false = -1 ,如果发生了交换,那么 false = 1 ,那么就表明需要进行下一次的循环判断,否则直接退出循环,因为遍历一遍结束之后,false如果还为-1,就表明数组已经是有序的了,代码如下:

cpp 复制代码
for (int j = 1; j < size - 1; j++)
{
	//假设整组数据有序
	int false = -1;
	//单趟
	for (int i = 0; i < size - 1; i++)
	{
		//如果前一个元素比后一个元素大
		if (arr[i] > arr[i + 1])
		{
			Exchange(&arr[i], &arr[i + 1]);
			//假设不符合
			false = 1;
		}
	}
	if (false == -1)
	{
		break;
	}
}
优缺点分析:

冒泡排序是稳定的排序算法,相等的元素在排序之后依然保持原来的相对顺序,但是时间复杂度原先很高,达到了O(n^2),但是我们通过优化,提高了一定的算法效率,在处理数据时可以排除有序数据,表现更好

Hoare排序

Hoare排序是快速排序的一种经典实现,由Tony Hoare于1960年提出,核心思想是基于分治策略,通过Hoare分区方法将数组分为两部分,递归的再对子数组排序,看不懂没有关系,咱们学习Hoare排序肯定要知道它咋来的,以上是术语,下面我们进行学习!(特别提醒!很不好理解!)

算法思路:

Hoare排序的关键在于Hoare分区方法,其特点是通过双指针从数组两端向中间扫描,减少冗余交换,尤其擅长处理重复的元素,整体分治思路如下:

划分:选择一个基准值(pivot),将数组分为两部分,左半部分元素 <= pivot ,右半部分元素 >= pivot

递归:对左右两部分分别递归调用Hoare排序

复杂度分析:

每次递归将数组分为两部分,递归深度为 log n ,每层处理时间为 O(n),因此平均时间复杂度为(n logn)

当每次分区完全逆序,递归深度为 n ,最坏情况达到了 O(n^2)

实现步骤:

首先咱们来讲解单趟的实现(重点):

(1)假设我们开始有一个数组,将它的最左边的值 6 作为基准值pivot

(2)现在我们再设置这个数组的最左边的值 和 最右边的值分别为leftright

(3)下面我们通过循环找:左边大于等于基准值的元素(找大),右边小于等于基准值的元素(找小),因为我们需要让大于基准值的元素摞到右边,小于基准值的移到左边

注意事项:我们的初衷是大的在右边,小的在左边,因此需要从右边开始移动,只有这样,才能保证小于基准值的移到左边来。反之,如果基准值在右边,就从左边开始移动 。 如下:

(4)将找到的满足条件的元素进行交换 ,如下图:

(5)继续循环找到满足条件的两个元素,直到两者相遇, 然后交换相遇的元素与基准值元素

(6)现在我们把数组分为了两组,以 6 为分界线,左边的数字小于等于6,右边的数字大于等于6

然后我们采用递归,两端采用分组进行Hoare排序,随着递归函数的调用,这两个组又会分为几个小组,因此我们必须要弄清楚单趟的思路。注意left、right、pivot需要更新,

通俗理解:理解成数,树根就是一整个数组,它会分为很多个子数组(这就是对这里分组的理解)

代码实现:

按照上面的思路,我们先实现单趟,也就是先排序一个元素:(我再强调一下注意点)

(1)pivot 作为下标开始是left 的位置,这里不要写死为0 ,因为我们递归是需要灵活变化的, pivot 的开始位置应该是left的位置

(2)基准值在左边,就从右边开始找;基准值在右边,就从左边开始找

(3)大循环条件就是它们相遇停止;下面的两个子循环:首先判断大小我就不解释了,之所以加 上 left < right ,是因为避免leftright一直走,出界(建议画图最好理解)

(4)记得更新相遇位置为新的 pivot

cpp 复制代码
int pivot = left;
//单趟
while (left < right)
{
	//左边找大于等于基准值的元素(找大)
	while (left<right && arr[right]>=arr[pivot])
	{
		right--;
	}
	//右边找小于等于基准值的元素(找小)
	while (left<right && arr[left]<=arr[pivot])
	{
		left++;
	}
	//找到之后进行交换
	Exchange(&arr[left], &arr[right]);
}
//交换基准值和相遇的元素
Exchange(&arr[left], &arr[pivot]);
pivot = left;

上面我们已经实现了单趟, 下面我们来实现递归,这里有2个注意要点:

(1)我们的left-- right++ 每次排序已经移到了一起,但是我们每次递归需要从原先的位置开始(这里我不好用语言形容,大家看下面的图!)所以咱们需要记录left right的初始位置,每次调用函数可以初始化

(2)我们递归需要有结束条件,这个结束条件就是left <= right ,我们循环的条件刚好是left > right,反过来就行!

递归的实现,我们知道第一组结束之后,我们左边的分组起始地点是left ,末尾是pivot-1 ;我们右边的分组初始为pivot+1 ,末尾是right( 如下图理解**)**

第一次排序结束

第n次排序开始

cpp 复制代码
void Hoare(int* arr, int left, int right, int size)
{
	assert(arr);

	//递归结束
	if (left >= right)
	{
		return;
	}

	//记录
	int begin = left;
	int end = right;

	int pivot = left;


	//单趟
    ......
	
	//调用递归
	
	//左边
	Hoare(arr, begin, pivot - 1, size);
	//右边 
	Hoare(arr, pivot + 1, end, size);
}
代码优化:

首先我们看下面这个问题:

每次选 pivot 都是选的数组的初始位置,然后不论有序还是无序,每次分区只能减少一个元素,导致递归深度为O(n),时间复杂度写死了为O(n^2)

优化方式:如果我们随机选择 pivot 那么可以使得最坏情况发生的概率为 n^2 / 1,从而保证平均时间复杂度为 O(n logn)

解释:为哈选择随机位置之后需要交换 pivot 处的数据与数据开始的位置?

(1)简化逻辑分区,我们是将基准值的位置作为分区的起点,左分区从【left ,pivot-1 】开始移动,寻找大于等于基准值的元素;右分区从【pivot+1 , right】开始移动,找小于等于基准值的元素

(2)避免额外判断,如果基准值保留在原位置,分区时需要处理pivot跨越数组界限的问题,交换到初始位置后,分区逻辑只需要关注左右left right的移动

cpp 复制代码
//记录
int begin = left;
int end = right;

//随机生成下标
int randIndex = left + rand() % (right - left + 1);
//交换生成的下标与数组初始位置
Exchange(&arr[left], &arr[randIndex]);

int pivot = left;
优缺点分析:

Hoare排序是一种高效的平均O(n logn )排序算法,原地排序,适合大数据,但是它属于不稳定排序,未优化之前可能出现O(n^2)的时间复杂度,总的来说优化性能更佳,处理重复元素表现更好

小编寄语

数据排序的精妙还未结束,【上篇】的算法对比【中篇】,难度又提升了!但是小编已经将思路进行了拆分讲解,相信看几遍也可以理解,再也不用担心自己在哪个细节会犯错了!随着难度的升级,【下篇】将更加精彩,是指针排序?还是又是你从未见过的新排序?关注我,带你揭开【下篇】的面纱!

相关推荐
算AI9 小时前
人工智能+牙科:临床应用中的几个问题
人工智能·算法
我不会编程55510 小时前
Python Cookbook-5.1 对字典排序
开发语言·数据结构·python
似水এ᭄往昔10 小时前
【C语言】文件操作
c语言·开发语言
owde11 小时前
顺序容器 -list双向链表
数据结构·c++·链表·list
第404块砖头11 小时前
分享宝藏之List转Markdown
数据结构·list
hyshhhh11 小时前
【算法岗面试题】深度学习中如何防止过拟合?
网络·人工智能·深度学习·神经网络·算法·计算机视觉
蒙奇D索大11 小时前
【数据结构】第六章启航:图论入门——从零掌握有向图、无向图与简单图
c语言·数据结构·考研·改行学it
A旧城以西11 小时前
数据结构(JAVA)单向,双向链表
java·开发语言·数据结构·学习·链表·intellij-idea·idea
杉之12 小时前
选择排序笔记
java·算法·排序算法
烂蜻蜓12 小时前
C 语言中的递归:概念、应用与实例解析
c语言·数据结构·算法