图论算法 有图有代码 万字总结 向前辈致敬
图的定义
背景知识
看到这篇博客相信一开始映入读者眼帘的就是下面这幅图了,这就是传说中的七桥问题(哥尼斯堡桥问题)。在哥尼斯堡,普雷格尔河环绕着奈佛夫岛(图中的A岛)。这条河将陆地分成了下面4个区域,该处还有着7座连接这些陆地的桥梁。
问题是如何从某地出发,依次沿着各个桥,必须经过每座桥且每座桥只能经过1次,最终回到原地。
不知道这个问题且好奇的童鞋现在肯定在忙活着找出来这道题的结果了。
是伟大的数学家欧拉(Leonhard Euler)在1736年首次使用图的方法解决了该问题。
欧拉将上面的模型转换成了下面这种”图“的形式。
欧拉把顶点的度定义为与该顶点相关联的边的条数,并且他证明了存在从任意点出发,经过所有边恰好一次,并最终回到出发顶点的走法的充分必要条件是:每个顶点的度均为偶数。人们称之为欧拉闭迹(Eulerian walk)。
简要定义
图
有时也把边称作弧(arc),如果点对
顶点
我们可以给边赋予各式的属性,比如权值(cost)。权值可以表示从一个顶点到另一个顶点的距离,也可以表示一个顶点到另一个顶点说话费的代价(比如时间、金钱等)。一个边上带权值的图称为网络(network)。
如果无向图中从每一个顶点到其他每个顶点都存在一条路径,则称该无向图是连通的(connected)。具有这样性质的有向图称为是强连通的的(strongly connected)。如果有向图不是强连通的,但它的基础图(underlying graph)(也就是其弧上去掉方向说形成的图)是连通的,那么称该有向图是弱连通的(weakly connected)。完全图(complete graph)是其每一对顶点间都存在一条边的图。
所谓入度(indegree)是指的顶点
如下表示了一个有着7个顶点和12条边的有向图。
如果具有n个顶点,e条边的图G的顶点i的度为
以上这个数学公式的markdown“源码”:
$ e =\frac { \sum_{0}^{n-1} d_i} {2} $
现在将图看作抽象数据类型,下面给出ADT图的结构:
objects | 一个非空顶点的集合和一个无向边的集合,其中每条边都是一个顶点对 |
---|
functions | 对于所有的 |
---|---|
Graph Create() | return一个空图 |
Graph InsertVertex (graph, v) | 向图graph中插入没有关联边的新顶点v,return改变后的图 |
Graph InsertEdge (graph, |
在图graph的顶点 |
Graph DeleteVertex (graph, v) | 删除图graph的顶点v及与其关联的所有边,return改变后的图 |
Graph DeleteEdge (graph, |
删除图graph的边( |
Boolean IsEmpty (graph) | if(graph==空图) return TRUE,else return FALSE |
List Adjacent (graph, v) | return顶点v的所有邻接结点 |
图的存储表示方式
图主要有3种常用的存储表示方式:邻接矩阵(adjacency matrices),邻接表(adjacency lists),邻接多重表(adjacency multilists)。
邻接矩阵
邻接矩阵使用
1)因为在无向图中,我们只需要知道顶点
2)而在有向图中,我们只需要知道是否有从顶点
3)在带权值的图中,
使用这种存储方式,可以很方便地判断任意两个顶点之间是否有边以及确定顶点的度,这也是这种表示法最大的优势。任意一个顶点i的度等于其邻接矩阵中顶点i所对应的行中的数字之和:
以上这个数学公式的markdown“源码”:
$ \sum_{j=0}^{n-1} g[i][j] $
在这种表示法中扫描所有边至少需要
邻接表
如果用邻接矩阵表示稀疏图就会浪费大量内存空间,而用链接表,则是通过把顶点所能到的顶点的边保存在链表中来表示图,这样就只需要
而所谓的邻接表,就是用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;
邻接多重表
在无向图的邻接表存储表示中,每一条边
marked | vertex1 | vertex2 | path1 | path2 |
---|
邻接多重表结点结构的C语言声明为:
typedef struct edge *edge-pointer
typedef struct edge
{
short int marked;
int vertex1;
int vertex2;
edge_pointer path1;
edge_pointer path2;
};
图的基本操作和算法
广度优先搜索
请先忽视下图中所有的下标,让我们从头开始。随意选择一个点,此处选择
这就是广度优先搜索(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次。如果采用邻接表存储表示,那么该算法所需要的时间为:
其中
而如果采用邻接矩阵来实现,那么对于每个顶点的访问,while循环的时间为
深度优先搜索
深度优先搜索内容较多,已经在下文中单独列出。
连通图
使用以上的两种搜索算法也可以用来判断一个无向图是否是连通的。具体步骤如下:
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时间开销为
双连通图
双联通图(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(
下面这个图从v5到v4的最短加权路径可就有意思了,它是1么?不是。按照
当未指明所讨论的是加权路径还是无权路径时,如果图是加权的,那么路径就是加权的。
下面列出单源最短路径算法:
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)是一个矩阵,记作
自反传递闭包矩阵是一个矩阵,记作
Dijkstra算法
前面的广度优先搜索中的图是无权图,而如果一旦变成了加权图,那么问题就变得困难起来了。
对于每个顶点,我们标记为known以及unknown,和上面一样,还得有一个距离的
那么这个算法到底是怎么回事了?请看下图。
图中已经对权重做好了标记,以
毫无疑问这里会接下来走到v4去,因为v4的权重为1比v2的权重为2要小。调整为如下左图。
可能你已经看到了上图中的右图而好奇为什么下一步是
下一步便走到了
紧接着走到了
最后便顺势走到了v6完成了整个Dijkstra算法,它们都已被标记为known。
在后面还将会有一种斐波那契堆,针对Dijkstra算法做了优化,欢迎大家的继续关注。
具有负边值得图
而如果一个图具有负的边值,那么Dijkstra算法就行不通了。这是因为一个顶点u被声明为known后,那就可能从某个另外的unknown顶点v有一条回到u的负的路径。而“回到”就意味着循环,前面的例子中我们已经知道了循环是多么的……
问题并非没有解决的办法,如果我们有一个常数X,将其加到每一条边的值上,这样除去负的边,再计算新图的最短路径,最后把结果应用到原图上。然后这个解决方案也是布满了荆棘,因为居多许多条边的路径变得比那些具有很少边的路径权重更重了。如果我们将s放到队列中,然后再每一个阶段让一个顶点v出队,找出所有与v邻接的顶点w,使得
无环图
如果图是无环的,则可以通过改变声明顶点为known的顺序,或者叫做顶点选取法则来改进Dijkstra算法。这种方法通过拓扑排序来选择顶点,由于选择和更新可以在拓扑排序执行的过程中执行,因此新的算法只需要一趟就可以完成。
通过下面这个动作结点图(activity-node graph)来解释什么是关键路径分析(critical path analysis)再合适不过了。一条边
为了进行这些运算,我们把动作结点图转化成事件结点图(event-node graph),每个事件对应于一个动作和所有与它相关的动作完成。
所以现在我们需要找出事件的最早完成时间,只要找出从第一个事件到最后一关事件的最长路径的长。因为有正值回路(positive-cost cycle)的存在最长路径问题常常是没有意义的。而由于事件结点图是无环图,那就不需要担心回路的问题了,这样一来就不用有所顾忌了。
以下是最早完成时间。
以下是最晚完成时间。
借助顶点的拓扑排序计算最早完成时间,而最晚完成时间则通过倒转拓扑排序来计算。
而事件结点图中每条边的松弛时间(slack time)代表对应动作可以被延迟而不推迟整体完成的时间量,最早完成时间、最晚完成时间和松弛时间如下所示。
某些动作的松弛时间为0,这些动作是关键性的动作,它们必须按计划结束。至少存在一条完成零-松弛边组成的路径,这样的路径是关键路径(critical path)。
网络流问题
如下左图所示,有一个顶点
要想计算最大流,同样可是使用前面的思想——分阶段进行。令开始时所有边都没有流,如下中间图所示。我们可以用残余图(residual graph)来表示对于每条边还能再添加上多少流。对于每一条边,可以从容量中减去当前的流而计算出残留的流。
第一步:假设我们选择
第二步:接下来选择
第三步:从上图的残余图中我们已经可以看出来最后一步的唯一一种走法了,也就是从
很显然从
如果一开始我们选择了
为了使算法得以成功运作,那么就要让流图中具有以相反方向发送流的路径,如下所示。那么对于如下右图中的残余图而言,从d返回到a的便成了3而非4,这是因为从t流到d的流量是3个单位。现在在残余图中就有a和d之间有2个方向,或者还有1个单位的流可以从
紧接着如果通过
思考:找出下面网络中的一个拓扑排序以及最大流。
活动网络
AOV网络
除了一些不能再简单的工程外,所有的工程都可以划分为若干个成为活动(activities)的子工程。比如说大学的课程,得修完了大学英语1才能修大学英语2,也得修完了高等数学才能修线性代数、概率论、离散数学等。将这些作为顶点标记为图时,它便是一个有向图。
顶点表示活动的网(activity on vertex network)或AOV网,是用顶点表示活动或任务,边表示活动或任务之间的优先关系的有向图G。在AOV网络G中,当且仅当从顶点i到j存在一条有向路径,则顶点i称为顶点j的前驱(predecessor);当且仅当
拓扑排列是由有向图中所有顶点形成一个线性序列,对图中任意两个顶点
我们在前面已经介绍了拓扑排序,这里给出它的伪代码。
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域的值。当输入一条边
对于topsort的分析:对于具有n个顶点和e条边的AOV网络,第一个for循环的时间开销为
因此这个算法的渐进时间为O(e+n),与问题的规模呈线性关系。
AOE网络
AOE网络就是边表示活动的网络(activity on edge network),它的有向边表示在一个工程中所需完成的任务或活动,而顶点表示事件,用来标识某些活动的完成。在AOV网络中,事件高数2完成意味着要先完成高数1;AOE网络中,事件高数2完成意味着已经完成了高数1。也就是说在AOE中,当一个事件发生时,就表明触发该事件的所有活动都已经完成。
在AOE网络中,有些活动可以并行地进行,所以完成整个工程所需的最短时间是从开始顶点到终止顶点的最长路径的长度。关键路径(critical path)就是一条具有最长路径长度的路径。
一个事件
关键网络(critical activity)是指满足
下面列出两个比较常用的公式:
1)事件最早发生时间的计算
以上这个数学公式的markdown“源码”:
$ earliest[j] = \displaystyle \max_{x \in {P(j)}} \{ earliest[i] + <i , j> 的持续时间 \} $
2)事件最晚发生时间的计算
最小生成树
一个无向图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算法是在处理一个森林——树的集合。下图展示了边被添加到森林中的顺序。
当添加到森林中的边足够多时,算法终止,这里算法的作用在于决定边
在该算法执行的过程中,两个顶点属于同一个集合当且仅当它们在当前的生成森林(spanning forest)中连通。如果
如果这两个顶点不在同一个集合中,就应该将这条边加入,并对包含顶点
Sollin算法
Sollin算法在每一步都为生成树递归地选取若干条边,在每一步处理开始时,说选取的边与图中的所有n个顶点形成一个生成森林。在执行过程中,为森林中的每棵树都选取一条边,与选取的边都是恰有一个顶点在树上且代价最小。由于森林中的两棵树可能选取同一条边,所以需要去掉同一条边被多次选取的情况。在开始时,说选取的边集为空,当最后结果只有一棵树或者再没有边可供选取时,算法就此结束。
深度优先搜索
深度优先搜索(depth-first search)是对前序遍历的推广,对每一个顶点,字段visited被初始化成false,通过哪些尚未被访问的结点递归调用该过程,我们保证不会陷入无限循环。如果图是无向且连通的,或是有向但非强连通的,这种方法可能会访问不到某些结点。此时我们搜索一个未被标记的结点,然后应用深度优先遍历,并继续这个过程直到不存在未标记的结点为止。因为该方法保证每一条边只被访问一次,所以只要使用邻接表,执行遍历的总时间就是
深度优先搜索的算法实现:
#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进行分析。
如果采用邻接表来存储,就可以沿着顶点的链表来确定其所有邻接顶点。因此在邻接表中的每一个顶点都至多扫描一次,所以完成搜索时间复杂性为
如果采用邻接矩阵来存储,访问顶点的所有邻接顶点的时间为
无向图
如下左图中是一个无向图,我们以此为例,假设从A开始,标记A为known并递归地调用dfs(B)。dfs(B)标记B为known并递归调用dfs(C)。dfs(C)标记C为known并递归调用dsf(D)。而后D的邻接结点只有C,但C已经是knwon的,这里便无法再继续递归下去,因此返回到dfs(C)。dfs(C)看到已经标记为known的B邻接点和D邻接点,因此调用dfs(E)。dfs(E)标记E为known,同样的它只能返回到dfs(C),再返回到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开始通过树的零条或多条边,且可能还有一条后向边而以该序达到。
有向图
如前所述,如果图不是强连通的,那么从某个结点开始的深度优先搜索可能访问不了所有的结点,这种情况下我们从某个未做标记的结点处开始,反复执行深度优先搜索,直到所有的结点都被访问到为止。
对此我们从顶点
深度优先生成森林中的虚线是一些
深度优先搜索还可以用来检测一个有向图是否是无环图,因为一个有向图是无环图当且仅当它没有向后边。上面的例子明显不是无环图,因为它有向后边。而拓扑排序也可以用来检测一个图是否是无环图,进行拓扑排序的另一种方法是通过深度优先搜索生成森林的后序遍历给顶点指定拓扑编号
备注1:为保证权威性,所有定义、公式、图片均来自《数据结构(C语言版)》、《数据结构与算法分析 C++描述》、《离散数学》、《算法导论》、《挑战程序设计竞赛》等书籍和必应、维基百科等网络。
备注2:希望不会因为参考了在备注1中提到的诸多书籍与网络而被批判,毕竟这篇博客也融合了我许多时间、精力和思考。我只是希望像我所读的所有外国书籍一般在书尾列出参考文献一样尊重原作者。
备注3:个人能力有限,暂时无力为图论这一领域增加新算法,本文中所有算法均来自伟大的前辈们的著作,为表敬意,此处以金色显示。
感谢您的访问,希望对您有所帮助。
欢迎大家关注或收藏、评论或点赞。
为使本文得到斧正和提问,转载请注明出处:
http://blog.csdn.net/nomasp