【数据结构与算法分析——C语言描述】第九章:图论算法
标签(空格分隔):【数据结构与算法】
以有向图为例(无向图可以类似表示)
假设可以从 1 开始对定点进行编号,如下所示的图中,包含有 7 个顶点 和 12 条边。
表示图的一种简单方法可以使用二维数组,称为 邻接矩阵(adjacency matric)表示法。对于每一条边 (u,v) ( u , v ) ,我们令 A[u][v]=1 A [ u ] [ v ] = 1 , 否则,数组的元素就是 0 .如果边有一个权,那么我们可以令 A[u][v] A [ u ] [ v ] 的值等于该权,而使用一个很大或者很小的标记表示不存在的边。
虽然这种表示方法十分简单,但是它的空间需求为 Θ(|V|2) Θ ( | V | 2 ) , 如果图的边不是很多,那么花费的代价是非常大的;如果或图是稠密的(dense), E=Θ(|V|2) E = Θ ( | V | 2 ) ,则邻接矩阵是一个非常好的选择。
如果图是稀疏(sparse)的,更好的解决方法是使用邻接表(adjacency list),对于每一个顶点,我们使用一个表存放所有邻接的顶点。此时的空间需求是 O=(|V|+|E|) O = ( | V | + | E | ) .如果边有权,那么这个附加的信息也可以存储在单元中。
邻接表是表示图的标准方法。无向图可以类似地表示:每条边 (u,w) ( u , w ) 出现在两个表中,因此空间的使用基本上是双倍的。在图论算法中经常需要找出与某个给定的顶点 v v 邻接的所有的顶点。而这可以通过简单地扫描相应的邻接表来完成,所用时间与这些顶点的个数成正比。
大部分实际应用中顶点都有名字而不是数字,这些名字在编译时都是未知的。由于我们不能通过未知名字为一个数组做索引,因此我们必须提供从名字到数字的映射。完成这项工作最容易的方法是使用散列表,在该散列表中我们对每个顶点储存一个名字以及一个范围在 1 1 到 |V| | V | 之间的内部编号。这些编号在图被读入时指定。指定的第一个数是 1 1 .在每条边被输入时,我们检查是否它的两个顶点都被指定了一个数,检查的方法是看是否顶点在散列表中。如果在,我们就是用这个内部编号;否则,我们将下一个可用的编号分配给该定点并把该顶点的名字和对应的编号插入散列表中。经过这样的变换,图论算法都将使用内部编号。由于最终我们还要输出顶点的名字而不是内部编号,因此对于每一个内部编号我们必须记录相应顶点的名字。一种记录方法是使用字符串数组。如果顶点名字过长,那么就会花费大量的空间。另一种是保留一个指向散列表内的指针数组,代价是稍微损失散列表ADT的纯洁性。
此外,排序还不是唯一的,任何合理的排序都是可以的,对于下图, v1,v2,v5,v4,v3,v7,v6 v 1 , v 2 , v 5 , v 4 , v 3 , v 7 , v 6 和 v1,v2,v5,v4,v7,v3,v6 v 1 , v 2 , v 5 , v 4 , v 7 , v 3 , v 6 两个都是拓扑排序。
一个简单的求拓扑排序的算法是先找出任意一个没有入边的顶点。然后我们显示出该顶点,并将它和它的边从图中删除。然后,我们对图的其余部分应用同样的方法处理。
//简单的拓扑排序伪代码
void Topsort( Graph G){
int Counter;
Vertex V,W;
for( Counter = 0; Counter < NumVertex; Counter++){
V = FindNewVertexOfIndegreeZero();
//FindNewVertexOfIndegreeZero函数扫描 Indegree 数组,寻找一个尚未被分配拓扑编号的入度为 0 的顶点。如果这样的顶点不存在,那么便返回 NotAVertex。这意味着该图有圈
if( V == NotAVerttex){
Error(" Graph has a cycle");
break;
}
TopNum[ V] = Counter;
for each W adjacent to V
Indegree[W]--;
}
}
FindNewVertexOfIndegreeZero 是对 Indegree 数组的一个简单的顺序扫描,所以每次它的调用都花费 O(|V|) O ( | V | ) 时间,所以 V V 次调用花费的时间就是 O(|V|2) O ( | V | 2 )
void Topsort( Graph G){
Queue Q;
int Counter = 0;
Vertex V,W;
Q = CreatQueue( NuVertex);
MakeEmpty( Q);
for each vertex V
if( Indegree[V] == 0)
Enqueue( V,Q)
while( !IsEmpty(Q)){
V = Dequeue(Q);
TopNum[V] == ++Counter;
for each W adjacent to V
if( --Indegree[W] == 0)
Enqueue( W,Q);
}
if( Counter != NumVertex)
Error(" Graph has a clycle");
DisposeQueue( Q);
}
如果使用邻接表,那么执行这个算法所需要的时间是 O(|E|+|V|) O ( | E | + | V | ) .
单源最短路径问题:
给定一个赋权图 G=(V,E) G = ( V , E ) 和一个特定点 s s 作为输入,找出从 s s 到 G G 中每一个其他顶点的最短赋权路径。
例如:
下图中,从 v1 v 1 到 v6 v 6 的最短赋权路径的值是 6 ,这条路径为 v1,v4,v7,v6 v 1 , v 4 , v 7 , v 6 .在两点间,最短的无权路径为 2 .一般而言,当我们不指明讨论的是赋权路径还是无权路径时,如果图是赋权的,那么路径就是赋权的。需要注意的是,在下图中,没有从 v6 v 6 到 v1 v 1 的路径。
在下图中,我们考虑了存在负边的问题。从 v5 v 5 到 v4 v 4 的路径的值为 1 ,但是通过循环 v5,v4,v2,v5,v4 v 5 , v 4 , v 2 , v 5 , v 4 存在一条最短路径,它的值是 -5.这条路径不是最短的,因为我们可以在循环中滞留人一场。因此,在这两个顶点之间最短的路径是不能确定的。类似的,从 v1 v 1 到 v6 v 6 的最短路径也不是确定的,因为我们可以进入同样的循环。这个讯号被称作负值圈(negative-cost cycle),当它出现在图中时,最短路径问题无法确定。为方便起见,在没有负值圈时,从 s s 到 s s 的最短路径为 0.
我们将考虑解决问题四种形态的算法:
广度优先搜索方法按层处理顶点:距离开始点最近的那些顶点首先被赋值,距离最远的那些顶点最后被赋值。这与树的层次遍历很类似。
下图显示了该算法要用到的记录过程的表的初始配置:
翻译成代码:对于每一个顶点,我们将跟踪三个信息。首先,我们把从 s s 开始到顶点的距离放到 dv d v 一栏中。开始的时候,除了 s s 之外的所有顶点都是不可到达的,而 s s 的路径长为 0 . Pv P v 一栏中的项为薄记变量,它将显示出实际的路径。 Known 一栏中的向在顶点被处理之后标记为 1 .起初,所有的顶点的 Known 标记都是为0,包括开始顶点,当一个顶点被标记为已知时,我们就确信不会再找到更便宜的路径,因此对该顶点的处理实质上已经完成。
基本的算法如下代码描述,这个算法模拟这些图表,它把距离 d=0 d = 0 上的顶点声明为 Known ,然后声明距离 d=1 d = 1 上的顶点声明为 Known , 等等,并且将 dw=∞ d w = ∞ 的所有邻接的顶点 w w 置为距离 dw=d+1 d w = d + 1 .之后追溯 Pv P v 变量,可以显示实际的路径.
//无权最短路径 广度优先
void Unweighted( Table T){
int CurrDist;
Vertex V,W;
for( CurrDist = 0; CurrDist for each vertex V
if( !T[V].Known && T[V].Dist == CurrDIst){
T[V].Known = True;
for each W adjacent to V
if( T[W].Dist == Infinity){
T[W].Dist = CurrDist + 1;
T[W].Path = V;
}
}
}
}
由于双层嵌套 for 循环,因此该算法运行时间为 O(|V|2) O ( | V | 2 ) .这个效率明显很低,因为尽管所有的顶点早就成 Known 了,但是外层的循环还是在继续,直到 NumVertex - 1 为止。虽然额外的附加测试避免了这种情况,但是最坏运行时间依旧如此,例如下图。
我们可以使用非常类似于对拓扑排序的做法来派出这一种低效性。在任一时刻,只要存在两种类型未知的顶点,它们的 dv≠∞ d v ≠ ∞ , 一些顶点的 dv=CurrDist d v = C u r r D i s t , 而其余的则有 dv=CurrDist+1 d v = C u r r D i s t + 1 .由于这种附加的结构,在第 2 行和第 3 行搜索整个的表以找出合适顶点的做法非常浪费。
一种非常简单但是抽象的解决方案是保留两个盒子。1#盒将装有 dv=CurrDist d v = C u r r D i s t , 而 2# 盒子中装有 dv=CurrDist+1 d v = C u r r D i s t + 1 个顶点。上述代码的测试中可以用查找 1# 盒内的任意顶点代替。在 if 语句之后,我们可以把 w w 加到 2# 盒中。在外层 for 循环中之以后,1# 盒是空的,而 2# 盒则可以转换成 1# 盒进行下一趟 for 循环。
精炼的算法如下,在下列伪代码中,我们假设已经开始顶点 s s 并且是直到的且 T[s].Dist T [ s ] . D i s t 为 0.
void Unweighted( Table T){
Queue Q;
Vertex V, W;
Q = CreatQueue( NumVertex);
MakeEmpty( Q);
Enqueue( S, Q);
while( !IsEmpty(Q)){
V = Dequeue( Q);
T[V].Knowm = True;
for each W adjacent to V
if( T[W].Dist == Infinity){
T[W].Dist = T[V].Dist + 1;
T[W].Path = V;
Enqueue( W,Q);
}
}
DisposeQueue( Q);
}
考虑图是赋权图得到情况。我们保留所有与前面相同的信息。因此,每个顶点或者标记为 Known 或者标记为 UnKnown.像以前一样,对于每一个顶点保留一个临时距离 dv d v ,这个距离实际上是使用已知顶点作为中间定点从 s s 到 v v 的最短路径长.和以前一样,我们记录 pv p v ,它是引起 dv d v 变化的最后的顶点。
举个例子,如下图。
假设开始节点 s s 是 v1 v 1 .第一个选择的顶点是 v1 v 1 , 路径长为 0 ,该定点的标记为已知。既然 v1 v 1 已知,那么某些表项就需要调整,邻接 v1 v 1 的顶点是 v2 v 2 和 v4 v 4 .这两个顶点的项得到调整,便得到下图中最右侧一个表。
下一步,选择 v4 v 4 并标记为 Known,顶点 v3,v5,v6,v7 v 3 , v 5 , v 6 , v 7 都是邻接的顶点,而它们实际上都需要调整,如下图所示:
紧着是 v2 v 2 , v4 v 4 是邻接点,但是已经处于 Known 状态,因此对它无需改动。 v5 v 5 是邻接点,但是由于从 v2 v 2 经过到达 v5 v 5 的值为 10 + 2 = 12.而从 v4 v 4 经过的值为 3.因此不用改动。结果如下图:
下一个被选择的顶点是 v5 v 5 ,其值为 3. v7 v 7 是唯一的邻接顶点,但是它不用调整,因为 3 + 6 > 5.
然后选择顶点 v3 v 3 ,对 v6 v 6 可以调整为 8.结果如下图:
下一个选择的顶点是 v7 v 7 , v6 v 6 可以更改为6.最后选择的是 v6 v 6 .以上两步的表格如下:
为了显示出从开始定点到某个顶点 v v 的实际路径,我们可以编写一个递归程序跟踪 p p 数组留下的踪迹.
现在给出实现Dijkstra算法的伪代码,为了方便起见,我们假设这些顶点从 0 到 NumVertex - 1 标号,并假设通过例程 ReadGraph 我们的图可以被读入到一个邻接表中。
//有权路径的Dijkstra算法
typedef in Vertex;
struct TableEntry{
List Header;
int Known;
DistType Dist;
Vertex Path;
};
#difine NotAVertex (-1)
typedef struct TableEntry Table[ NumVertex];
void InitTable( Vertex Start, Graph G, Table T){
int i;
ReadGraph( G,T)
for( i = 0; i< NumVerttex; i++){
T[i].Known = False;
T[i].Dist = Infinity;
T[i].Path = NotAVertex;
}
T[Start].dist = 0;
}
void PrintPath( Vertex V, Table T){
if( T[V].Path != NotAVertex){
PrintPath( T[V].Path, T);
printf(" to");
}
printf("%v", V);
}
void Dijkstra( Table T){
Vertex V, W;
for( ;;){
V = smallest unknown distance Vertex;
if( V == NotAVetex)
break;
T[V].Known = True;
for each W adjacent to V
if( !T[W].Known)
if( T[V].Dist + Cvw < T[W].Dist){
Decrease( T[W].Dist to T[V].Dist + Cvw);
T[W].Path = V;
}
}
}
如果图具有负边值,那么Dijkstra算法算法是行不通的,原因在于,一旦一个顶点 u u 被声明是已知的。那么就有可能从另外的某个位置顶点 v v 到 u u 的负的路径。在
- 一个方案是在各边的值上增加一个常数 Δ Δ ,如此一来,除去了负值边。可是这种方案不可能直接实现,因为那些具有许多条边的路径变得比那些具有很少边的路径权重更重了。