第7章 图

7.1 图的定义和术语

  • 顶点(Vertex)
    V是顶点的有穷非空集合
  • <v,w>表示vw的弧(Arc),其中v称为弧尾(Tail)初始点(Initial Node)w称为弧头(Head)终端点(Terminal Node)。记图中弧的集合为VR
    与图中的边或弧相关的数称为,带权的图称为网(Network)
  • 图的种类
    • 若对于\forall<v,w>\in VR均有<w,v>\in VR(即VR是对称的),则称此图为无向图(Undigraph),否则称为有向图(Digraph)
      对于无向图,可以以无序对(v,w)代替<v,w><w,v>,表示vw之间的一条边(Edge)
    • \frac{1}{2}n(n-1)条边的无向图称为无向完全图(Completed graph),即任意两个结点之间均有边相连的无向无环图(顶点到其自身的弧或边称为环)。具有n(n-1)条边的无向图称为有向完全图
    • 有很少条边或弧的图称为稀疏图(Sparse graph),反之称为稠密图(Dense graph)。
    • 子图:通俗地说,从图中取出一部分顶点及有关的部分边。
  • 顶点v的度(Degree)是和v相关联的边的数目,记为TD(v);以顶点v为头的弧的数目称为v入度(InDegree),记为ID(v);以顶点v为尾的弧的数目称为v出度(OutDegree),记为OD(v)。对于顶点v显然有TD(v) = ID(v) + OD(v)
  • 路径和简单路径、回路和简单回路(环,Cycle):(略)
  • 和“连通”有关的概念
    • 在图中如果从顶点v到顶点w有路径(若图为有向图,则路径也是有向的),则称顶点v和顶点w连通的。
    • 在无向图中如果对于任意两个顶点v,w\in Vvw是连通的,则称此图为连通图(Connected Graph)。无向图中的极大连通子图称为连通分量(Connected Component)
    • 在有向图中如果对于任意两个顶点v,w\in Vv \neq w,从vw和从wv都存在路径,则称此图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量。(所以“强连通图”和“强连通分量”是针对有向图而言的,无向图的定义比有向图严格)。
    • 连通图的生成树:含有连通图中全部n个结点,却只有足以构成一棵树的n-1条边的一个极小连通子图。图中有且仅有n-1条边是生成树的必要条件(但不是充分条件)。如果在生成树上添加一条边,则必定构成一个环,因为加入的这条边使得其依附的两个顶点之间有了第二条的路径。生成树一般不唯一。

7.2 图的存储结构

7.2.1 数组表示法

用两个数组分别存储数据元素(顶点)的信息和数据元素之间的关系(边或弧)的信息。以二位数组表示n个顶点的图时,需要存储n个顶点信息和n^2个弧信息。

  • 对于无权图
    邻接矩阵中的每个元素w_{ij}定义为w_{ij}=\left\{ \begin{aligned} 1,<v_i,v_j>\in VR\\ 0,<v_i,v_j>\notin VR \end{aligned} \right.
    则对于无向图,顶点v_i的度是邻接矩阵中第i行或第i列的元素之和;对于有向图,顶点v_i的出度是邻接矩阵中第i行的元素之和,入度是邻接矩阵中第i列的元素之和。
  • 对于带权图(网)
    邻接矩阵中的每个元素定义为\left\{ \begin{aligned} w_{ij},<v_i,v_j>\in VR\\ \infty,<v_i,v_j>\notin VR \end{aligned} \right.

7.2.2 邻接表

邻接表(Adjacency List)是图的一种链式存储结构。在邻接表中,对图中每个顶点建立一个单链表,第i个单链表中的结点表示依附于顶点v_i的边(对于有向图是以顶点v_i尾(初始点)的弧)。

  • 每一个结点由三部分组成
    1. adjacentVertex:该弧所邻接的另一点的位置
    2. nextArc:指向表示下一条边或弧的结点
    3. data/info:与当前边或弧相关的信息,如权值等。

为优化随机访问,表头结点通常以顺序结构的形式存储。在无向图的邻接表中,第i个链表中的结点数即为顶点v_i的度。而在有向图中,第i个链表中的结点数只是顶点v_i的出度。为求入度必须遍历整个邻接表,在所有链表中adjacentVertex的值为i的个数即为v_i的入度。


namespace graph
{
    //邻接表实现(存储结构)
    namespace adjacency_list_impl
    {
        //进行声明以在实现中使用
        struct ArcNode_s;
        struct VertexNode_s;

        typedef struct ArcNode_s
        {
            //指向邻接顶点,若为有向弧,adjacentVertex为该弧的终端点
            struct VertexNode_s *adjacentVertex;

            struct ArcNode_s *lastArc;//指向上一条弧
            struct ArcNode_s *nextArc;//指向下一条弧
            void *data;//与该弧有关的数据
        } ArcNode_t;
        typedef struct VertexNode_s
        {
            //依附于当前顶点(Vertex)的边(对于有向图是以当前顶点作为弧尾的弧)
            //作为结点构成了一个链表,firstArc指向该链表的第一个结点
            struct ArcNode_s *firstArc;
            void *data;
        } VertexNode_t;
        typedef struct
        {
            VertexNode_t vertices[256];
            size_t numberOfVertex;
        } Graph_t;
    }

    //邻接多重表实现(存储结构)
    namespace adjacency_multilist_impl
    {
        //进行声明以在实现中使用
        struct ArcNode_s;
        struct VertexNode_s;

        typedef struct ArcNode_s
        {
            //指向邻接顶点
            //若为有向弧,i为弧尾,j为弧头
            struct { struct VertexNode_s *i, *j; } vertices;

            //link.i指向下一条依附于顶点vertices.i的弧
            //link.j指向下一条依附于顶点vertices.j的弧
            //特别地,若为有向弧
            //link.i指向弧尾vertices.i相同的下一条弧
            //link.j指向弧头vertices.j相同的下一条弧
            struct { struct ArcNode_s *i, *j; } link;

            struct ArcNode_s *lastArc;//指向上一条弧
            struct ArcNode_s *nextArc;//指向下一条弧
            void *data;//与该弧有关的数据
        } ArcNode_t;
        typedef struct VertexNode_s
        {
            //依附于当前顶点(Vertex)的边(对于有向图是以当前顶点作为弧尾的弧)
            //作为结点构成了一个链表,firstArc指向该链表的第一个结点
            struct ArcNode_s *firstArc;
            void *data;
        } VertexNode_t;
        typedef struct
        {
            VertexNode_t vertices[256];
            size_t numberOfVertex;
        } Graph_t;
    }

    //十字链表实现(存储结构)
    namespace orthogonal_list_impl
    {
        //进行声明
        struct ArcNode_s;
        struct VertexNode_s;

        typedef struct ArcNode_s
        {
            //指向邻接顶点
            //若为有向弧,i为弧尾,j为弧头
            struct { struct VertexNode_s *i, *j; } vertices;

            //link.i指向下一条依附于顶点vertices.i的弧
            //link.j指向下一条依附于顶点vertices.j的弧
            //特别地,若为有向弧
            //link.i指向弧尾vertices.i相同的下一条弧
            //link.j指向弧头vertices.j相同的下一条弧
            struct { struct ArcNode_s *i, *j; } link;

            struct ArcNode_s *lastArc;//指向上一条弧
            struct ArcNode_s *nextArc;//指向下一条弧
            void *data;//与该弧有关的数据
        } ArcNode_t;
        typedef struct VertexNode_s
        {
            //firstIn指向该顶点的第一条入弧
            struct ArcNode_t *firstIn;
            //firstOut指向该顶点的第一条出弧
            struct ArcNode_t *firstOut;
            void *data;//与该弧有关的数据
            //这样弧头相同的弧都在一条链表上
            //弧尾相同的弧也在同一链表上
        } VertexNode_t;
        typedef struct
        {
            VertexNode_t vertices[256];
            size_t numberOfVertex;
        } Graph_t;
    }
    //由有向图和无向图的定义可知,无向图是有向图的特例。
    //实际应用中的弧通常是有向的,无向图的应用较少。
    //所以在算法实现中我们认为所有的弧都是有向的
    //如需使用边(无向弧),则加入两条数据域相同而头尾互换的有向弧
    

    //邻接表和邻接多重表的顶点的存储结构是一致的
    //每一个顶点都含有一个链表的入口(和与该顶点有关的数据域),区别在于
    //在邻接表中,从每一个顶点所对应的链表的入口中获得的每一个结点为以其对应顶点作为弧尾的弧
    //在邻接多重表中,从每一个顶点所对应的链表的入口中获得的每一个结点为与其邻接的弧(而不仅限于以其对应顶点作为弧尾的弧)

    //邻接多重表和十字链表的弧的存储结构是一致的,区别在于
    //邻接多重表中每一个顶点都含有一个链表的入口(其中每一个结点为与该顶点邻接的弧)
    //十字链表中每一个顶点都含有两个链表的入口
    //从其中一个链表入口(firstIn)中获得的每一个结点为以该顶点为弧尾的弧
    //从另外一个链表入口(firstOut)中获得的每一个结点为以该顶点为弧头的弧

    //由上述存储结构的异同,我们可以使用宏(Macro)将图的上述存储结构编写在一起
    //在C++中不提倡使用宏,可以以模板类和模板函数。
}
  • 由有向图和无向图的定义可知,无向图是有向图的特例。实际应用中的弧通常是有向的,无向图的应用较少。因此在算法实现中我们认为所有的弧都是有向的,如需使用边(无向弧),则采用两条数据域相同而头尾互换的有向弧。
  • 上述存储结构的异同
    • 邻接表和邻接多重表的顶点的存储结构是一致的,每一个顶点都含有一个链表的入口(和与该顶点有关的数据域),区别在于:
      • 在邻接表中,从每一个顶点所对应的链表的入口中获得的每一个结点为以其对应顶点作为弧尾的弧。
      • 在邻接多重表中,从每一个顶点所对应的链表的入口中获得的每一个结点为与其邻接的弧(而不仅限于以其对应顶点作为弧尾的弧)。
    • 邻接多重表和十字链表的的存储结构是一致的,区别在于:
      • 邻接多重表中每一个顶点都含有一个链表的入口(其中每一个结点为与该顶点邻接的弧)。
      • 十字链表中每一个顶点都含有两个链表的入口:从其中一个链表入口(firstIn)中获得的每一个结点为以该顶点为弧尾的弧;从另外一个链表入口(firstOut)中获得的每一个结点为以该顶点为弧头的弧。
  • 由上述存储结构的异同,我们可以使用宏(Macro)将图的上述存储结构编写在一起;在C++中不提倡使用宏,可以以模板类和模板函数。

7.3 图的遍历(深度优先搜索和广度优先搜索)

教材第170页算法7.6(广度优先搜素)中的队列中的每一个结点可以理解为——该结点已访问过但是其子结点尚未访问过。深度优先搜索用递归方法非常易于实现。

7.4 图的连通性问题

7.4.1 无向图的连通分量和生成树

    • 对于连通图:从图中任一顶点出发进行搜索,即可访问图中所有顶点,且图中所有顶点和遍历过程中经过的边的集合一起构成了极小连通子图,同时也是该连通图的一棵生成树。(其极大连通子图便是连通图本身)
    • 对于非连通图:需要从多个顶点出发进行搜索,每一次从一个新的起始点出发进行搜索过程中所得到的顶点集,和所有依附于这些顶点的边便构成了非连通图的各个连通分量(极大连通子图);而该顶点集和遍历该顶点集时所走过的边一起构成了各个连通分量的生成树(极小连通子图)。这些连通分量的生成树组成非连通图的生成森林。
  • 称由深度优先搜索得到的生成树为深度优先生成树;由广度优先搜索得到的生成树为广度优先生成树。

7.4.2 有向图的强连通分量

7.4.3 最小生成树

最小生成树(Minimum Cost Spanning Tree):连通网的最小代价生成树(以各边权值表示对应的代价)。

  1. Prim算法
    U是顶点集V的一个非空子集,TEN上最小生成树中边的集合。
    • 初始状态:U = \{ u_0 \},u_0 \in VTE= \{ \}
      在所有满足u \in U,v \in V-U的边中找一条代价最小的边(u,v)并入集合TE,并将v并入U,直到U = V。此时TE中必有n-1条边,(V,\{ TE \} )即为最小生成树。
      Prim算法的时间复杂度显然为平方阶。
  2. Kruskal算法

7.4.4 关节点和重连通分量

  • 若在删去顶点v以及和v相关联的各边之后,顶点v所在的连通分量被分割成两个或两个以上的连通分量,则称顶点v是该图的一个关节点(articulation point)
  • 一个没有关节点的连通图称为重连通图(biconnected graph)。在重连通图中,任意一对顶点之间至少存在两条路径,则在删去某个顶点以及依附于该顶点的各边时不会破坏图的连通性。
  • 关节点的特性
    • 若生成树的根有两颗或者两颗以上的子树,则此根节点必为关节点。
    • 若生成树的某个非叶子节点v 的子树的根和结点均没有指向v的祖先的边,则v为关节点。
  • 根据关节点的特性,由一次深度优先搜索即可求得连通图中存在的所有关节点。易于编程实现。

7.5 有向无环图及其应用(在当代最著名的应用便是Git)

  • 检查无向图和有向图是否存在环
    • 对于无向图:若深度优先搜索过程中出现指向已访问过的顶点的边,则必定存在环。
    • 对于有向图:(见7.5.1 拓扑排序)

7.5.1 拓扑排序(Topological Sort)

  • 拓扑排序(Topological Sort):由某个集合上的偏序得到该集合上的一个全序,即由偏序定义得到拓扑有序序列)。
    • 偏序(partial order):集合中仅有部分成员之间可以比较。
    • 全序(total order):集合中任意两个成员之间均可比较。
  • 如何进行拓扑排序
    do {
        在有向图中选一个无前驱的顶点并将其输出;
        从图中删去该顶点和以它为尾的弧;
    } until(全部顶点均已输出 || 图中不存在无前驱的顶点)
    if(图中不存在无前驱的顶点) println("有向图中存在环");
    
    当有向图中无环时,也可利用的深度优先搜索进行拓扑排序。

7.5.2 关键路径(Critical Path)

  1. AOV与AOE
    • 以顶点表示活动的网——Activity On Vertex Network,AOV
    • 以边表示活动的网——Activity On Edge,AOE
      对于AOE网,可以在以边表示活动的同时,以弧的权表示活动的持续时间等信息,并且同时可以在顶点中表示活动之间的信息,例如事件(Event)。所以,AOE的适用范围比AOV更广;但是对于一些应用而言,AOV更简洁,例如Git就是AOV的应用的一个十分成功的例子。
  2. 在AOE网中,如果以边的权表示持续时间,则AOE网可以用来估算工程的完成时间,此时,定义路径长度为路径上各活动的持续时间之和(而不是路径上弧的数目)。
    • 由于有些活动可以并行地进行,所以完成工程的最短时间是从开始点到完成点的的最长路径的长度。路径长度最长的路径称为关键路径(Critical Path)
    • 若将v_0作为起始点,则从v_0v_i的最长路径长度即为事件v_i的最早开始时间,这个时间决定了所有以v_i为尾的弧所表示的活动a_i的最早开始时间e(i)(这些活动的开始时间不仅取决于事件v_i的最早开始时间,同时也取决于事件v_i所消耗的时间)。在编程中表示事件v_i的下标i和表示活动及活动最早开始时间e(i)的编号i可能是不同的,应当具体情况具体分析。
    • 定义活动的最迟开始时间l(i)为不推迟整个工程完成时间的前提下,活动a_i最迟必须开始的时间。两者之差l(i) - e(i)即为完成活动a_i的时间余量。将l(i)=e(i)的活动称为关键活动。显然关键路径上的活动都是关键活动;因此提前完成非关键活动不能加快整个工程的进度,分析关键路径的目的是辨明哪些是关键活动,以便争取提高关键活动的效率以缩短整个工期。
    • 若以弧<j,k>表示活动a_i,其持续时间为dut(<j,k>),事件j的最早发生时间为ve(j),事件k的最迟发生时间为vl(k)。则有如下关系:
      \begin{align*} e(i) &= ve(j) \\ l(i) &= vl(k)-dut(<j,k>) \end{align*}
      显然我们需要求得ve(j)vl(k)
      1. 向前递推求ve(j)
        \begin{align*} ve(j)&=\max_i \{ ve(i) + dut(<i,j>) \},<i,j> \in T \\ ve(0) &= 0 \end{align*}

        其中T是所有以第j个顶点为头的弧的集合。

      2. 向后递推求vl(i)
        \begin{align*} vl(i) &= \min_j \{ vl(j)-dut(<i,j>) \}&,&<i,j> \in S \\ vl(i) &= ve(i)&,& \ S=\phi \end{align*}

        其中S是所有以第i个顶点为尾的弧的集合。由此可见,为确定出度为0的顶点(一般为工程的完成点)的最迟发生时间,我们需要求得该顶点的最早发生时间。所以上述两个递推公式的计算必须在拓扑有序的前提下进行。从起始点v_0出发,令ve(0)=0,按拓扑有序求其余各顶点(包括完成点)的最早发生时间。

      • 算法
        1. 从起始点v_0出发,令ve(0)=0,按拓扑有序求其余各顶点(包括完成点)的最早发生时间。若得到的拓扑有序序列中顶点的个数小于网络中的顶点数,则说明网络中存在环,不能求其关键路径。
        2. 从完成点(出度为0的点)出发,令其最迟发生时间等于最早发生时间,按上述第二项递推公式求得其余各顶点的最迟发生时间。
        3. 根据各事件(顶点)的最早发生时间和最迟发生时间求得各活动(弧)的最早开始时间和最迟开始时间。满足最早开始时间与最迟开始时间相等的弧即为关键活动。

      如果有时间,可以自己实现一遍。

7.6 最短路径

Dijkstra算法和Floyd算法

Dijkstra算法(迪杰斯特拉算法)

  1. 选定起始点v。设置线性表D,其中每个元素D(i)表示当前所找到的从起始点v到每个终点v_i最短路径的长度,以弧<v,v_i>的长度或\infty(若弧不存在)初始化之。设置S为已找到的从v出发的最短路径的终点的集合,初始状态为空集。arcs为邻接矩阵。
  2. 选择j=\mathop{\arg\min}_{i} \big \{ D(i)|v_i \in V-S \big \},那么v_j就是当前求得的一条从v出发的最短路径的终点,并将v_j并入集合S中(S = S \cup v_j
  3. 遍历集合V-S中的所有顶点:对于v_k \in V-S,如果D(j) + arcs[j][k]<D(k),则以其更新D[k]
  4. 重复2,3步直到n-1次,即直到V-S为空集。

Floyd算法

  1. 算法步骤
    令网络的权矩阵为D = (d_{ij})_{n \times n},设l_{ij}为从顶点v_iv_j的直接距离,其中d_{ij}= \left\{ \begin{aligned} l_{ij},<v_i,v_j>\in VR\\ \infty,<v_i,v_j>\notin VR \end{aligned} \right.
    初始权矩阵D^{(0)}=D。计算D^{(k)} = (d_{ij}^{(k)})_{n \times n},k=1,2,...,n,其中d_{ij}^{(k)}=\min \{ d_{ij}^{(k-1)},d_{ik}^{(k-1)}+d_{kj}^{(k-1)} \}
    进行n次迭代之后D^{(n)} = (d_{ij}^{(n)})_{n \times n},k=1,2,...,n中的各个元素即为点v_i到点v_j的最短路长。
  2. 本文作者的一些经历和想法
    最初接触Dijkstra算法和Floyd算法是在低年级时准备全国大学生数学建模竞赛时,当时有一本书给出了Dijkstra算法和Floyd算法的MATLAB程序,但是当时我的编程能力比较弱,对于算法方面的内容抱有畏惧心理。故只知其然,而不知其所以然。
    Floyd算法的迭代过程实在是令我错愕,d_{ij}^{(k)}=\min \{ d_{ij}^{(k-1)},d_{ik}^{(k-1)}+d_{kj}^{(k-1)} \}的原理是什么呢?为什么迭代n次,即顶点的数量即可得到最短路径呢?
  3. 算法简析
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • 大年初一,上山受寒,得感冒.回房里,尝试在新的一年里坚持写读书笔记.2018年1月份读过5本书,自己也计划着1...
    童亚圣阅读 1,781评论 0 1
  • 都说“少不读水浒,老不读三国”,《水浒传》通篇充斥着暴力,也许人们会认为其对于青少年的影响不亚于港片《古惑仔》系列...
    神勇小辉阅读 5,150评论 1 8
  • 朱纱到达平蓝市,已经是下午16点左右。 她站在火车站旁的天宇大厦楼下打了个电话,约好朋友来接。后又将特意从泰安买来...
    d97ff08d6b12阅读 2,682评论 1 2
  • 有一个小小的人,他一直在找来找去。找一样东西,清晨,他面对太阳升起的东方,在山脚下扒开草丛,小心的不让那些刺...
    那畔千浔阅读 1,514评论 0 2
  • 文|肥妈 -01- 算起来,熬过这个九月,叶子和她的前男友分手就过去整整三年了,我问过叶子:“你条件这么好,又有那...
    喵婷与肥妈阅读 2,929评论 2 1