目录

1. 数据结构

线性结构

非线性结构

2. 复杂网络的数组表示

3. 复杂网络的邻接表表示

4. 邻接矩阵与邻接表的比较

5. 复杂网络的其他表示方法

逆邻接表

十字链表

邻接多重表

前向星

链式前向星

5.各种数据结构的比较 


通常意义下的链表有单链表,双向链表,循环链表等,而复杂网络每个节点可能会同时指向任意个节点,从数据结构上来说两者是不同的。所以首先我们先认识一下数据结构有哪些。

1. 数据结构

数据结构有很多种,一般来说,按照数据的逻辑结构对其进行简单的分类,包括线性结构和非线性结构两类。

  • 线性结构

简单地说,线性结构就是表中各个结点具有线性关系。如果从数据结构的语言来描述,线性结构应该包括如下几点:

1)线性结构是非空集。

2)线性结构有且仅有一个开始结点和一个终端结点。

3)线性结构所有结点都最多只有一个直接前驱结点和一个直接后继结点。

线性表就是典型的线性结构,还有栈、队列和串等都属于线性结构。

  • 非线性结构

简单地说,非线性结构就是表中各个结点之间具有多个对应关系。如果从数据结构的语言来描述,非线性结构应该包括如下几点:

1)非线性结构是非空集。

2)非线性结构的一个结点可能有多个直接前驱结点和多个直接后继结点。

在实际应用中,数组、广义表、树结构和图结构等数据结构都属于非线性结构。

而此处要讲到的网络结构就是一种非线性结构,常见的网络结构有以下几种:

2. 复杂网络的数组表示

我们一般使用数组(邻接矩阵)表示上面相应的网络(其中+∞可以用很大的数实现): 

//图的邻接矩阵存储表示
#define MaxInt 32767      //表示极大值,即∞
#define MVNum 100         //最大顶点数
typedef char VerTexType;  //假设顶点的数据类型为字符型
typedef int ArcType;      //假设边的权值类型为整形
typedef struct    
{
  VerTexType vexs[MVNum]; //顶点表
  ArcType arcs[MVNum][MVNum];   //邻接矩阵
  int vexnum,arcnum;      //图的当前点数和边数
}AMGraph;

3. 复杂网络的邻接表表示

使用邻接表(在表示树数据结构中,也称为孩子表示法)表示有向网络(图),有两种方法,一个是以起点存储,一个是以终点存储,后一种可以叫做逆邻接表(后面还会介绍)。

使用起点存储时,我们可以把一个双向网络表示如下:

由上可见邻接表的结构由顺序节点和每个节点下的相连节点组成,后者使用链表储存,由上可以看出邻接表相当于一个列数伸缩可调的数组。

相应的,在有权重的网络中可以在链表的数据中再添加一个权重项:

邻接表是一个二维容器,第一维描述某个点,使用顺序存储(数组),第二维描述这个点所对应的边集们,使用链式存储(链表)。

网络的邻接表存储方法跟树的孩子链表示法相类似,是一种顺序分配和链式分配相结合的存储结构。如这个表头结点所对应的顶点存在相邻顶点,则把相邻顶点依次存放于表头结点所指向的单向链表中。如词条概念图所示,表结点存放的是邻接顶点在数组中的索引。对于无向网络来说,使用邻接表进行存储也会出现数据冗余,表头结点A所指链表中存在一个指向C的表结点的同时,表头结点C所指链表也会存在一个指向A的表结点。
实现邻接表的方法绝对有100种以上。即使是前向星这种东西也是邻接表,因为它还是描述某个点和这个点所对应的边集们。
常用的邻接表在c++中必须有开O1以及以上编译的条件,不然没有测试的效率无任何意义。
第一维是描述点的。在c++中可以用vector,list,forward_list,deque,map,multimap,unordered_map,unordered_multimap等(一般不能用set,mutiset,unordered_set,unordered_multiset )。
按照你的要求去选择。一般来讲存完图以后不涉及点的加入与删除优先使用vector.map,multimap,unordered_map,unordered_multimap。
第二维是描述这个点的边集,可以用全部的容器。也是的一般来讲存完图以后,不涉及点的加入与删除优先使用vector,空间充足可以考虑deque。涉及点的删除用forward_list或者是list,map,multimap,unordered_map,unordered_multimap。

下面先对有向无权网络进行编程,分别使用c和c++:

//c
//顶点的结构体
typedef struct VNode     //对结构体使用 typedef 来定义一个新的数据类型名字;
                         //然后使用这个新的数据类型来直接定义结构变量;
{
  VertType data;   //顶点的信息
  ArcNode *first;  //顶点的第一条边;
}VNode;

//边链表的结构体
typedef struct ArcNode
{
  int adjvex;     //边指向的顶点(与上述顶点相连)的数组下标(伪指针);
  InfoType info;  //边相关的信息(权值);
  struct ArcNode *next   //指向下一条边的指针;
}ArcNode;

//图的结构体
typedef struct
{
  VNode vexs[MAXVNUM];    //顶点的数组;
  int vexnum,arcnum;      //图的顶点数|V|和边数|E|;
}ALGraph;
//c++
using namespace std
struct GraphNode{
  int label;  //网络节点
  vector<GraghNode*> neighbors; //存储与节点相邻的节点指针
};

int main(){
  GraphNode G[4];  //创建长度为4的网络节点结构体数组
  //0、1、2、3四个顶点就对应了G[0]、G[1]、G[2]、G[3]四个结构体
  for (int i=0;i<4;i++){  //使用循环为表头中的label赋值
    G[i].label=i;  //节点编号赋值为0、1、2、3
  }
  //添加0到2和3的边
  G[0].neighbors.push_back(&G[2]);
  G[0].neighbors.push_back(&G[3]);
  //添加1到0和2的边
  G[1].neighbors.push_back(&G[0]);
  G[1].neighbors.push_back(&G[2]);
  //添加2到3的边
  G[2].neighbors.push_back(&G[3]);
  //添加3到0的边
  G[3].neighbors.push_back(&G[0]);

  //输出打印邻接表
  printf("G:\n");
  for (int i=0;i<4;i++){
    printf("Label(%d):",i);
    for (int j=0;j<G[i].neighbors.size();j++){
      printf("%d",G[i].neighbors[j]->label);
    }
    printf("\n");
  }
  return 0;
}

4. 邻接矩阵与邻接表的比较

总的来说,存储网络的时候可以使用数组(邻接矩阵),也可以使用链表(邻接链表),后者可以看作一种比较特殊的数组,其实都是数据存储的结构。相较前者,链表具有的优势有:

  • 不需要连续的内存存储,所以可以存储更大的网络;
  • 删除添加节点的操作灵活,且设计的内存操作小,数组改变长度的时候,需要平移某个位置后面的所有内存;
邻接表邻接矩阵
空间复杂度无向图O(|V|+2|E|),有向图O(|V|+|E|),适合存储稀疏图O(|V|2),适合存储稠密图
表示方式边链表结点的顺序是任意的,不唯一矩阵是固定的,唯一
求度、出度、入度求无向图的度和有向图的出度的时间复杂度为O(1),求有向图的入度为O(|E|)遍历矩阵的行或列,时间复杂度为O(|V|)

那么在表示网络中,数组与链表之间如何做出选择呢?

如果把存储空间多少作为标准,首先判断网络是否稀疏(E表示边的条数,V表示顶点个数):

 一般使用邻接表表示稀疏图,而用数组表示稠密图:

 如果把判断两个节点是否相连接作为标准,则使用数组(复杂度为O(1)):

 

 另外上述讲到的都是以节点为主要存储对象,其实还可以构建专门存储边的数组,当然会遗漏点(没有边的情况):

起点终点权重
10420
20230
31010
41240
52350
63470
74360

这种存储方法在稠密网络情形下,其数量级为O(2),数组的数量级为O(2),邻接表也数量级也为O(2);在稀疏网络的情形下,只有数组的数量级还是O(2),其余两种方法会有所减小。

5. 复杂网络的其他表示方法

到此对于网络的构建已经差不多了,但是对于某些所需功能,需要对邻接表做一下拓展:

  • 逆邻接表

上面是以起点做邻接表的,当我们搜索某个节点的出度时(也就是以此节点作为起点时),我们可以直接找到某个节点及其容器下的链表,而当我们搜索某个节点的入度时(也就是以此节点作为终点时),需要一个一个节点去搜索,这样时比较麻烦的,为了解决这一点,我们可以在构建含起点的邻接表之外,构建含终点的邻接表,此时被称为逆邻接表

  • 十字链表

上述讲到我们可以使用邻接表和逆邻接表表示网络,但是这种双份的存储显得非常冗余,为了解决这个问题,需要引进一种新的数据结构,即十字链表。

创建节点数据结构的时候,其数量与节点的数量一致,而创建边的数据结构的时候,其为有向边的数量。

节点的数据结构里面包含了Data,firstIn,firstOut三种类型,Data为节点的值,firstIn为指向终点为该节点的边的指针,firstOut为指向起点为该节点的边的指针,在边的数据结构里面有四项,start为边的起点,end为边的终点,nextIn为指向终点为该节点的下一个边的指针,nextOut为指向起点为该节点的下一个边的指针。由上可见边的数据结构会被两个节点指向,所以称为十字链表。

值得一提的是,之所以需要建立边的数据结构有两个目的,一个是存储边是否连接以及与谁的信息,第二个是可以在上述结构中增加一项权重数据。

  • 邻接多重表

上面讲到的是有向图,在普通的邻接表表示无向图的时候,可能会造成同一条边被两重存储的现象:

 为了解决这个问题,我们引入邻接多重表:

 

 

创建节点数据结构的时候,其数量与节点的数量一致,而创建边的数据结构的时候,其数量为无向边的数量。

节点的数据结构里面包含了Data,first两种类型,Data为节点的值,first为指向与该节点相连的边的指针,在边的数据结构里面有四项,Vi和Vj为相连节点,ViNext为指向与节点Vi相连的下一条边的指针,VjNext为指向与节点Vj相连的下一条边的指针。

  • 前向星

前向星也是一种存图工具,一种特殊的边集数组,所以前向星数组对应的其实是边的信息,下标就是边的下标。之所以叫做前向星,是因为每次的指向都是往前的。其也是一种静态链表,即用数组实现邻接表的功能。

首先把边集数组中的每一条边按照起点从小到大排序,如果起点相同就按照终点从小到大。

并且记录下以某个点为起点的所有边在数组中的起始位置和存储长度。

len[i]数组记录以i为起点的边在数组中的存储长度。

head[i]数组记录以i为边集在数组中的第一个存储位置(哪条边)

1        2

2        3

3        4

1        3

4        1

1        5

4        5

那么排完序后得到:

编号:  1        2        3        4        5        6        7

起点v:1        1        1        2        3        4        4

终点v:2        3        5        3        4        1        5

得到:

head[1]=1        len[1]=3

head[2]=4        len[2]=1

head[3]=5        len[3]=1

head[4]=6        len[4]=2

但是利用前向星会有排序操作,如果用快排时间至少为O(nlog(n))。

如果用链式前向星,就可以避免排序。

  • 链式前向星

我们建立边结构体为:

struct Edge
{
    int next;
    int to;
    int w;
};

其中edge[i].to表示第i条边的终点,edge[i].next表示与第i条边同起点的下一条边的存储位置,edge[i].w为边权值。

另外还有一个数组head[],它是用来表示以i为起点的第一条边存储的位置,实际上你会发现这里的第一条边存储的位置其实是在以i为起点的所有边的最后输入的那个编号(排序完之后)。

head[]数组一般初始化为-1,对于加边的add函数是这样的:

void add(int u,int v,int w)
{
    edge[cnt].w = w;
    edge[cnt].to = v;
    edge[cnt].next = head[u];
    head[u] = cnt++;
}

初始化cnt=0,这样,现在我们还是按照上面的图和输入来模拟一下:

edge[0].to=2;        edge[0].next=-1;        head[1]=0;

edge[1].to=3;        edge[1].next=-1;        head[2]=1;

edge[2].to=4;        edge[2].next=-1;        head[3]=2;

edge[3].to=3;        edge[3].next=0;        head[1]=3;

edge[4].to=1;        edge[4].next=-1;        head[4]=4;

edge[5].to=5;        edge[5].next=3;        head[1]=5;

edge[6].to=5;        edge[6].next=4;        head[4]=6;

很明显,head[i]保存的是以i为起点的所有边中编号最大的那个,而把这个当作顶点i的第一条起始边的位置。

这样在遍历时是倒着遍历的,也就是说与输入顺序是相反的,不过这样不影响结果的正确性。

比如以上图为例,以节点1为起点的边有3条,它们的编号分别是0,3,5,而head[1]=5.

在遍历以u节点为起始位置的所有边的时候是这样的:

for(int i=head[u];~i;i=edge[i].next)

那么就是说先遍历编号为5的边,也就是head[1],然后就是edge[5].next,也就是编号3的边,然后继续edge[3].next,也就是编号0的边,可以看出是逆序的。

如果说邻接表是不好写但效率好,邻接矩阵是好写但效率低的话,前向星就是一个相对中庸的数据结构。前向星固然好写,但效率并不高。而在优化为链式前向星后,效率也得到了较大的提升。虽然说,世界上对链式前向星的使用并不是很广泛,但在不愿意写复杂的邻接表的情况下,链式前向星也是一个很优秀的数据结构。

图的存储方法很多,最常见的除了邻接矩阵、邻接表和边集数组外,还有链式前向星。链式前向星是一种静态链表存储,用边集数组和邻接表相结合,可以快速访问一个顶点的所有邻接点。

链式前向星存储包括两种结构:

边集数组:edge[],edge[i]表示第i条边;

头结点数组:head[],head[i]存以i为起点的第一条边的下标(在edge[]中的下标)。

struct node{
    int to,next,w;
}edge[maxe];  //边集数组,边数一般要设置比maxn*maxn大的数(即点数n*(n-1));
int head[maxn];  //头结点数组;

每一条边的结构,如图所示:

在输入边的时候,无向网络的edge个数是边的两倍,有向网络的edge个数跟边相等。

例如,一个无向图,如图所示:

(1)输入1 2 5

创建一条边1-2,权值为5,创建第一条边edge[0],如图所示:

 然后将该边链接到1号结点的头结点中。(初始时head[]数组全部初始化为-1)即edge[0].next=head[1]; head[1]=0; 表示1号结点关联的第一条边为0号边。图中的虚线箭头仅表示他们之间的链接关系,不是指针。

 

 

 

 

 

 添加一条边u v w的代码如下:

void add(int u,int v,int w){//添加一条边
    edge[cnt].to=v;
    edge[cnt].w=w;
    edge[cnt].next=head[u];
    head[u]=cnt++;
}

如果是有向图,每输入一条边,执行一次add(u,v,w)即可;如果是无向图,则需要执行两次add(u,v,w); add(v,u,w)。

如何使用链式前向星访问一个节点u的所有邻接点呢?

for(int i=head[u];i!=-1;i=edge[i].next){
    int v=edge[i].to;  //u的邻接点;
    int w=edge[i].w;   //u-v的权值;
    ···
}

链式前向星的特性:

1)和邻接表一样,因为采用头插法进行链接,所以边输入顺序不同,创建的链式前向星也不同。

2)对于无向图,每输入一条边,需要添加两条边,互为反向边。例如,输入第一条边1 2 5,实际上添加了两条边,如图所示:

 这两条边可以通过互为反向边,可以通过与1的异或运算得到其反向边,0^1=1,1^1=0。也就是说如果一条边的下标为i,则其反向边为i^1。这个特性应用在网络流中非常方便。

3)链式前向星具有边集数组和邻接表的功能,属于静态链表,不需要频繁地创建节点,应用十分灵活。

代码实现:

#c++
#include<iostream>//创建无向网络的链式前向星
#include<cstring>
using namespace std;
const int maxn=100+5;
int head[maxn];
int n,m,x,y,w,cnt;

struct Edge{
    int to,w,next;
}e[maxn*maxn];

void init(){//初始化
    memset(head,-1,sizeof(head));
    cnt=0;
}

void add(int u,int v,int w){//添加一条边u--v
    e[cnt].to=v;
    e[cnt].w=w;
    e[cnt].next=head[u];
    head[u]=cnt++;    
}


void printg(){//输出链式前向星
    cout<<"----------链式前向星如下:----------"<<end1;
    for(int v=1;v<=n;v++){
        cout<<v<<":    ";
        for(int i=head[v];~i;i=e[i].next){
            int v1=e[i].to,w1=e[i].w;
            cout<<"["<<v1<<" "<<w1<<"]\t";
        }    
        cout<<end1;
    }
}

int main(){
    cin>>n>>m;
    init();
    for(int i=1;i<=m;i++){
        cin>>x>>y>>w;
        add(x,y,w);//添加边;
        add(y,x,w);//添加反向边;
    }
    printg();
    return 0;
}


/*
4 5
1 2 5
1 4 3
2 3 8
2 4 12
3 4 9
*/

5.各种数据结构的比较 

边链表总数空间复杂度

求顶点出度的

时间复杂度

求顶点入度的

时间复杂度

无向图邻接表2|E|O(|V|+2|E|)O(1)
有向图邻接表|E|O(|V|+|E|)O(1)~O(|E|)O(1)~O(|E|)
十字链表|E|O(|V|+|E|)O(1)~O(|E|)O(1)~O(|E|)
邻接多重表|E|O(|V|+|E|)
链式前向星|E|O(|V|+|E|)

其中出度为某节点指向其他节点,入度为某节点被其他节点指向。

  • 总结一下:

数据结构特点
图的存储邻接矩阵顺序存储(数组)
邻接表顺序存储+链式存储(数组+链表)
十字链表适用于有向图
邻接多重表适用于无向图
链式前向星

参考文献:

邻接表_百度百科

看动画,彻底理解数据结构中图的知识,图的邻接表和邻接矩阵_哔哩哔哩_bilibili

50-图-1.图的逻辑结构和邻接矩阵存储法_哔哩哔哩_bilibili

图的存储结构(邻接矩阵、邻接表、十字链表、邻接多重表)_哔哩哔哩_bilibili

严蔚敏 - 数据结构(c语言版 第2版)-人民邮电出版社 (2015)

前向星和链式前向星 - Emcikem - 博客园

深度理解链式前向星_ACdreamers的博客-CSDN博客_链式前向星

链式前向星--最通俗易懂的讲解_sugarbliss的博客-CSDN博客_链式前向星

图的存储结构_张松超的博客-CSDN博客

前向星和链式前向星 - Emcikem - 博客园

链式前向星教学_PK__PK的博客-CSDN博客

暑假acwing算法总结5:链式前向星_piachua的博客-CSDN博客

【数据结构】 对于“前向星”的理解 - 简书

一口气搞懂「链表」,就靠这20+张图了_CSDN资讯的博客-CSDN博客

图的存储-链式前向星_哔哩哔哩_bilibili

算法训练营 陈小玉

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐