《算法导论》学习(一)---- 插入排序和归并排序

系列文章

《算法导论》学习(一)---- 插入排序和归并排序
《算法导论》学习(七)----堆排序和优先队列(C语言)
《算法导论》学习(八)----快速排序(C语言)
《算法导论》学习(九)----为什么比较排序算法时间复杂度的下界是确定的?
《算法学习》学习(十)----计数排序,基数排序,桶排序(C语言)


前言

文章主要内容基于《算法导论》一书中的第二章的内容
主要讲解了插入排序和归并排序
自己用C语言进行了实现两种排序算法
同时用书中的方法进行了算法的分析:
包括循环不变式,时间复杂度的分析,分治思想


一、插入排序

1.什么是插入排序

对插入排序大概描述就是:

对于原始的一组数据,依次将每一个数据插入已经排好序的部分数据序列中,得到一个新的有序的数据序列。如此循环进行,直到最后一个数据插入合适的位置后,排序也随之完成。

在《算法导论》中有形象的比喻:
插入排序的工作方式像大多数人给扑克牌排序。刚开始手里牌空,先随便从牌堆拿起一张牌入手。之后我们每拿起一张牌就会和手里现有的牌比较,找到这张新牌的位置,然后将新牌插入。最后将牌摸完,手里的牌已经排好序了。

2.插入排序的C语言实现

#include<stdio.h>
#include<time.h>
#include<stdlib.h>


void insertion_sort(int *x,int num)
{
	int i=0;//循环变量初始化 
	int j=0;//循环变量初始化 
	int tempval;//中间暂存变量初始化,因涉及两数交换所需
	/*
	第一个循环是要遍历一遍数组
	依次为每一个变量找到它合适的位置
	这个合适的位置是一个局部的范围
	范围是在这个变量之前的空间,包括这个变量
	随着循环的进行,到最后
	这个局部的范围就是所有变量
	那么就完成排序 
	*/ 
	for(i=1;i<num;i++)
	{
		j=i-1;//为位置指正赋值,目的是设置局部范围的界限
		/*
		第二个循环是找准第一个循环所确定变量的合适位置
		循环的方向是从确定变量位置往前
		循环条件包含了判断规则
		满足循环就需要进行交换数据
		不满足循环时,就是局部排序完成时 
		*/ 
		while((x[j+1]<x[j])&&(j>=0))
		{
			tempval=x[j+1];//数据交换 
			x[j+1]=x[j];
			x[j]=tempval;
			j=j-1;//循环变量赋值,推动循环进行 
		}
	}
}

int main()
{
	int i=0;
	int x[1000];
	srand((unsigned)time(NULL));//生成与时间有关的随机数种子 
	for(i=0;i<500;i++)//生成500个1-1000的随机数 
	{
		x[i]=rand()%1000;
	}
	for(i=0;i<500;i++)//将生成的数据打印出来 
	{
		printf("%d ",x[i]);
	}
	printf("\n");
	insertion_sort(x,500);//将原始随机数据排序 
	for(i=0;i<500;i++)//将排好序的数打印出来 
	{
		printf("%d ",x[i]);
	}
	printf("\n");
	return 0; 
} 

执行结果:
(数据量为50)
50数据量

3.插入排序的分析

(1)核心逻辑

插入排序最关键的就是为拿出来的数据找位置的操作,就相当于扑克牌摸牌时,最重要的就是将新摸牌和已有的牌组合,排序等。由于C语言是面向过程的语言,因此要实现找位置这个操作,需要的是一个好的循环不变式

循环不变式

根据《算法导论》书中介绍,循环不变式要满足三个性质

1.初始化:循环的第一次迭代之前,它为真
2.保持:迭代需要进行时,每一次迭代前的它为真,迭代后对于下一次迭代也为真
3.终止:在循环终止时,不变式为我们提供了一个有力判据,来判断算法的正确性

那么对于插入排序,的循环不变式就是如下所描述的:

		while((x[j+1]<x[j])&&(j>=0))
		{
			tempval=x[j+1];//数据交换 
			x[j+1]=x[j];
			x[j]=tempval;
			j=j-1;//循环变量赋值,推动循环进行 
		}

初始化:

首先第一次循环的时候,j=1,而x[0…j-1]只有一个元素x[0],一个元素显然是有序的。这也说明了第一次循环迭代之前循环不变式就是成立的。

保持:

while循环每次都会通过“j=j-1”推动“新牌”移动,而每次移动都是会交换位置的。
但是这个移动会有前提,那就是“新牌”比它左边的“牌”要小,即“x[j+1]<x[j]”所表达的内容
通过这个前提,我们始终能保证牌的从小到大的有序性。
如果一直满足前提,循环将一直保持这个不变的动作持续迭代下去,直至退出循环

终止:

终止时的条件是

(j<0||x[j+1]>=x[j])

那么显然,这个就是我们所需要的正确结果,保证了序列的有序性
通过这个结束条件,我们可以判断算法正确了

(2)编程细节

在编程实现的过程中,有一个细节就是为”新牌“找位置的方向

while((x[j+1]<x[j])&&(j>=0))

这里的是从j-1向0的方向来进行比较定位,这样的好处是可以通过交换数据的方式,实现数据的插入。

假设若是从0到j-1的方向,虽然也可以找准位置,但是仅仅是找到了位置,之后还需要再次进行插入。

方向的区别带来的本质影响就是可利用的空间。因为从0到j-1的方向的话,0以前没有空间;但是从j-1到0的方向,x[j]是“新牌”的位置,由于“新牌”要找新位置,这个空间就是可利用的空间。

4.插入排序的评估

(1)时间复杂度

显然插入排序需要进行两层循环,那么时间的表达式可以写成:

	T(n)=c1*n*n+c2*n+c3

我们考虑时间复杂度时,就是考虑输入规模足够大的时候的情况。
而当输入规模足够大时,n^2对于T(n)的影响是巨大的,我们甚至可以忽略其它项
因此可以说时间复杂度是O(n*n)

二、归并排序

1.什么是归并排序

归并排序运用了分治的思想。分治思想呢就是将一个大规模问题拆分成很多小规模问题逐个解决的方法。那么我们的归并排序就是将大规模的数据,通过不断地分割成最微小规模的排序问题,这些问题仅仅是针对两个数据或者是一个数据的排序,可以直接操作,比较简单。然后将最微小规模的数据排好序后,再不断合并成一个有序数据。

这里的合并不是简单的将两个数组合并为一个数组,而是将两个有序的数组合并成一个有序的数组,这个也是较容易实现的。

将问题分割我们采用的编程方法是递归,通过传递分割变量的方式,实现数据的分割。

2.归并排序的C语言实现

#include<stdio.h>
#include<stdlib.h>
#include<time.h>


#define SIZE  1000//定义数据量的大小 

/*
用来实现归并功能
归并的过程就是一次排序的过程
归并的对象是两个已经排好序的数组
那么最微元的时候
归并的对象是一个数据或者是两个数据
对于一个单数据的数组 
归并函数就是直接不作处理,返回
对于两个单数据的数组 
归并就是对它们排好序,返回一个两个元素的有序数组 
对于两个多数据的有序数组
归并就是将它们合并为一个有序的数组 
*/ 
void merge(int *x,int a,int b,int c)
{
	//若是一个单数据的数组就不做处理,直接返回 
	if(a==c)
	{
		return;
	} 
	int n1;
	n1=b-a+1;//分割的第一个顺序序列大小 
	int n2;
	n2=c-b;//分割的第二个顺序序列大小 
	int x1[n1];//为两个顺序序列分配空间 
	int x2[n2];
	int i=0;
	int j,k;
	j=n1-1;//为两个顺序序列的合并,提供位置指针 
	k=n2-1;
	//从原始数据中提取两个顺序序列 
	for(i=0;i<n1;i++)
	{
		x1[i]=x[i+a];
	}
	for(i=0;i<n2;i++)
	{
		x2[i]=x[i+b+1];
	}
	//将提取的两个顺序序列合并为一个顺序序列于原存储空间 
	for(i=c;i>=a;i--)
	{
		//两个序列都到底,就退出 
		if(j<0&&k<0)
		{
			break;
		}
		//一个序列到底,另外一个序列没有到底,那就可以直接赋值,因为两个序列自身都是有序序列 
		else if(j>=0&&k<0)
		{
			x[i]=x1[j];
			j--;//行进到序列下一个元素 
		}
		else if(j<0&&k>=0)
		{
			x[i]=x2[k];
			k--;
		}
		//两个序列都没有到底,那么就是谁大谁先在前 
		else 
		{
			if(x1[j]>=x2[k])
			{
				x[i]=x1[j];
				j--;
			}
			else
			{
				x[i]=x2[k];
				k--;
			}
		}
	}
	return;
}

/*
用来实现分治功能
该函数就是通过递归,调用自己的方式
将原始数据的数组分解为最小元
每一个最小元是两个数据或者一个数据
然后再结合归并程序
进行排序功能 
*/ 
void merge_sort(int *x,int a,int c)
{
	int b;
	b=(a+c)/2;//二分数据
	//如果数据分割至一个或者两个,说明已经是最小微元,直接开始归并 
	/*
	这里并没有直接排序,而是用了归并函数,是因为归并函数里面也可以进行排序
	当然这里的if语句里面可以直接进行排序操作,会比调用归并函数更加节约时间和空间
	这里之所以要用归并函数属于个人偏好。
	*/
	if(a==b||b==c)
	{
		merge(x,a,b,c);
		return;
	}
	//没有分割至最小微元,调用递归再次分割 
	else
	{
		merge_sort(x,a,b);//利用提前计算好的b进行数据二分 
		merge_sort(x,b+1,c);
		merge(x,a,b,c);//最后分割完要执行归并,排序且合并 
		return;
	}
}




int main()
{
	int i=0;
	int *x;
	x=(int *)malloc(SIZE*sizeof(int));//动态分配宏定义指定的内存空间 
	srand((unsigned)time(NULL));//生成与时间有关的随机种子 
	for(i=0;i<SIZE;i++)//生成宏定义指定大小的1-1000的随机数 
	{
		x[i]=rand()%1000;
	}
	for(i=0;i<SIZE;i++)//打印原始数据 
	{
		printf("%d ",x[i]);
	}
	printf("\n");
	merge_sort(x,0,(SIZE-1));//进行排序 
	for(i=0;i<SIZE;i++)//打印排序好的数据 
	{
		printf("%d ",x[i]);
	}
	printf("\n");
	free(x);//释放动态生成的内存 
	return 0;
} 

执行结果:
(数据量为100)
100数据量

3.归并排序的分析

(1)分治思想

许多有用的算法在结构上都是递归的,为了解决一个给定的问题,算法一次或多次递归地调用其自身以解决紧密相干的若干子问题,这些算法都是典型地遵循分治思想

分治思想是将原问题分解为几个规模较小,但是类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。

分治模式在每层递归时有三个步骤;

1.分解:原问题分解为若干个子问题,这些子问题是原问题规模较小的实例
2.解决:递归地解决这些子问题,即将规模大的分割;但是规模小的子问题可以直接求解
3.合并:合并这些子问题的解成原问题

这三个步骤在归并排序中的表现就是:

1.分解待排序的n个元素的序列成各具n/2个元素的两个子序列
2.适用归并并排序递归地排序两个子序列
3.合并两个已经排序的子序列以产生已排序的答案

(2)核心逻辑

归并排序的核心逻辑就是递归的过程。
1.首先计算分割变量,可以利用公式:

	b=(a+c)/2;//二分数据

2.判断是否问题已经可以直接解决
对于排序来说,对一个或者两个数据排序是完全可以不用循环就可以解决的

	if(a==b||b==c)
	{
		merge(x,a,b,c);
		return;
	}

这里并没有直接排序,而是用了归并函数,是因为归并函数里面也可以进行排序
当然这里的if语句里面可以直接进行排序操作,会比调用归并函数更加节约时间和空间
这里之所以要用归并函数属于个人偏好。

3.分割问题
如果问题的规模很大,即超过两个数据,就需要递归地分割问题

	else
	{
		merge_sort(x,a,b);//利用提前计算好的b进行数据二分 
		merge_sort(x,b+1,c);
		merge(x,a,b,c);//最后分割完要执行归并,排序且合并 
		return;
	}

当然分割完需要合并

(3)编程细节

由于在归并的过程中,会发生一个数组的数据已经到头,而另外数组的数据没有到头的情况
因此我们需要再多分情况进行讨论
假如不这样作做,就会访问未知内存,发生内存错误

//两个序列都到底,就退出 
		if(j<0&&k<0)
		{
			break;
		}
		//一个序列到底,另外一个序列没有到底,那就可以直接赋值,因为两个序列自身都是有序序列 
		else if(j>=0&&k<0)
		{
			x[i]=x1[j];
			j--;//行进到序列下一个元素 
		}
		else if(j<0&&k>=0)
		{
			x[i]=x2[k];
			k--;
		}
		//两个序列都没有到底,那么就是谁大谁先在前 
		else 
		{
			if(x1[j]>=x2[k])
			{
				x[i]=x1[j];
				j--;
			}
			else
			{
				x[i]=x2[k];
				k--;
			}
		}

4.归并排序的评估

(1)时间复杂度

归并排序的时间复杂度是O(nlogn)
这里的lgn指的是log2(n)
具体的计算需要用到“主定理”的方法,之后将会进行一定的讲解
但是我们可以进行大概的讲解:
1.我们递归是将数据不断二分,直到最小微元,那分解的过程本身就是是以2^m在不断增加,那对应到算法的时间上就是m的大小,即大概是log2(n)
2.我们分割完数据,每一此分割都需合并。合并的时间复杂度是O(n)
3.总的来说,我们要合并m次,每次用log2(n),那么总的时间复杂度我们可以用O(nlogn)来描述,为了简单表示
没有写成nlog2(n)

总结

由于个人所学有限。仅仅按照当前理解编写了算法对应的C语言程序,但是程序本身一定有很大的优化空间,请各位指正。
本人将继续学习《算法导论》并且进行总结,大家可以关注之后的文章
下一篇: 《算法导论》学习(二)---- 算法时间规模与函数的增长

  • 5
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

SigmaBull

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

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

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

打赏作者

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

抵扣说明:

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

余额充值