【数据结构】排序

0. 前言

hello!我们又见面了!在生活中我们对 "排序" 肯定不陌生!在数据结构中,排序也占有很大的地位,相信大家可能被这些排序弄得比较混淆或者对某个排序原理没有弄清,本期博客就带大家一起来学习一下这些常见的排序算法!相信学习完本期内容,你会受益匪浅!

1. 排序的概念及其运用

1.1 排序的概念

排序 :所谓排序,就是使⼀串记录,按照其中的某个或某些关键字的⼤⼩递增或递减的排列起来的操作

其中关于排序可以划分为:

外部排序: 数据元素全部放在内存中的排序

内部排序: 数据元素太多不能同时放在内存中,根据排序过程的要求不能再内外存之间移动数据的

排序。

1.2 排序的稳定性

假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变 ,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

1.3 生活中排序的运用

1.3.1 购物筛选排序

我们经常在淘宝,京东等等购物平台购买商品时,为了满足消费者的购物需求,平台会根据各种商品价格方面、销量方面、评价方面进行排序。

1.3.2 院校排名

我们国内的很多著名高校会结合学术水平、教学质量等等因素,综合考虑进行排序。

1.4 常⻅排序算法

2. 常见排序算法的实现

2.1 直接插入排序 (InsertSort)

2.1.1 基本思想:

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

举个例子大家就明白了,实际中我们玩儿扑克牌时,就用到了插入排序的思想

假如我们手里有4张牌了,2, 4, 5, 10, 现在我摸了一张牌7,原来手里的4张牌2 4 5 10 已经有序了,现在要插入 7。那就先从 2 开始比较,2 比 7 小,再和 4 比较,4 比 7 小,再和 5 比较,5 比7小, 再和 10 比较,10 比 7 大,所以把 7 插入到 10 的前面。这样我们手里的5张牌就有序了。

2.1.2 实例讲解

我们有一个待排序序列为【3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48】

1、我们将第一个元素3看成已经排序好的序列,即有序序列。
2、从第二个元素44到最后一个元素48我们看作为无序的的序列,即待排序的序列。

3、我们将待排序序列中的第一个元素【44】,插入到有序序列中。

①待排序元素【44】和有序序列中元素【3】进行比较,【44】比【3】大则直接插入到有序序列中。

②此时有序序列为【3,44】,待排序序列为【38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48】

3、我们将待排序序列中的第一个元素【38】,插入到有序序列中。

①待排序元素【38】和有序序列中元素【44】进行比较,【38】比【44】小,则将【44】向后移动,然后在将【38】和【3】进行比较,【38】大于【3】则将元素【38】插入到【3】位置后。


注意: 需要将待排序元素与有序序列中的每一个元素进行比较。

②此时有序序列为【3,38,44】,待排序序列为【 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48】

4、然后按照以上操作,将待排序序列中的元素依次插入到有序列中。




②此时有序序列为【3,38,44】,待排序序列为【 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48】
4、然后按照以上操作,将待排序序列中的元素依次插入到有序列中。

2.1.3 代码实现

cpp 复制代码
// 插入排序
void InsertSort(int* a, int n)
{
	assert(a);
	int i = 0;
	for (i = 0; i < n - 1; i++)//因为x元素位置是i的下一个位置,为防止x越界,需要使 i < n-1
	{
		int end = i;//已经有序的最后一个元素(一个元素不需要排序,所以默认从0开始)
		int x = a[end + 1];//需要排序的元素
 
        //单趟
		while (end >= 0)
		{
 
            //若前一个数字大于x,则需将他向右移动
			if (a[end] > x)
			{
				a[end + 1] = a[end];
                //继续判断前面的元素
				--end;
			}
 
            //前面元素小于x
			else
			{
				break;
			}
		}
 
        //将x插入正确位置(两种情况)
        //1.前面的数字小于x
        //2.前面的数字都大于x,x放在下标为0处
		a[end + 1] = x;
	}
}

直接插入排序的特性总结:

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

2.2 希尔排序 (ShellSort)

希尔排序(英语:Shell sort),也称为缩小增量排序法,是直接插入排序的一种改进版本。

2.2.1 基本思想

先选定⼀个整数(通常是),把待排序⽂件所有记录分成各组,所有的距离相等的记录分在同⼀组内,并对每⼀组内的记录进⾏排序,然后gap=gap/3+1得到下⼀个整数,再将数组分成各组,进⾏插⼊排序,当gap=1时,就相当于直接插⼊排序。

它是在直接插⼊排序算法的基础上进⾏改进⽽来的,综合来说它的效率肯定是要⾼于直接插⼊排序算法的。

静态图演示:

说白了就是,将这些数分为几个组,这几个组再分别直接插入排序,然后分的组数减少,重复上述过程,直到只剩1组时,再对这个组排序,就完成了排序。
组数多则每组的数据少,组数少则每组的数据多。

动图演示:

2.2.2 实例讲解

希尔排序分为两部分:预排序+插入排序

现在我们给定如下数组,并以3为gap,可将数组根据颜色分为3组

之后我们对这三组数据进行插入排序

之后我们将间隔缩小, 以2为间隔,我们就可以分出两组。

这里也并不一定要只减少1,减少多少看我们想减少多少。

现在我们完成第二次排序

现在我们的数组已经非常接近有序,我们最后再以1为间隔,得到一组以1为间隔的等差数列,再完成最后一次排序,也就是直接插入排序,即可使得我们的数组有序。

2.2.3 代码实现

现在我们根据我们的思路来用代码逐步实现希尔排序,下面我们进入代码演示环节

第一步:以3为间隔,排序第一组绿色的

在已经学习了插入排序的基础上,我们来实现一下排序绿色

cpp 复制代码
//代码中的n代表数组长度,后面的代码不再解释。
int gap = 3;
//n-gap后的数据为最后一组数据,而当i等于我们的前一组数据时
//排序的就是最后一组数据,因此结束条件为i<n-gap
for (int i = 0; i < n - gap; i += gap)
{
	int end = i;
	int tmp = a[end + gap];
	while (end >= 0)
	{
		if (tmp < a[end])
		{
			a[end + gap] = a[end];
			end -= gap;
		}
		else
		{
			break;
		}
	}
	a[end + gap] = tmp;
}

第二步:进行第一次排序

由于我们先前已经实现了排序绿色的,而排序蓝色的和排序黄色的不过是起始位置不同,因此我们再嵌套一层循环即可。

cpp 复制代码
for (int j = 0; i < gap; j++)
{
	int gap = 3;
    //n-gap后的数据为最后一组数据,而当i等于我们的前一组数据时
    //排序的就是最后一组数据,因此结束条件为i<n-gap
	for (int i = j; i < n - gap; i += gap)
	{
		int end = i;
		int tmp = a[end + gap];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + gap] = a[end];
				end -= gap;
			}
			else
			{
				break;
			}
		}
		a[end + gap] = tmp;
	}
}

现在我们已经完成了第一次排序,那么后面的排序我们控制gap即可

cpp 复制代码
for (int gap = 3; gap > 0; gap--)
{
	for (int j = 0; i < gap; j++)
	{
		for (int i = j; i < n - gap; i += gap)
		{
			int end = i;
			int tmp = a[end + gap];
			while (end >= 0)
			{
				if (tmp < a[end])
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}
	}
}

这时我们发现我们的代码达到了惊人的四层循环...这段代码未免有些过于恐怖...

那我们有没有什么办法优化这段代码呢?

2.2.4 希尔排序的优化

这时有人给出了这么一个解决方法:

我们不再一次比较一个数据组,

而是先比较第一个数据组的第一个数据和第二个数据,

然后比较第二个数据组的第一个数据和第二个数据,

之后比较第三个数据组的第一个数据和第二个数据,

然后比较第一个数据组的第二个数据和第三个数据,

这么一直比较下去,就可以完成我们第一次预排序的效果。

如下图所示,相同颜色的线表示比较的数据。

代码如下所示:

cpp 复制代码
int gap = 3
for (int i = 0; i < n - gap; i++)
{
	int end = i;
	int tmp = a[end + gap];
	while (end >= 0)
	{
		if (tmp < a[end])
		{
			a[end + gap] = a[end];
			end -= gap;
		}
		else
		{
			break;
		}
    }
	a[end + gap] = tmp;
}

现在我们已经完成了第一趟的排序,接下来我们控制gap即可。

cpp 复制代码
int gap = 3;
while (gap > 0)
{
	for (int i = 0; i < n - gap; i++)
	{
		int end = i;
		int tmp = a[end + gap];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + gap] = a[end];
				end -= gap;
			}
			else
			{
				break;
			}
		}
		a[end + gap] = tmp;
	}
	gap--;
}

现在这段代码看起来就舒服多了。但是我们的gap就一定每次都减1吗?

我们之前说过,预排序是为了让数组更加有序,我们只要能够让数组更加有序就可以了,没有必要每次让gap减1,gap太大了反而会有一些副作用。

这时有人就设计了这么一个希尔排序:

cpp 复制代码
int gap = n;
while (gap > 0)
{
	gap /= 2;
	for (int i = 0; i < n - gap; i++)
	{
		int end = i;
		int tmp = a[end + gap];
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + gap] = a[end];
				end -= gap;
			}
			else
			{
				break;
			}
		}
		a[end + gap] = tmp;
	}
}

这里的第一趟循环以二分之数组长度为间隔,后续的循环每次都除以2。

到了最后一次循环之时,gap要么等于2,要么等于3;而它们除2都等于1。这样就保证了最后一次循环是直接插入排序,可谓是相当完美了。

现在我们将其封装在函数体内,完成最终版的希尔排序

cpp 复制代码
void ShellSort(int* a, int n)
{
	int gap = n;
	while (gap > 0)
	{
		gap /= 2;
		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int tmp = a[end + gap];
			while (end >= 0)
			{
				if (tmp < a[end])
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}
	}
}

2.2.5 时间复杂度

我们发现我们最终版的希尔排序也拥有三层循环,于是我们大家就对希尔排序的效率产生了疑问.但是利用我们现有数学能力无法计算出希尔排序的时间复杂度,只能给出一个大致范围

下面给出严蔚敏老师《数据结构(C语⾔版)》书中的相关论述:

在这里也可以给大家大概画一下图,由于每次排序都会对后续的排序产生影响,因此我们后续的排序移动的数据会越来越少,因此效率还是比较高的。

外层循环:

外层循环的时间复杂度可以直接给出为:或者, 即O(logN).

内层循环:

假设⼀共有n个数据,合计gap组,则每组为n/gap个;在每组中,插⼊移动的次数最坏的情况下为:

,⼀共是gap组,因此:

总计最坏情况下移动总数为:

gap取值有(以除3为例):

当gap为n/3时,移动总数为:

当gap为n/9时,移动总数为:

最后⼀趟数,gap=1即直接插⼊排序,内层循环排序消耗为n

通过以上的分析,可以画出这样的曲线图:

因此,希尔排序在最初和最后的排序的次数都为n,即前⼀阶段排序次数是逐渐上升的状态,当到达某⼀顶点时,排序次数逐渐下降⾄n,⽽该顶点的计算暂时⽆法给出具体的计算过程.

2.3 选择排序

2.3.1 直接选择排序

2.3.1.1 基本思想:

第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始(末尾)位置,

然后选出次小(或次大)的一个元素,存放在最大(最小)元素的下一个位置,

重复这样的步骤直到全部待排序的数据元素排完 。

动图演示:

2.3.1.2 代码实现

这里我们可以进行一个优化,最小值和最大值同时选,然后将最小值与起始位置交换,将最大值与末尾位置交换。

cpp 复制代码
// 选择排序
void SelectSort(int* a, int n)
{
	assert(a);
	int begin = 0;//保存数组的起始位置
	int end = n - 1;//保存换数组的末尾位置
	
	while (begin < end)
	{
		int maxi = begin;//保存最大元素下标
		int mini = begin;//保存最小元素下标
 
        //遍历数组寻找最小和最大元素
		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 (maxi == begin)
		{
			maxi = mini;    
		}
    
        //将最大元素交换到末尾位置
		Swap(a+end, a+maxi);
        //移动数组起始和末尾位置
		begin++;
		end--;
	}
}

注意:

在进行最小值和最大值同时交换时也会出现一个问题,

如果最大值在起始位置的时候,交换了最小值之后,最大值就被交换到了min的位置,

如果继续交换max,就会将最小值交换到末尾位置。

所以,在每次交换了最小值之后应该判断一下最大值是否在起始位置,如果在需要将max赋值为min。

**直接选择排序的特性总结:

  1. 直接选择排序思考⾮常好理解,但是效率不是很好。实际中很少使⽤
  2. 时间复杂度:
  3. 空间复杂度:**

2.3.2 堆排序(Heapsort)

堆排序(Heapsort)是指利⽤堆积树(堆)这种数据结构所设计的⼀种排序算法,它是选择排序的⼀

种。在⼆叉树章节我们已经实现过堆排序,我们可以带大家回顾一下:

堆排序即利用堆的思想来进行排序,总共分为两个步骤:

1. 建堆

升序:建大堆

降序:建小堆

2. 利用堆删除思想来进行排序

建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。

这里以升序为例:

首先应该建一个大堆,不能直接使用堆来实现。可以将需要排序的数组看作是一个堆,但需要将数组结构变成堆。

我们可以从堆从下往上的第二行最右边开始依次向下调整直到调整到堆顶,这样就可以将数组调整成一个堆,且如果建立的是大堆,堆顶元素为最大值。

然后按照堆删的思想将堆顶和堆底的数据交换,但不同的是这里不删除最后一个元素。

这样最大元素就在最后一个位置,然后从堆顶向下调整到倒数第二个元素,这样次大的元素就在堆顶,重复上述步骤直到只剩堆顶时停止。

动图演示:

注意:实际中并没有删除堆中元素,图中为了方便表示,将交换后的位置画成了空。

代码实现:

cpp 复制代码
// 堆排序
void AdjustDown(int* a, int n, int root)//向下调整
{
	assert(a);
	int parent = root;
	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 = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
 
void HeapSort(int* a, int n)
{
	assert(a);
 
    //建堆
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}
 
    //交换
	for (int i = n - 1; i > 0; i--)
	{
		Swap(&a[i], &a[0]);
		AdjustDown(a, i, 0);
	}
}

2.4 冒泡排序

冒泡排序应该是我们最熟悉的排序了,在C语言阶段我们就学习了冒泡排序。

2.4.1 基本思想

他的思想非常简单,就是相邻的两个元素相比较,

前一个比后一个大就交换,直到将最大的元素交换到末尾位置。这是第一趟。一共进行(n-1)趟。这样的交换将可以把所有的元素排好。

((n-1)趟是因为只剩两个元素时只需要一趟就可以完成)

动图演示:

2.4.2 代码实现

cpp 复制代码
// 冒泡排序
void BubbleSort(int* a, int n)
{
    assert(a);
	int i = 0;
	int flag = 0;
 
    //n-1趟排序
	for (i = 0; i < n-1; i++)
	{
		int j = 0;
 
        //一趟冒泡排序
		for (j = 0; j < n - i - 1; j++)
		{
			if (a[j] > a[j+1])
			{
				Swap(&a[j], &a[j+1]);
				flag = 1;
			}
		}
 
        //若某一趟排序中没有元素交换则说明所有元素已经有序,不需要再排序
		if (flag == 0)
		{
			break;
		}
	}
}

冒泡排序的特性总结:

  1. 冒泡排序是一种非常容易理解的排序
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:稳定

本期学习,同学们要熟练掌握以上排序的思想和代码实现,

下期博客博主会专门讲解在排序算法中非常重要且理解较难的排序 ------ 快速排序!

同学们一定要搬好小板凳,专心听讲哦~

如果你觉得博主讲的还不错对你有帮助的话,给我的博客留个赞和关注,后期不断给大家讲解新的知识。我们下期再见~👋👋

相关推荐
ChoSeitaku6 分钟前
链表循环及差集相关算法题|判断循环双链表是否对称|两循环单链表合并成循环链表|使双向循环链表有序|单循环链表改双向循环链表|两链表的差集(C)
c语言·算法·链表
Fuxiao___14 分钟前
不使用递归的决策树生成算法
算法
我爱工作&工作love我20 分钟前
1435:【例题3】曲线 一本通 代替三分
c++·算法
白-胖-子1 小时前
【蓝桥等考C++真题】蓝桥杯等级考试C++组第13级L13真题原题(含答案)-统计数字
开发语言·c++·算法·蓝桥杯·等考·13级
workflower1 小时前
数据结构练习题和答案
数据结构·算法·链表·线性回归
好睡凯1 小时前
c++写一个死锁并且自己解锁
开发语言·c++·算法
Sunyanhui11 小时前
力扣 二叉树的直径-543
算法·leetcode·职场和发展
一个不喜欢and不会代码的码农1 小时前
力扣105:从先序和中序序列构造二叉树
数据结构·算法·leetcode
前端郭德纲1 小时前
浏览器是加载ES6模块的?
javascript·算法
SoraLuna2 小时前
「Mac玩转仓颉内测版10」PTA刷题篇1 - L1-001 Hello World
算法·macos·cangjie