Fork me on GitHub

《数据结构与算法分析》学习笔记-第九章-图论算法


9.1 定义

  • 一个图G=(V, E)由顶点集V和边集E组成。每一条边就是一个点对(v,w),其中v,w属于V。有时也把边称作弧。如果点对是有序的,那么图就是有向的,有向的图也成为有向图。顶点v和w邻接,当且仅当(v, w)属于E。在一个具有边(v, w)从而具有边(w, v)的无向图中,w和v邻接且v和w邻接。有时候边还具有第三种成分,称作权或值
  • 图中的一条路径是一个顶点序列w1, w2, w3, ..., wN,使得(wi, wi+1)属于E,其中1<=i<N.这样一条路径的长是该路径上的边数,它等于N-1。从一个顶点到它自身可以看成是一条路径。如果路径不包含边,那么路径的长为0。如果图含有一条从一个顶点到它自身的边(v, v),那么路径v有时候也叫做一个环。一条简单路径是这样一条路径,其上的所有顶点都是互异的,但第一个顶点最后一个顶点可能相同
  • 有向图中的圈是满足w1=wN且长至少为1的一条路径,如果该路径是简单路径,那么这个圈就是简单圈。对于无向图,我们要求互异的,这些要求的根据在于无向图中的路径u, v, u不应该认为是圈。因为(u,v)和(v,u)是同一条边。但是在有向图中他们是两条不同的边,因此称他们为有意义的。如果一个有向图没有圈,则称其为无圈的。一个有向无圈图有时也称为DAG。
  • 如果在一个无向图中从每一个顶点到其他每个顶点都存在一条路径,则称该无向图是联通的。具有这样性质的有向图称为是强连通的。如果一个有向图不是强连通的,但是他的基础图,即其弧上去掉方向所形成的图,是联通的,那么该有向图成为弱联通的完全图是其每一对顶点间都存在一条边的图。

9.1.1 图的表示

  • 表示图使用一个二维数组,称为邻接矩阵。对于每条边(u, v),我们置A[u][v] = 1。否则,数组的元素就是0.如果边有一个,那么我们可以置A[u][v]等于该权,而使用一个很大或者很小的权作为标记表示不存在的边。若图是稠密的,那么邻接矩阵是合适的表示方法。空间需求为Θ(|V|^2),且|E| = Θ(|V|^2)但是如果图的边不是很多,那么这种表示的代价就太大了。我们希望我们的数据结构表示那些实际存在的数据,而不是表示不存在的数据
  • 如果图是稀疏的,更好的解决方法是使用邻接表表示,对每一个顶点,我们使用一个表存放所有邻接的顶点,此时的空间需求为O(|E| +|V|)。最左边得结构只是头单元的数组,如果边有权,那么这个附加的信息也可以存储在单元中。邻接表是表示图的标准方法。无向图可以类似地表示。每条边(u, v)出现在两个表中,因此空间的使用基本上是双倍的。图论算法中通常需要找出与某个给定顶点v邻接的所有的顶点,这可以通过简单的扫描相应的邻接表来完成,所用时间与这些顶点的个数成正比。大部分实际应用中顶点都有名字而不是数字,这些名字在编译时都是未知的,由于我们不能通过未知名字作为一个数组的索引,因此必须提供从名字到数字的映射。完成这项工作最容易的方法是使用散列表。在该散列表中我们对每个顶点存储一个名字以及一个范围在1到|V|之间的内部编号,这些编号在图被读入的时候指定,指定的第一个数是1.在每条边被输入时,我们检查是否它的两个顶点都已经指定了一个数。检查的方法是看是否顶点在散列表中。如果在,我们就使用这个内部编号,否则,我们将下一个可用编号分配给该顶点并把该顶点的名字对应的编号插入到散列表中。所有的图论算法都将只使用内部编号,由于最终还要输出顶点的名字而不是内部编号,对于每个内部编号必须记录相应的顶点名字。一种记录方法是使用字符串数组,如果顶点名字长,就要花费大量的空间,因为顶点的名字要存两次。另一种方法是保留一个指向散列表内的指针数组,这种方法的代价是稍微损失三列表ADT的纯洁性。(散列表的元素就不是通过基本的散列表操作来访问的了)

9.2 拓扑排序

拓扑排序是对有向无圈图顶点的一种排序,它使得如果存在一条从vi到vj的路径,那么在排序中vj出现在vi的后面。显然如果图含有圈,那么拓扑排序是不可能的,因为对于圈上的两个顶点v和w,v先于w同时w又先于v。同时,排序不是唯一的,任何合理的排序都是可以的。一个简单的拓扑排序的算法是先找出任意一个没有入边的顶点。然后显示出该顶点,并将它和它的边一起从图中删除,然后对图的其余部分应用同样的方法处理。我们把顶点v的入度定义为边(u, v)的条数,我们计算图中所有顶点的入度,假设Indegree数组被初始化且图被读入一个邻接表中,则此时我们可以应用算法生成一个拓扑排序。

void
Topsort(Graph G)
{
    int Counter;
    Vertex V, W;
    
    for (Counter = 0; Counter < NumVertex; Counter++)
    {
        V = FindNewVertexOfIndegreeZero();
        if (V == NotAVertex)
        {
            ERROR("Graph has a cycle");
            break;
        }
        TopNum[V] = Counter;
        for each W adjacent to V
            Indegree[W]--;
    }
}

函数FindNewVertexOfIndexZero扫描Indegree数组,寻找一个尚未被分配拓扑编号的入度为0的定点,如果这样的顶点不存在,那么它返回NotAVertex;这就意味着该图有圈。因为FindNewVertexOfIndexZero是对Indegree数组的一个简单的顺序扫描,所以每次对他的调用都花费O(|V|)时间。由于有|V|次这样的调用,因此该算法的运行时间为O(|V|^2)。如果图是稀疏的,那么在每次迭代期间只有一些顶点的入度是被更新的。虽然只有一小部分发生变化,但是在搜索入度为0的顶点时,查看了所有的顶点。可以通过将所有(未分配拓扑编号)入度为0的定点放在一个特殊的盒子中而避免这种无效的劳动。此时FindNewVertexOfIndegreeZero函数返回(并删除)盒子中的任一顶点。当我们降低这些邻接顶点的入度时,检查没一个顶点并在它的入度降为0时把它放入盒子中。为实现这个盒子,我们可以使用一个栈或者队列。寿险,对每一个顶点计算它的入度,将所有入度为0的顶点放入一个初始为空的队列中。当队列不空时,删除一个顶点v,并将v临接的所有顶点的入度减1.只要一个顶点的入度降为0,就把该顶点放入队列中。此时,拓扑排序就是顶点出队的排序。假设图已经被读到一个邻接表中且入度已计算并被放入一个数组内。实践中,通常是把每一个顶点的入度放入投弹园中。我们还假设有一个数组TopNum,该数组存放的是拓扑编号。

void
Topsort(Graph G)
{
    Queue Q;
    int Counter = 0;
    Vertex V, W;
    
    Q = CreateQueue(NumVertex);
    MakeEmpty(Q);
    for each vertex V
        if (Indegree[V] == 0)
            Enqueue(V, Q);
    while (!IsEmpty(Q))
    {
        V = Dequeue(Q);
        TopNum[V] = ++Counter; /* Assign next number */
        
        for each W adjacent to V
            if (--Indegree[W] == 0)
                Enqueue(W, Q);
    }
    
    if (Counter != NumVertex)
        ERROR("Graph has a cycle");
    
    DisposeQueue(Q);    /* Free the memory */
}

使用邻接表,执行这个算法所用的时间为O(|E| + |V|)。当认识到for循环日对每条边顶多执行一次时,这个结果是明显的。队列操作对每个定点最多进行一次,而初始化各步花费的时间也和图的大小呈正比

9.3 最短路径算法

穿越该边(vi, vj)的代价或称为值ci, j。一条路径v1v2...vN的值是代价相加。叫做赋权路径长。而无权路径长只是路径上的边数,即N-1。

单源最短路径问题

给定一个赋权图G=(V, E)和一个特定顶点s作为输入,找出从s到G中每一个其他顶点的最短赋权路径。如果赋权图没有负值,那么最短路径是确定的。当含有负值时,最短路径就是不确定的。在没有负值圈时,从s到s的最短路径为0.当前还不存在从s到一个顶点的路径比找出从s到所有顶点路径更快的算法(快多于一个常数因子)考察求解该问题四种形态的算法

9.3.1 无权最短路径

相当于每条边的权为1.设一个顶点s,从s出发,逐层找到距离s长度为0,1,2,3...的顶点,并将长度写在顶点上。已经写过的顶点不再重复去写。这种搜索一个图的方法称为广度优先搜索。即按层处理顶点:距开始点最近的那些顶点首先被赋值,而最远的那些顶点最后被赋值。这很像对树的层序遍历.关注三个值:

  1. 是否为known
  2. Dist
  3. Path
void
AdjacencyListInitialDisAndPath(AdjacencyList* adList, int StartVertex)
{
	int cnt;
	QuickListNode_T *node = NULL;
	QuickList_for_each_entry(adList[StartVertex], node)
	{
		adList[node->value1]->value2 = adList[StartVertex]->value2 + 1;
		adList[node->value1]->value3 = StartVertex;
		AdjacencyListInitialDisAndPath(adList, node->value1);
	}
}

9.3.2 Dijkstra算法

  • 如果图是赋权图,问题就变得复杂了,仍然可以使用来自无权情形时的想法。保留所有与前面相同的信息,每个顶点或者标记为known或者unknown的。每一个顶点保留一个临时距离dv。这个距离是使用已知顶点作为中间顶点从s到v的最短路径的长。解决单源最短路径问题的一般方法叫做Dijkstra算法。是贪婪算法最好的例子。贪婪算法一般分阶段求解一个问题,在每个阶段它都把当前出现的当作是最好的去处理。贪婪算法的主要问题在于,该算法并不总是成功的
  • Dijkstra算法像无权最短路径算法一样,按阶段进行,在每个阶段,Dijkstra算法选择一个顶点v,它在所有未知顶点中具有最小的dv,同时算法声明从s到v的最短路径是已知的。阶段的其余部分由dw值的更新工作组成。在无权的情形,若dw=无穷,则置dw=dv+1.因此,若顶点v能提供一条更短的路径,则本质上降低了dw的值。如果对赋权的情形应用同样的逻辑,那么当dw的新值dv + cv,w是一个改进的值时我们就置dw = dv + cv,w。简言之,使用通向w路径上的顶点v是不是一个好主意由算法决定。原始的值dw是不用v的值,上面所算出的值是使用v(和仅仅已知的顶点)最便宜的路径。
  • 说人话,就是和无权有序图一样,当前节点指向节点边的权值,整条路径小于当前其所记录的路径权值,那么就更新该权值,否则就不更新。每次选取路径最短的节点进行遍历
  • 通过反证法证明,只要边的权值没有负值,该算法总能顺利完成,否则任何一边出现负值,该算法可能得出错误的答案。运行时间依赖于对表的处理方法,我们必须考虑。如果通过扫描表来照出最小值dv,那么每一步将花费O|V|时间找到最小值,从而整个算法过程将花费O|V|^2时间查找最小值,每次更新dw的时间是常数,而每条边最多有一次更新,总计为O(|E|)。因此,总的运行时间为O(|E| + |V|2)=O(|V|2).如果图是稠密的,边数|E|=C(|v|^2),则该算法不仅简单而且基本最优,因为它的运行时间与边数成线性关系。反之,如果图是稀疏的,边数|E|=V(|V|),那么这种算法就太慢了,在这种情况下,距离需要存储在优先队列中。DeleteMin操作,一旦未知的最小值顶点被找到,那么它就不再是未知的,以后不再考虑。两种方法:
    1. DecreaseKey操作:查找最小值的时间为O(log|V|),即为执行那些更新的时间,它相当于执行那些DecreaseKey操作的时间。由此得出运行时间为O(|E|log|V| + |V|log|V|) = O(|E|log|V|).它是对稀疏图的界的改进。由于优先队列不是有效的支持Find操作,因此di的每个值在优先队列的位置需要保留并当di在优先队列中改变时更新,如果优先队列是使用二叉堆实现的,将很难办,如果使用配对堆,程序不会太差。
    2. 把w和新值dw插入到优先队列中去,这样在优先队列中的每个顶点就可能有多于一个的代表。当DeleteMin操作把最小的顶点从优先队列中删除时,必须检查以肯定它不是已经知道的。对于一些诸如计算机邮件和大型公交传输的典型问题,他们的图一般是非常稀疏的,因为大多数定点只有少数几条边。因此,许多应用中使用优先队列来解决这种问题是很重要的。如果使用不同的数据结构,Dijkstra算法可能会有更好的时间界

9.3.3 具有负边值的图

将赋权和无权的算法结合起来将会解决这个问题,但是要付出运行时间激烈增长的代价,开始将s放到队列中,然后,在每一阶段,我们让一个顶点v出队,找出所有与v邻接的顶点w,使得dw > dv + cv,w,然后更新dw和pw,并在w不在队列中的时候把它放到队列中,可以为每个顶点设置一个比特位(bit)以指示他在队列中出现的情况,重复这个过程直到队列为空。

void
WeightedNegative(Table T)
{
    Queue Q;
    Vertex V, W;
    
    Q = CreateQueue(NumVertex);
    MakeEmpty(Q);
    Enqueue(S, Q); /* Enqueue the start vertex S */
    
    while (!IsEmpty(Q))
    {
        V = Dequeue(Q);
        for each W adjacent to V
            if (T[V].Dist + Cvw < T[W].Dist)
            {
                /* Update W */
                T[W].Dist = T[V].Dist + Cvw;
                T[W].Path = V;
                if (W is not already in Q)
                    Enqueue(W, Q);
            }
    }
    DisposeQueue(Q);
}

9.4 网络流问题

设给定边容量为Cv,w的有向图G=(V,E)。这些容量可以代表通过一个管道的水的流量或在两个交叉路口之间马路上的交通流量。有两个顶点,一个是发点s,一个是收点t。对于任一条边(v,w),最多有“流”的Cv,w个单位可以通过。在既不是发点s也不是收点t的任意顶点v,总的进入的流必须等于总的发出的流。最大流问题就是确定从发点s到收点t可以通过的最大流量。一个顶点可以以它喜欢的任何方式结合和发送流,只要不违反边的容量以及保持流守恒(进入必须流出)

9.4.1 一个简单的最大流算法

首要想法是分阶段进行,我们从图G开始并构造一个流图Gf,Gf表示在算法的任意阶段已经达到的流,开始时Gf的所有边都没有流,我们希望算法终止时Gf包含最大流。我们还构造一个图Gr,称为残余图。他表示对于每条边还能再添加上多少流。对于每一条边,可以从容量中减去当前的流而计算出残余的流。Gr的边叫做残余边。

  • 在每个阶段,我们寻找图Gr中从s到t的一条路径,这条路径叫做增长通路。这条路径上的最小值边就是可以添加到路径每一边上的流的量。我们通过调整Gf和重新计算Gr做到这一点。当发现在Gr中没有从s到t的路径时算法终止。这个算法是不确定的,因为从s到t的路径是任意选择的。有些选择会比另外一些选择好。
  • 在残余图中有很多从s到t的路径,选择其中任意一条,发送其最小个单位的流X通过这条路径的每一边,一旦注满(使饱和)一条边,则这条边就要从残余图中除去,其他边的容量要减去X.重复此步骤,直到s到t不可达到,该算法终止

参考文献

  1. Mark Allen Weiss.数据结构与算法分析[M].America, 2007

本文作者: CrazyCatJack

本文链接: https://www.cnblogs.com/CrazyCatJack/p/14408189.html

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

关注博主:如果您觉得该文章对您有帮助,可以点击文章右下角推荐一下,您的支持将成为我最大的动力!


posted @ 2021-02-22 10:19  CrazyCatJack  阅读(197)  评论(0编辑  收藏  举报