数据结构与算法专题之图——图的基本定义与存储

  本章我们开启一个全新的世界——图。这里的图不是我们日常理解的jpg,而是一种网状的拓扑结构。图在我们日常生活中应用相当广泛,比如我们常用的地图导航,如何找到出发点到目的地的最短路线?次短路线?最优选择?其中就包含了对图结构的各种算法处理。图论不光能解决直观的路径问题,甚至可以用来求解方程式、解决工程生产问题等,应用相当广泛,如果说树是最重要的数据结构,那么图则是最实用的数据结构。

  通过之前对树的学习,我们应该已经学会的非线性数据结构的基本存储方式。下面我们先介绍一下关于图的一些基本概念。

图的基本概念

定义

  图是由顶点集合顶点间关系(边)集合所构成的一种数据结构。我们写作G=(V, E),其中V代表顶点(Vertex)的非空有穷集合,E代表边(Edge)的非空有穷集合。

类型

  图的基本类型有两种:有向图无向图。顾名思义就是图上的边是否有表示方向的顺序关系,如下图,a为无向图,b为有向图。


  为了便于理解,我们可以认为无向图是一种特殊的有向图,我们认为无向图的每一条边都是具有两个方向的有向边,这样便于对后边图的存储的理解。

其他概念

  1. 图上两点之间路径不唯一。

  2. 对于图上任意两点都可以连通的图叫做连通图。

  3. 图的边是可以带权值的,一般代表路径长度。

  4. 图顶点的度:对于无向图来说,顶点的度就是其直接相邻的点的个数;对于有向图来说,顶点的度分为入度和出度,分别代表可直接到达的点的数量和可直接到达它的点的数量。如上图b中最右边的点的出度为2,入度为 1。

  5.  按照边的数量多少分为稀疏图和稠密图,应用中要根据图的稠密程度来使用不同的算法解决问题。


图的存储

  上面提到根据边的数量,图可以分为稀疏图和稠密图,根据不同的图,我们有不同的存储方式,我们常用的有两种:邻接矩阵邻接表

邻接矩阵

  邻接矩阵就是用二维数组表示的点与点之间的关系,通常适用于顶点数较少的图

  假设图G是一个具有n个顶点的无向图,则邻接矩阵为n*n的矩阵,如下图定义:


  如下图,无向图G与其邻接矩阵:


  观察上图可以看出,无向图的邻接矩阵是一个关于对角线对称的矩阵,我们有了这个矩阵,就可以知道下面这些事情:

  1. 任意两点之间有没有边相连,比如我想知道vi和vj是否直接相连,对于无向图来说,只需查看数组arr[i][j]或者arr[j][i]是否为1即可。

  2. 顶点的度。我们也可以计算出某一点的度,对于某一个顶点,只需计算邻接矩阵中对应行或者列的1的数量即可,例如v2的度为2。

  3. 获取邻接点。对于顶点Vi,只需遍历矩阵中第i行所有元素,若有arr[i][j]==1,则说明Vj为Vi的一个邻接点。

  当然对于有边权的图来说,只需把矩阵中的1改为对应的值即可。另外需要注意的是,我们上面用0代表无边(不可达),若边权值有0存在,则我们需要使用另外的整数来表示点与点不可达,通常我们用正无穷(∞)来表示,当然计算机中没有正无穷,我们通常使用一个大于最大边权的值,一般是一个较大的int(0x7f7f7f7f),来表示不可达。

  对于有向图来说,与无向图的区别就是矩阵不会关于对角线对称,原因是两点之间,可能是一条单向的边,比如Vi和Vj之间只有一条Vi指向Vj的边,则arr[i][j] = 1,而arr[j][i] = 0,因为并没有Vj指向Vi的边,所以矩阵不会对称。现在反观无向图,其实相当于有边关系的两点之间是有一条双向的边。

  关于邻接矩阵,是一个很简单的图的存储方式,只需要一个二维数组即可将图存储。下面给出代码:

const int MAX_P = 1010; // 最大点数量
const int NO_PATH = 0x7f7f7f7f; // 定义不可达的边权值
int G[MAX_P][MAX_P];

void init() // 初始化图
{
    memset(G, 0x7f, sizeof(G));
}

void addEdge(int i, int j, int w) // 添加一条i->j权值为w的边
{
	G[i][j] = w;
}

bool isConnection(int i, int j) // 检查是否有直接i->j的边
{
	return G[i][j] != NO_PATH;
}

邻接表

  对于一个稀疏图来说,使用邻接矩阵存储太浪费内存空间了,如果一个含有100个顶点的图,对于V0来说,只有V4和V6两个邻接点,那么仅邻接矩阵的第一行就浪费掉了98个存储单元,空间消耗是巨大的。所以对于点较多而边较少的稀疏图,我们引入了邻接表。

  我们将图的所有顶点存入到一个一维数组中,数组中每个元素都对应着一个链表,链表每个元素都代表了该点的邻接点,如下图:


  为什么使用链表呢?不难看出,每个顶点的邻接点数量都不确定,而链表恰好可以动态地添加或删除元素。

  通过上图,我们可以看出,所谓的一维数组其实就是一组链表的头结点,每个头结点都代表了图的一个顶点,对于每个头结点都对应了一个链表,链表中每个元素都是该点的一个邻接点,也代表了该点到邻接点有一条单向边(弧)。当然上图中没有体现出边的权值,链表的数据域存储的是邻接点的编号,若要用邻接表存储带权图,只需要为链表的结点增加一个数据域来存储权值即可。

  代码实现起来也是很简单的,我们定义好结点的结构,根据图顶点的数量声明一组头结点,然后再根据图的边依次为各顶点添加邻接点即可。至于为什么要把头结点声明成数组的形式,是因为我们可以方便地通过数组下标来找到对应顶点的头结点,从而找到相应的链表来进行一系列操作。代码如下:

const int MAX_P = 1010;

struct node
{
	int idx, w; // 分别代表邻接点边号和边的权值
	node *next;
};

node G[MAX_P]; // 声明一个头结点数组

void init(int n) // 初始化n个顶点的图
{
	for(int i = 0 ; i <= n ; i++)
	{
		G[i].next = NULL; 
		// 这里是初始化链表,注意我们声明的是头结点数组,并不是指针 
		// 在图使用完毕后记得像链表一样释放内存
	}
}
void addEdge(int u, int v, int w) // 添加一条u->v权值为w的边
{
	node *head = &G[u]; // 获取u对应的头结点的指针
	node *p = new node;
	p->idx = v; // 邻接点编号
	p->w = w; // 边的权值
	// 为了方便,我们使用头部插入
	p->next = head->next;
	head->next = p;
}
bool isConnection(int u, int v) // 判断u->v边是否存在
{
	node *head = &G[u];
	node *p = head->next;
	while(p)
	{
		if(p->idx == v)
			return true;
	}
	return false;
}

  可以看出,邻接表只不过是一组链表而已,它与邻接矩阵的区别是,邻接表的第二维度的长度是不确定的,可以动态地增减。我们在操作的时候只需要拿到头结点指针,即可像单链表一样操作了。

使用STL vector实现邻接表

  前面提到使用链表来实现邻接表的原因,是因为链表可以动态地增加元素,整个链表的长度是不定的,那么也就是说,完全可以找到一种数据不定长的容器来代替链表从而实现邻接表。很幸运,C++ STL为我们提供了这样一种容器——Vector

  Vector,向量,泛型容器,相当于是一个动态的数组,不定长的数组,与我们的链表极为相似,只不过C++STL对其进行了封装,使其可以像数组一样通过[]访问下标来访问vector内的元素,使用起来相当地方便,下面我们就用vector来实现邻接表,在今后图论的学习中,除特殊情况,基本上代码都会采用vector式的邻接表。

  vector的使用相当方便,只需要这样:

vector<int> arr;
  即可声明一个初始为空的int型数组,可以通过调用arr.push_back();来向数组尾部添加元素从而 动态地扩展数组的大小。可以通过调用arr.size();来获取不定长数组当前的长度,也可以通过arr[i]来访问不定长数组的第i个元素(下标也是从0开始的)。

  由于不定长数组与数组一样,每个元素只能存储一个值,所以如果我们的图是带权图,则需要两个数值(二元组)来存储一个邻接点(点编号、权值),由于我们的vector是泛型,所以我们可以定义一个含有两个数据域的结构体,并将vector的泛型声明为该结构体类型,如下:

struct node
{
	int idx, w;
};

vector<node> G[MAX_P];

  为了简便,我们下面的实现就假设图没有权值,就单纯地存储点与点之间的边关系,那么我们就可以这样实现邻接表:

const int MAX_P = 1010;

vector<int> G[MAX_P];

void init(int n) // 初始化n个顶点的图
{
	for(int i = 0 ; i <= n ; i++)
	{
        	G[i].clear(); // 清除不定长数组的所有元素,即初始化,置空
	}
}

void addEdge(int u, int v) // 添加一条u->v边
{
	G[u].push_back(v);
}

bool isConnection(int u, int v)
{
	int len = G[u].size();
	for(int i = 0 ; i < len ; i++)
	{
        	if(G[u][i] == v)
			return true;
	}
	return false;
}
  是不是这样就简单了好多?可以像数组一样用下标操作“链表”。

链式前向星

  这里就不介绍链式前向星了,我们只需要知道链式前向星是一种更加简便高效的图的存储方式,由于我们通常情况下使用vector足以对应一般的图论问题,所以前向星在此不多介绍,如果今后的讲解中涉及到需要用前向星解决的问题,再细讲。


  附几个练习题传送门,主要练习图的基本存储方式,代码自己在理解的基础上实现 ,套用应该是不行的……

  SDUT OJ 3116 图的基本存储的基本方式一

  SDUT OJ 3117 图的基本存储的基本方式二

  SDUT OJ 3118 图的基本存储的基本方式三

  SDUT OJ 3467 图的基本存储的基本方式四



  以上就是本章节的全部内容了,内容不是很多,但却是图论的第一课也是重要的一课。掌握了图的结构和存储原理,才能在今后遇到多种多样的图的变体时,快速而准确地构造出存储方案。



  下集预告&传送门: 数据结构与算法专题之图——图的遍历(深度优先遍历和广度优先遍历)


  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值