【数据结构】手撕排序NO.2----直接插入排序与希尔排序

 

目录

一. 导入

二. 直接插入排序

        2.1 基本思想

        2.2 过程分析

        2.3 代码实现

        2.4 复杂度/稳定性分析

三. 希尔排序(缩小增量排序)

        3.1 基本思想

        3.2 过程分析 

        3.3 代码实现 

        3.4 复杂度/稳定性分析


 一. 导入

        本期是排序篇的第二期,我们的主角是插入排序。在座的各位或多或少都玩过扑克牌吧!我们在摸扑克牌时,往往会将大牌插到小牌后面,小牌插到大牌前面。当摸完所有的牌后,我们手中的牌自然也就有序了,这实际上就是用到了插入排序的思想

本期要点

  • 对直接插入排序进行解析
  • 对希尔排序进行解析
  • 两种插入排序的复杂度和稳定性进行分析

二. 直接插入排序

        2.1 基本思想

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

        2.2 过程分析

        我们可以将第一个元素作为一个有序序列,从第二个元素开始插入到这个有序序列中,过程如下:

以升序为例:

当插入第i个元素时(i>=1),说明前面的array[0],array[1],…,array[i-1]已经排好序,我们将array[i]位置的值从后往前与array[i-1],array[i-2],…依次进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移


为什么不从前往后开始进行比较?

如果我们从前往后进行比较,当找到插入位置时,根据顺序表的插入我们知道:必须先将后面的元素全部后移,而后移又不能从前往后移,否则会造成元素覆盖,我们必须从后往前移动。折腾了半天又要从后往前遍历,那为什么不一开始就从后往前比较,在比较的同时一并挪动元素,何乐而不为呢?

        单趟插入动图:

        全过程动图


         2.3 代码实现

//插入排序
void InsertSort(int* a, int n)
{
	assert(a); //确保a不为空
	int end;
	for (int i = 1; i < n; i++) //从第二个元素开始插入,下标为1
	{
		int tmp = a[i];
		end = i - 1; //前面的元素已经有序,从末端,也就是i-1处开始比较
		while (end >= 0) //查找插入位置
		{
			if (a[end] > tmp) //大于则往后挪动数据
			{
				a[end + 1] = a[end];
				end--;
			}
			else //a[end] <= tmp,找到插入位置,跳出循环
			{
				break;
			}
		}
		a[end + 1] = tmp; //在合适位置插入
	}
}

         2.4 复杂度/稳定性分析

          复杂度

         根据上面的动图我们可以发现,当元素集合越接近有序,直接插入的时间效率越高,当元素集合已经有序时,时间复杂度便是O(N),即遍历一遍数组(最优情况)。

         而时间复杂度我们看的是最坏情况,即数组元素逆序的情况。此时每次插入相当于头插,而头插的时间复杂度为O(N),因此总时间复杂度为O( 插入次数 * 单次插入时间复杂度 ) O(N^{2})

         而除了待排序数组之外只有常数级的辅助空间,空间复杂度为O(1)

          稳定性

          由于我们是大于tmp才进行元素挪动,当等于tmp时直接将tmp放到后方,不会对相同元素的顺序进行改变,因此直接插入排序是稳定的排序

         

三. 希尔排序(缩小增量排序)

        3.1 基本思想

         希尔排序又称缩小增量法,其基本思想是:选出一个整数gap表示增量,根据gap将待排序的记录分为gap组所有距离为gap的记录分在同一组,然后对每一组进行直接插入排序随着排序次数的增多,增量gap逐渐减少,当gap=1时,即所有记录分在同一组进行直接插入排序,排序完成后序列便有序了,算法结束。分组方法如下:

        3.2 过程分析 

  1. 首先,我们需要对gap的初始值进行确定,这并没有一个确定的答案,一般是按照数据量来进行选取。一般我们选取gap = n/3 + 1或者gap = n/2作为gap的初始值。
  2. 根据算法的思想,我们观察到gap是在不断地进行缩小,最后缩小到1进行直接插入排序。因此我们gap的缩小增量可以继续使用gap = gap/3 + 1gap = n/2来表示。
  3. 对1、2点进行合并后,我们可以这样用来表示gap的迭代更新:
int gap = n; //n为序列元素个数
while(gap > 1)
{
    //gap = gap/2;
    gap = gap/3 + 1;

    //每组进行直接插入排序
    //...
}

上面的循环在以下两种情况下会结束:

  • n == 1:即序列只有一个元素,此时无需进行排序,不会进入循环
  • n != 1 ,gap == 1:由于gap的更新是在插入排序之前,因此当循环判断到gap == 1时,上一次进行的就是以1为gap增量的直接插入排序,此时序列已经有序,退出循环。

为什么要取gap = gap/3 + 1而不是gap = gap/3?

由于最后gap要缩小到1进行直接插入排序,而如果我们选取gap = gap/3时,假设gap初始为6,第一次更新后gap=2,第二次更新后gap=0(向下取整),循环便结束了,并不会进行gap=1时的插入排序。因此,为了避免这种情况的发生,我们让gap = gap/3 + 1保证最后一次gap一定为1。


那为什么取gap = gap/2而不是gap = gap/2 + 1?

这种情况不需要处理的原因是gap不可能等于0,因为进入循环的条件是gap>1,而gap只有等于0或1时gap/2才会为0。因此,无论gap初始为多少,最后一定都会在gap=1处停下。并且,当gap=2时,使用gap = gap/2 + 1会出现死循环

      4. 每组之间进行的就是我们上面介绍的直接插入排序,不一样的是相邻元素的距离是gap而不是1。以下是gap = 3时的单趟希尔排序的动图过程:

        5. 下面是希尔排序的全过程(gap = gap/3 + 1为例):

         6. 通过观察,我们可以发现实际上希尔排序就是直接插入排序的优化版。随着每一趟的分组排序,序列越来越接近有序。前面我们说过,直接插入排序在序列越接近有序的情况下效率越高,希尔排序就是通过每趟的预排序来使得序列越来越接近有序,从而提高直接插入排序的整体效率。

        7. 要点提炼gap > 1时,对序列进行分组预排序,目的是使得序列越来越接近有序;当gap == 1时,数组接近有序,此时进行直接插入排序效率大幅提高。


         3.3 代码实现 

//写法1:一组一组排
void ShellSort1(int* a, int n)
{
	int gap = n;
	while (gap > 1)
	{
		//1:gap > 1,预排序
		//2:gap == 1,直接插入排序
		gap = gap / 3 + 1; //缩小增量
		for (int j = 0; j < gap; j++) //一组一组进行插入排序,共gap组
		{
			//对当前组进行直接插入排序,组内相邻元素相距gap。
			//(其实就相当于把上面介绍的直接插入排序代码中的-1改成-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,而组与组之间距离都为1。那么,我们实际上不用一组一组分开排,而是采用多组并排的方式,这样就可以少写一个for循环,代码如下

//写法2:多组并排
void ShellSort2(int* a, int n)
{
	int gap = n;
	while (gap > 1)
	{
		//1:gap > 1,预排序
		//2:gap == 1,直接插入排序
		gap = gap / 3 + 1; //缩小增量

		for (int i = 0; i < n - gap; i++) //多组并排,i就不是+gap了,而是+1,排下一组的元素
		{
			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;
		}



	}
}

         3.4 复杂度/稳定性分析

          复杂度

         尽管上面的代码有多个循环嵌套,但这并不意味着希尔排序的效率低下。我们根据代码来分析一下希尔排序的时间复杂度,过程如下图所示:

         我们可以看到,在gap很大或者gap很小的情况下,每趟排序的时间复杂度为O(N),共进行log_{3}n趟,那我们是不是可以认为希尔排序的时间复杂度为O(NlogN)?实际上并不行,因为当gap处于中间的过程时,时间复杂度的分析实际上是个很复杂的数学问题。每一趟预排序之后都对下一趟排序造成影响,这就好比叠buff的过程。

以下分别是两本书中对希尔排序时间复杂度的说法:

1、《数据结构(C语言版)》--- 严蔚敏


 2、《数据结构-用面相对象方法与C++描述》--- 殷人昆

         因为我们上面的gap是按照Knuth提出的方式取值的,并且Knuth进行了大量的试验统计,时间复杂度我们就按照:O(n^{1.25})O(1.6n^{1.25})来进行取值。

         然后就是空间复杂度,由于我们依旧只用到了常数级的辅助变量,因此空间复杂度为O(1)

          稳定性

          由于希尔排序是分组进行排序,当相同的数被分到不同组时,很可能就会改变相同的数的顺序,因此,希尔排序是不稳定的排序。 


以上,就是本期的全部内容啦🌸

制作不易,能否点个赞再走呢🙏

  • 90
    点赞
  • 82
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 121
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

忆梦初心

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值