图论算法 有图有代码 万字总结

图的定义

背景知识

看到这篇博客相信一开始映入读者眼帘的就是下面这幅图了,这就是传说中的七桥问题(哥尼斯堡桥问题)。在哥尼斯堡,普雷格尔河环绕着奈佛夫岛(图中的A岛)。这条河将陆地分成了下面4个区域,该处还有着7座连接这些陆地的桥梁。

这里写图片描述

问题是如何从某地出发,依次沿着各个桥,必须经过每座桥且每座桥只能经过1次,最终回到原地。

不知道这个问题且好奇的童鞋现在肯定在忙活着找出来这道题的结果了。

是伟大的数学家欧拉(Leonhard Euler)在1736年首次使用图的方法解决了该问题。

欧拉将上面的模型转换成了下面这种”图“的形式。

这里写图片描述

欧拉把顶点的度定义为与该顶点相关联的边的条数,并且他证明了存在从任意点出发,经过所有边恰好一次,并最终回到出发顶点的走法的充分必要条件是:每个顶点的度均为偶数。人们称之为欧拉闭迹(Eulerian walk)。

简要定义

( g r a p h ) G = ( V , E ) (graph)G = (V,E) graphG=VE由顶点(vertex)的集 V V V和边(Edge)的集 E E E组成。顶点代表了对象,在示意图中我们使用点或圆来表示它;边代表了两个对象的连接关系,在示意图中我们使用连接两顶点的线段来表示。

有时也把边称作弧(arc),如果点对 ( v , w ) (v,w) vw是有序的,那么图就叫做有向的图(有向图)。如果点对$ (v, w) $是无序的,那么图就叫做无向的图(无向图)。简单的讲,边没有指向性的图叫做无向图,边具有指向性的图叫做有向图。

顶点 v v v w w w邻接(adjacent)当且仅当 ( v , w ) (v,w) vw属于 E E E

我们可以给边赋予各式的属性,比如权值(cost)。权值可以表示从一个顶点到另一个顶点的距离,也可以表示一个顶点到另一个顶点说话费的代价(比如时间、金钱等)。一个边上带权值的图称为网络(network)。

如果无向图中从每一个顶点到其他每个顶点都存在一条路径,则称该无向图是连通的(connected)。具有这样性质的有向图称为是强连通的的(strongly connected)。如果有向图不是强连通的,但它的基础图(underlying graph)(也就是其弧上去掉方向说形成的图)是连通的,那么称该有向图是弱连通的(weakly connected)。完全图(complete graph)是其每一对顶点间都存在一条边的图。

这里写图片描述

所谓入度(indegree)是指的顶点 v v v的边 ( u , v ) (u,v) uv的条数。

这里写图片描述

如下表示了一个有着7个顶点和12条边的有向图。

这里写图片描述

如果具有n个顶点,e条边的图G的顶点i的度为 d i d_i di,则G的边数为:

$ e =\frac { \sum_{0}^{n-1} d_i} {2} $

以上这个数学公式的markdown“源码”:
$ e =\frac { \sum_{0}^{n-1} d_i} {2} $

现在将图看作抽象数据类型,下面给出ADT图的结构:

objects一个非空顶点的集合和一个无向边的集合,其中每条边都是一个顶点对
functions对于所有的 $ graph \in Graph $ ,$ v , , v_1 , , v_2 \in Vertices $
Graph Create()return一个空图
Graph InsertVertex (graph, v)向图graph中插入没有关联边的新顶点v,return改变后的图
Graph InsertEdge (graph, $ v_1 , , v_2 $)在图graph的顶点 v 1 v_1 v1 v 2 v_2 v2之间插入一条边,return改变后的图
Graph DeleteVertex (graph, v)删除图graph的顶点v及与其关联的所有边,return改变后的图
Graph DeleteEdge (graph, v 1 v_1 v1 v 2 v_2 v2)删除图graph的边( v 1 v_1 v1, v 2 v_2 v2),顶点 v 1 v_1 v1, v 2 v_2 v2不删除,return改变后的图
Boolean IsEmpty (graph)if(graph==空图) return TRUE,else return FALSE
List Adjacent (graph, v)return顶点v的所有邻接结点

图的存储表示方式

图主要有3种常用的存储表示方式:邻接矩阵(adjacency matrices),邻接表(adjacency lists),邻接多重表(adjacency multilists)。

邻接矩阵

邻接矩阵使用 ∣ V ∣ ∗ ∣ V ∣ |V|*|V| VV的二维数组来表示图。 g [ i ] [ j ] g[i][j] g[i][j]表示的是顶点 i i i和顶点 j j j的关系。

1)因为在无向图中,我们只需要知道顶点 i i i和顶点 j j j是否是相连的,因此我们只需要将 g [ i ] [ j ] g[i][j] g[i][j] g [ j ] [ j ] g[j][j] g[j][j]设置为1或是0表示相连或不相连即可。如下图所示。

这里写图片描述

2)而在有向图中,我们只需要知道是否有从顶点 i i i到顶点 j j j的边,因此如果顶点 i i i有一条指向顶点 j j j的边,那么 g [ i ] [ j ] g[i][j] g[i][j]就设为1,否则设为0。有向图与无向图不同,并不需要满足 g [ i ] [ j ] = g [ j ] [ i ] g[i][j]=g[j][i] g[i][j]=g[j][i]

这里写图片描述

3)在带权值的图中, g [ i ] [ j ] g[i][j] g[i][j]表示的是顶点i到顶点j的边的权值。由于在边不存在的情况下,如果将 g [ i ] [ j ] g[i][j] g[i][j]设为0,就无法和权值为0的情况区分开来,因此选取适当的较大的常数INF(只要能和普通的权值区别开来就可以了),然后令 g [ i ] [ j ] = I N F g[i][j]=INF g[i][j]=INF就好了。当然,在无向图中还是要保持 g [ i ] [ j ] = g [ j ] [ i ] g[i][j]=g[j][i] g[i][j]=g[j][i]。在一条边上有多种不带权值的情况下,定义多个同样的 ∣ V ∣ ∗ ∣ V ∣ |V|*|V| VV数组,或者是使用结构体或类作为数组的元素,就可以和原来一样对图进行处理了。

这里写图片描述

使用这种存储方式,可以很方便地判断任意两个顶点之间是否有边以及确定顶点的度,这也是这种表示法最大的优势。任意一个顶点i的度等于其邻接矩阵中顶点i所对应的行中的数字之和:

$ \sum_{j=0}^{n-1} adjmat[i][j] $

以上这个数学公式的markdown“源码”:
$ \sum_{j=0}^{n-1} g[i][j] $

在这种表示法中扫描所有边至少需要$ O(n^2) 时 间 , 因 为 必 须 检 查 矩 阵 中 的 时间,因为必须检查矩阵中的 n^2-n$个元素才能确定图中边的条数(邻接矩阵对角线上的n个元素都是0,因此不用检查。又因为无向图的邻接矩阵是对称的,实际只需检查邻接矩阵的一半元素)。通常把边很少的图成为稀疏图(sparse graphs)。

邻接表

如果用邻接矩阵表示稀疏图就会浪费大量内存空间,而用链接表,则是通过把顶点所能到的顶点的边保存在链表中来表示图,这样就只需要 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(V+E)的内存空间。

这里写图片描述

而所谓的邻接表,就是用n个链表代替邻接矩阵中的n行。链表中的结点结构至少要包含一个顶点域和一个链域。对于任意给定的链表i,链表中的结点就是与顶点i相邻的所有顶点。邻接表存储声明的C语言声明如下:

#define MAX_VERTICES 50 
typedef struct node *node-pointer;
typedef struct node
{
	int vertex;
	struct node *link;
};
node_pointer graph[MAX_VERTICES];
int n=0;

邻接多重表

在无向图的邻接表存储表示中,每一条边$ ( v_i,v_j ) $ 都表示为两项:一项在顶点$ v_i $ 的邻接表中,而另一项在顶点 $ v_j $ 的邻接表中。在多重表中,各链表中的结点可以被几个链表共享,此时图中的每一条边只对应于一个结点,而这个结点出现在该边所关联的两个顶点的每个邻接链表中。如下图所示:

markedvertex1vertex2path1path2

邻接多重表结点结构的C语言声明为:

typedef struct edge *edge-pointer
typedef struct edge
{
	short int marked;
	int vertex1;
	int vertex2;
	edge_pointer path1;
	edge_pointer path2;
};

图的基本操作和算法

广度优先搜索

请先忽视下图中所有的下标,让我们从头开始。随意选择一个点,此处选择 v 3 v3 v3,作为切入点。因此到 v 3 v3 v3的距离为0。从 v 3 v3 v3出发,距离为1的结点是 v 1 v1 v1 v 6 v6 v6;继续下一步, v 6 v6 v6已经无路可走,而与 v 1 v1 v1距离为1的是 v 2 v2 v2 v 4 v4 v4,因此对它们标记上2;继续下去, v 2 v2 v2 v 4 v4 v4走一步都可以到 v 5 v5 v5 v 4 v4 v4走一步可以到 v 7 v7 v7,因此 v 5 v5 v5 v 7 v7 v7被标记为3。至此搜索便结束了。

这就是广度优先搜索(breadth-first search),该方法按层处理顶点。距起始点最近的那些顶点首先被求值,最远点则最后被求值,这很像对树的层序遍历(level-order traversal)。

这里写图片描述

为了实现广度优先搜索,可以使用动态链接队列。在队列中的每个顶点都包含两个域:顶点的序号和链接指针。

函数bfs所使用的队列的定义和函数原型声明为:

typedef struct queue *queue_pointer;
typedef struct queue
{
	int vertex;
	queue_pointer link;
};
void addq(queue_pointer *, queue_pointer *,int);
int deleteq(queue_pointer *);

图的广度优先搜索算法:

void bfs(int v)
{
	node_pointer w;
	queue_pointer front,rear;
	front=rear=NULL;
	printf("%5d",v);
	visited[v]=TRUE;
	addq(&front,&rear,v);
	while(front)
	{
		v=deleteq(&front);
		for(w=graph[v];w;w=w->link)
		{
			if(!visited[w->vertex])
			{
				printf("%5d",w->vertex);
				addq(&front,&rear,w->vertex);
				visited[w->vertex]=TRUE;
			}
		}
	}
}

图中每个顶点都被存入队列一次,所以该算法中的while循环至多重复n次。如果采用邻接表存储表示,那么该算法所需要的时间为:

$ d_0 + d_1 + … + d_{n-1} = O(e) $

其中$ d_i $ 为顶点 $ v_i $ 的度。

而如果采用邻接矩阵来实现,那么对于每个顶点的访问,while循环的时间为 O ( n ) O(n) O(n),所以算法的总耗时为$ O(n^2) $ 。和接下来的深度优先搜索一样,一次广度优先搜索访问到的顶点以及与这些顶点相关联的边形成的图G的一个连通分支。

深度优先搜索

深度优先搜索内容较多,已经在下文中单独列出。

连通图

使用以上的两种搜索算法也可以用来判断一个无向图是否是连通的。具体步骤如下:

1.调用bfs(0)或dfs(0)
2.检查是否存在未被访问过的顶点

具体代码如下:

void connected(void)
{
	int i;
	for(i=0;i<n;i++)
	{
		if(!visited[i])
		{
			dfs(i);
			printf("\n");
		}
	}
}			

算法分析:如果采用邻接表存储,那么函数dfs时间开销为 O ( e ) O(e) O(e)。这里for循环的时间开销为 O ( n ) O(n) O(n),所以整个算法的时间复杂性为 O ( n + e ) O(n+e) O(n+e)

双连通图

双联通图(biconnected graph)是没有关节点的连通图。对此有一个比较重要的公式如下:

low(u) = min{dfn(u), min{low(w)|w是u的儿子}, min{dfn(w)|(u,w)是一条回退边} }

回退边也叫back edge,大家顾名思义就好,下面有更多应用。

下面来段求解图的双连通分支的算法:

void bicon(int u, int v)
{
	node_pointer ptr;
	int w,x,y;
	dfn[u]=low[u]=num++;
	for(ptr=graph[u];ptr;ptr=ptr->link)
	{
		w=ptr->vertex;
		if(v!=w && dfn[w]<dfn[u])		
			add(&top,u,w);
		if(dfn[w]<0)
		{
			bicon(w,u);
			low[u]=MIN2(low[u],low[w]);
			if(low[w]>=dfn[u])
			{
				printf("New biconnected component: ");
				do
				{
					delete(&top,&x,&y);
					printf(" <%d,%d>",x,y);
				}while(!((x==u)&&(y==w)));
				printf("\n");
			}
		}
		else if(w!=v)
			low[u]=MIN2(low[u],dfn[w]);
	}
}

拓扑排序

拓扑排序(topological sort)是对有向无环图的顶点的一种排序,它使得如果存在一条从vi到vj的路径,那么在排序中vj出现在vi的后面。正是由于这个特性,如果图含有回路,那么拓扑排序是不可能的。

这里写图片描述

拓扑排序简单的说,就是将上图变成下图。

这里写图片描述

求拓扑排序算法的一种简单方式:选中一个没有入边的顶点,显示出该点,并将它和它的边一起从图中删除,然后对图的其余部分应用同样的方法处理。

假设每一个顶点的入度被存储且图被读入一个邻接表中,下面的代码则可以生成一个拓扑排序。

这里写图片描述

对上图应用拓扑排序的结果如下:

这里写图片描述

最短路径算法

单源最短路径问题:给定一个加权图G=(V,E)和一个特定顶点s作为输入,找出从s到G中每一个其他点的最短加权路径。

如下图所示,从v1到v6的最短加权路径的值为6( v 1 − v 4 − v 7 − v 6 v1-v4-v7-v6 v1v4v7v6),从 v 2 v2 v2 v 5 v5 v5的最短加权路径的值为5( v 2 − v 4 − v 5 v2-v4-v5 v2v4v5)。

这里写图片描述

下面这个图从v5到v4的最短加权路径可就有意思了,它是1么?不是。按照 v 5 − v 4 − v 2 − v 5 − v 4 v5-v4-v2-v5-v4 v5v4v2v5v4的路径走则是一条更短的路径了,因为这是带负值回路的图。而由于带负值而引入的循环,这个循环叫做负值回路(negative-cost cycle),当它出现在图中时,最短路径问题就是不确定的了。有负值的边未必不好,但它们明显使问题更加难了。

这里写图片描述

当未指明所讨论的是加权路径还是无权路径时,如果图是加权的,那么路径就是加权的。

下面列出单源最短路径算法:

void shortestpath(int v,int cost[][MAX_VERTICES],int distance[],int n,short int found[])
{
	int i,u,w;
	for(i=0;i<n;i++)
	{
		found[i]=FALSE;
		distance[i]=cost[v][i];
	}
	found[v]=TRUE;
	distance[v]=0;
	for(i=0;i<n-2;i++)
	{
		u=choose(distance,n,found);
		found[u]=TRUE;
		for(w=0;w<n;w++)
			if(!found[w])
				if(distance[u]+cost[u][w]<distance[w])
					distance[w]=cost[u][w]+distance[u];
	}
}

int choose(int distance[],int n,short int found[])
{
	int i,min,minpos;
	min=INT_MAX;
	minpos=-1;
	for(i=0;i<n;i++)
		if(distance[i]<min && !found[i])
		{
			min=distance[i];
			minpos=i;
		}
	return minpos;
}

思考:找出A到所有其他顶点的最短路径以及B到所有其他顶点的最短无权路径。

这里写图片描述

如果要求所有顶点对之间的最短路径,可以用下面这个算法:

void allcosts(int cost[][MAX_VERTICES],int distance[][MAX_VERTICES],int n)
{
	int i,j,k;
	for(i=0;i<n;i++)
		for(j=0;j<n;j++)
			distance[i][j]=cost[i][j];
	for(k=0;k<n;k++)
		for(i=0;i<n;i++)
			for(j=0;j<n;j++)
				if(distance[i][k]+distance[k][j]<distance[i][j])
					distance[i][j]=distance[i][k]+distance[k][j];
}

传递闭包

我们由一个问题引入传递闭包的概念。有一个边不带权值的有向图G,要判断任意两个顶点i 和j 之间是否存在一条路径,此处有两种情况,一种是路径长度为正数,一种是路径长度为非负。以上两种情况分别被称为图的传递闭包(transitive closure),和自反传递闭包(reflexive transitive closure)。

传递闭包矩阵(transitive closure matrix)是一个矩阵,记作$ A^+ , 如 果 从 顶 点 i 到 j 存 在 一 条 长 度 大 于 0 的 路 径 , 则 ,如果从顶点i到j存在一条长度大于0的路径,则 ij0 A^+ [i][j] = 1 , 否 则 ,否则 A^+ [i][j] = 0$ 。

自反传递闭包矩阵是一个矩阵,记作$ A^* $ ,如果从顶点i到j存在一条长度大于0的路径,则$ A^* [i][j] = 1 , 否 则 ,否则 A^* [i][j] = 0$ 。

Dijkstra算法

前面的广度优先搜索中的图是无权图,而如果一旦变成了加权图,那么问题就变得困难起来了。

对于每个顶点,我们标记为known以及unknown,和上面一样,还得有一个距离的 d v d_v dv。与无权最短路径一样,Dijkstra算法也是按阶段进行,在每个阶段选择一个顶点v,它在所有unknown顶点中具有最小的 d v d_v dv,同时算法声明从 s s s v v v的最短路径是known的。然后紧接着,不断的进行下去即可。

那么这个算法到底是怎么回事了?请看下图。

这里写图片描述

图中已经对权重做好了标记,以 v 1 v1 v1作为切入点,因此初始情况如下左图。

v 1 v1 v1此时已经是known的了,而其有2个邻接点 v 2 v2 v2 v 4 v4 v4,因此可以调整为如下右图。正无穷图标标识没有连通。 p v p_v pv表示前一个邻接点。

这里写图片描述

毫无疑问这里会接下来走到v4去,因为v4的权重为1比v2的权重为2要小。调整为如下左图。

这里写图片描述

可能你已经看到了上图中的右图而好奇为什么下一步是 v 2 v2 v2,但是 v 4 v4 v4根本不能走到 v 2 v2 v2。因为 v 4 v4 v4能够走到的,比如 v 3 v3 v3,权重从 v 1 v1 v1开始一共是3,这比从 v 1 v1 v1 v 2 v2 v2还要大。于是就跳转回到了 v 2 v2 v2

下一步便走到了 v 5 v5 v5,因为只有值为3的权重,同样的 v 3 v3 v3也是,于是它们俩被双双标记为known。如下左图所示。

紧接着走到了 v 7 v7 v7,同时 v 6 v6 v6下调到了 5 + 1 = 6 5+1=6 5+1=6得到了如下右图。至于为什么要做这个调整,是因为此时 v 1 v1 v1 v 7 v7 v7的加权为 1 + 4 = 5 1+4=5 1+4=5,而 v 7 v7 v7 v 6 v6 v6的加权为1,所以就有了这个调整。

这里写图片描述

最后便顺势走到了v6完成了整个Dijkstra算法,它们都已被标记为known。

这里写图片描述

在后面还将会有一种斐波那契堆,针对Dijkstra算法做了优化,欢迎大家的继续关注。

具有负边值得图

而如果一个图具有负的边值,那么Dijkstra算法就行不通了。这是因为一个顶点u被声明为known后,那就可能从某个另外的unknown顶点v有一条回到u的负的路径。而“回到”就意味着循环,前面的例子中我们已经知道了循环是多么的……

问题并非没有解决的办法,如果我们有一个常数X,将其加到每一条边的值上,这样除去负的边,再计算新图的最短路径,最后把结果应用到原图上。然后这个解决方案也是布满了荆棘,因为居多许多条边的路径变得比那些具有很少边的路径权重更重了。如果我们将s放到队列中,然后再每一个阶段让一个顶点v出队,找出所有与v邻接的顶点w,使得 d w > d v + c v , w d_w>d_v+c_{v,w} dw>dv+cv,w,然后更新到 d w d_w dw p w p_w pw,并在 w w w不在队列中时将它放到队列中,可以为每一个顶点设置一个位(bit)以指示它在队列中出现的情况。

无环图

如果图是无环的,则可以通过改变声明顶点为known的顺序,或者叫做顶点选取法则来改进Dijkstra算法。这种方法通过拓扑排序来选择顶点,由于选择和更新可以在拓扑排序执行的过程中执行,因此新的算法只需要一趟就可以完成。

通过下面这个动作结点图(activity-node graph)来解释什么是关键路径分析(critical path analysis)再合适不过了。一条边 ( v , w ) (v,w) vw表示动作v必须在动作w开始前完成,如前面说描述的那样,这就意味着图必须是无环的。

这里写图片描述

为了进行这些运算,我们把动作结点图转化成事件结点图(event-node graph),每个事件对应于一个动作和所有与它相关的动作完成。

这里写图片描述

所以现在我们需要找出事件的最早完成时间,只要找出从第一个事件到最后一关事件的最长路径的长。因为有正值回路(positive-cost cycle)的存在最长路径问题常常是没有意义的。而由于事件结点图是无环图,那就不需要担心回路的问题了,这样一来就不用有所顾忌了。

以下是最早完成时间。

这里写图片描述

以下是最晚完成时间。

这里写图片描述

借助顶点的拓扑排序计算最早完成时间,而最晚完成时间则通过倒转拓扑排序来计算。

而事件结点图中每条边的松弛时间(slack time)代表对应动作可以被延迟而不推迟整体完成的时间量,最早完成时间、最晚完成时间和松弛时间如下所示。

这里写图片描述

某些动作的松弛时间为0,这些动作是关键性的动作,它们必须按计划结束。至少存在一条完成零-松弛边组成的路径,这样的路径是关键路径(critical path)。

网络流问题

如下左图所示,有一个顶点 s s s,称为源点(source);还有一个顶点 t t t,称为汇点(sink)。对于顶点 c c c,它最大流出2,因此它的最大流入为2,如下右图所示。而 t t t的最大流也就是5。

这里写图片描述

要想计算最大流,同样可是使用前面的思想——分阶段进行。令开始时所有边都没有流,如下中间图所示。我们可以用残余图(residual graph)来表示对于每条边还能再添加上多少流。对于每一条边,可以从容量中减去当前的流而计算出残留的流。

这里写图片描述

第一步:假设我们选择 s − b − d − t s-b-d-t sbdt路径,此时会发出2个单位的流通过这条路径的每一边,如下中间图所示。对比左图,我们做如下约定:一旦注满(使饱和)一条边,例如 a a a b b b b b b d d d,就将这条边从残余图(也就是中间图)去掉,如下右图所示。

这里写图片描述

第二步:接下来选择 s − a − c − t s-a-c-t sact路径,此时也会发出2个单位的流通过这条路径的每一边,如下中间图所示(只看 s − a − c − t s-a-c-t sact即可, s − b − d − t s-b-d-t sbdt为上一步说走过的路径)。同样将残余图更新如下右图所示。

这里写图片描述

第三步:从上图的残余图中我们已经可以看出来最后一步的唯一一种走法了,也就是从 s − a − d − t s-a-d-t sadt。做如下图所示更新。

这里写图片描述

很显然从 t t t无法走到 s s s,因此算法至此便终止了。因此正好5个单位的流是最大值。前面的三步我们走的如此顺利,那么问题真的如此简单么?

如果一开始我们选择了 s − a − d − t s-a-d-t sadt,那么算法就会失败了,因为路已经被堵死了。

这里写图片描述

为了使算法得以成功运作,那么就要让流图中具有以相反方向发送流的路径,如下所示。那么对于如下右图中的残余图而言,从d返回到a的便成了3而非4,这是因为从t流到d的流量是3个单位。现在在残余图中就有a和d之间有2个方向,或者还有1个单位的流可以从 a a a导向 d d d,或者是3个单位的流导向相反的反向,当然,我们也可以撤销流。

这里写图片描述

紧接着如果通过 d d d a a a导入2个单位的流,算法就会从边 ( a , d ) (a,d) ad取走2个单位的流,更新流图如下。

这里写图片描述

思考:找出下面网络中的一个拓扑排序以及最大流。

这里写图片描述

活动网络

AOV网络

除了一些不能再简单的工程外,所有的工程都可以划分为若干个成为活动(activities)的子工程。比如说大学的课程,得修完了大学英语1才能修大学英语2,也得修完了高等数学才能修线性代数、概率论、离散数学等。将这些作为顶点标记为图时,它便是一个有向图。

顶点表示活动的网(activity on vertex network)或AOV网,是用顶点表示活动或任务,边表示活动或任务之间的优先关系的有向图G。在AOV网络G中,当且仅当从顶点i到j存在一条有向路径,则顶点i称为顶点j的前驱(predecessor);当且仅当$ <i , j> $ 是G中的一条边,则称顶点i为顶点j的直接前驱(immediate predecessor)。如果顶点i是顶点j的前驱,则称顶点j为顶点i的后继(successor);如果顶点i是顶点j的直接前驱,则称顶点 j j j为顶点 i i i的直接后继。

拓扑排列是由有向图中所有顶点形成一个线性序列,对图中任意两个顶点 i i i j j j,如果顶点$ i 是 顶 点 是顶点 j 的 前 驱 , 则 顶 点 的前驱,则顶点 i 在 拓 扑 序 列 中 排 在 顶 点 在拓扑序列中排在顶点 j$的前面。

我们在前面已经介绍了拓扑排序,这里给出它的伪代码。

for(i=0;i<n;i++)
{
	if every vertex has a predecessor
	{
		fprintf(stderr,"Network has a cycle.\n");
		exit(1);
	}
	pick a vertex v that has no predecessors;
	output v;
	delete v and all edges leading out of v	from the netwok;
}

对于拓扑排序问题,所需的操作主要有:
1)判断顶点是否有前驱;
2)删除顶点和关联于该顶点的所有边。

在操作1中,我们可以在每个顶点中都保存其直接前驱个数的计数。对于操作2,可以使用前面介绍过的邻接表来表示AOV网络。于是可以将其实现为以下C代码:

// 声明
typedef struct node *node_pointer;
typedef struct node
{
	int vertex;
	node_pointer link;
};
typedef struct 
{
	int count;
	node_pointer link;
}hdnodes;
hdnodes graph[MAX_VERTICES];

// 函数
void topsort(hdnodes graph[],int n)
{
	int i,j,k,top;
	node_pointer ptr;
	top=-1;
	for(i=0;i<n;i++)
	{
		if(!graph[i].count)
		{
			graph[i].count=top;
			top=i;
		}
	}
	for(i=0;i<n;i++)
	{
		if(top==-1)
		{
			fprintf(stderr,"\nNetwork has a cycle. Sort terminated.\n");
			exit(1);
		}
		else
		{
			j=top;
			top=graph[top].count;
			printf("v%d, ",j);
			for(ptr=graph[j].link;ptr;ptr=ptr->link)
			{
				k=ptr->vertex;
				graph[k].count--;
				if(!graph[k].count)
				{
					graph[k].count=top;
					top=k;
				}
			}
		}
	}
}	

在topsort的声明中,count域用来保存顶点的入度,而link域则是指向邻接表首结点的指针。邻接表中的每个结点又包含了两个域:vertex和link。在输入时,可以方便地设置count域的值。当输入一条边$ <i, j> $ 时,顶点j的count就会加1。用一个栈来保存count值为0的顶点序列。当然也可以使用队列,但栈更容易实现。由于在count域减至0以后,count域就没有用了,所以通过头结点的count域把栈中的各个结点链接起来。

对于topsort的分析:对于具有n个顶点和e条边的AOV网络,第一个for循环的时间开销为 O ( n ) O(n) O(n)。而第二个for循环执行n次。if子句在常数时间内完成;else子句中的for循环时间开销为O( d i d_i di),其中 d i d_i di是顶点i的出度。由于这个循环会在每个顶点输出时执行一次,所以总时间为:

$ O((\sum_{i=o}^{n-1} d_i)+n)=O(e+n) $

因此这个算法的渐进时间为O(e+n),与问题的规模呈线性关系。

AOE网络

AOE网络就是边表示活动的网络(activity on edge network),它的有向边表示在一个工程中所需完成的任务或活动,而顶点表示事件,用来标识某些活动的完成。在AOV网络中,事件高数2完成意味着要先完成高数1;AOE网络中,事件高数2完成意味着已经完成了高数1。也就是说在AOE中,当一个事件发生时,就表明触发该事件的所有活动都已经完成。

在AOE网络中,有些活动可以并行地进行,所以完成整个工程所需的最短时间是从开始顶点到终止顶点的最长路径的长度。关键路径(critical path)就是一条具有最长路径长度的路径。

一个事件$ v_i 可 以 发 生 的 最 早 发 生 时 间 ( e a r l i e s t t i m e ) , 是 从 开 始 顶 点 可以发生的最早发生时间(earliest time),是从开始顶点 earliesttime v_o 到 顶 点 到顶点 v_i 的 最 长 路 径 的 长 度 。 活 动 的最长路径的长度。活动 v_i 的 最 迟 开 始 时 间 ( l a t e s t t i m e ) , 记 作 的最迟开始时间(latest time),记作 latesttimelate(i) , 是 指 在 不 增 加 工 程 工 期 的 前 提 下 , 活 动 ,是指在不增加工程工期的前提下,活动 a_i $能够最迟的开始时间。

关键网络(critical activity)是指满足 e a r l y ( i ) = l a t e ( i ) early(i)=late(i) early(i)=late(i)的活动,一个活动的最迟开始时间 l a t e ( i ) late(i) late(i)与最早开始时间 e a r l y ( i ) early(i) early(i)之间的差说明了该活动的关键程度。

下面列出两个比较常用的公式:

1)事件最早发生时间的计算

$ earliest[j] = \displaystyle \max_{i \in {P(j)}} { earliest[i] + <i , j> 的持续时间 } $

以上这个数学公式的markdown“源码”:
$ earliest[j] = \displaystyle \max_{x \in {P(j)}} \{ earliest[i] + <i , j> 的持续时间 \} $ 

2)事件最晚发生时间的计算

$ latest[j] = \displaystyle \min_{i \in {S(j)}} { latest[i] - <j , i> 的持续时间 } $

最小生成树

一个无向图G的最小生成树(minimum spanning tree)就是由该图的那些连接G的所有顶点的边构成的总值最低的树。最小生成树存在当且仅当G是连通的。

下面第二个图是第一个图的最小生成树(碰巧是唯一的,但并不能代表一般情况)。最小生成树是一棵树;因为它无环;因为最小生成树包含每一个顶点,所以它叫生成树;此外,它显然是包含所有顶点的最小的树。

这里写图片描述

Prim算法

计算最小生成树的一种方法是使其连续地一步一步成长,在每一步中,都要把一个结点当作根并且往上累加边,于是就将关联的顶点加到了增长中的树上。

Prim算法和前面求最短路径的Dijkstra算法思想类似,因此和前面一样我们对每一个顶点保留值dv和pv以及一个标记顶点的known或unknown。

这里写图片描述

还是老办法,在Prim算法中也设定一个表的初始状态如下。

这里写图片描述

将v1设置为known的,根据Prim算法上一张图所示,v1连接了v2、v3、v4,其dv分别为2、4、1,因此更新如下。

这里写图片描述

将v4声明为known,更新如下。

这里写图片描述

将v2和v3先后声明为known,更新如下。

这里写图片描述

将v7声明为known后更新如下左图,最后将v6和v5也更新为known后更新如下右图。

这里写图片描述

下面是Prim算法的伪代码实现,其中T为生成树的边集,TV是当前生成树T的顶点集合

Kruskal算法

第二种贪心策略是连续地按照最小的权选择边,并且当所选的边不产生回路时就把它作为取定的边。同样是前面的示例,用Kruskal算法执行如下。

这里写图片描述

形式上,Kruskal算法是在处理一个森林——树的集合。下图展示了边被添加到森林中的顺序。

这里写图片描述

当添加到森林中的边足够多时,算法终止,这里算法的作用在于决定边 ( u , v ) (u,v) uv是应该添加还是舍弃。

在该算法执行的过程中,两个顶点属于同一个集合当且仅当它们在当前的生成森林(spanning forest)中连通。如果 u u u v v v在同一个集合中,那么连接它们的边就要放弃,因为当它们已经连接的情况下,再添加边 ( u , v ) (u,v) uv就会形成一个回路。

如果这两个顶点不在同一个集合中,就应该将这条边加入,并对包含顶点 u u u v v v的两个集合执行一次union操作。这样将保持集合的不变性,因为一旦边 ( u , v ) (u,v) uv添加到生成森林中,若 w w w连通到 u u u x x x连通到 v v v,这 x x x w w w必然是连通的,因此属于相同的集合。虽然将边排序便于选取,但用线性时间建立一个堆则是更好的想法,此时deleteMin使得边依次得到测试。

Sollin算法

Sollin算法在每一步都为生成树递归地选取若干条边,在每一步处理开始时,说选取的边与图中的所有n个顶点形成一个生成森林。在执行过程中,为森林中的每棵树都选取一条边,与选取的边都是恰有一个顶点在树上且代价最小。由于森林中的两棵树可能选取同一条边,所以需要去掉同一条边被多次选取的情况。在开始时,说选取的边集为空,当最后结果只有一棵树或者再没有边可供选取时,算法就此结束。

深度优先搜索

深度优先搜索(depth-first search)是对前序遍历的推广,对每一个顶点,字段visited被初始化成false,通过哪些尚未被访问的结点递归调用该过程,我们保证不会陷入无限循环。如果图是无向且连通的,或是有向但非强连通的,这种方法可能会访问不到某些结点。此时我们搜索一个未被标记的结点,然后应用深度优先遍历,并继续这个过程直到不存在未标记的结点为止。因为该方法保证每一条边只被访问一次,所以只要使用邻接表,执行遍历的总时间就是 O ( ∣ E ∣ + ∣ V ∣ ) O(|E|+|V|) O(E+V)

深度优先搜索的算法实现:

#define FALSE 0
#define TRUE 1
short int visited[MAX_VERTICES]

void dfs(int v)
{
	node_pointer w;
	visited[v]=TRUE;
	printf("%5d",v);
	for(w=graph[v];w;w=w->link);
		if(!visited[w->vertex])
			dfs(w->vertex);
}

和上文中的广搜一样,我们也来对dfs进行分析。

如果采用邻接表来存储,就可以沿着顶点的链表来确定其所有邻接顶点。因此在邻接表中的每一个顶点都至多扫描一次,所以完成搜索时间复杂性为 O ( e ) O(e) O(e)

如果采用邻接矩阵来存储,访问顶点的所有邻接顶点的时间为 O ( n ) O(n) O(n),而整个过程至多访问 n n n个顶点,因此完成搜索时间复杂性为$ O(n^2) $ 。

无向图

如下左图中是一个无向图,我们以此为例,假设从A开始,标记A为known并递归地调用dfs(B)。dfs(B)标记B为known并递归调用dfs©。dfs©标记C为known并递归调用dsf(D)。而后D的邻接结点只有C,但C已经是knwon的,这里便无法再继续递归下去,因此返回到dfs©。dfs©看到已经标记为known的B邻接点和D邻接点,因此调用dfs(E)。dfs(E)标记E为known,同样的它只能返回到dfs©,再返回到dfs(B),最后返回到dfs(A)。实际上这里接触每条边2次,一次是作为边(v,w),另一次是作为边(w,v)。

如下右图展示了深度优先搜索树(depth-first spanning tree)的步骤。虚线表示向后边(back edge),表示这条“边”并不是树的一部分。

这里写图片描述

树将模拟我们执行的遍历,使用树的边对该树的前序编号(preorder numbering)表示顶点被标记的顺序;如果图不是连通的,那么处理所有的结点(以及边)自然需要多次反复调用dfs,每次都生成一棵树,整个集合就是深度优先生成森林(depth-first spanning forest)。

双连通性

前面我们已经介绍过了双连通图,如果删除一个无向图的仁一顶点后,剩下的图仍然连通,那么这样的无向连通图就称为是双连通的(biconnected)。

如果图不是双联通的,那么将其删除后不再连通的那些顶点叫做割点(articulation point)。

深度优先搜索提供了一种找出连通图中所有割点的线性时间算法。从图中任一顶点开始,执行深度优先搜索并在顶点被访问时给它们编号。对于每一个顶点v,我们称其前序编号为Num(V)。然后,对于深度优先搜索生成树中的每一个顶点v,计算编号最低的顶点,我们称之为Low(V),该点可从v开始通过树的零条或多条边,且可能还有一条后向边而以该序达到。

这里写图片描述

有向图

如前所述,如果图不是强连通的,那么从某个结点开始的深度优先搜索可能访问不了所有的结点,这种情况下我们从某个未做标记的结点处开始,反复执行深度优先搜索,直到所有的结点都被访问到为止。

这里写图片描述

对此我们从顶点 B B B开始深度优先搜索,依次访问 B 、 C 、 A 、 D 、 E 、 F B、C、A、D、E、F BCADEF。而后从某个未被标记的顶点重新开始,比如 H H H,然后访问 J J J I I I。最后从 G G G开始,也从此结束。

这里写图片描述

深度优先生成森林中的虚线是一些 ( v , w ) (v,w) vw边,其中的 w w w在考察时已经做了标记。在无向图中,它们总是一些向后边,但是可以看到,存在三种类型的边并不通向新的顶点。这里有一些向后边(back edge),如 ( A , B ) (A,B) AB;还有一些向前边(forward edge),如 ( C , D ) (C,D) CD;最后还有一些交叉边(cross edge),如 ( F , C ) (F,C) FC,它们把不直接相关的两个树结点连接起来。深度优先搜索森林一般通过把一些子结点和一些新的树丛左到右添加到森林中形成。

深度优先搜索还可以用来检测一个有向图是否是无环图,因为一个有向图是无环图当且仅当它没有向后边。上面的例子明显不是无环图,因为它有向后边。而拓扑排序也可以用来检测一个图是否是无环图,进行拓扑排序的另一种方法是通过深度优先搜索生成森林的后序遍历给顶点指定拓扑编号 N , N − 1 , … … , 1 N,N-1,……,1 NN11。只要图是无环的,这种排序就是一致的。


备注1:为保证权威性,所有定义、公式、图片均来自《数据结构(C语言版)》、《数据结构与算法分析 C++描述》、《离散数学》、《算法导论》、《挑战程序设计竞赛》等书籍和必应、维基百科等网络。

备注2:希望不会因为参考了在备注1中提到的诸多书籍与网络而被批判,毕竟这篇博客也融合了我许多时间、精力和思考。我只是希望像我所读的所有外国书籍一般在书尾列出参考文献一样尊重原作者。

备注3:个人能力有限,暂时无力为图论这一领域增加新算法,本文中所有算法均来自伟大的前辈们的著作,为表敬意,此处以金色显示。



感谢您的访问,希望对您有所帮助。

欢迎大家关注或收藏、评论或点赞。


为使本文得到斧正和提问,转载请注明出处:
http://blog.csdn.net/nomasp


已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: Age of Ai 设计师:meimeiellie 返回首页