教你随心所欲的操作希尔排序,而不是死记希尔模板。它的精髓,你知道吗?

希尔排序是一种效率很高,很重要的排序,在了解希尔排序之前我们先要了解一下直接插入排序以及它的思想。在这里讲直接插入排序的时候我会把它和希尔排序进行高度的统一,以便于大家的对比学习。

直接插入排序

先来看直接插入排序是一个什么东西

直接插入排序故名,通过一次一次的直接插入进行排序,这里文字不好阐述,上面的图已经很清楚了。就不解释了,其实它和选择排序,冒泡排序等是没有本质区别的,属于很低能的排序,正因为低能,所以算法大师Shell想出了Shell Sort这个伟大的算法。接下来我们从直接插入排序开始逐渐了解一些排序的精髓。 

首先,纠正很多人的一个概念:很多人觉得写出一个排序的方法有很多种,就算只改一下循环的条件,也可以诞生出很多不同的写法,但是事实真的如此吗?改一下循环条件真的可以诞生出很多写法吗?其实,循环的起始,结束,开闭,决定了下面所有程序的所有写法,这是一层套一层的关系,只有上面的写法全部正确继续往下才不会出错!这是一个浑然一体的程序,牵一发而动全身!

接下来以直接插入排序为例子对上述观点进行验证。

虽然冒泡排序,选择排序,直接插入排序其实没有什么很大的区别,但是为了尽可能的体现出插入的特点,我们严格看到上图,直接插入排序不涉及到多次交换。所以接下来的代码,我们以直接插入排序的风格来书写,体现出这种排序的特点。(代码上的特点,论效率还是跟其他高级一点的排序没有任何可比性的)

所谓排序,就是通过多次单次达到整体有序的目的,单次的直接插入是建立在有序的条件下进行插入。见如下代码:

#include<stdio.h>
int main()
{
	int n = 10;
	int a[20];
	for (int i = 0; i < 10; i++)
	{
		scanf("%d", &a[i]);
	}//先初始化10个吧,这里是有序(升序)的序列,不然插入是没有意义的,乱插入了就
	int x;
	scanf("%d", &x);//输入你要插入的数字
	int end = 9;//标记最后一个数字
	while (end >= 0)
	{
		if (a[end] < x)//那么我们要把所有的数字往后面移动
//那么这个时候我们又要注意另外一个问题,怎么移动数字不会被覆盖,
//正因为有了这个想法,所以才有了下面这个循环的条件的写法,这是一一对应的
		{
			for (int j = 9; j > end; j--)//首先是你的end已经是要插入的前一位了,所以
				//这个end已经不可以取了,因此是开区间,之所以起始条件是9,而不是end,
				//是因为防止数据在移动过程中被覆盖的问题
			{
				a[j + 1] = a[j];
			}
				a[end + 1] = x;
				break;
		}
		end--;
	}
	if (end == -1)
	{
		for (int j = 9; j >= 0; j--)
		{
			a[j + 1] = a[j];
		}
		a[0] = x;
	}
	for (int i = 0; i < 11; i++)//插入了一个数据之后已经变成11个数据了
	{
		printf("%d ", a[i]);
	}
	return 0;
}

这个就是直接插入,数组的直接插入,这里的前提是数组是一个有序的数组,请大家仔细阅读注释,这里已经开始体现上方红色字体的思想了,我的循环怎么写的不是由我决定的,是由我上方的程序决定的!

那么直接插入排序就是像这样不断的直接插入知道排序,再仔细看动图,第一趟只有一个数字,这个数字默认是有序的,第二趟,第三趟....每一趟前面的数字都已经有序了。所以才有插入的意义。正因为每次都是插入到了正确的位置,而只有一个元素的时候又是有序的,所以这样迭代最终达到了排序的目的。所以代码就是一次插入的改良。

但是,作为强迫症患者,我认为这一段代码太垄长,不完美,而且要移动一整个数组,当倒序排成正序时实在是矮人国里面的矮人,所以我们优化一下,让它稍微的效率高点,我们不移动整个数组,而是在第一次遍历找插入点的时候就进行移动:以下是改良过的代码:

#include<stdio.h>
int main()
{
	int n = 10;
	int a[20];
	for (int i = 0; i < 10; i++)
	{
		scanf("%d", &a[i]);//还是升序
	}
	int x;
	scanf("%d", &x);//输入要插入的数据,用x保存起来,这个保存过程实际上是相当的重要的
	int end = 9;//从最后一个数字开始,准确来说是从最后一个有序的数字开始,不然插入就是白白的插入了
	while (end >= 0)
	{
		if (a[end] > x)//如果这个数字大于x,那么
		{
			a[end + 1] = a[end];//直接将其移动到下一个,这里可以明显看到没有出现覆盖的问题
			//这就像是坑位一样,你可以设想一下,当轮到最后一个数字的时候,那这个数字的位置就是我们x要放的位置
			end--;
		}
		else
		{
			break;
		}
		a[end + 1] = x;//因为之前end--了,实际上是减到了目标的前一个位置,我们要+1
		//为什么是前一个位置?因为我们写的是if (a[end] > x),也就是x<a[end]的时候停止的,而且这个时候是小1
	}
	for (int i = 0; i < 11; i++)
	{
		printf("%d ", a[i]);
	}
	return 0;
}

还是请大家仔细看注释,然后看下面这个图便于理解:

 单趟插入理解清楚了,那么直接插入排序就是多次单趟插入,我之前已经提到了,现在复制过来再看一遍:

那么直接插入排序就是像这样不断的直接插入知道排序,再仔细看动图,第一趟只有一个数字,这个数字默认是有序的,第二趟,第三趟....每一趟前面的数字都已经有序了。所以才有插入的意义。正因为每次都是插入到了正确的位置,而只有一个元素的时候又是有序的,所以这样迭代最终达到了排序的目的。所以代码就是一次插入的改良。

接下来开始直接插入的排序:(注释要仔细看)

这里我先放一段正确的代码,然后带大家分析里面的内涵:

#include<stdio.h>
int main()
{
	int n = 10;
	int a[10] = { 9,8,7,6,5,4,3,2,1,0 };
	int i;
	for (i = 0; i < n-1; i++)
	{
		int end = i;
		int x = a[end+1];
		while (end >= 0)
		{
			if (a[end] > x)
			{
				a[end+1] = a[end];
				end--;
			}
			else
			{
				break;
			}
			a[end + 1] = x;
		}
	}
	for (int j = 0; j < n; j++)
	{
		printf("%d ", a[j]);
	}
	return 0;
}

拿到这一段代码,相信大家已经不难看懂了吧,但是请思考以下几个问题:

我是怎么把控的:

1.为什么是for (i = 0; i < n-1; i++)

2.为什么是while (end >= 0)

3.为什么是a[end+1] = a[end];

答1:数组的下标是从0开始的,所以我们习惯性的从i=0开始,从i=1开始也可以,但是这样的话控制上就增加了难度,我们就要考虑减1的问题了,所以不推荐,我们有个数字,只需要n-1趟就自然有序了,所以是n-1次循环,因为我从i=0开始的所以是<n-1,这是必然的关系,你从决定写i=0开始,后面的大多数代码怎么写就已经被决定了!!!!你可以试想一下,如果我写的是i=1的话

那么代码就必须改成这样,是必须,错一点都不行,因为你从开始写i=1,其他的因果关系就已经被完全固定了

这里顺带一提的是,写i=1的话while循环处可以是end>0也可以是end>=0,因为从1开始不不存在说越界问题的,但是如果写i=0的话这个end就必须严格把控。

#include<stdio.h>
int main()
{
	int n = 10;
	int a[10] = { 9,8,7,6,5,4,3,2,1,0 };
	int i;
	for (i = 1; i < n; i++)
	{
		int end = i;
		int x = a[end];
		while (end >= 0)
		{
			if (a[end-1] > x)
			{
				a[end] = a[end-1];
				end--;
			}
			else
			{
				break;
			}
			a[end] = x;
		}
	}
	for (int j = 0; j < n; j++)
	{
		printf("%d ", a[j]);
	}
	return 0;
}

2.end>=0说明是end=-1结束的,end=0的时候被包含进去了,没有漏情况end=-1的时候由于a[end+1]=x(i=0的代码),所以这个数组又巧妙的没有越界,简直是浑然一体,无懈可击,不可以有一点错误,就像一块孔明锁一样。

3.这一点就由上面两点决定的,不这么写就会出现覆盖或者越界的问题

接下来总结一下这种牵制关系:

现在我们正式进入希尔排序!! 

希尔排序的实质:希尔排序是直接插入排序的优化升级

你可以这么理解,直接插入排序是希尔排序的一种特殊情况。

希尔排序是怎么操作的?

希尔排序体现的是分组,分治的思想。

它由两个步骤实现:1. 分组预排序——让数组接近有序

                                2.直接插入排序——让数组有序

这里我们引入一个gap的概念,直接插入排序的gap是1,gap就是相隔为gap的数之间进行直接插入排序的操作,看图:

我们要做的就是。先假设gap为3,这里只是先让大家了解大概的流程而已。

我们把上图三种颜色的数字通过gap进行了分组而且分为了3组,对这3组分别进行直接插入排序最后可以得到相对有序的情况。如下图:

 

 我们写出这种情况下的代码,之前说过直接插入排序是gap=1的希尔排序,所以把gap换成我们定义的组别就是希尔排序的一部分了。

	int gap = 3;
	for (i = 0; i < n - gap; i++)
	{
		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种颜色的组别同时排序的话。

#include<stdio.h>
int main()
{
	int n = 10;
	int a[10] = { 9,8,7,6,5,4,3,2,1,0 };
	int i;
	int gap = 3;
	for (int j = 0; j < gap; j++)
	{
		for (i = j; i < n - gap; i++)
		{
			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;
			}
		}
	}
	for (int j = 0; j < n; j++)
	{
		printf("%d ", a[j]);
	}
	return 0;
}

接下来我们就要解释了:1.首先有人会问,你怎么知道gap是多少你就分了gap组?

原因很简单:我们说过gap是指数组的下标加了gap个,那么我们的总个数%gap的值一定是小于gap的,也就是我们一定会在下标n-1,n-2.....n-gap的地方有组别结束,这些组别如果再加上gap就会超过下标n-1的容纳范围,所以分组停止,不会继续向下分组了。反之如果比n-gap还要小,那么总个数/gap就可以有除数,也就是一定还会有一个数组被分进组来。

可以直观的看到,n-1,n-2,..n-gap的位置就一定会分组结束,而每一次分组结束就代表了一个组别,因此有gap个组别是必定成立的。

这个图就很形象。

2.这里巧妙的地方就来了,如果你只是想快速的写完希尔排序,那么写完我的直接插入排序,注意是我给出的直接插入排序,然后把有1的地方换成gap就可以了。i=0和i=1都可以。可以这么做的原因再次解释一遍:直接插入排序是gap=1的时候的特殊情况,代表的是整个数组就是一组,而gap=3就是中间间隔2个数字分组,其本质是完全一样的。

会不会存在越界问题?

答案是不可能,for (i = j; i < n - gap; i++)这里的n-gap保证了不会越出数组边界,而因为我后面是跟[end+gap]进行比较的,所以又保证了确实所有的数字都可以被遍历一遍,也不会存在遗漏的状态。你可以对比着上面的图仔细的看,自己再试一下看看你的疑惑会不会出现。

现在我们也知道了希尔排序第一个步骤中的预排序是什么样的一个道理了。但是我们刚才展示是gap=3的时候,是单趟的预排序,我们想要真正完成预排序需要多趟。

看图:

我们先大概的思考一下,gap越大,说明一次跨越的大,可以更快的跨越整个数组,速度很快,但是有序程度低。gap越小,说明一次跨越的小,跨越整个数组很慢,但是有序程度在逐渐变高,随着gap逐渐减小,小的数字会往前移,这个是一个趋势,而这个趋势gap越小越明显,而在趋势越明显的过程中,你会发现直接插入排序更容易很快就插入了,不需要遍历到最右边,这就是希尔排序速度快的秘密。当gap逐渐缩小直到gap=1的时候,此时必定只需要一次直接插入就完全有序了,如图。原因大家可以这么理解,你每次的gap都把分的组完全按照顺序排好了,当gap=1的时候就是下标加1的所有组别分别已经排序完成,如图,此时再次使用直接插入即可。

因此我们在上述代码的基础上对gap进行一个控制希尔排序就彻底完成了

简而言之就是:多组预排序(里面渗透着直接插入排序)+直接插入排序(最后一次)

代码奉上:

#include<stdio.h>
int main()
{
	int n = 10;
	int a[10] = { 9,8,7,6,5,4,3,2,1,0 };
	int i;
	int gap = n;//这里通常就从n开始
	while (gap>1)
	{
		gap /= 2;//我们通常每次把gap缩2倍
		for (int j = 0; j < gap; j++)
		{
			for (i = j; i < n - gap; i++)
			{
				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;
				}
			}
		}
	}
	for (int j = 0; j < n; j++)
	{
		printf("%d ", a[j]);
	}
	return 0;
}

 那么问题又出现了。

第一个:while (gap>1),写gap>=0行不行。

不行,因为gap>1的循环结束条件是gap=1,也就是gap=1的时候结束,gap=1的时候进行最后的直接插入排序(我们的gap/2写在最前面的,所以相当于先求值了再进行下面的程序,假如你写在最下面的话那么可以是gap>0

)。但是如果你写gap>=0的话那么是在gap=-1的时候结束的,此时gap=0的时候仍然在循环,那么你会发现这是一场死循环。总之这种问题看的是循环结束的时候

第二个问题:int gap = n;//这里通常就从n开始

这里能不能写int gap=n/2,可以,这无所谓,因为这个时候gap非常大,这个时候的排序是非常快的,所以无所谓,但是如果你想不影响效率也仅限于此,这里不好计算怎么写效率最佳,因为希尔排序不稳定,它的效率一部分取决于你给出了什么数,不是恒定的值,因此很难计算,前面我们也提到了希尔排序有效的秘密了。

第三个问题:gap /= 2;//我们通常每次把gap缩2倍

我能不能一次缩3倍,甚至4倍,5倍。首先缩2倍是Shell本人提出的,缩3倍是Knuth提出的,其他的没有被提倡是因为这可能没有寻找到速率最快的一个平衡点。

那么如果我们要缩3倍怎么写,首先明白一个道理,如果你的gap不小心等于0了那么循环就直接结束了,好巧不巧,gap/2恰好不好出现这个情况。但是如果我们缩小3倍的话可能性就相对大了,比如我们写gap/3,我的gap=2的时候循环已经结束了,明显没有排序完成,所以我们要+1,也就是

gap=gap/3+1,这样确保了一定可以排序完成,至于是不是标准的3倍其实无所谓,因为你大概已经完成了认为了而且速度没有收到多少影响。

希尔排序完成,但是我们发现了一个问题,循环调用层数太多了,能不能对代码本身进行一下优化呢?

混杂式写法:

#include<stdio.h>
int main()
{
	int n = 10;
	int a[10] = { 9,8,7,6,5,4,3,2,1,0 };
	int i;
	int gap = n;//这里通常就从n开始
	while (gap>0)
	{
		gap /= 2;//我们通常每次把gap缩2倍

			for (i = 0; i < n - gap; i++)//注意对比,这里是唯一的区别
			{
				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;
				}
			}
	}
	for (int j = 0; j < n; j++)
	{
		printf("%d ", a[j]);
	}
	return 0;
}

这大概是个什么思路呢,就是

之前嵌套循环是把一组排完之后再进行下一组,而这个是同时多组进行,第一组第一个数字9和第二个数字6比较之后来到第二个数字8的位置,也就是第二组第一个数字,然后第二组第一个数字和第二组第二个数字5再进行比较,依次类托,发现是可以多组同时进行的。这里时间复杂度完全没有优化,仅仅是优化了代码

到此为止,希尔排序就讲完了。接下来看看希尔排序的时间复杂度和稳定性

时间复杂度:o(n^1.25)-1.6o(n^1.25)

稳定性:不稳定,你看时间复杂度也不是定值不是吗。

接下来,我会带给大家更多稍微有点深度的文章,这个排序的系列我会坚持做完,敬请期待.....

评论 20
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

胡桃姓胡,蝴蝶也姓胡

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

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

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

打赏作者

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

抵扣说明:

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

余额充值