图
在线性表中,数据元素之间是被串起来的,仅有线性关系,每个数据元素只有一个直接前驱和直接后继。在树形结构中,数据元素之间有着明显的层次关系,并且每一层上的数据元素可能和下一层中多个元素相关,单只能和上一层中一个元素相关,这个一对父母可以有多个孩子,但是每个孩子只能由一对父母相对应。
但是在处理复杂关系的时候,例如人与人之间的关系,这个并不能简单的使用一对一或一对多的形式来进行表示,而是要考虑多对多的一些情况。故这里引入图结构。
图的定义
图是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为 G ( V , E ) G(V,E) G(V,E),其中, G G G表示一个图, V V V是图 G G G中顶点的集合, E E E是图 G G G中边的集合。
上述定义也较为直观,简单来说图就是由顶点和边组成,其中顶点必须要有,也就是顶点数量大于0,边可以没有,即边的数量大于等于0。
接下来对于图中的其他定义进行解释:
-
无向边:若顶点 v i v_i vi到 v j v_j vj之间的边没有方向,则称这条边为无向边,也简称为边,记作 ( v i , v j ) (v_i,v_j) (vi,vj)或 ( v j , v i ) (v_j,v_i) (vj,vi),代表 v i v_i vi可以到达 v j v_j vj, v j v_j vj也可以到达 v i v_i vi。
-
**有向边:**若顶点 v i v_i vi到 v j v_j vj之间的边有方向,则称这条边为有向边,也称为弧,记作 < v i , v j > <v_i,v_j> <vi,vj>,代表 v i v_i vi可以到达 v j v_j vj, v j v_j vj不可以到达 v i v_i vi,其中 v i v_i vi是弧尾, v j v_j vj是弧头。
-
**无向图:**图中任意两点之间的边都是无向边,则称该图为无向图。
-
无向完全图:如果任意两个结点之间都有边,则称改图尾无向完全图。含有 n n n个顶点的无向完全图有 n ( n − 1 ) 2 \frac{n\left( n-1 \right)}{2} 2n(n−1)条边。
-
**有向图:**图中任意两点之间的边都是有向边,则称该图为有向图。
-
**有向完全图:**在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有 n n n个顶点的有向完全图有 n ( n − 1 ) n(n-1) n(n−1)条边。
在有向图和无向图中,有些定义名字虽然相同,但是含义却有所区别,这里分开进行介绍。
无向图中:
- 邻接点:如果 ( x , y ) ∈ E (x,y)\in E (x,y)∈E,则称 x , y x,y x,y互为临接点,即 x , y x,y x,y相邻接。
- 依附:边 ( x , y ) (x,y) (x,y)依附于顶点 x , y x,y x,y。
- 相关联:边 ( x , y ) (x,y) (x,y)与 x , y x,y x,y相关联。
- 顶点的度:和顶点 x x x相关联的边的数目,记作 T D ( x ) TD(x) TD(x)
有向图中:
- 邻接:如果 < x , y > ∈ E <x,y>\in E <x,y>∈E,则称 x x x邻接到 y y y,或 y y y邻接自 x x x。
- 相关联:弧 < x , y > <x,y> <x,y>与 x , y x,y x,y相关联。
- 入度:以顶点 x x x为头的弧的。数目,记为 I D ( x ) ID(x) ID(x)。
- 出度:以顶点 x x x为尾的弧的数目,记为 O D ( x ) OD(x) OD(x)。
- 度: T D ( x ) = I D ( x ) + O D ( x ) TD(x)=ID(x)+OD(x) TD(x)=ID(x)+OD(x)。
接下来对于图中其他的内容进行解释。
-
路径:记录顶点 x x x到顶点 y y y的顶点序列 < x , v 1 > , < v 1 , v 2 > , . . . , < v i , y > <x,v_1>,<v_1,v_2>,...,<v_i,y> <x,v1>,<v1,v2>,...,<vi,y>(这里以有向图为例),路径可能存在也可能不存在,不存在则说明 x x x不可达 y y y。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N3rgg20n-1628621235114)(…\6.图\pics\0b1bbe19a2040894ac5d52ed099ff8a.png)]
-
路径长度:路径长度指的是路径上边或弧的数目。
-
简单路径:路径的顶点序列中,顶点不重复出现
-
回路:路径的开始顶点与最后一个顶点相同,即 x = y x=y x=y。
-
连通:如果顶点 x x x到 y y y有路径,称 x x x和 y y y是连通的。
-
连通图:图中所有的顶点都是连通的。
-
子图:设有两个图 G = ( V , E ) G=(V,E) G=(V,E)和 G ′ = ( V ′ , E ′ ) G'=(V',E') G′=(V′,E′)。若 V ′ ⊆ V V'\subseteq V V′⊆V且 E ′ ⊆ E E'\subseteq E E′⊆E,则称图 G ′ G' G′是图 G G G的子图。
-
生成树:一个连通图的生成树是一个极小连通子图,它含有图中全部 n n n个顶点,但只有足以构成一棵树的 n − 1 n-1 n−1条边。
图的存储结构
关于如何将图中的信息,也就是顶点和边存储在计算机中,有许多的方式:
- 邻接矩阵
- 邻接表
- 邻接多重表
- 十字链表
- 边集数组
这里重点介绍前两种,也就是邻接矩阵和邻接表。
邻接矩阵
图主要是由两部分构成,顶点和边(弧),考虑如何将这两个部分合并在一起进行表示。对于顶点的信息,可以使用一个一维数组来保存每个顶点的具体包含的信息,例如字符串等,通过数组下标来唯一识别一个顶点。那么就可以借用一个二维数组来表示一个图中顶点与顶点之间的连接关系。
假设一个图中有
n
n
n个顶点,那么就可以使用一个
n
∗
n
n*n
n∗n的矩阵来表示图中的边的关系。对于不带权的图,以1代表两顶点之间存在边或弧,以0代表两顶点不邻接,即
A
[
i
]
[
j
]
=
{
1
,
<
i
,
j
>
∈
E
0
,
<
i
,
j
>
∉
E
A\left[ i \right] \left[ j \right] =\left\{ \begin{array}{c} 1, <i,j>\in E\\ 0, <i,j>\notin E\\ \end{array} \right.
A[i][j]={1,<i,j>∈E0,<i,j>∈/E
根据上述定义可以看出,如果顶点
i
i
i和顶点
j
j
j之间存在边,则
A
[
i
]
[
j
]
=
1
A[i][j]=1
A[i][j]=1,否则
A
[
i
]
[
j
]
=
0
A[i][j]=0
A[i][j]=0。接下来简单看两个例子。
接下来就可以对邻接矩阵的性质进行一些总结。
- 无向图的邻接矩阵是对称的。
- 在无向图中,第 i i i行或第 i i i列中1的个数,等于顶点 i i i的度 T D ( i ) TD(i) TD(i)。
- 有向图的邻接矩阵可能是不对称的。
- 在有向图中,第 i i i行中1的个数等于顶点 i i i的出度 O D ( i ) OD(i) OD(i),第 i i i列中1的个数等于顶点 i i i的入度 I D ( i ) ID(i) ID(i)。
上述即为一个简单的图,也就是图中的边没有权重值,也可以理解为距离,当边有距离的时候,矩阵的值就需要进行一些简单的变动。
A
[
i
]
[
j
]
=
{
w
i
,
j
,
<
i
,
j
>
∈
E
∞
,
<
i
,
j
>
∉
E
A\left[ i \right] \left[ j \right] =\left\{ \begin{array}{c} w_{i,j}, <i,j>\in E\\ \infty, <i,j>\notin E\\ \end{array} \right.
A[i][j]={wi,j,<i,j>∈E∞,<i,j>∈/E
根据上述定义,给出一个简单的例子。
在具体如何实现时,也较为简单,直接将邻接矩阵和保存结点信息的数组封装在一个结构体里面即可,同时添加上一些其他的属性值即可。
#include <iostream>
#include <string>
#include <stack>
#include <queue>
using namespace std;
#define MAX_NUM 20
typedef struct Map
{
bool visit[MAX_NUM]; // 访问数据,用于遍历图
int map[MAX_NUM][MAX_NUM]; // 邻接矩阵
int num; // 结点数量
string nodes[MAX_NUM]; // 结点中保存的内容
// 构造函数
Map()
{
// 内容初始化
for (int i = 0; i < MAX_NUM; i++)
{
visit[i] = false;
nodes[i] = "";
}
for (int i = 0; i < MAX_NUM; i++)
for (int j = 0; j < MAX_NUM; j++)
map[i][j] = 0;
num = 0;
}
}
在初始化图的时候,初始化的方法有很多,这里给出最简单的一种,也就是直接给出邻接矩阵来进行初始化。
// 构造图-----直接给出邻接矩阵
void create_map(int n, int m[MAX_NUM][MAX_NUM])
{
num = n; // 结点数量赋值
// 逐个赋值即可
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
map[i][j] = m[i][j];
}
}
}
邻接表
邻接表就是借助链表来存储图中的顶点和弧的特点,在邻接表中没有边的概念,只有弧。根据前面对于邻接矩阵的定义,可以看出邻接矩阵虽然使用起来非常简便,但是其在空间利用率上却并不高,对于稀疏图来说,即边较少的图来说,该二维数组中绝大部分数据均为0,没有较好的空间利用率,故这里引入邻接表来改善这一点。
在邻接表中,共分为两种结构,一种是弧结构,其结构如下所示。
- a d j v e x adjvex adjvex:弧尾,该弧所指向的顶点的位置
- n e x t a r c nextarc nextarc:指向下一条弧
- w e i g h t weight weight:该弧的权重
// 邻接表----弧
typedef struct ArcNode
{
int adjvex; // 该弧指向的顶点的下标
ArcNode* nextArc; // 下一条弧
int weight; // 该弧的权重
// 构造函数
ArcNode()
{
adjvex = -1;
nextArc = NULL;
weight = -1;
}
}ArcNode;
第二种结构式顶点结构,其内容如下所示。
- d a t a data data:顶点信息
- f i r s t first first:指向第一条依附于该顶点的弧。
// 邻接表----顶点
typedef struct VNode
{
string data; // 顶点中的数据
ArcNode* first; // 顶点后的第一条弧
// 构造函数
VNode()
{
data = "";
first = NULL;
}
}VNode;
邻接表的处理方法是这样的:
- 图中顶点用一个一维数组存储,当然,顶点也可以使用单链表来存储,不过数组可以较容易的读取顶点信息,更加方便,另外,对于顶点数组中,每个数据元素还需要存储指向第一个邻接点的指针,以便于查找该顶点 的边信息。
- 图中每个顶点 v i v_i vi的所有邻接点构成一个线性表,由于邻接点的数量不定,故使用单链表来存储。
// 邻接表
typedef struct ALmap
{
VNode vertices[MAX_NUM]; // 顶点数组
int vexnum; // 顶点数量
int arcnum; // 弧的数量
// 构造函数
ALmap()
{
vexnum = 0;
arcnum = 0;
}
}
接下来通过两个例子对于邻接表有一个较为直观的认识。
接下来就是如何初始化邻接表,这个根据边的信息来初始化,其实就是一个单链表的初始化的问题,故这里插入方式既可以是头插法,也可以是尾插法,这里采用尾插法,代码实现较为简单,如下所示。
// 根据元素获取数组下标
int getIndex(string data)
{
for (int i = 0; i < vexnum; i++)
{
if (data == vertices[i].data)
return i;
}
return -1;
}
// 初始化邻接表
void createMap(int vex, int arc, string nodes[], string begin[], string end[])
{
vexnum = vex; // 结点数量赋值
arcnum = arc; // 弧的数量赋值
// 初始化顶点信息
for (int i = 0; i < vexnum; i++)
{
vertices[i].data = nodes[i];
}
for (int i = 0; i < arcnum; i++)
{
int index1 = getIndex(begin[i]); // 弧头
int index2 = getIndex(end[i]); // 弧尾
cout << index1 << index2 << endl;
ArcNode* edge = new ArcNode(); // 根据数据新建一个弧
edge->adjvex = index2; // 设置弧尾
edge->nextArc = NULL; // 尾插法
if (vertices[index1].first == NULL) // 头结点为空
{
vertices[index1].first = edge;
}
else // 头结点不为空
{
ArcNode* p = vertices[index1].first;
while (p->nextArc) // 找到链表尾插入
p = p->nextArc;
p->nextArc = edge;
}
}
}
图的遍历
在建立好图之后,就需要考虑如何遍历一张图,从哪里开始又该以何种方式结束。一般来说,遍历一张图通常使用两种方法:
- 深度优先遍历 D F S DFS DFS
- 广度优先遍历 B F S BFS BFS
图的遍历往往都是从图的某一顶点开始,访问图中其余顶点,且使得每一个顶点只被访问一次。
图的遍历原理和图的存储结构无关,只是代码实现部分会有一些不同,这里以邻接矩阵为例来进行 D F S DFS DFS和 B F S BFS BFS的学习。
深度优先遍历 D F S DFS DFS
图的深度优先遍历其实就是树的先根遍历的推广,在二叉树中也被称为先序遍历。但是图的结构与树的结构不同,更为复杂,即图中可能存在环,这就可能导致重复访问某些顶点。故在遍历时使用一个标志顶点的数组 v i s i t visit visit来标记某个顶点是否被访问过,只有没有被访问过的顶点才能被访问。
接下来介绍 D F S DFS DFS的遍历过程:
-
所有顶点访问标志 v i s i t visit visit全部设置为 f a l s e false false。
-
从某个顶点 v = v 0 v=v_0 v=v0开始遍历:
i. 如果 v i s i t [ v ] = f a l s e visit[v]=false visit[v]=false,则访问该顶点,且设 v i s i t [ v ] = t r u e visit[v]=true visit[v]=true。
ii. 如果找到当前顶点的一个新的相邻顶点 w w w,设 v = w v=w v=w,重复 i i i。
iii. 如果没有相邻的顶点,说明当前顶点所有相邻顶点都已经被访问过,返回到上一层顶点。
通过上述流程不难看出整个过程需要使用递归来进行实现,每次其实访问的就是包含
v
0
v_0
v0的一个连通分量,即如果图中包含多个连通分量,其实这里只能输出包含
v
0
v_0
v0的连通分量。接下来给出两个简单的例子来进行实现。
具体代码实现过程和先序遍历几乎相同,只是在代码最开始使用一个 f o r for for循环,将图中所有的连通分量都进行输出。
/*
* DFS遍历图
*/
// 调用dfs方法
void dfs()
{
// 先对访问数组初始化
for (int i = 0; i < num; i++)
visit[i] = false;
// 逐个连通图进行遍历
for (int i = 0; i < num; i++)
{
if (visit[i] == false)
dfsMethod(i);
}
}
// 递归实现dfs遍历
void dfsMethod(int index)
{
cout << index << endl; // 输出当前遍历的结点信息
visit[index] = true; // 修改访问状态,代表已经访问过
for (int i = 0; i < num; i++) // 将当前结点相连的结点逐个进行遍历
{
if (visit[i] == false && map[index][i] != 0)
dfsMethod(i);
}
}
广度优先遍历 B F S BFS BFS
图的广度优先搜索 B F S BFS BFS算法是一种分层搜索方法,和之前二叉树中的层次遍历相类似。
在 B F S BFS BFS中,不存在回退的情况,故 B F S BFS BFS也就不是一个递归的过程。其遍历步骤如下所示。
-
所有顶点的访问标志 v i s i t visit visit设置为 f a l s e false false。
-
从某个顶点 v 0 v_0 v0开始,更新其标记 v i s i t visit visit为 t r u e true true,将其插入队列 Q Q Q中。
i. 如果队列 Q Q Q不空,则从队列 Q Q Q头上取出一个顶点,并访问该节点。
ii. 依次找到顶点 v v v所有的相邻顶点 v ′ v' v′,如果 v i s i t [ v ′ ] = f a l s e visit[v']=false visit[v′]=false,将 v ′ v' v′压入队列 Q Q Q中,并更新标记 v i s i t visit visit为 t r u e true true。
iii. 重复 i , i i i,ii i,ii
从上述过程中可以看出,在进行 B F S BFS BFS的时候,其实就是从某个顶点开始,然后将与其相邻一条边的顶点输出,再将与其相邻两条边的顶点输出,以此类推,直到将该连通分量中所有的顶点输出。
接下来通过两个例子来对
B
F
S
BFS
BFS的过程进一步的理解。
具体到代码实现,这里还是选择将所有的连通分量进行输出,并没有规定某个起始点。
/*
* BFS遍历图
*/
void bfs()
{
// 初始化visit数组
for (int i = 0; i < num; i++)
visit[i] = false;
queue<int> q; // 用于记录访问顺序
for (int i = 0; i < num; i++) // 逐个连通图遍历
{
if (visit[i] == false) // 没有遍历过才会继续遍历
{
visit[i] = true; // 设置标记
q.push(i); // 压入队列
while (!q.empty()) // 直到当前队列为空退出循环(连通图中所有点被遍历)
{
int now = q.front(); // 当前被访问的结点
cout << now << endl;
q.pop(); // 弹出
for (int j = 0; j < num; j++) // 将下一层的所有未访问过的点压入
{
if (visit[j] == false && map[now][j])
{
visit[j] = true; // 设置标记为访问过
q.push(j); // 压入队列
}
}
}
}
}
}
扩展
扩展部分主要对图中一些较为新奇的内容进行一些学习,这里目前只想到了使用非递归方式实现 D F S DFS DFS,日后想到其他再做补充。
D F S DFS DFS非递归实现
这里非递归实现 D F S DFS DFS其实也可以参考非递归实现二叉树的先序遍历,首先还是根据 D F S DFS DFS算法,将所有顶点的 v i s i t visit visit标记全部设置为 f a l s e false false。之后定义一个栈去记录当前的访问顺序,首先压入一个起始点,然后不断往后,直到该连通图中的点都被遍历到,退出循环的判断依据依旧是栈不为空。
在循环内容,每次取出栈顶元素当作当前访问的结点,然后将其所有的邻接点压入栈中,这里注意和先序遍历相同,以相反的顺序将所有的邻接点压入,这一步和之前的先压入右子树,再压入左子树是一样的道理。代码实现也和非递归实现二叉树先序遍历的代码几乎相同。
void _dfs()
{
// 初始化visit数组
for (int i = 0; i < num; i++)
visit[i] = false;
stack<int> s; // 定义一个栈用于记录访问顺序
for (int i = 0; i < num; i++) // 逐个连通图进行访问
{
if (visit[i] == false) // 只访问未访问过的结点
{
visit[i] = true; // 重新设置标记位
s.push(i); // 压入栈中
while (!s.empty()) // 直到当前连通图中内容结束
{
int now = s.top(); // 取出栈顶元素当作当前访问的结点
cout << now << endl;
s.pop();
// 将与其有关系的结点压入栈中,要倒序添加(因为栈的特性)
for (int j = num - 1; j >= 0; j--)
{
if (visit[j] == false && map[now][j])
{
visit[j] = true;
s.push(j);
}
}
}
}
}
}