数据结构(3)

七、图

(一)图的相关概念

  1. 图的组成

在这里插入图片描述

  1. 有向图和无向图。有向图的边通常称之为弧。无向图的边用圆括号表示,圆括号内顶点顺序任意,而有向图的边用尖括号表示,括号内的顶点顺序比较重要,如图表示A1指向A3的边。

在这里插入图片描述

  1. 顶点的度:和某个顶点有关系的边的个数。在有向图中,把指向顶点的边的个数称为入度,把由顶点引出的边的个数称为出度。

在这里插入图片描述

  1. 我们只研究简单图

在这里插入图片描述

  1. 无向完全图和有向完全图

在这里插入图片描述

  1. 如果图的边是有长度的,我们称这样的图为带权图,也称为网。

在这里插入图片描述

  1. 子图

在这里插入图片描述

  1. 路径

在这里插入图片描述

在这里插入图片描述

  1. 简单路径,如图的不是简单路径:

在这里插入图片描述

  1. 简单环,如图的不是简单环:

在这里插入图片描述

  1. 连通以及连通图

在这里插入图片描述

  1. 极大连通子图(连通分量),如图为一个非连通图,黄色部分和蓝色部分(它们是一个图)都是一个极大连通子图,极大体现在无法在子图的基础上通过扩展顶点或边得到更大的连通子图了。

在这里插入图片描述

  1. 强连通图,强连通图的概念是针对有向图的

在这里插入图片描述

  1. 强连通分量,如图是一个非连通图,黄色和蓝色部分称为强连通分量:

在这里插入图片描述

(二)图的存储结构

(1)顺序存储结构

以有向图为例,需要存顶点信息以及顶点之间的关系。对于一个图有n个顶点,它最多有n*n个关系,因此我们可以借助一个二维数组来存储,用行标代表起点,列标代表终点,用数组中所存的数据来代表顶点之间的关系。由行标和列标所指出的元素的值为1代表这一对顶点之间的边存在,0则为不存在。
在这里插入图片描述
对于带权图,存储方式也是一样的,唯一的不同就是二维数组存储的元素,如果两个顶点之间存在边,对应存储的元素是边的长度,如果不存在边,就存一个非常大的值(无穷大,不同的学校有不同的规定)
在这里插入图片描述
C语言实现,定义一个二维数组,由于是带权图,定义为float型,然后对这个二维数组进行初始化,把数组所有元素都赋值上一个很大的值(MAX):
在这里插入图片描述
以上的是一种简化的图的存储结构,顶点都是数字并且都是从0开始的连续的数字,这样恰好能够使用二维数组的行标和列标来代表顶点,如果顶点的信息不是数字或者不连续的话,我们需要按照下面这样做,增加一个一维数组存储顶点,由于一维数组的下标和二维数组的行标以及列表对应,则可以相应做出替换:
在这里插入图片描述
这种顺序存储结构称为邻接矩阵。

(2)链式存储结构

① 邻接表和逆邻接表

树的孩子存储结构也能够拿来存储图。新建一个数组用来存储图的顶点,和顶点相关的边我们用链表连接起来。新建三个结构体来实现图的存储,树的孩子存储结构用来存储图的话我们称之为邻接表。对于邻接表,存储的是和每一个顶点相关的边,并且对于有向图来说,存储的是以每个顶点为起点的有向的边。

//边结点
typedef struct ArcNode
{
	//邻接顶点(这条边所指的顶点)在数组中的下标
	int adjV;
	//指向下一个边结点的指针
	struct ArcNode *next;
}ArcNode;

//顶点结点
typedef struct 
{
	//顶点所存的数据
	int data;
	//指向顶点相关的第一条边
	ArcNode *first;
}VNode;


//图的结构体
typedef struct
{
	//顶点数组
	VNode adjList[maxSize];
	//n:顶点个数 e:边的条数
	int n,e;
}AGraph;

在这里插入图片描述
前面所讲的邻接表存储的是顶点和以顶点为起点的有关的边,我们也可以存储顶点和以顶点为终点的边,这样的表我们称为逆邻接表。我们将指向顶点的边称为入边,由顶点引出的称为出边。如果我们经常要访问某个顶点的出边,使用邻接表来处理问题比较方便,相反,如果我们经常要访问某个顶点的入边,使用逆邻接表来处理问题比较方便。
在这里插入图片描述

②十字链表

如果我们既关心入边又关心出边该怎么办呢?初步的想法是给每个顶点增加一个指针域,这样每个顶点就能够既存储入边又存储出边了,可是这样存储边的结点就会比原来多了一倍,也不是很好。

比较好的方法是:修改边的结构体设计,给边增加两个数据域(存储数组下标),一个存储边的起点的下标,一个存储边的终点的下标,这样就能够看出边的方向,这样的话,我们就可以把同一条边挂在多个链表中,因为对于某个顶点来说,这条边是入边,而对于另外一个顶点来说,这条边是出边。这样的话,每个顶点会有两个链表,但是链表中的顶点是可以共享的,不会出现结点多出一倍的情况。

修改顶点结构体和边的结构体

//此代码考的极少
//边结点
typedef struct ArcNode
{
	//起点下标
	int start;
	//终点下标
	int end;
	//指向下一个入边的指针
	struct ArcNode *nextIn;
	//指向下一个出边的指针
	struct ArcNode *nextOut;
	
}ArcNode;

//顶点结点
typedef struct 
{
	//顶点所存的数据
	int data;
	//指向顶点相关的第一条入边
	ArcNode *firstIn;
	//指向顶点相关的第一条出边
	ArcNode *firstOut;
}VNode;

示意图,这种存储结构我们称之为十字链表存储结构。
在这里插入图片描述

③邻接多重表

前面所讲的存储结构都是在存储有向图,那么我们现在试着用邻接表来存储无向图。能够发现,由于无向图中的每一条边都没有方向,相当于对于每一条边都有两个方向,这样的话对于同一条边,在邻接表中会出现用来表示这一条边的两个结点,这样会有点浪费内存,那如何实现对于一条边用一个结点表示它呢?

在这里插入图片描述
解决方法是我们只用一个方向来确定一条边,一条边只对应一个结点,对于另外一个方向我们就不构建结点了,然后试着完成顶点之间逻辑关系的存储。

修改边的结构体:

//此代码考的极少
//边结点
typedef struct ArcNode
{
	//起点下标
	int vi;
	//指向与vi相关的下一个边结点
	struct ArcNode *vinext;
	//终点下标
	int vj;
	//指向与vj相关的下一个边结点
	struct ArcNode *vjnext;
	
}ArcNode;

//顶点结点
typedef struct 
{
	//顶点所存的数据
	int data;
	//指向顶点相关的第一条边
	ArcNode *first;
}VNode;

示意图:
在这里插入图片描述

几点总结:

  1. 邻接表可用来存储有向图和无向图
  2. 对于有向图,根据需求的不同又出现了逆邻接表和十字链表。逆邻接表没有修改邻接表的结构,只是边结点的存储规则改变而已,边结点由存储边的终点下标变成了存储边的起点下标。而十字链表是在邻接表的基础上进行了改造。
  3. 对于无向图,为了节省空间,我们又从邻接表衍生出邻接多重表。

(三)遍历算法

(1)深度优先遍历DFS

图的存储结构跟树的孩子存储结构十分相似,因此遍历图的代码可能会跟遍历树的代码十分相似,我们可以将遍历树的代码拿过来进行修改,变成遍历图的代码。遍历过程就是取顶点p的指针访问第一个边结点,进而访问p的所有边,进而采用同样的方法访问p的所有的邻接点。

树的孩子存储结构深度优先遍历算法:

void preOrder(TNode *p,TNode tree[])
{
	if(p != NULL)
	{
		Visit(p);
		Branch *q;
		q = p->first;
		while(q != NULL)
		{
			preOrder(&tree[q->cIdx],tree);
			q = q->next;
		}
}

此算法适用于树的原因是:树中没有环,且树的孩子结点与父结点有阶层之分,父结点到孩子结点之间的分支是一条有向边,只能由父结点到孩子结点,因此在遍历过程中就不会出现死循环的情况。而对于图,可能会存在环,某些结点可能被多次访问,而某些结点可能被多次访问,所以这个算法如果直接套用上图的遍历的话,可能会出现错误,需要进行修改。我们可以给每个顶点设计一个标记,在访问某个顶点之前判断这个顶点是否被访问过,被访问过则跳过它。

修改后的代码,新增一个visit数组,作为顶点的访问标记,visit数组是和我们的顶点数组并列的一个数组,也就是visit数组和顶点数组的下标是对其的,visit数组元素初始化为0,如果某个顶点被访问过了,我们就把visit数组对应位置元素设置为1。

//v初值为0,用来指向visit数组中的某一个元素
//v的值也是遍历顶点数组的当前顶点的下标
//G:图
//visit数组是一个全局变量
void DFS(int v,AGraph *G)
{
 	//第一次不需要判断是否访问过,直接访问顶点
	visit[v] = 1;
	Visit(v);
	//取v所指的顶点的第一条边
	ArcNode *q = G->adjList[v].first;
	//开始访问v所指顶点的所有的边结点
	while(q != NULL)
	{
		//以q的邻接点为起点执行递归遍历
		//判断q的邻接点是否被访问过
		if(visit[q->adjV] == 0)
			DFS(q->adjV,G);
		q = q->next;
	}
}

给出例子帮助理解:
建议画出图的邻接表结合来看更直观。
在这里插入图片描述

(2)广度优先遍历BFS

图的广度优先遍历代码应该也能通过树的广度优先遍历代码来进行修改。同样的我们仍需要一个visit数组来存储每个顶点的状态。

先看看树的广度优先遍历代码,核心部分就是出队一个结点访问它并将其所有的孩子结点入队,然后重复操作。:

void level(TNode *tn,TNode tree[])
{
	if(tn != NULL)
	{
		//队头队尾指针,定义一个辅助队列
		int front,rear;
		TNode *que[maxSize];
		front = rear = 0;
		TNode *p;
		//根结点入队
		rear = (rear + 1)% maxSize;
		que[rear] = tn;
		//开始遍历
		while(front != rear)
		{
			//出队一个结点并访问
			front = (front + 1)% maxSize;
			p = que[front];
			Visit(p);
			//找到第一个孩子结点
			Branch *q = p->first;
			//将这个孩子结点的兄弟结点依次入队
			while(q != NULL)
			{
				rear = (rear + 1)% maxSize;
				que[rear] = &tree[q->cIdx];
				q = q->next;
			}
		}
	}
}

再看看修改后的图的广度优先遍历代码,相似点在于,图的广度优先遍历是出队一个顶点,然后将其所有的邻接点入队,以便下次访问:

//G为图
//这里的visit数组作为参数传递进来
//v初值为0,用来指向visit数组中的某一个元素
//v的值也是遍历顶点数组的当前顶点的下标
void BFS(AGraph *G, int v, int visit[maxSize])
{
	ArcNode *p;
	int que[maxSize],front = 0, rear = 0;
	int j;
	//访问顶点
	Visit(v);
	visit[v] = 1;
	//顶点的下标入队
	rear = (rear + 1)%maxSize;
	que[rear] = v;
	//开始遍历
	while(front != rear)
	{
		//出队一个顶点下标赋值给j
		front = (front + 1) % maxSize;
		j = que[front];
		//取顶点的第一条边
		p = G->adjList[j].first;
		//扫描顶点的所有的边,并且判断这些与边相连的另一端的顶点是否访问过
		//没有访问过的话则访问并将其入队
		while(p != NULL)
		{
			if(visit[p->adjV]) == 0)
			{
				Visit(p->adjV);
				visit[p->adjV] = 1;
				rear = (rear + 1) % maxSize;
				que[rear] = p->adjV;
			}
			p = p->next;
		}
	}
}

给出例子方便理解,建议画出邻接多重图帮助理解:
在这里插入图片描述

(四)最小生成树

由一个图按照某种规则导出的树就称这个树为这个图的生成树。这棵生成树是由图的所有顶点和部分边组成。
一个图可以导出多棵生成树,其中构成生成树的分支的权值和最小的生成树称为最小生成树。这个图是带权图。

如图,由黄色边构成的生成树为最小生成树。
在这里插入图片描述

(1)Prim(普里姆)算法

如何构建最小生成树?从某一个顶点开始,将其视为一棵只有一个顶点的子生成树,然后从与子生成树有关的图的其他边中选出一条最小的边,把这条边及边另一端的顶点并入生成树中,然后从和此时的生成树相连的边中选出一条最小的边,并把这条件及其另一端的顶点并入生成树中······(需要注意的是,在挑选侯选边的时候,只考虑生成树的顶点与图中剩余顶点之间的边,而不考虑生成树顶点之间的边)重复这个操作,直到把图中所有的顶点都并入树中,此时所得到的生成树就是最小生成树,也叫最小代价生成树。这就是Prim算法。

Prim算法求最小代价的实现代码,这里采用的是简单图:

//n:顶点的个数
//MGraph:二维数组表示的带权图、图的简单顺序存储结构
//v0:构造生成树的起始顶点、初值为0
//sum:存储生成树的最小代价,代价指各边的权值之和
void Prim(int n,float MGraph[][n],int v0, float &sum)
{
	//lowCost:当前生成树到图中其余顶点的边最小权值,下标代表相应顶点
	/* vSet:当数组某一位置上的值被设置为1的时候
	 ,代表其对应的顶点已经被并入生成树中了,下标代表相应顶点*/
	int lowCost[n],vSet[n];
	//v:指向当前刚并入的顶点
	//k:
	//min:
	int v,k,min;
	for(int i = 0;i < n; ++i)
	{
		//给lowCost赋初值,初值为起始顶点vo到其他顶点的权值,
		//根据二维数组能够得到
		lowCost[i] = MGraph[v0][i];
		//初始化vSet
		vSet[i] = 0;
	}
	//并入起始顶点
	v = v0;
	vSet[v] = 1;
	sum = 0;
	//循环n-1次,处理剩下的n-1个顶点
	for(int i = 0;i < n -1; ++i)
	{
		//INF:我们自定义的一个比图中所有的边都要大的多的值
		//代表无穷大
		min = INF;
		//扫描lowCost数组和vSet数组
		//找出与当前生成树相关的最小边
		//用min存储最小边的权值,用k指向与最小边相连的顶点
		for(int j = 0;j < n -1; ++j)
			if(vSet[j] == 0 && lowCost[j] < min)
			{
				min = lowCost[j];
				k = j;
			}
		//代表找到的最小边相连的顶点被并入生成树中
		vSet[k] = 1;
		//v指向刚并入的顶点
		v = k;
		//权值和
		sum += min;
		//由于并入了新的顶点,生成树到图中剩余顶点的边的权值可能会发生变化
		//所以要更新lowCost数组的值
		//更新的原则是lowCost数组存储当前生成树到图中其余顶点权值最小的边的权值
		for(int j = 0;j < n -1; ++j)
			//用新并入的顶点到图中剩余顶点的边的权值与lowCost数组进行比较
			//留下小的
			if(vSet[j] == 0 && MGraph[v][j] < lowCost[j])
				lowCost[j] = MGraph[v][j];
	}
}

给出例子帮助理解代码,起始顶点为0,lowCost数组中值为无穷大的元素在代码实现的时候用一个较大的数替代:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(2)Kruskal(克鲁斯卡尔)算法

前面所讲的Prim算法是以顶点为操作的最主要单位,生成树每次都是并入一个顶点,顶点的边也算是并入进去了。现在我们以边为操作的主要单位,也就是每次并入一条边,策略就是每次选当前未被并入的并且并入之后不产生环的最短的边并入。
在这里插入图片描述
那我们如何判断并入这条边之后会不会产生环呢,这就要使用到并查集了。

并查集的初始情况起始就是生成树中散落的顶点,将每个顶点看成一棵棵树,每当打算并入一条边的时候,通过并查集检查一下这条边的两个顶点是否属于同一棵树,不属于同一棵树才把这条边并入,并把其中一个顶点所属的树的根结点挂在另一个顶点的下面作为其孩子结点。判断这两个顶点是否属于同一棵树的方法是,判断它们所在的树的根结点是否相同。这种方法我们就称之为Kruskal算法。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

接下来看看完成kruskal算法所需要的相关的存储结构:

左边是图的存储情况,右边是并查集的存储情况,需要一个结构体数组用来存储边的信息,用一个数组来存储并查集(我们用的是树的双亲存储结构)。

由于我们这里所采用的是简单图,顶点是数字并且从0开始,数字也是连续的,因此并查集数组下标就代表了顶点的信息,每个下标位置上存储的信息就是指向其父结点的信息。如果某个顶点是根结点,我们规定这个顶点所对应的下标位置上存储的是这个顶点本身的信息。初始状态下,每一个顶点都是根结点,存储其本身的信息。
在这里插入图片描述
在这里插入图片描述
这种图的存储结构设计,是一个简单的结构体,a和b是边的两个顶点,w是边的权值,这里结构体名称为Road是因为考题经常会出连接几个城市所需要铺设的最短的路的总长度:
在这里插入图片描述
在并查集中,我们需要多次查找某个顶点所在的树的根结点,我们将这个操作封装到一个函数中去,当下标跟下标对应位置存储的信息是相同的则找到根结点,v是存储并查集的数组,p是某一个顶点的下标:
在这里插入图片描述
kruskal算法求最小代价代码实现:

//road:存储图的边的数组 
//n:顶点个数
//e:边的个数
//权值和
void Kruskal(Road road[],int n,int e,int &sum)
{
	int a,b;
	sum = 0;
	//初始并查集数组
	//每一个顶点都是根结点
	for(int i = 0; i < n; ++i)
		v[i] = i;
	//将存储图的边的数组按照从小到大的顺序排序
	//这样从左到右扫描到的就是当前未被并入的最短的边
	sort(road,e);
	//生成树构建过程
	for(int i = 0; i < e; ++i)
	{
		//获得边的两端的顶点所在树的根结点
		a = getRoot(road[i].a);
		b = getRoot(road[i].b);
		//判断根结点是否相等,即是否属于同一棵树
		if(a != b)
		{
			//不属于则
			v[a] = b;
			sum += road[i].w;
		}
	}
}

给出例子帮助理解:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(五)最短路径

(1)Dijkstra(迪杰斯特拉)算法

迪杰斯特拉算法能够用来求某一个顶点到其余各顶点的最短路径。

我们通过例子来进行理解操作过程,这里有一个有向带权图,要求求顶点0到其余各顶点的最短路径。
在这里插入图片描述
我们需要三个辅助数组,其中dist数组存储了当前顶点到其余顶点的最短路径的长度;path存储了起点到其余顶点的最短路径;set数组标记了哪些顶点已经被选入了最短路径。第一行为数组下标,由于我们使用的是简单图,数组下标可以拿来反映各顶点的顶点信息。
在这里插入图片描述

  1. dist数组初始化,将与起点直接相连的顶点的距离记录下来,无直接相连的顶点的距离记为无穷大,自身记为0,随后再进行不断更新。
  2. path数组初始化,存储了起点到该顶点的所在最短路径上前一个顶点的信息,如果某个位置上其值为-1,代表当前这个顶点再其最短路径上没有前一个顶点。如0是起点没有前一个顶点因此值为-1,4、5、6与起点没有直接相连,因此也没有前一个顶点,值为-1。
  3. set数组初始化,将起点所在位置的值设置为1,也就是起点已经确定在最短路径上了。

在这里插入图片描述
4. 从与起点0相连的顶点中取出距离最近的那一个,将其并入最短路径,也就是将set数组中对应的元素设置为1

在这里插入图片描述
5. 扫描图中剩余未被并入最小路径的所有顶点。

假如某个时刻扫描到了顶点v, 我们就将当前从起点0到顶点v的最短路径长度(也就是dist[v])与从起点0经过刚才并入的顶点再到顶点v的路径长度(由起点到刚并入的顶点可以是路径(由起点经过path数组指示到刚并入的顶点),而刚并入的顶点到顶点v必须为直线路径,没有则为无穷大)进行比较(新并入的顶点可能会导致起点到其余顶点的路径变短,所以要更新)。

如果后者小于前者,就把dist数组中从起点0到顶点v的最短路径长度更新为后者,并且把path数组中path[v]的值设置为刚刚并入的顶点(在这里也就是1),否则什么也不干。 (这里的操作跟Prim算法更新lowCost数组很相似)

先扫描顶点2,起点0经过刚并入的顶点1再到2的路径小于起点0到顶点2的路径dist[2],因此对dist数组进行更新。同时也要对path数组进行更新,设置path[2] = 1。
在这里插入图片描述
扫描顶点3,起点0经过刚并入的顶点1再到3的路径(1和3没有直接相连的路径,所以为无穷大)大于起点0到顶点3的路径dist[3],因此不对dist数组进行更新。
在这里插入图片描述
扫描顶点4,起点0经过刚并入的顶点1再到4的路径小于起点0到顶点4的路径dist[4](无穷大),因此对dist数组进行更新。同时也要对path数组进行更新,设置path[4] = 1。
在这里插入图片描述
再到顶点5、6,不论是从顶点直接到5、6,还是从刚并入的顶点再到5、6,目前都没有路径,路径长度为无穷大,就都没有必要更新了。

  1. 将目前未被并入且距离起点最近的顶点(也就是set[v]不等于1且dist[v]最小的那个顶点)并入最短路径,也就是将set数组中对应的元素设置为1。

在这里也就是将2进行并入,set[2] = 1。

在这里插入图片描述

  1. 回到 5的操作

新并入顶点之后,由起点0经过新并入的顶点2再到3的路径长度(为无穷大)大于dist[3](经过上一次更新由起点到顶点3的最短路径,也就是不经过新并入的顶点的最短路径,也即目前通过path数组指示到达顶点3的路径的路径长度),因此不更新dist数组以及path数组
在这里插入图片描述
新并入顶点之后,0到4的路径长度与dist[4]相等,不更新dist数组以及path数组。
在这里插入图片描述
更新dist[5]
在这里插入图片描述
新并入顶点之后,无论是从顶点不经过新并入的顶点2到6的路径长度,还是经过新并入的顶点2再到6的路径长度都为无穷大,因此不更新dist数组以及path数组

  1. 回到6的操作,将目前未被并入且距离起点最近的顶点(也就是set[v]不等于1且dist[v]最小的那个顶点)并入最短路径,也就是将3并入最短路径,更新set数组。

在这里插入图片描述

  1. 回到5的操作,扫描剩余未被并入最短路径的顶点,发现4、5、6都不需要更新dist数组、path数组。
  2. 回到6,将目前未被并入且距离起点最近的顶点(也就是set[v]不等于1且dist[v]最小的那个顶点)并入最短路径。也就是将5并入。
  3. 回到5,扫描剩余未被并入最短路径的顶点,更新dist[4]、path[4]、dist[6]、path[6]。

在这里插入图片描述
在这里插入图片描述

  1. 回到6,将目前未被并入且距离起点最近的顶点(也就是set[v]不等于1且dist[v]最小的那个顶点)并入最短路径。也就是将4并入。

在这里插入图片描述

  1. 回到5,扫描剩余未被并入最短路径的顶点。

在这里插入图片描述

  1. 回到6,将目前未被并入且距离起点最近的顶点(也就是set[v]不等于1且dist[v]最小的那个顶点)并入最短路径。也就是并入最后一个顶点6。至此,由起点到其余所有顶点的最短路径都求得了,就存在于path数组中。

以上操作的核心:v是当前要检测的顶点,vpre是上一步并入的顶点,比较由起点到当前要检测的顶点的距离与由起点到上一步并入的顶点再到当前要检测的顶点的距离的大小,如果后者小的话,就用后者来更新前者,并且path[v]也做相应的更新。
在这里插入图片描述

迪杰斯特拉的代码实现:

//n:图的顶点个数
//MGraph:存储图的边信息的二维数组
//v0:要求的最短路径的起始顶点
//dist:存最短路径的长度
//path:存最短路径	
void Dijkstra(int n, float MGraph[][n], int v0,int dist[],int path[])
{
	int set[maxSize];
	int min,v;
	for(int i = 0; i < n; i++)
	{
		//初始化dist数组和set数组
		dist[i] = MGraph[v0][i];
		set[i] = 0;
		//初始化path数组
		if(MGraph[v0][i] < INF)
			path[i] = v0;
		else 
			path[i] = -1;
	}
	//并入起始顶点
	set[v0] = 1;path[v0] = -1;
	//对剩下n-1个顶点进行处理
	for(int i = 0; i < n-1; i++)
	{
		//从当前的没有被并入的顶点中挑选出距离起点最近的一个顶点并入
		min = INF;
		for(int j = 0; j < n; j++)
			if(set[j] == 0 && dist[j] < min)
			{
				v = j;
				min = dist[j];
			}
		set[v] = 1;
		//核心操作,对dist和path数组的更新
		for(int j = 0; j < n; j++)
			if(set[j] == 0 && dist[v] + MGraph[v][j] < dist[j])
			{
				dist[j] = dist[v] + MGraph[v][j];
				path[j] = v;
			}
	}
}

(2)Floyd(弗洛伊德)算法

迪杰斯特拉算法是求单元最短路径(从图中某一个顶点到其余各顶点的最短路径)的算法,还有另一种需求,就是求图中任意两个顶点之间的最短路径,可以通过对图中每一个顶点使用迪杰斯特拉算法,但比较麻烦,还有另外一种比较简单的方法,那就是弗洛伊德算法。

弗洛伊德算法需要通过两个二维数组来实现,分别是A和Path数组,A数组存储了任意两个顶点之间的当前最短路径长度,Path存储了任意两个顶点所在最短路径上的中间点,假如Path[1][0]=3,则表示从顶点1到顶点0要经过顶点3。
在这里插入图片描述

弗洛伊德算法描述为:每次找出一个中间点v,对于每个中间v,和任一顶点对(i,j),当满足条件v≠i≠j时,如果A[i] [j] > A[i][v] + A[v][j],则将A[i] [j]更新为A[i][v] + A[v][j],并且将Path[i][j]更新为v。A-1和Path-1表示数组A和Path是初始状态,-1说明还没选择中间点v。A数组是这样初始化的:如果顶点i和顶点j之间有直接的边相连,就将A[i][j]的值初始化为边的长度。Path数组初始化将值全部设为-1,因为开始时还没有什么中间点。

下面看一下执行的过程:

  1. 先列出所有要检测的顶点对,不检测{0,0},{1,1},{2,2},{3,3},{4,4}是因为顶点到自身的长度为0。这里没有用尖括号,表明者它们只是待检测的顶点对,而不是有向边,因为这两个顶点之间可能是一条路径,可能是一条边,也可能什么也没有。

在这里插入图片描述

  1. 以0为中间点v,分别检测每一个顶点对,对包含中间点的顶点对的检测略过,因为它们都不满足前面弗洛伊德算法提出的条件A[i] [j] > A[i][v] + A[v][j](比如A[0][1],与A[0] [0] + A[0] [1]相比,显然它们是相等的,没有必要进行比较),比如这次是以0为中间对,因此不需要检测的顶点对有{0,1},{0,2},{0,3},{1,0},{2,0},{3,0}。
  2. 检测剩下来所需要检测的顶点对,如果它们满足弗洛伊德算法的条件,则更新A数组,将A[i] [j]更新为A[i][v] + A[v][j],并更新Path数组,将Path[i][j]更新为当前的中间点v。
  3. 检测完需要检测的顶点对之后,更换中间点为下一个顶点,重复执行上面的操作,直至所有的顶点都充当过中间点。

每次更新后的状态为:

这样算法就执行完毕了,A数组中存储了任意两个顶点之间的最短路径的长度,通过Path数组能够找到任意两个顶点之间的最短路径。

如果要查顶点1到顶点0的最短路径,首先查Path[1] [0]得到值3,然后查Path[1] [3]得到值-1,说明1到3有直接的边,然后查Path[3] [0]得到值2,然后查Path[3] [2]得到值-1,说明从3到2有直接的边,再查Path[2] [0]得到值-1,说明从2到0有直接的边。这样从1到0的最短路径已经得到了,就是1->3->2->0

执行查找最短路径的代码如下

//u,v:代表要查找从顶点v到顶点u的最短路径
//
void printPath(int u, int v, int path[][max])
{
	if(path[u][v] == -1)
	直接输出
	else
	{
		int mid = path[u][v];
		printPath(u,mid,path);
		printPath(mid,v,path);
	}
}

弗洛伊德算法的代码如下:

//n:图中顶点的个数
//MGraph:图的邻接矩阵存储
void Floyd(int n, float MGraph[][n], int Path[][n])
{
	
	int i,j,v;
	int A[n][n];
	//对A数组和Path数组进行初始化
	for (i = 0; i < n; ++i;)
		for(j = 0; j < n; ++j)
		{
			A[i][j] = MGraph[i][j];
			Path[i][j] = -1;
		}
	
	//三层循环,最外层是选出所有的顶点作为中间点
	for(v = 0; v < n; ++v)
		//内两层的作用是选出所有的顶点对
		//根据当前的中间点v进行检测,更新A数组和Path数组
		for(i = 0; i < n; ++i)
			for(j = 0; j < n; ++j)
				if(A[i][j] > A[i][v] + A[v][j])
					{
						A[i][j] = A[i][v] + A[v][j];
						Path[i][j] = v;
					}
}

(六)拓扑排序

(1)拓扑排序

Activity On Vertex,即活动在顶点上的网,用顶点来代表某一项活动,用顶点之间的边来代表活动之间的关系的图称为AOV网。

如图是一个由原材料生产成品的过程,最左边的顶点是原材料,为收集原材料的过程,由这个顶点引出三条边指向3个顶点,分别是部件1、2、3,意思是由我们找到的原材料分别生产出3种不同的部件,这3个顶点显然代表不同部件的生产活动;最后这三个顶点各引出一条边指向最后一个顶点,也就是将部件组装成为成品的活动。这就是一张AOV网,它描述了一个具有实际意义的过程,其中的顶点具有某种意义上的先后次序之分,这种图是不能具有环的。
在这里插入图片描述
根据这张网来指导我们生产产品的过程,我们可以导出网中很多活动的执行序列。但其中有一些执行次序是错误的,为了导出正确的执行次序,我们就需要用到拓扑排序了。
在这里插入图片描述
拓扑排序具体操作,可能得到的序列不止一种,因为删除一个入度为0的顶点可能会导致多个入度为0的顶点出现:
在这里插入图片描述
拓扑排序相关代码实现:在拓扑排序中有一个重要的操作就是检测某一个顶点的入度是否为0,每删除一个顶点,与其相关的边也会删除,这样删除一个顶点的话其余的顶点的入度也会发生变化,因此我们需要给顶点增加一个标记来指示当前顶点的入度是多少。要完成拓扑排序,要修改图的顶点的结构体设计。由于拓扑排序需要删除某个顶点和其相连的所有的边,并且要找到所有邻接的顶点修改其入度的值,因此我们这里使用的是图的邻接表存储结构来操作比较方便。

//图的顶点结构体
typedef struct
{
	//指示当前此顶点的入度
	int count;
	int data;
	ArcNode *first;
}VNode

//G:图
/*返回值int:进行拓扑排序的图必须是一个有向无环图,如果含环的话,拓扑排序
  进行到某一时刻必定会出现不含有入度为0的顶点,而顶点又没有输出完,使得不能
  继续下去,因此需要返回一个标记来判定拓扑是否真正完成了
  */
int TopSort(AGraph *G)
{
	//i,j:循环变量
	//n:用来统计当前已经输出的拓扑序列中顶点的个数
	int i,j,n = 0;
	//用来保存当前图中所有入度为0的顶点,只是一个容器,换成队列或数组都可
	int stack[maxSize],top = -1;
	//用来遍历顶点后边链表的辅助变量
	ArcNode *p;
	
	//假设顶点中的count域已经有值
	//将入度为0的顶点入栈
	for(i = 0; i <G->n; ++i)
		if(G->adjList[i].count == 0)
		stack[++top] = i;
	
	while(top != -1)
	{
		//出栈一个顶点,等效于将顶点从图中删去
		i = stack[top--];
		//记录取出的顶点的个数
		++n;
		//输出当前取出的顶点
		std::count<<i<<" ";
		
		//遍历出栈顶点的所有边,调整其邻接顶点的count值
		//相当于将与顶点相关的边删去
		p = G->adjList[i].first;
		while(p != NULL)
		{
			j = p->adjV;
			--(G->adjList[j].count);
			if(G->adjList[j].count == 0)
				stack[++top] = j;
			p = p->next;
		}
	}

	//返回成功标记
	if(n == G->n)
		return 1;
	else
		return 0;
}

通过拓扑排序代码既可以得到一个图的拓扑排序序列又可以判断这个图是否存在环。

(2)逆拓扑排序

将拓扑排序的过程描述进行修改,我们就得到逆拓扑排序的排序方法。
在这里插入图片描述
逆拓扑排序代码实现与拓扑排序代码实现类似,只需要稍加修改就可以,只需要将图的存储结构改为逆邻接表即可。这里我们不讲这种,而是以另外一种思路来实现,也是考题中经常出现的,用深度优先遍历的方法来实现逆拓扑排序。

通过一个例子来帮助理解:

由于图的深度优先遍历是一种递归的算法,因此就会有系统栈来辅助我们完成递归,这里我们用一个简单的图和系统栈来说明如何通过图的深度优先遍历来实现逆拓扑排序。
在这里插入图片描述
从顶点0开始,p指向顶点0,让0入栈,涂成黄色代表访问过了(在图的深度遍历中是用visit数组来标记),然后p沿着0的一条边来到另一个顶点,1和2都可以。
在这里插入图片描述
来到顶点1,标记顶点1,并让顶点1入栈,p指针指向下一个顶点
在这里插入图片描述
来到顶点3,标记顶点3,并让顶点3入栈,3没有从它本身发出的边了,把它出栈并输出
在这里插入图片描述
p指针返回顶点1,此时顶点1有一条发出的边,但是已经访问过了,相当于对顶点1的访问也完成了,出栈并输出顶点1
在这里插入图片描述
p指针回到顶点0,顶点0还有一条边没有被访问过,就沿着这条边访问
在这里插入图片描述
p来到顶点2,标记顶点2,并把顶点2入栈,此时顶点2只引出了一条边,并且边另一端的顶点已经被访问过,所以顶点2的访问完成,将顶点2出栈并输出
在这里插入图片描述
p指针又回到顶点0,此时顶点0的所有边也被访问过了,顶点0出栈并输出
在这里插入图片描述
这时候我们得到的序列就是逆拓扑序列了,原因是输出顶点的时机是在它出栈的时候,而这个顶点是在这个顶点的所有邻接点都被访问过的情况下出栈的,等效于它此时已经没有边引出指向其他顶点了,等效于此时它的出度为0,也就是输出一个出度为0的顶点,这恰好满足逆拓扑排序的规则。
在这里插入图片描述
以这种思路来实现逆拓扑排序,它的代码只要在图的深度优先遍历代码上修改以下即可,图的深度优先遍历是来到一个顶点就访问一个顶点,也就是来到一个顶点就调用visit函数,而我们将visit函数的位置调到最后,也就是离开一个顶点时才访问(输出)这个顶点。从系统栈的角度来理解也就是进入一个递归函数相当于入栈,结束一个递归函数则相当于出栈,从入栈时访问变成出站时才访问。

//v初值为0,用来指向visit数组中的某一个元素
//v的值也是遍历顶点数组的当前顶点的下标
//G:图
//visit数组是一个全局变量
void DFS(int v,AGraph *G)
{
 	//第一次不需要判断是否访问过,直接访问顶点
	visit[v] = 1;
	//取v所指的顶点的第一条边
	ArcNode *q = G->adjList[v].first;
	//开始访问v所指顶点的所有的边结点
	while(q != NULL)
	{
		//以q的邻接点为起点执行递归遍历
		//判断q的邻接点是否被访问过
		if(visit[q->adjV] == 0)
			DFS(q->adjV,G);
		q = q->next;
	}
	Visit(v);
}

(七)关键路径

Activity On Edge,AOE网,活动在边上的网,用边来表示活动,并且表示活动的持续时间,而用顶点用来表示活动发生或结束的事件。

如图就是一个AOE网,一般用AOE网表示一个工程的执行过程的时候,有且仅有一个顶点没有入度,我们称之为源点;有且仅有另外一个顶点没有出度,我们称之为汇点。由源点发出若干条边,发出的边若指的顶点又会发出若干条边,最后汇聚到汇点。顶点1表示活动a2和a3的开始,顶点6表示活动a6和a7的结束。在AOE网中,从源点开始,到汇点结束,具有最大路径长度的路径就叫关键路径。图中由a3和a7组成的路径就是关键路径,a3和a7是关键路径上的活动,称之为关键活动。关键路径代表了整个工程完成的最短时间,必须关键路径上的活动都完成了整个工程才能完成,关键路径上的活动是否能准时完成直接关系到整个工程能否准时完成。
在这里插入图片描述
对于简单的AOE网我们很容易就能看出关键路径是哪条,但是对于复杂的我们该怎么做呢?按照以下步骤。

  1. 首先求出图的拓扑排序序列和逆拓扑排序序列,然后利用拓扑排序序列求出每一个事件的最早发生时间。求拓扑排序序列的原因是事件的发生有前后的次序关系,而拓扑排序序列就能正确得反映这种关系。

在这里插入图片描述

  1. 我们规定源点的发生时间为0,也就是最早发生时间为0,记为ve(1) = 0。对于顶点3和4它们的最早发生时间为2和1。

前面求的顶点都只有一条边指向它,也就是入度都为1,只需要拿它的前驱顶点的最早发生时间加上这条边的长度即可,而对于顶点6,有两条边指向它,应该选哪一个前驱顶点呢?对于顶点6这个事件,需要它之前的事件和活动全部完成之后才能发生,所以我们必须选择那一个完成时间点最晚的那个活动的完成时间点来作为事件6的最早发生时间,在这里也就是a7完成时间最晚,a7的完成时间就是顶点4的最早发生时间加上a7的长度,我们用它来作为顶点6的最早发生时间也就是9。

在求解的过程中,如果某个顶点(事件)有多边指向它,或者说有多个活动导致它的发生,其中最晚完成的活动就是这个事件的最早发生时间了。

在这里插入图片描述

  1. 接下来我们求图中顶点的最迟发生时间,其中汇点的最迟发生时间是不确定的,我们规定汇点的最迟发生时间等于它的最早发生时间。确定了汇点的最迟发生时间,我们就能确定前边的事件的最迟发生时间,我们利用逆拓扑排序序列来求得前边的事件的最迟发生时间。最迟发生时间用vl表示。

根据逆拓扑排序序列所指出的顶点顺序,我们下一步应该求事件4的最迟发生时间,用它的后继事件也就是汇点的最迟发生时间减去a7的持续时间。同样的求时间3的最迟发生时间用汇点的最迟发生时间减去a6的持续时间。

在这里插入图片描述
对于事件1,也就是起点,它的最迟发生时间怎么求呢?之前求事件3和4的最迟发生时间,它们都只引出一条边,只有一个后继事件,只需要用它们的后继事件的最迟发生时间减去它引出的活动的持续时间即可;而现在对于事件1,它引出了两个活动,对应了两个后继事件,如果根据事件3来求1的最迟发生时间可以求得vl(1)= 1,如果根据事件4来求1的最迟发生时间可以球的vl(1)= 0 ,显然事件1的最迟发生事件应该为0,如果为1的话则无法保证事件4的最迟发生时间。
在这里插入图片描述

也就是说,对于某个事件,如果它引出了多条活动,即有多个后继事件,那么它的最迟发生时间是根据所有的后继事件所求出来的最迟发生时间中最早的一个

  1. 再看每一个活动的最早发生时间,显然发出这些活动的事件的最早发生时间就是这些活动的最早发生时间。

在这里插入图片描述

  1. 再看活动的最迟发生时间,显然就是活动所指向的事件的最迟发生时间减去活动的持续时间。 因为如果活动晚于这个时间发生,那它所指的事件的最迟发生时间就无法保证了。

在这里插入图片描述

  1. 求出活动的最迟发生时间和最早发生时间之后,我们就可以找出所有的关键活动了。关键活动就是最早发生时间和最迟发生时间重合的那些活动,因为它不能提前发生或晚一点发生。它是可以影响到整个工程是否准时完成的关键活动,所有的关键活动就构成了关键路径。

在这里插入图片描述

以一个更复杂的例子来理解关键路径求法:
求所有事件的最早发生时间,根据拓扑排序序列顺序来求
在这里插入图片描述
求所有事件的最迟发生时间,根据逆拓扑排序序列顺序来求:
在这里插入图片描述

根据事件的最早发生时间得出活动的最早发生时间:
在这里插入图片描述
根据事件的最迟发生时间求出活动的最迟发生时间:

在这里插入图片描述

对比活动的最早发生时间和最迟发生时间,得到关键活动、再得到关键路径。
在这里插入图片描述
对于关键路径考点主要考手工求解,代码实现几乎不考。

八、内部排序

内部排序其操作的对象我们都称之为关键字,关键字是有大小之分的。

(一)直接插入排序

给出一个例子,条状物的高度就代表关键字的大小。我们进行排序的策略是:把一排关键字的首个看成一个有序序列,这样我们就把整个序列划分为两部分。

红色代表已经排好序的有序序列,而蓝色代表还未排序的序列。
在这里插入图片描述
然后从未排序序列挑出一个关键字(一般都挑第一个),把它插入到有序序列中合适的位置,要求保持有序序列的有序性。当未排序序列中的关键字全部被插入到有序序列之后,整个序列也就变得有序了。

每次从未排序序列中挑出一个关键字,从右往左扫描当前的有序序列,当扫描到的关键字比我们待插入的关键字大的时候就往后移动一位,直到扫描到的关键字比我们待插入的关键字小的时候就把这个关键字插入到扫描到的关键字后边。重复操作。
在这里插入图片描述
如果待插入的关键字比有序序列中的任意关键字都小的话,就把它插入到有序序列的第一个位置。
在这里插入图片描述
相反,如果待插入的关键字比有序序列的任意关键字都大的话,就把它插入到有序序列的最后一个位置。
在这里插入图片描述
在这里插入图片描述
代码实现:

//arr:待排序的关键字
//n:待排序的关键字个数
void insertSort(int arr[],int n)
{
	//temp:存储待插入的关键字
	//i,j:循环变量
	int temp,i,j;
	//循环从1开始,将数组的第一个元素认为是有序序列
	for(i = 1; i < n; i++)
	{
		//取未排序的第一个
		temp  = arr[i];
		//j指向有序序列中最右边的元素
		j = i - 1;
		//从有序序列右边开始往左扫描
		//当扫描到的关键字大于待插入的关键字时则这个关键字右移一位
		//然后指针j左移一位,直至j<0或者扫描到的关键字小于待插入的关键字
		while(j >= 0 && temp < arr[j])
		{
			arr[j+1] = arr[j];
			--j;
		}
		//将待插入的关键字插入到指针的后一位位置
		arr[j+1] = temp;
	}
}

(二)折半插入排序

折半插入排序的基本思想与直接插入排序类似,区别是查找插入位置的方法不同,折半插入排序是采用折半查找法来查找插入位置的。

相关代码:

//arr:关键字
//n:关键字个数
void BInsertSort(int arr[],int n)
{
	int temp,i,j,low,high,mid;
	//i从1开始,将arr[0]视作有序序列
	for(i = 1; i <=n; ++i)
	{
		temp = arr[i];
		//low和high分别指向有序序列的首部和尾部
		low = 0;
		high = i-1;
		//折半查找
		while(low <= high)
		{
			mid = (low + high)/2;
			//如果有序序列中部的关键字比要插入的关键字大
			if(arr[mid] > temp) 
			//则插入点在低半区,将high调整到低半区
				high = mid - 1;
			else 
			//否则插入点在高半区,将low调整到高半区
				low = mid +1;
		}
		//将high之后的关键字全部后移一位
		for(j = i-1; j >= high + 1;j--)
			arr[j+1] = arr[j];
		//关键字插入点在high指向的关键字后边
		arr[high + 1] = temp;
		
	}
}

(三)简单选择排序

每次从无序序列中挑出一个最小的关键字,让它与无序序列中的第一个关键字交换位置,把此时的无序序列的第一个关键字划分为有序序列中的关键字。相当于每次在无序序列中挑出一个最小的放在有序序列的最右边,当无序序列中的关键字都划分到有序序列中时,整个序列就变为有序序列了。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
代码实现:

//arr:关键字 n:关键字个数
void selectSort(int arr[],int n)
{
	//辅助变量
	int i,j,k;
	int temp;
	//一共有n个关键字,循环n次
	for(i = 0; i < n; i++)
	{
		//k用来存储最小值在数组中的下标
		k = i;
		for(j = i+1; j < n; j++)
			if(arr[k] > arr[j]) k = j;
		//将无序序列中的最小值与无序序列的第一个关键字交换位置
		//无序序列的第一个关键字也就是当前i所指的关键字
		temp = arr[i];
		arr[i] = arr[k];
		arr[k] = temp;
	}
}

(四)冒泡排序

从左往右扫描整个关键字序列,如果发现当前扫描到的关键字比其前面的关键字小的话,则与其前面的关键字交换位置,这样扫描完一遍关键字之后,整个序列中最大的关键字会被交换到最右边的位置,把它并入有序序列。 重复之前的过程,对无序序列进行扫描,当无序序列中的所有关键字被并入有序序列之后,整个序列就是有序的了。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
当扫描无序序列的过程中没有发现交换,则说明整个无序序列已经有序了,把剩余的无序序列中的关键字都并入有序序列中。因此我们可以设计一个标记,当扫描一遍无序序列的时候发现没有执行关键字的交换,则给这个标记赋予一个特殊的值,用它来指示这趟扫描是否执行了关键字的交换,对这个标记进行判断,就知道下一次需不需要扫描无序序列了。
在这里插入图片描述

代码实现:

void bubleSort(int arr[].int n)
{
	//辅助变量
	int i,j,flag;
	int temp;
	//i指示无序序列的范围
	for(i = n-1; i >= 1; i--)
	{
		flag = 0;
		//扫描当前的无序序列
		for(j = 1; j <= i; j++)
			//当j当前所指的关键字小于j所指前一个关键字,则交换位置
			if(arr[j-1] > arr[j])
			{
				temp = arr[j];
				arr[j] = arr[j-1];
				arr[j-1] = temp;	
				flag = 1;
			}
		//flag仍为0则证明在扫描无序序列的时候没有发生交换
		//函数结束,排序完成
		if( flag == 0) return;
	}
}

(五)希尔排序

希尔排序是对直接插入排序的改进。直接插入排序在插入关键字的时候,可能会造成大量的关键字的移动,这样的话,当序列的初始状态越接近有序,在插入关键字的时候,所需要移动的关键字就越少,排序也就越快。希尔排序就是根据直接插入排序的这种性质进行改进得到的。

  1. 对一段序列,我们用取增量的方式把它分成一些子序列。开始的时候,我们取增量为表长的一半,则当前的待排序列所分成的子序列就是每隔5个关键字是一个子序列。当前白色标记范围的两端所指出的关键字就构成了一个子序列。

在这里插入图片描述
我们对这个子序列进行直接插入排序:

按照同样的方法对其余的子序列进行排序:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  1. 将增量取为上一步增量的一半并向下取整,也就是将增量取为2。增量的取法是随意的,只要最后增量能够变为1即可。在考研中一般都是取上一步的一半并向上或向下取整。

上一步的增量为5,每一个子序列的关键字只有两个,而现在的增量变为2,每一个子序列的关键字变为了5个。我们对子序列进行直接插入排序。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  1. 将增量取为上一步增量的一半并向下取整,也就是将增量取为1。增量变为1时,对序列进行直接插入排序。希尔排序结束。

在这里插入图片描述
在这里插入图片描述
能够发现关键字的移动次数变少了,希尔排序就是通过划分子序列的方式来使得关键字的移动次数减少的。

实现代码:

void shellSort(int arr[],int n)
{
	int temp;
	//gap:增量
	for(int gap = n/2;gap > 0; gap/=2)
	{
		/*i用来取出无序序列(gap及其后面的关键字)中的一个关键字
		然后将它插入到其所在子序列的有序序列的合适位置中*/
		for(int i = gap; i <n; i++)
		{
			//取出i所指向的关键字
			temp = arr[i];
			//对i所指的关键字所在的子序列进行直接插入排序
			int j;
			for(j = i; j >= gap && arr[j-gap] > temp; j-=gap)
				arr[j] = arr[j-gap];
			arr[j] = temp;
			/* 个人觉得这种写法更好理解
			int j = i - gap;
            while(j >= 0 && arr[j] > temp)
            {
                arr[j+gap] = arr[j];
                j-=gap;
            }
            arr[j+gap] = temp;
			*/
		}
	}
}

通过例子帮助理解希尔排序:
i的初值为什么是gap增量?在直接插入排序中,i是从1开始的,因为前边有一个关键字,我们直接视它为有序序列,对于直接插入排序,1就是增量,因为直接插入排序的增量就是1。所以我们这里i从gap开始,gap之前的关键字,都是其所在子序列的第一个关键字,也就是其所在子序列的有序序列的关键字。gap之后的就是所有子序列的无序序列的关键字所在的范围了。也就是用i从这些关键字中每次挑选出一个插入到其所在的子序列中的有序序列中。
在这里插入图片描述
将i所指的关键字赋值给temp也就是取出i所指的关键字。i所指向的关键字是其所在子序列中无序序列中的第一个关键字,也是其所在子序列中有序序列后的第一个关键字。(这点其实就是在进行直接插入排序了,只是是在对子序列进行)

用j来扫描并且在需要的时候移动扫描到的元素以方便插入,j所扫描的关键字是当前待插入关键字arr[i]所在的子序列的有序序列中的关键字。j每次都减去gap的原因是我们通过gap来将原序列划分为多个子序列,每个子序列中相邻两个关键字的下标差距就为gap。
在这里插入图片描述
循环示例:
在这里插入图片描述
在这里插入图片描述
至此,以gap=5的外层循环结束了,开始下一趟外层循环,gap=2。
在这里插入图片描述
循环示例:
在这里插入图片描述
在这里插入图片描述
至此,以gap = 2的外层循环结束了。此时gap=1,也就是相当于对整个序列进行直接插入排序。
在这里插入图片描述
最后一次外层循环排序完的结果:
在这里插入图片描述

(六)快速排序

前面在讲线性表的考点的时候,有一个划分的考点,就是将线性表的第一个作为枢轴然后对这个线性表进行划分,使得枢轴左边的元素都小于枢轴,而右边的元素都大于枢轴,这样的话,划分之后的枢轴的位置跟将来进行排序之后的枢轴的位置是一样的。这时候,我们将枢轴左边的序列和右边的序列看成两个子序列,用同样的方法对其进行划分,就又能够使得两个元素落到其排序后的正确的位置。将这几个元素划分出来的序列进行重复的操作,直至每个序列的元素只有一个的时候,排序就完成了。这就是快速排序,是一个递归的过程。

//arr:待处理的序列
//low,high:当前处理的序列的范围,初始的时候low为0,high为关键字个数n-1
void quickSort(int arr[], int low, int high)
{
	//辅助变量
	int temp;
	//i,j分别指向序列的头和序列的尾
	int i = low, j = high;
	//递归出口,当序列的长度大于1的时候才经行递归
	//等于1的时候就不用进行递归了,因为已经有序了
	if(low < high)
	{
		//开始划分
		//将序列的第一个元素作为枢轴
		//具体操作可参照线性表划分
		//将这个序列划分为两部分,左边部分比枢轴小,右边部分比枢轴大
		temp = arr[low];
		//当i,j相等时本次划分结束
		while(i < j)
		{
			while(i < j && arr[j] >= temp) j--;
			if(i < j)
			{
				arr[i] = arr[j];
				i++;
			}
			while(i < j && arr[i] < temp) i++;
			if(i < j)
			{
				arr[j] = arr[i];
				j--;
			}
		}
		//将枢轴赋值给i、j指向的元素
		arr[i] = temp;
		//对划分出来的子序列利用同样的方法进行处理
		quickSort(arr,low,i-1);
		quickSort(arr,i+1,high);
	}
}

利用一个三元组level来记录每层的递归信息,括号从左到右的信息分别是low,i(或j),high,右下角的一二代表第几个递归函数,这样记录以方便我们结束每个递归函数时恢复到上一层的状态。
在这里插入图片描述

(七)归并排序(二路)

用一个例子来展示归并排序:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
这就是归并排序的过程。开始的时候把待排序序列中每个关键字看成一个单独的有序序列,然后把当前待排序序列中的有序序列,每两个一组,归并为一个新的有序序列,这样有序序列的个数会变少,最后变成一个有序序列。

归并排序中涉及到的归并操作代码实现:

//arr:存储所有待排关键字的数组
//我们规定,把从low到mid之间的关键字算作待归并一个子表,
//从mid+1到high之间的关键字算作待归并的另外一个子表
//要求从low到mid,mid+1到high这两个子表是有序的
//归并的结果就存储在从low到high的范围内
void merge(int arr[], int low, int mid, int high)
{
	//循环变量
	int i,j,k;
	//从low到mid之间关键字的个数
	int n1 = mid - low + 1;
	//从mid+1到high之间关键字的个数
	int n2 = high - mid;
	//将划分出来的数组暂存在这两个数组中,
	//然后由这两个数组归并成一个数组放回原数组low到high范围内
	int L[n1],R[n2];
	for(i = 0; i < n1; i++)
		L[i] = arr[low + i];
	for(j = 0; j < n2; j++)
		R[j] = arr[mid + 1 + j];
	
	//归并的关键过程
	//i、j分别指向数组L、R的第一个存储位置
	i = 0;
	j = 0;
	//k指向待排序列的第一个关键字
	k = low;
	//遍历L、R数组
	while(i < n1 && j < n2)
	{
		//比较数组L和R的元素,将小的复制到原来的数组中去
		//进行了复制的位置的指针要后移
		if(L[i] <= R[j])
			arr[k] = L[i++];
		else
			arr[k] = R[j++];
		k++;
	}
	while(i < n1)
		arr[k++] = L[i++];
	while(j < n2)
		arr[k++] = R[j++];
}

从某个数组中取下的待排序序列以及新建的两个用来存放划分后的两个子表的数组。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
然后就是归并排序的主函数代码实现:

void mergeSort(int arr[], int low, int high)
{
	if(low < high)
	{
		int mid = (low + high) / 2;
		//在low到mid范围进行归并排序
		mergeSort(arr,low,mid);
		//在mid+1到high范围进行归并排序
		mergeSort(arr,mid+1,high);
		//把得到的左右两个有序序列进行归并操作
		merge(arr,low,mid,high);
	}
}

用一个三元组来存储每次递归的信息,括号从左到有分别是low、mid、high
在这里插入图片描述

(八)堆排序

(1)堆的逻辑结构和存储结构

堆,在逻辑上是一棵完全二叉树,但是在关键字上还有进一步的约束。
在这棵完全二叉树中,任意一个结点的关键字的值都不小于它的左右孩子结点的关键字的值,这种堆叫大顶堆。
在这里插入图片描述
在这棵完全二叉树中,任意一个结点的关键字的值都小于它的左右孩子结点的关键字的值,这种堆叫小顶堆。
在这里插入图片描述
堆是一种完全二叉树,它的存储结构就用完全二叉树最方便的存储方式来存储即可,用一个数组来存储。对结点的编号从0开始,以这种编号规则,能够知道最后一个非叶结点的的编号为n/2向下取整再-1。
在这里插入图片描述

(2)建堆

有了堆的逻辑结构和存储结构,我们应该在这个存储结构上研究一些操作。下面来看看如何建堆。

这里有了一棵完全二叉树并存储在一维数组中。
在这里插入图片描述

  1. 首先找出完全二叉树最后一个非叶结点。求得最后一个非叶结点的编号为3。用p标出结点的位置,观察p所指的结点以及其孩子结点的关键字值,如果p所指结点的关键字值比其孩子结点中的最大的那一个小,那就让p所指结点与较大的孩子结点交换位置。否则不交换,p往前移动一个编号的位置。

在这里插入图片描述

  1. p来到了结点2,其值比其孩子结点的值大,不需要交换,p再往前移动一个位置。

在这里插入图片描述

  1. p来到了结点1,其值比较大的孩子结点小,与它的孩子结点交换位置。

在这里插入图片描述
这时候观察被交换到下边的结点值,看看它是否小于其当前的较大的孩子结点的值,如果小于则继续交换。

在这里插入图片描述
交换到下边的结点如果是叶子结点的话,就不用继续交换了。
在这里插入图片描述

  1. 当交换到下边的结点不再交换,就可以继续前移p指针了,然后重复之前的操作,直至走到根结点,交换完这一轮建堆就结束了,我们得到了一个大顶堆。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

建一个小顶堆的方法是一样的,只不过是每次将大的值换到下面去而已。

(3)插入节点

如何在一个建好的堆中插入一个节点呢?

  1. 首先把待插入的结点按照编号放在所有的结点之后

在这里插入图片描述
在这里插入图片描述

  1. 标记出从新节点到根节点的路径,将刚插入的节点与其父节点进行比较关键字的值,如果刚插入的节点的关键字的值比其父节点的大,就与父节点交换位置。重复操作,直到其关键字的值不大于父节点的关键字的值为止。

在这里插入图片描述
在这里插入图片描述

(4)删除节点

  1. 假如我们要把堆中的根节点删掉

先把这个节点拿出来放在一边
在这里插入图片描述

  1. 然后用此时堆中最后一个位置的节点来填充根位置

在这里插入图片描述

  1. 对这个节点进行堆的调整,也就是建堆时进行的交换操作。

在这里插入图片描述
在这里插入图片描述

删除操作主要的步骤是:

  1. 把要删除的节点拿出来,用堆中最后一个位置的节点填补上这个位置
  2. 然后对这个节点进行调整即可

(5)堆排序

堆进行调整,每一次都能将一个最大的关键字或最小的关键字放在根节点的位置,每次将这个根节点位置上的关键字插入到有序序列的一端,等所有的关键字都被插入,就有序了。之前所讲的简单选择排序,每次都是从整个无序序列中挑出一个最大或最小的关键字与无序序列的第一个进行交换,现在我们用堆来选择最大或最小的关键字,显然要比简单选择排序要快得多,每次通过调整的方式,最多从根节点扫描到叶子节点,走了一个不大于树的高度的路径,这条路径上关键字的数量显然要比无序序列中关键字的数量要少得多。

那么进行堆排序的过程是:

  1. 用一堆关键字建立一棵完全二叉树,也就是把它们放在数组中即可
  2. 然后通过建堆方法,用这些关键字建一个堆。
  3. 然后通过堆删除节点的方法,得到一个最大或最小的关键字。
  4. 删除节点后会得到一个新的堆,继续删除根节点,将得到的关键字插入到上一次得到的关键字之后。直至所有的节点都被删除完之后,我们就会得到一个升序或降序序列。

堆排序的代码实现:

//建堆中涉及到的调整过程,以及删除根节点后涉及的调整过程,调整为大顶堆
//arr:存储完全二叉树的数组
//low,high:可能需要被调整的节点的范围
void sift(int arr[], int low, int high)
{
	//i:本次需要调整的节点的下标
	//j:指向i所指的节点的左孩子节点
	int i = low, j = 2*i + 1;
	//存储当前要调整的节点的关键字的值
	int temp = arr[i];
	//当i所指节点有左孩子节点时才执行下面的操作
	while(j <= high)
	{
		/*j<high的意思就是i所指节点有左右孩子,
		在i所指节点有左右孩子的情况下,
		将j指向i所指的节点的左右孩子节点中关键字较大的那一个*/
		if(j < high && arr[j] < arr[j+1])
			++j;
		//如果i所指节点的关键字小于其孩子节点中较大的关键字,则交换它们位置
		if(temp < arr[j])
		{
			//将关键字较大的孩子节点的值赋值给i所指位置
			arr[i] = arr[j];
			//i移动到j的位置
			i = j;
			//j继续指向i所指节点的左孩子节点的位置
			j = 2*i + 1;
		}
		//如果i所指节点的关键字大于其孩子节点中较大的关键字,则跳出循环
		else
			break;
	}
	//最后将要调整的节点的关键字放到正确的位置上
	arr[i] = temp;
}

//堆排序的主函数
void heapSort(int arr[], int n)
{
	int i;
	int temp;
	//对堆中的所有非叶节点进行调整的过程,也就是一个建堆的过程,得到一个大顶堆
	for(i = n/2 - 1; i >= 0; i--)
	//arr:存储完全二叉树的数组
	//i:本次循环需要调整的非叶节点的下标
	//n-1:堆中最后一个节点的下标,
		sift(arr,i,n-1);

	//每次挑出当前堆中根节点,进行调整得到新堆
	//i从堆的最后一个位置开始,每次将根节点位置的关键字交换到i位置
	//也就是每次都将堆中最大的关键字放到数组后面
	for(i = n - 1; i > 0; i--)
	{
		//挑出根节点与堆的最后一个节点交换位置,也就是从堆中删除根节点
		temp = arr[0];
		arr[0] = arr[i];
		arr[i] = temp;
		//对新的根节点进行调整,得到新堆,注意调整的范围应该是新堆的范围
		sift(arr,0,i-1);
	}
}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(九)基数排序

前面所讲的排序都是需要通过关键字的比较来进行排序,而基数排序不需要关键字之间的比较就能进行排序。基数排序要求每一个关键字的位数是相同的。继续排序是通过不断地收集和分配来逐渐实现排序过程的。

通过例子展示基数排序的执行过程:
在这里插入图片描述

  1. 将序列中关键字位数小于最高位数的关键字的高位补0:

在这里插入图片描述

  1. 准备基数排序中最重要的东西,我们称之为桶。桶是用来收集和分配关键字的。桶的个数要和待排序的关键字的每一位上的最多的基本单元的个数是一样的。这里我们待排的关键字是十进制数,所以它每一位上的基本单元就有十个,也就是0-9这些罗马数字。如果待排的关键字的是英文单词,假设英文单词的字母都是小写的,那显然桶的个数就是26个。每一个桶下面都有一个编号,就是对应的基本单元了。

在这里插入图片描述

  1. 进行分配,就是把关键字按照其某一位的数字放到对应的桶中。首先按照关键字的最低位进行一次分配。

在这里插入图片描述

  1. 进行收集,收集就是把桶中的关键字按照某种方法重新收集起来组成某种序列的过程。收集的过程要按照桶的顺序从左到右、从下到上依次收集。收集到的序列是按照最低位有序进行排列的。

在这里插入图片描述
5. 进行第二次分配,第一次分配可以称之为随意分配,什么都不需要管,只需要挑出关键字最低位和桶号相同的关键字并放进桶中即可。但是从第二次分配开始,需要从左到右扫描整个序列,把扫描到的关键字放在与其第二位数字编号相同的桶中。

在这里插入图片描述
在这里插入图片描述

  1. 收集关键字,收集到的序列是按照第二位有序进行排列的,并且所有第二位相同的子序列是按照第一位有序的。

在这里插入图片描述

  1. 进行第三次分配,从左到右扫描整个序列,把扫描到的关键字放在与其第三位数字编号相同的桶中。

在这里插入图片描述

  1. 进行收集,得到的序列是按照第三位有序的,在第三位相同的情况下是按照第二位有序的。如果第三位和第二位相同的话,是按照第一位有序的。显然这就是一个有序序列。

在这里插入图片描述

以上的排序算法都是内部排序算法。


(九)排序算法的稳定性分析

什么是稳定性?举例说明:

现在有一组待排的关键字:其中有两个关键字值是相同的,关键字值为10的有两个。
在这里插入图片描述
通过任何一种排序方法,当这个序列变得有序的时候,这两个值相同的关键字必然会变得相邻,但是相邻会有两种情况。相邻的两个值相同的关键字的次序和其在未排序中的序列中的次序相同,这种排序后的序列就是稳定的排序算法能够得到的序列。

在这里插入图片描述
通过不稳定的排序算法得到的序列,相邻的两个值相同的关键字的次序和其在未排序中的序列中的次序不同:
在这里插入图片描述

稳定的排序算法:如果一个排序算法对某个序列排序之后,这个序列中所有相同的关键字的次序能够保持不变,换句话说就是,排序后这些关键字的相对顺序与其在初始序列中是一样的,这样的算法就是稳定的排序算法。反之就是不稳定的。

(1)冒泡排序的稳定性(稳定)

从一个简单的例子看,假设10之间的关键字都小于10:
在这里插入图片描述
当两个10相邻时,并不会发生交换,因此它是稳定的排序算法。
在这里插入图片描述

(2)简单选择排序的稳定性(不确定)

也是通过例子来看:
扫描无序序列中的关键字,找到关键字的值最小的,与无序序列的第一个关键字交换位置,多次交换完成排序。
在这里插入图片描述
显然这种简单选择排序是不稳定的。我们使用的这种版本的简单选择排序是基于关键字交换的简单选择排序,它的是不稳定的。

在这里插入图片描述
简单选择排序还有另外一种版本,也就是基于关键字插入的简单选择排序,它的做法就是每次从无序序列中挑选出一个最小的关键字把它插入到无序序列中第一个关键字的前边,由于存在线性表中的关键字,对其进行插入操作常常会造成关键字的移动,所以这种版本的简单选择排序一般适用于存储在链表中的关键字序列。

我们将刚才的关键字序列存储在一个链表中,对其进行插入版本的简单选择排序,看看其是否稳定。用指针q来遍历整个无序序列,指针p初始的时候指向无序序列的第一个关键字。用q扫描整个链表,当p所指的关键字大于q所指的链表结点的关键字时,就让p指向q指向的关键字,等q扫描完整个链表的时候,p就指向了最小的那个关键字。然后将p所指的关键字插入到无序序列的第一个关键字的前边,插入的关键字属于有序序列。然后重复操作,直至排序结束。
在这里插入图片描述
在这里插入图片描述
由此可见,基于关键字插入的简单选择排序是稳定的。换一个例子,假如被插入到无序序列第一个前边的是这两个值相同的关键字,那么它们的相对位置也不会发生改变,由此可以证明是稳定的。
在这里插入图片描述
总结来说,就是基于交换的简单选择排序是不稳定的,基于插入的简单选择排序是稳定的。基于交换的简单选择排序适用于顺序存储结构,因为它不会导致大量的关键字的移动;基于插入的简单选择排序如果用于顺序存储结构,会导致大量的关键字的移动,导致排序效率过低,一般用于链式存储结构。

(3)直接插入排序稳定性(稳定)

将第一个关键字视为有序序列,后面的视为无序序列,扫描无序序列,将无序序列中的每一个关键字插入到合适位置。
在这里插入图片描述
可以得到直接插入排序是稳定的。
在这里插入图片描述

(4)快速排序的稳定性(不稳定)

在这里插入图片描述
从例子可知道快速排序是不稳定的算法:
在这里插入图片描述

(5)希尔排序的稳定性(不稳定)

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
可以知道希尔排序是不稳定的。

(6)归并排序的稳定性(稳定)

在这里插入图片描述
在这里插入图片描述
可以通过if条件判断来使得值相同的两个关键字归并前后的相对位置不变,因此归并排序也是稳定的。
在这里插入图片描述

(7)堆排序的稳定性(不稳定)

在这里插入图片描述
调整成大顶堆,然后进行堆排序,每次将根结点取出并用最后一个结点补上并调整。

在这里插入图片描述
到这一步时,根据代码,3会与左孩子进行交换
在这里插入图片描述
在这里插入图片描述
接着进行堆排序得到结果,显然堆排序是不稳定的。
在这里插入图片描述

(8)基数排序的稳定性(稳定)

基数排序的稳定性几乎不考,它是稳定的。

在这里插入图片描述

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值