【邓俊辉版数据结构】之图总结(一)

1.图的概念

       图结构是描述和解决实际应用问题的一种基本而有力的工具,所谓的图可定义为G = (V,E)。其中,集合V中的元素称作顶点;集合E中的元素分别对应于V中的某一对顶点(U,V)。表示它们之间存在某种关系,故亦称作(edge)①。一种直观显示图结构的方法是,用小圆圈或小方块代表顶点,用联接于其间的直线段或曲线弧表示对应的边。

       从计算的需求出发,我们约定V和E均为有限集,通常将其规模分别记n = |V| 和 e = |E|。 

无向图,有向图及混合图

       若边(U,V)所对应顶点U和V的次序无所谓,则称作无向边(undirected edge)。例如表示同学关系的边。反之若U和V不对等,则称(U,V)为有向边。例如描述企业与银行之间的借贷关系,或者程序之间的相互调用关系的边。

      如此,无向边(U,V)也可记作(V,U)。而有向的(U,V)和(V,U)则不可混淆。这里约定,有向边(U,V)从U指向V,其中U称作该边的起点(origin)或尾顶点(tail)。而V称作该边的终点(destination)或头顶点(head)。

       若E种各边均无方向。则G称作无向图。例如在描述影视演员相互合作关系的图G中。若演员U和V若曾经共同出演过至少一部影片。则在他们之间引入一条边(U,V)。反之,若E中只含有向边,则G称作有向图(directed graph。简称digraph)。例如在C++类的派生关系图中,从顶点U指向顶点V的有向边,意味着类U派生自类V。特别地。若E同时包含无向边和有向边,则G称作混合图(mixed graph)。例如在北京市内交通图中,有些道路是双行地,另一些是单行地,对应地可分别描述为无向边和有向边。

      相对而言,有向图地通用性更强。因为无向图和混合图都可转化为有向图,比如每条无向边都可(U,V)都可等效地替换为对称的一对有向边(U,V)和(V,U)。因此,这里主要针对有向图,介绍图结构及其算法的具体实现。

      对于任何边e = (U,V)。称顶点U和V彼此邻接(adjacent)。互为邻居;而它们都与边e彼此关联(incident)。在无向图中,与顶点V关联的边数。称作V的度数(degree)。记作deg(v)。以下图为例。顶点{A,B,C,D}的度数为{2,3,2,1}

扫描二维码关注公众号,回复: 6750574 查看本文章

     对于有向边e = (U,V)。e称作u的出边(outgoing edge),v的入边(incoming edge)。v 的出边总数称作其出度(out-degree).记作outdeg(v);入边总数称作其入度(in-degree)。记作indeg(v)。在下图中,各顶点的初读为(1,3,1,1},入度为{2,1,2,1}。

简单图

   联接于同一顶点之间的边。称作自环。在某些特定的应用中,这类边可能的确具有意义——比如在城市交通中,沿着某条街道。有可能不需经过任何交叉路口即可直接返回原处。不含任何自环的图称作简单图(simple graph)。也是本书主要讨论的对象。

通路与环路

       所谓路径或通路(path)。就是由m + 1个顶点与m条边交替而成的一个序列:

        Π  =  { v0, e1, v1, e2, v2, ..., em, vm }

      且对任何0  < i <= m都有ei = (vi-1, vi)。也就是说,这些边一次地首尾相联。其中沿途边的总数m亦称作通路的长度。记作|Π | = m;

      为简化描述,也可依次给出通路沿途的各个顶点,而省略联接于其间的边,即表示为:

      Π  =  { v0, v1, v2, ..., vm } 

     下图a中的{C,A,B,A,D}即是从顶点C到D的一条通路,其长度为4,可见,尽管通路上的边必须互异,但顶点却可能重复。沿途顶点互异的通路,称作简单通路(simple path)。下图 b中{C,A,D,B}即是从顶点C到B的一条简单通路,其长度为3。

      特别地,对于长度m >= 1的通路 Π ,若起止顶点相同,则称作环路(cycle)。其长度也取作沿途边的总数。下图(a) 中,

{A,B,A,D,B,C}即是一条环路,其长度为6.反之,不含任何环路的有向图,称作有向无环图(diected acyclic graph ,DAG).

       同样,尽管环路上的各边必须互异,但顶点却也可能重复,反之若沿途除起点终点相等外均互异,则称作简单环路。例如上图中的b中的{C,A,B,C}即使一条简单环路,其长度为3.特别地,经过图中各边一次且恰好一次地环路,称作欧拉环路(Eulerian tour) ——当然,其长度也恰好等于图中边地总数e。

带权网络

      图不仅需要表示顶点之间是否存在某种关系,有时还需要表示这一关系地具体细节,以铁路运输为例。可以用顶点表示城市之间地联边,表示对应地城市之间是否有客运铁路联接;同时。往往还需要记录各段铁路的长度,承运能力,以及运输成本等信息。

     为适应这类应用要求,需通过一个权值函数,为每一边e指定一个权重(weight)。比如wt(e)即为边的权重。各边均带有权重的圈。称作带权圈或带权网络,有时也简称网络(network)。记作G(V,E,wt()).

复杂度

    与其它算法一样,图算法也需要就实际性能和空间性能,进行分析和比较,相应地,问题地输入规模,也应该以顶点数与边数地总和( n + e)来度量。不难看出,无论顶点多少,边数都有可能为0。那么反过来,在包含n个顶点的图中,最多包含多少条边呢?

     对于无向图。每一对顶点至多贡献一条边,故总共不超过n(n-1)/2条边,且这个由完全图达到。对于有向图,,每一对顶点都可能贡献(互逆的)两条边。因此至多可有n(n-1)条边。总而言之,必有e = 0(n2).(n2指的是n的平方)

2.抽象数据类型

   Graph模板类

typedef enum{ UNDISCONERED,DISCONERED,VISITED} VStatus;//顶点状态
typedef enum{ UNDETERMINED,TREE,CROSS,FORWARD, BACKWARD} EStatus;//边状态

template <typename Tv,typename Te> //顶点类型,边类型
class Graph
{
private:
    void reset()
    {  //所有顶点,边的辅助信息复位
        for(int i = 0;i < n;i++) //所有顶点的
        {
            status(i) = UNDISCONERED;
            dTime(i) = fTime(i) = -1;//状态,时间标签
            parent(i) = -1;
            priority(i) = INT_MAX;//(在遍历树中的)父节点,优先级别
            for(int j = 0;j < n;j++) //所有边的
            {
                if(exists(i,j))
                    status(i,j) = UNDETERMINED;//状态
            }
        }
    }
    void BFS(int,int&); //(连通域)广度优先搜索算法
    void DFS(int,int&); //(连通域)深度优先搜索算法
    void BCC(int,int&,Stack<int>&);//(连通域)基于DFS的双连通分量分解算法
    bool TSort(int,int&,Stack<Tv>*);//(连通域)基于DFS的拓扑排序算法
    template <typename PU>void  PFS(int ,PU);//(连通域)优先级搜索框架

public:
//顶点
      int n;//顶点总数
      virtual int insert(Tv const&) = 0;插入顶点,返回编号
      virtual Tv remove(int) = 0;//删除顶点及其关联边
      virtual Tv& vertex(int) = 0;//顶点v的数据(该顶点的确存在)
      virtual int inDegree(int) = 0;//顶点v的入度(该顶点的确存在)
      virtual int outDegree(int) = 0;//顶点v的出度(该顶点的确存在)
      virtual int firstNbr(int) = 0;//顶点v的首个邻接顶点
      virtual int nextNbr(int,int) = 0;//顶点v的(相对于顶点j的)下一邻接顶点
      virtual VStatus& status(int) = 0;//顶点v的状态
      virtual int& dTime(int) = 0;//顶点v的时间标签dTime
      virtual int& fTime(int) = 0; //顶点v的时间标签fTime
      virtual int& parent(int) = 0;//顶点v在遍历树中的父亲
      virtual int& priority(int) = 0;//顶点v在遍历树中的优先级数
      //边:这里约定,无向边统一转化为方向互逆的一对有向边,从而将无向图视作有向图的特例
      int e;//边总数
      virtual bool exists(int ,int ) = 0;//边(v,u)是否存在
      virtual void insert(Te const&,int,int,int) = 0;//在顶点 v和 u插入权重为w的边e
      virtual Te remove(int,int) = 0;//剔除顶点v和u之间的边e,返回该边信息
      virtual EStatus& status(int,int) = 0;//边(v,u)的状态
      virtual Te& edge(int,int) = 0;//边(v,u)的数据(该边的确存在)
      virtual int& weight(int,int) = 0;//边(v,u)的权重
      //算法
      void  bfs(int);//广度优先搜索算法
      void  dfs(int);//深度优先搜索算法
      void  bcc(int);//基于DFS的双连通分量分解算法
      Stack<Tv>* tSort(int);//基于DFS的双连通分量分解算法
      void prim(int);//最小支撑树Prim算法
      void dijkstra(int);//最短路径 Dijkstra算法
      template <typename PU> void pfs(int,PU);//优先级搜索框架
};

       仍为简化起见,这里直接开放了变量n和e。除以上所列的操作接口,这里还明确定义了顶点和边可能处于的若干状态。并通过内部接口reset()复位顶点和边的状态。

       图的部分基本算法在此也以操作接口的形式供外部用户直接使用。比如广度优先搜索,等,为v求解更多的具体应用问题,读者可照此模式,独立地补充相应地算法。

       就功能而言,这些算法均超脱于图结构地具体实现方式,借助统一地顶点和边ADT操作接口直接编写。尽管如此,正如以下即将看到地。图算法地时间,空间性能,却与图结构地具体方式紧密相关。在这方面地理解深度,也将反映和决定我们对图结构的驾驭与运用能力。

邻接矩阵

     邻接矩阵(adjacency matrix)是图ADT最基本的实现方式。使用方法A[n][n]表示由n个顶点构成的图,其中每个单元,各自负责描述一对顶点之间可能存在的邻接关系,故此得名。

对于无权图,存在(不存在)从顶点u到v的边 ,当且仅当A[u][v] = 1(0)。下图无向图和有向图的邻接矩阵实例。

     这一表示方式,不难推广至带权网络。此时如图(c)所示,矩阵各单元可从布尔型改为整型或浮点型。记录所对应边的权重。对于并不存在的边。通常统一取值为(无限大)或0。

#include "../Vector/Vector.h"   //引入向量
#include "../Graph/Graph.h"     //引入图ADT

template <typename Tv>struct Vertex    //顶点对象(为简化起见,并未严格封装)
{
    Tv data;
    int inDegree,outDegree;VStatus status;//数据,出入度数,状态
    int dTime,fTime;//时间标签
    Vertex(Tv const& d = (Tv) 0)://构造新顶点
        data(d),inDegree(0),outDegree(0),status(UNDISCOVERED)。
        dTime(-1),fTime(-1),parent(-1),priority(INI_MAX){} //暂不考虑权重溢出
};
template <typename Te>struct Edge   //边对象(为简化起见,并未严格封装
{
    Te data;
    int weight;
    EStatus status;//数据,权重,状态
    Edge(Te const& d,int w) :data(d),weight(w),status(UNDETERMINED){} //构造新边
};
template <typename Tv,typename Te> //顶点类型,边类型
class GraphMatrix:public Graph<Tv,Te>  //基于向量,以邻接矩阵形式实现的图
{
private:
    Vector<Vectex<Tv>> V;//顶点集(向量)
    Vector<Vector<Edge<Te>*>> E;//边集{邻接矩阵}
public:
    GraphMatrix(){ n = e = 0;} //构造
    ~GraphMatrix() //析构
    {
        for(int j = 0;j < n;j++)
           for(int k = 0;k < n;k++)
           delete E[j][k];//逐条清除
    }
    //顶点的基本操作:查询第i个顶点(0 <= i < n)
    virtual Tv& vertex(int i)
    {
        return V[i].data;//数据
    }
    virtual int inDegree(int i)
    {
        return V[i].inDegree;//入度
    }
    virtual int outDegree(int i)
    {
        return V[i].outDegree;//出度
    }
    virtual int firstNbr(int i)
    {
        return nextNbr(i,n); //首个邻接顶点
    }
    virtual int nextNbr(int i,int j)
    {
        while((-1 < j) && (!exist(i,--j)));
        return j;//逆向线性试探(改用邻接表可提高效率)
    }
    virtual VStatus& status(int i)
    {
        return V[i].status;//状态
    }
    virtual int& dTime(int i)
    {
        return V[i].dTime;//时间标签dTime
    }
    virtual int& fTime(int i)
    {
        return V[i].fTime;//时间标签fTime
    }
    virtual int& parent(int i)
    {
        return V[i].parent;//在遍历树中的父亲
    }
    virtual int& priority(int i)
    {
        return V[i].priority;//在遍历树中的优先级数
    }
     //顶点的动态操作
     virtual int insert(Tv const& vertex)
     {
         //插入顶点,返回编号
         for(int j = 0; j < n;j++)
            E[j].insert(NULL);
         n++;//各顶点预留一条潜在的关联边
         E.insert(Vector<Edge<Te>*>(n,n,(Edge<Te>* NULL)));//创建新顶点对应的边向量
         return V.insert(Vertex<Tv>(vertex));//顶点向量增加一个顶点
     }
     virtual Tv remove(int i)  //剔除第i个顶点以及其关联边(0 <= i < n)
     {
        for(int j = 0;j < n;j++)  //所有出边
            if(exists(i,j))
        {
            delete E[i][j];
            v[j].inDegree--;//逐条剔除
        }
        Tv vBak = vertex(i);
        V.remove(i);//剔除顶点i
        return vBak;//返回被剔除顶点的信息
     }
     virtual bool exists(int i,int j)   //边(i,j)是否存在
     {
         return (0 <= i) && (i < n) && (0 <= j) && (j < n) && E[i][j] != NULL;
     }
     //边的基本操作:查询顶点i与j之间的联边(o <= i,j < n且exists(i,j))
     virtual Estatus& status(int i,int j)
     {
         return E[i][j]->status;  //边(i,j)的状态
     }
     virtual Te& edge(int i,int j)
     {
         return E[i][j]->data;//边(i,j)的数据
     }
     virtual int& weight(int i,int j)
     {
         return E[i][j]->weight;//边(i,j)的权重
     }
     //边的动态操作
     virtual void insert(Te const& edge,int w,int i,int j)
     {
         
         //插入权重为w的边e = (i,j) 
         if(exist(i,j))
            return;//确保该边尚不存在
         E[i][j] = new Edge<Te>(edge ,w);//创建新边
         e++;V[i].outDegree++;
         V[j].inDegree++;//更新边计数与关联顶点的度数
     }
     virtual Te remove(int i,int j)
     {
         //删除顶点i和j之间的联边(exist(i,j)
         Te eBak  = edge(i,j);
         delete E[i][j];
         E[i][j] = NULL;//备份后删除记录
         e--;
         V[i].outDegree--;
         V[j].inDegree--;//更新边计数与关联顶点的度数
         return eBak;//返回被删除边的信息                                                                   
     }
};

      可见,这里利用Vector结构,在内部将所有顶点组织为一个向量V[];同时要通过嵌套定义,将虽有(潜在的)边组织为一个二维向量E[][] ——亦即邻接矩阵。

      每个顶点统一表示为Vertex对象,每条边统一表示为Edge对象。

      边对象的属性weight统一简化为整型。既可用于表示无权图,亦可表示带权网络。

时间性能

      按照上述代码的实现方式,各顶点的编号可直接转换为其在邻接矩阵中对应的秩,从而使得图ADT中所有的静态操作接口,均只需要0(1)时间——这主要是得益于向量“寻秩访问”的特长与优势。另外,边的静态和动态操作也仅需0(1)时间——其代价是邻接矩阵的空间冗余。然而,这种方法并非完美无缺。其不足主要体现在。顶点的动态操作接口均十分耗时。为了插入新的顶点。顶点集向量V[]需要添加一个元素:边集向量[][]也需要增加一行。且每行都需要添加一个元素。顶点删除操作。亦与此类似,不难看出,这些恰恰也是向量结构固有的不足。

     好在通常的算法中。顶点的动态操作远少于其它操作,而且,即便计入向量扩容的代价。就分摊意义而言。单次操作的耗时亦不过0(n).

空间性能

    上述实现方式所用空间,主要消耗于邻接矩阵,亦即其中的二维边集向量E[][].每个Edge对象虽需记录多项信息,但总体不过常数。Vector结构的装填因于始终不低于50%,故空间总量渐进的不超过0(n * n) = 0(n2)。

     当然,对于无向图而言,仍又改进的余地,如上图6.5(a)所示。无向图的邻接矩阵必为对称阵,其中除自环以外的每条边。都被重复地存放了两次,也就是说,近一半地单元都是冗余地,为消除这一缺点,可采用压缩存储等技巧,进一步提高空间利用率。

邻接表

    1.原理

     即便就有向图而言,0(n2)的空间亦有改进的余地。实际上,如此大的空间足以容纳所有潜在的边。然而实际应用所处理的图,所含的边通常远远少于0(n2)。比如在平面图之类的稀疏图(sparse graph)中。边数渐进地不超过0(n)。仅与顶点总数大致相当。习题6-3

    由此可见,邻接矩阵地空间效率之所以低,是因为其中大量单元所对应地边,通常并未在图中出现。因静态空间管理策略导致地此类问题,并非首次出现,比如此前,就曾指出这类缺陷并试图改进既然如此,为何不仿照之前地思路,将这里地向量替换为列表呢? 

     是的,按照这一思路,的确可以导出图结构的另一种表示与实现形式。

      以如图所示的无向图为例,只需将如图所示的邻接矩阵,逐行地转换为如图所示地一组列表。即可分别记录各顶点地关联边(或等价地,邻接顶点)。这些列表,也因此称作邻接表(adjacency list)。实际上,这种通用方法不难推广至有向图。

复杂度

     可见,邻接表所含列表数等于顶点总数n。每条边在其中仅存放一次(有向图)或两次(无向图)。故空间总量为0(n + e)。与图自身地规模相当,较之邻接举证有很大改进。

     当然,空间性能地这一改进,需以某些方面时间性能地降低为代价。比如,为判断顶点v到u的联边是否存在。exists(v,u)需在v对应的邻接表中顺序查找,共需0(n)时间。

      与顶点相关操作接口,时间性能依然保持,甚至有所提高。比如,顶点的插入操作,可在0(1)而不是0(n)时间内完成。当然。顶点的删除操作,仍需遍历所有邻接表,共需0(e)时间。

     尽管在邻接表访问单条边的效率并不算高,却十分擅长于批量方式,处理同一顶点的所有关联边,在以下图遍历等算法中,这是典型的处理流程和模式。比如,为枚举从顶点v发出的所有边,现在仅需(1 + outDegree(v))而非(n)时间。故总体而言,邻接表的效率较之邻接矩阵更高。因此,本章对以下各算法的复杂度分析,多以基于邻接表的实现方式为准。

猜你喜欢

转载自blog.csdn.net/qq_39218906/article/details/86534233
今日推荐