数据结构入门6-2(图 - 图的应用)

目录

最小生成树

1. 普利姆算法(加点法)

2. 克鲁斯卡尔算法(加边法)

最短路径

1. 单源点的最短路径(迪杰斯特拉算法)

2. 每一个顶点之间的最短路径(弗洛伊德算法)

拓扑排序

AOV-网

关键路径

AOE-网


        本笔记参考:《数据结构(C语言版)(第2版)》


        接下来将记录的是几个常用的算法。

最小生成树

【情景】

现在需要在n个城市之间建立通信网。已知:

  1. 连通n个城市仅需n - 1条线路;
  2. n个城市之间最多可能设置n(n - 1) / 2条线路;
  3. 连通一条线路需要付出相应的经济代价。

【需求】

        需要在上述可能的n(n - 1) / 2条线路中选择n - 1条线路,使得总的耗费最少。

【分析】

        可以用一个连通图来展示上述情景:

        由上述这张网可以建立许多不同的生成树。其中,最合理的通信网是代价之和最小的生成树,这棵生成树就是该连通网的最小生成树

        在进行最小生成树的生成时,多数会用到MST性质:假设N = (V, E)是一张连通网,U是顶点集V的一个非空子集。若(u, v)是一条具有最小权值(代价)的边,其中u ∈ U,v ∈ V - U,则必定存在一棵包含边(u, v)的最小生成树。

    接下来出现的普利姆算法和克鲁斯卡尔算法都利用了MST性质。

1. 普利姆算法(加点法)

        假设N = (V, E)表示连通网G₅,TE是N上最小生成树中边的集合。则普利姆算法的构造过程如图所示(该算法从顶点V₁开始):

        该算法寻找对某一顶点而言权值最小的边,再在最小边对应的另一结点上重复该过程。在这一过程中,U中的顶点在不断增加,所以普利姆算法也可以被称为“加点法”。

    注意:选择最小边时,若存在多条同样权值的边可选,则任选其一。

该算法需要一个数组承接信息,因此设计辅助数组closedge:

//---辅助数组,用以记录从顶点集U到V-U的权值的最小边---
struct 
{
	VerTexType adjvex;	//最小边在U中的那个顶点
	ArcType lowcost;	//最小边上的权值
}closedge[MVNum];

 【参考代码:Min函数】

int Min(int vexnum)
{
	int k = 0;
	for (int i = 0; i < vexnum; i++)
		if (closedge[i].lowcost != 0)
			k = i;

	for (int i = 0; i < vexnum; i++)
		if (closedge[i].lowcost != 0 && closedge[k].lowcost > closedge[i].lowcost)
			k = i;

	return k;
}

【参考代码:MiniSpanTree_Prim函数,以邻接矩阵存储(算法本体)

void MiniSpanTree_Prim(AMGraph G, VerTexType u)
{//从u出发构造G的最小生成树T,并输出T的各条边
	int k = LocateAMGVex(G, u);						//k为顶点u的下标
	for (int j = 0; j < G.vexnum; j++)				//对V-U的每个顶点,初始化closeedge[j]
		if (j != k)
			closedge[j] = { u, G.arcs[k][j] };		//{顶点, 权值}
	closedge[k].lowcost = 0;						//初始化,初始时U = {u}

	for (int i = 1; i < G.vexnum; i++)				//选择剩余的n-1个顶点,生成n-1条边
	{
		k = Min(G.vexnum);							//从closedge内存储的各条边中,选出最小边closedge[k]
		VerTexType u_0 = closedge[k].adjvex;		//u_0是最小边的顶点
		VerTexType v_0 = G.vexs[k];					//v_0是最小比边的另一个顶点,v_0∈V - U
		cout << u_0 << v_0 << endl;
		closedge[k].lowcost = 0;					//将第k个顶点并入U集

		for (int j = 0; j < G.vexnum; j++)			//将新顶点并入U,再选择新的最小边
		{
			if (G.arcs[k][j] < closedge[j].lowcost)	//若k到j的边,其权值比原本的closedge[j].lowcost小
				closedge[j] = { G.vexs[k], G.arcs[k][j] };
		}
	}
}

【算法分析】

设网中有n个顶点,则:

  1. 进行初始化的循环语句的频度是:n;
  2. 第二个循环语句的频度是:n - 1,其中还有两个内循环:
    1. Min函数求最小值的频度是:n - 1;
    2. 重新选择具有最小值的边,其频度是:n。

        由此可知,普利姆算法的时间复杂度为O(n²)。该算法与网的边数无关,适合稠密图


2. 克鲁斯卡尔算法(加边法)

        依旧通过连通网G₅对算法进行说明:

克鲁斯卡尔算法同样需要辅助数组,不同的是,需要两个不同的数组完成不同的工作:

//------存储边,及其两个顶点的信息------
struct 
{
	VerTexType Head;	//边的起点
	VerTexType Tail;	//边的终点
	ArcType lowcost;	//边的权值
}Edge[MVNum];

//------标识两个连通分量------
int Vexset[MVNum];

【参考代码:MiniSpanTree_Kruskal函数,以邻接矩阵存储(算法本体)

//------克鲁斯卡尔算法(无向网,用邻接矩阵存储)------
void MiniSpanTree_Kruskal(AMGraph G)
{
	SortEdge(G.arcnum);					//将数组Edge中的各个元素按权值从小到大排序
	for (int i = 0; i < G.vexnum; i++)	//初始化时,各个结点各为一个连通向量
		Vexset[i] = i;

	for (int i = 0; i < G.arcnum; i++)	//依次查看数组Edge中的边
	{
		int v_head = LocateAMGVex(G, Edge[i].Head);		//v_head是边的始点Head的下标
		int v_tail = LocateAMGVex(G, Edge[i].Tail);		//v_tail是边的终点Tail的下标

		int cc_head = Vexset[v_head];		//获取始点所在的连通分量
		int cc_tail = Vexset[v_tail];		//获取终点所在的连通分量

		if (cc_head != cc_tail)			//若边的两个顶点属于不同的连通分量
		{
			cout << Edge[i].Head;		//输出此边
			cout << Edge[i].Tail << endl;

			for (int j = 0; j < G.arcnum; j++)
				if (Vexset[j] == cc_tail)	//统一两个集合的编号
					Vexset[j] = cc_head;
		}
	}
}

【算法分析】

        若已学习过堆排序,通过“堆”来存放网中的边,对于包含e条边的网,上述算法排序时间是O(elog₂e)。(但是笔者还未学习到此,故没有将Sort函数的实现展示出来)

        在上述算法中,最耗时的操作是合并两个不同的连通分量,若数据结构合理,该步骤的执行时间是O(log₂e)。则整个for循环的执行时间会是O(elog₂e)

        综上所述,克鲁斯卡尔算法的时间复杂度可以达到O(elog₂e)。其只与网的边有关,更适合对稀疏图求最小生成树。

最短路径

        假设在计算机上建立一个交通咨询系统,可以用图的结构标识实际的交通网络。其中:

  • 顶点:表示城市;
  • 边:表示城市间的交通联系。

        若有人要从A城到B城,不同的人对于路径的选择有不同的要求:譬如要节省交通费用,要速度最快、里程度最少等等。为了在图上能够表示相关信息,就需要对边赋予权,权值就是两城市之间的距离、交通费用等。

        在上述情况下,对于路径的度量就被转变为了对权值之和的度量。一般而言,交通图都存在方向,因此,交通网往往通过带权有向网表示。习惯上,① 带权优先网的第一个顶点被称为源点,② 最后一个顶点被称为终点。

1. 单源点的最短路径(迪杰斯特拉算法)

        接下来将会讨论:给定带权有向图G和源点v,求从v到G中其余各顶点的最短路径。迪杰斯特拉算法是一个按路径长度递增的次序产生最短路径的算法。

        在求解过程中,将把网N = (V, E)的顶点分为两组:

  • S:已求出的最短路径的终点集合(初始时只包含源点v₀);
  • V - S:尚未求出的最短路径的顶点集合(初始时为V - {v₀})。

    现在约定,在最短路径问题下所述的路径长度均指其权值。

        该算法依照各个顶点与v₀之间的间最短路径长度递增的次序,将集合V - S中的顶点加入到集合S中去。在此过程中,总保持:

从v₀到集合S中各个顶点的路径长度 ≤ 从v₀到集合V - S中各个顶点的路径长度

【例1】

        总结有向图G₆中从v₀到各个顶点的最短路径:

源点终点最短路径路径长度
v₀v₂(v₀, v₂)10
v₄(v₀, v₄)30
v₃(v₀, v₄, v₃)50
v₅(v, v₄, v₃, v₅)60
v₁

    当路径不存在时,默认该条路径长度为无限大。

        上述从v₀到各个顶点的路径长度就是按照递增的顺序进行排列的(其中,从v₀到v₁没有路径)

        为了实现该算法,需要引入辅助的数据结构:

        由于每当一个新顶点被加入集合S中时,对于V - S中的各个顶点而言,都会多出一个“中转”顶点,一条“中转”路径,此时需要更新V - S中各个顶点的最短路径长度。

【参考代码】

bool S[MVNum];			//记录从源点v_0到终点v_i是否已被确定最短路径
int Path[MVNum];		//记录从源点v_0到终点v_i,其当前的最短路径上vi的直接前驱顶点的序号
int D[MVNum];			//记录从源点v_0到终点v_i的当前最短路径长度
void ShortestPath_DIJ(AMGraph G, int v_0)
{
	//---初始化---
	int n = G.vexnum;			//用n存储G中顶点的个数

	for (int v = 0; v < n; v++)
	{
		S[v] = false;			//初始化S
		D[v] = G.arcs[v_0][v];	//初始化D(为v_0到各个终点的路径长度)
		if (D[v] < MaxInt)		//若v_0与当前终点之间存在路径
			Path[v] = v_0;
		else
			Path[v] = -1;		//路径不存在,赋值为-1
	}
	S[v_0] = true;				//将v_0加入集合S
	D[v_0] = 0;					//源点到源点的距离是0

	//---求到各个终点的最短路径---
	for (int i = 1; i < n; i++)	//对剩余n - 1个顶点求最短路径
	{
		int min = MaxInt;
		int v = 0;						//保管终点
		for (int j = 0; j < n; j++)
		{
			if (!S[j] && D[j] < min)	//选择当前的最短路径
			{
				v = j;
				min = D[j];
			}
		}
		S[v] = true;

		for (int j = 0; j < n; j++)		//更新从v_0出发到集合V - S上的所有顶点的最短路径长度
		{
			if (!S[j] && (D[v] + G.arcs[v][j] < D[j]))
			{
				D[j] = D[v] + G.arcs[v][j];	//更新路径长度
				Path[j] = v;				//更新j的前驱
			}
		}
	}
}

    在上述算法中,若IntMax定义过大,可能在最后的if处造成溢出。或者,可以将数组D的类型改为unsigned int类型,通过增大存储空间,来防止溢出。

        通过上述算法处理有向图G₆,其结果用表格表示:

有向图G₆的算法处理结果
v = 0v = 1v = 2v = 3v = 4v = 5
Struefalsetruetruetruetrue
D0MaxInt10503060
Path-1-10403

【算法分析】

        该算法的主循环执行了n - 1次,而每次进行主循环,其执行时间都是O(n),故该算法的时间复杂度为O(n²)(对邻接矩阵和邻接表都如此)。

    若只需要寻找源点到某一顶点的最短路径,而仍用迪杰斯特拉算法,则时间复杂度不变(一样复杂)。


2. 每一个顶点之间的最短路径(弗洛伊德算法)

        若要求每一个顶点之间的最短路径,可以使用两种方法:

  1. 调用n次的迪杰斯特拉算法;
  2. 使用弗洛伊德算法

实际上,两种方式的时间复杂度都为O(n³),但弗洛伊德算法的形式更简单。

        同样的,该算法也需要辅助的数据结构:

    弗洛伊德算法不需要标记最短路径是否已被确认,因此不存在数组S。

        若将弗洛伊德算法和迪杰斯特拉算法进行比较,会发现弗洛伊德算法也是在不断地修正中得到最终的最短路径:

  1. 算法通过初始化确定顶点之间的路径状况;
  2. 通过遍历寻找每个源点;
  3. 类似于迪杰斯特拉算法,弗洛伊德算法通过将原本的路径长度与“中转”路径长度比较,确定较小值;
  4. 循环,直到所有源点遍历结束。

【参考代码】

int Path[MaxInt][MaxInt];			//记录最短路径上顶点v_j的前一个顶点的序号
int D[MaxInt][MaxInt];				//记录顶点v_i和v_j之间的最短路径长度
void ShortestPath_Floyd(AMGraph G)
{
	for (int i = 0; i < G.vexnum; i++)
	{
		//---初始化---
		for (int j = 0; j < G.vexnum; j++)
		{
			D[i][j] = G.arcs[i][j];
			if (D[i][j] < MaxInt)
				Path[i][j] = i;
			else
				Path[i][j] = -1;
		}
		D[i][i] = 0;
	}

	//---遍历,寻找每个源点的最短路径---
	for (int v = 0; v < G.vexnum; v++)
	{
		for (int i = 0; i < G.vexnum; i++)
		{
			for (int j = 0; j < G.vexnum; j++)
			{
				if (D[i][v] + D[v][j] < D[i][j])		//若从i经k到j的一条路径更短
				{
					D[i][j] = D[i][v] + D[v][j];		//更新D
					Path[i][j] = Path[v][j];			//更改j的前驱为v
				}
			}
		}
	}
}

         接下来通过G₇简单说明上述算法:

        若把该算法的G₇的初始化结果即最终结果通过表格的形式显示,则如:

G₇的弗洛伊德算法 初始化结果(表格1)
DPath
序号(下标)01230123
0014-10-10
1052-1-111
2390822-12
360-1-13-1

        而当算法结束,就可以得到其最终的最短路径结果:

         若需要通过表格2获取关于某一最短路径的信息,例如获得顶点0到顶点3的最短路径信息,可以参考:

  • 最短路径长度:D[0][3] = 3。表示顶点0和顶点3之间的最短路径长度是3。
  • 最短路径:Path[0][3] = 1(在当前最短路径中,顶点3的前驱是顶点1),Path[0][1] = 0。表示从顶点0到顶点3的最短路径是<0, 1>,<1, 3>。

拓扑排序

AOV-网

        一个无环的有向图被称为有向无环图(Directed Acycline Graph),简称DAG图。这种图通常被用于描述一项工程或者系统的进行过程。一般地,一项工程可以可以被分为若干个被称为活动的子工程,子工程之间往往有着某些约束,譬如子过程开始的先后顺序。

        以课程的学习为例:必修课可以被分为基础课和专业课。基础课独立于其他课程,而专业课的学习却存在先后顺序,例如:

必修课之间的关系
课程编号课程名称先修课程
C₁程序设计基础
C₂离散数学C₁
C₃数据结构C₁、C₂
C₄汇编语言C₁
C₅高级语言程序设计C₃、C₄
C₆计算机原理C₁₁
C₇编译原理C₃、C₅
C₈操作系统C₃、C₆
C₉高等数学
C₁₀线性代数C₉
C₁₁普通物理C₉
C₁₂数值分析C₁、C₉、C₁₀

如图所示:

        类似于上图这种,通过顶点表示活动,使用弧表示活动间优先关系的有向图被称为:顶点表示活动的网(Activity On Vertex Network),简称AOV-网

    注意:在AOV-网中不应该出现有向环,因为若存在有向环,则代表着有活动是以自己为先决条件的。若这样设计,则工程将会无法进行,程序陷入死循环。

        为了防止环的出现,就需要对有向图图中的顶点进行拓扑排序若网中的顶点都在其的拓扑有序序列中,则该AOV-网中必定不存在环

        拓扑排序,就是将AOV-网中的所有顶点排列为一个线性序列,序列满足:

【例如】

        当然,在上图中,拓扑有序序列并不止展示出来的两个。

        若要输出拓扑排序序列,需要:

  1. 寻找图中无前驱的顶点,输出该顶点;
  2. 删除该顶点以它为尾的弧
  3. 重复上述步骤,直到不存在无前驱的顶点。

如此,一串拓扑排序序列的输出如图所示:

         同样地,该算法也需要使用辅助的数据结构:

    注:接下来的图将用邻接表进行存储。 

【参考代码:FindInDegree函数(求顶点的入度)】

void FindInDegree(ALGraph G)
{
	ArcNode* p = NULL;
	for (int i = 0; i < G.vexnum; i++)        //遍历邻接表
	{
		if(G.vertices[i].firstarc)
			p = G.vertices[i].firstarc;
		while (p)
		{
			indegree[p->adjvex]++;            //计算入度
			p = p->nextarc;
		}
	}
}

【参考代码:TopologicalSort函数(算法本体)

int indegree[MVNum];			//用于存放顶点的入度,若顶点没有前驱,则将入度置为0。
int topo[MVNum] = { 0 };		//记录拓扑序列的顶点序号。

//------拓扑排序本体------
Status TopologicalSort(ALGraph G, int topo[])
{
	FindInDegree(G);			//求出各顶点的入度,将其存放在数组indegree中
	LinkStack S;				//此次所有的是链栈
	InitStack(S);				//将栈初始化

	for (int i = 0; i < G.vexnum; i++)
	{
		if (!indegree[i])
			Push(S, i);			//将入度为0的顶点入栈
	}

	int top = 0;				//对输出的顶点进行计数
	while (S)
	{
		int i = 0;
		Pop(S, i);
		topo[top++] = i;

		ArcNode* p = G.vertices[i].firstarc;
		while (p)
		{
			indegree[p->adjvex]--;
			if (!indegree[p->adjvex])	//将入度为0者入栈
				Push(S, p->adjvex);

			p = p->nextarc;
		}
	}

	if (top < G.vexnum)
		return false;
	else
		return true;
}

【算法分析】

        上述算法所需时间主要集中在三个方面:

  1. 求各顶点入度(FindInDegree函数),时间复杂度为O(e)
  2. 建立入度为0的顶点栈,时间复杂度为O(n)(若有向图无环,每个顶点入栈一次,出栈一次)
  3. 入度减1的操作在循环中总共执行了e次,时间复杂度为O(e)

        综上所述,总的时间复杂度为O(n + e)

关键路径

AOE-网

        与AOV-网相对的,也存在以边表示活动的网,被称为AOE-网(Activity On Edge)。AOE-网是一个带权的有向无环图,例如:

    通常,AOE-网被用来估算工程的完成时间,或者判断哪些活动会是影响工程进度的关键。

        在上图中,存在着从V₀ ~ V₈共9个事件。其中,每个事件表示在其之前的活动已经结束,而在其之后的活动可以开始。例如:V₄表示a₄和a₅已经完成,a₇和a₈可以开始。

        一个工程的开始和结束是唯一的,因此,(在正常情况下)AOE-网只有一个入度为0的点,即源点,也只有一个出度为0的点,即汇点。AOE-网中存在着一些概念:

概念解释
路径的带权路径长度(简称路径长度)一条路径各弧上的权值之和
关键路径即从源点到汇点,其中带权路径长度最长的路径
关键活动是关键路径上的活动

在上图中,各个概念对应着:

  • 源点:V₀;
  • 汇点:V₈;
  • 关键路径:(V₀, V₁, V₄, V₆, V₈) 或 (V₀, V₁, V₄, V₇, V₈);
  • 关键活动:(a₁, a₄, a₇, a₁₀) 或 (a₁, a₄, a₈, a₁₁)。(所谓关键活动,只要其中的某项活动能够缩短一天,则整个工程也能缩短一天)

        为了找到关键路径,就需要4个能够进行描述的量:

        由上可知:对于关键活动aₓ,必定存在 e(x) = l(x) 。把这个结论反过来,将每一个e(x) = l(x)的活动aₓ找出来,就可以形成关键路径。因此,现在的问题就转换为求出上述四个量的问题:

    逆拓扑排序可以通过反向查找数组topo实现。

【参考代码】

int ve[MVNum] = { 0 };				//事件的最早发生时间
int vl[MVNum] = { 0 };				//事件的最迟发生时间
Status CriticalPath(ALGraph G)
{
	int topo[MVNum] = { 0 };
	if (!TopologicalSort(G, topo))	//存在有向环
		return false;

	int n = G.vexnum;
	for (int i = 0; i < n; i++)
		ve[i] = 0;					//设定最早发生时间的初值
	//---求最早发生时间---
	for (int i = 0; i < n; i++)
	{
		int k = topo[i];						//取得拓扑排序中的顶点序号
		ArcNode* p = G.vertices[k].firstarc;	//p指向k的第一个邻接顶点
		while (p != NULL)						//更新k的所有邻接顶点的最早发生时间
		{
			int j = p->adjvex;					//取得邻接顶点的序号
			if (ve[j] < ve[k] + p->info)		//更新顶点j的最早发生时间
				ve[j] = ve[k] + p->info;
			p = p->nextarc;						//p指向k的下一个邻接顶点
		}
	}

	for (int i = 0; i < n; i++)					//设定最迟发生时间的初值
		vl[i] = ve[n - 1];
	//---求最迟发生时间---
	for (int i = n - 1; i >= 0; i--)
	{
		int k = topo[i];						//取得逆拓扑序列中的顶点序号
		ArcNode* p = G.vertices[k].firstarc;
		while (p != NULL)
		{
			int j = p->adjvex;
			if (vl[k] > vl[j] - p->info)		//更新顶点k的最迟发生时间
				vl[k] = vl[j] - p->info;
			p = p->nextarc;
		}
	}

	//---判断每一活动是否是关键活动---
	for (int i = 0; i < n; i++)
	{
		ArcNode* p = G.vertices[i].firstarc;	//p指向i的第一个邻接顶点
		while (p != NULL)
		{
			int j = p->adjvex;					//取得邻接顶点的序号
			int e = ve[i];						//取得活动<vi, vj>的最早开始时间
			int l = vl[j] - p->info;			//计算活动<vi, vj>的最迟开始时间

			if (e == l)							//若为关键活动,输出
				cout << G.vertices[i].data << G.vertices[j].data << endl;
			p = p->nextarc;
		}
	}
}

【算法分析】

        为了求出ve(i)、ve(i)、e 和 l这四个量,就需要遍历每个顶点和每个顶点边表中的所有边结点进行查找,因此,求关键路径的时间复杂度为O(n + e)

    若一个工程有不止一条关键路径,仅仅提高其中一条关键路径上关键活动的速度,是不足以缩短整个工程的工期的。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值