图是我们在《数据结构》这门课程中遇到的最后一个类型的结构了,图是一种多对多的结构,从广义的角度上来看,实际上线性表、树这两种结构也能算作图,不过在数据结构当中我们认为图还是一个多对多的结构,那么这样的结构相比于一对一和一对多的结构会更加难以表示一点
图其实跟我们平时说的图片还是有一些区别的,一个图 G G G由顶点集 V V V和边集 E E E构成,我们一般记为 G = ( V , E ) G=(V,E) G=(V,E),例如下面的这个图:
边和点共同构成了图,一条边由两个顶点构成,我们在这张图中的边是有方向的,这种边叫做有向边,表示为 < A , B > <A,B>;如果边是没有方向的,则称为无向边,表示为 ( A , B ) (A,B) (A,B);只有有向边的图,称为有向图,而只包含无向边的图称为无向图,在数据结构中,我们认为图要么是有向图,要么是无向图
所以我们就可以读取一下这张图:它的顶点集 V = { V 1 , V 2 , V 3 , V 4 } V=\{V_1, V_2, V_3, V_4\} V={V1,V2,V3,V4},然后边集 E = { < V 1 , V 2 > , < V 1 , V 3 > , < V 4 , V 1 > , < V 3 , V 4 > } E=\{
在数据结构中我们规定:顶点不能有指向自己的边,这种边称为环或自环,同时我们也规定,无向图的两个顶点之间不能有多于一条的边,有向图的两个顶点之间可以有两条方向相反的边,不可以有两条方向相同的边
首先我们有两个图 G = ( V , E ) , G ′ = ( V ′ , E ′ ) G=(V,E), G'=(V',E') G=(V,E),G′=(V′,E′),它们同为无向图或有向图,且满足 V ′ ⊆ V , E ′ ⊆ E V'\subseteq V, E'\subseteq E V′⊆V,E′⊆E,则称 G ′ G' G′是 G G G的子图,所以子图就是从原图中取了一部分顶点和对应的一部分边形成的新图
每对顶点之间都有一条边的无向图,称为完全无向图;而如果每对顶点 i i i和 j j j之间都有边 < i , j > <i,j>和 < j , i >
在无向图 G = ( V , E ) G=(V,E) G=(V,E)中,从顶点v到顶点w之间的路径是一个由顶点组成的顶点序列 ( v 0 , v 1 , . . . , v k ) (v_0, v_1,...,v_k) (v0,v1,...,vk),其中 v 0 = v , v k = w , ( v 1 , v i + 1 ) ∈ E ( G ) ( 0 ≤ j < k ) v_0=v,v_k=w,(v_1,v_{i+1})\in E(G) \ (0\leq j
用 ( v 0 , v 1 , . . . , v k ) (v_0, v_1, ..., v_k) (v0,v1,...,vk)表示这条路径,它的长度为k,如果只有一个顶点,则称v到自身的路径长度为0,若 G G G是有向图,则路径是有方向的, < v 0 , v 1 , . . . , v k >
如果无向图G中每对顶点v和w都有从v到w的路径,那么称无向图 G G G是连通的,无向图 G G G中的极大连通子图为G的连通分量
如果有向图 G G G中每对顶点v和w都有从v到w的路径,则称有向图 G G G是强连通的
如果有向图 G G G中每对顶点v和w,有一个由不同顶点组成的顶点序列 < v 0 , v 1 , . . . , v k >
强连通的有向图一定是弱连通的,有向图 G G G的极大强连通子图为图 G G G的强连通分量,有向图 G G G的极大弱连通子图为图 G G G的弱连通分量
如果图 G G G中有一条路径 ( v 0 , v 1 , . . . , v k ) (v_0, v_1,...,v_k) (v0,v1,...,vk),且 v 0 = v k v_0=v_k v0=vk,那么称这条路径为回路(或环)
在 无向图 G G G 中,如果 v ∈ V ( G ) v\in V(G) v∈V(G),那么v邻接的顶点个数称为顶点v的度
在 有向图 G G G 中,如果 v ∈ V ( G ) v\in V(G) v∈V(G),那么邻接到v的顶点个数为顶点v的入度,邻接于v的顶点个数称为顶点v的出度
图这一部分的基本概念还是相当多的,接下来我们就要看看怎么在计算机中表示和存储一个图了,我们一般用的是邻接矩阵和邻接表两种形式
对于一个具有 n n n个顶点的无向图,定义矩阵 A n × n A_{n\times n} An×n为:
A ( i , j ) = { 1 , ( i , j ) ∈ E ( G ) , 0 , ( i , j ) ∉ E ( G ) A(i,j) = \begin{cases} 1, (i,j)\in E(G),\\ 0, (i,j)\notin E(G) \end{cases} A(i,j)={1,(i,j)∈E(G),0,(i,j)∈/E(G)
所以可以很容易地知道,顶点i的度为:
∑ j = 1 n A ( i , j ) \sum_{j=1}^nA(i,j) j=1∑nA(i,j)
此时的 A ( i , j ) A(i,j) A(i,j)仅代表两个顶点是否连通,如果是带权边,则 A ( i , j ) A(i,j) A(i,j)存储的值是两个顶点之间的边的权重值,此时 A A A定义为:
A ( i , j ) = { w i j , i ≠ j , ( i , j ) ∈ E ( G ) , 0 , i = j , ∞ A(i,j)=\begin{cases} w_{ij}, i\neq j, (i,j)\in E(G),\\ 0, i = j, \\ \infty \end{cases} A(i,j)=⎩ ⎨ ⎧wij,i=j,(i,j)∈E(G),0,i=j,∞
毕竟在C++里没有一个真正的 ∞ \infty ∞,所以我们一般会在程序的最前面定义一个inf用于后续的赋值操作:
constexpr int inf = 0x3f3f3f3f;
那么对于 n n n个顶点的有向图,有:
A ( i , j ) = { 1 , < i , j > ∈ E ( G ) , 0 , < i , j > ∉ E ( G ) A(i, j) = \begin{cases} 1, \in E(G),\\ 0, \notin E(G) \end{cases} A(i,j)={1,<i,j>∈E(G),0,<i,j>∈/E(G)
所以此时也可以比较轻松地求出顶点i的入度和出度分别为:
i d = ∑ i = 1 n A ( i , j ) , o d = ∑ j = 1 n A ( i , j ) id = \sum_{i=1}^nA(i,j), od = \sum_{j=1}^nA(i,j) id=i=1∑nA(i,j),od=j=1∑nA(i,j)
其中id为入度,od为出度,分别是第j列的和以及第i行的和
所以邻接矩阵的空间复杂度为 O ( n 2 ) O(n^2) O(n2),毕竟需要存储一个 n × n n\times n n×n的矩阵嘛,邻接矩阵存储图的方式比较方便,对我们来说比较直观,但是假设图中的边数非常少(称为稀疏图),这时候就会有非常多的空间浪费,所以我们还有另一种存储方法—邻接表
邻接表的想法很简单:既然图是由点和边构成的,那么我们对应每个点,把由它为起点的所有边全都存储起来,这样一来,我们就可以有效地减少没有边的部分的空间浪费,当然,如果我们的图足够稠密,邻接表的存法就不如邻接矩阵了,我们看看邻接表的一般定义:
#include
constexpr int MAXN = 5e3 + 20; // 最大结点数量
struct edge
{
int end; // 终点
int w; // 边权
};
vector<edge> g1[MAXN]; // 带权图
vector<int> g2[MAXN]; // 无权图
带权图和无权图最主要的区别就在边权,所以存储带权图的时候我们需要在边上附带上边权的属性,那么无权图当然是没有必要的了
到这儿你应该也能看出来为什么邻接表更多用于存储稀疏图了,因为当图足够稠密的时候,邻接表就会退化成为邻接矩阵
因为我们是基于C++的数据结构博客,所以直接用vector实现会很方便,如果你使用C语言,我们可能还需要用到链表,这里大概介绍一下利用链表实现的邻接表:
constexpr int MANX = 5e3 + 20;
struct node
{
int end;
int w;
node* next;
};
// 此处忽略若干关于链表的操作定义
using list = node*;
list g[MAXN]; // 邻接链表
无向图和有向图都可以用邻接表来存储,不过如果是无向图,我们对于一条边需要存储两次,例如:
// n为顶点数,m为边数
for (int i = 1; i <= m; i++) {
int u, v, w;
cin >> u >> v >> w; // 无向边
g[u].push_back({v, w}); // u->v
g[v].push_back({u, w}); // v->u
}
因为是无向边,两边实际上都要连起来,否则如果只有一边就会导致两个顶点中单向连通,在后续会出现非常多问题
就像线性表的遍历、树的遍历,我们存储了一个图之后,首先也要能够把整张图遍历一遍,至少要把所有节点扫一遍吧,那么这就是个技术活了,线性表和树的遍历都可以保证不会走重复的路,但是图的结构太复杂,让我们来走都不一定能保证不重不漏,所以就需要一些方法,DFS和BFS就是两种常见的方法
走迷宫的时候有一种原则叫做右手法则,当然不是电磁学的那个,而是说,我们在走迷宫的时候始终沿着右手的方向走,最后总是能够走到出口,当然,因为遍历的那个顶点不是人,它其实不存在左右的的概念,不过我们可以借鉴一下这个过程
比如说,我们走到了某个顶点,就向可以走的所有顶点做一个试探,直到走到了死路,我们就认为这次试探结束,然后开始回溯,回溯到上一个有选项的地方,选择另一条路继续进行尝试,你发现,这个东西,实在是有点熟悉啊:我们在树的前序/中序/后序遍历好像好像都是这个思路做的,我们平时在玩有多分支但是可以回溯的游戏的时候,也会在打完了一个结局之后回溯到上一个分支点,选择其他的选项,所以其实,这就是深度优先搜索,我们每次找到一个终点,然后回溯到上一个分叉点选择其它的选项
不过我们现在考虑是图,有的顶点有可能会被重复访问,所以这时候我们需要一个对应的数组记忆哪一些顶点是已经被访问过的了,如果已经访问过,就不再重新访问
所以我们可以给出一个非常简单的基于邻接表的DFS代码,DFS更多还是一种思想:
#include
#include
using namespace std;
constexpr int MAXN = 5e3 + 20;
vector<int> g[MAXN];
bool visited[MAXN]{ 0 };
void dfs(int u)
{
visit[u] = true; // u为起点
cout << u << " ";
for (const auto& v : g[u]) {
if (!visited[v]) dfs(v);
}
}
对于邻接表,DFS的时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣),而对于邻接矩阵,其时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2),当然 ∣ E ∣ |E| ∣E∣最大可以到 ∣ V ∣ 2 |V|^2 ∣V∣2级别,所以对于稠密图,二者的时间复杂度几乎是一致的
广度优先搜索就是另一个思路了,我们可以用蛋糕来举个例子,某个国家的F小姐很喜欢吃蛋糕,今天她获得了10个品种的小蛋糕各一份,她应该怎么品尝10种蛋糕呢?当然是想吃什么吃什么,还是要按照一定的顺序来,她当然可以每次吃掉一块蛋糕,然后去吃下一块(类似DFS),也可以每次从头开始,10块蛋糕依次吃一小口,然后这么一直循环,直到把所有蛋糕全都吃完
所以这就是广度优先搜索,从起点开始,我们首先找到它连接的顶点(记为I),然后从I中的所有顶点中找到与它们连接的顶点,这么一直循环下去直到最后遍历完所有的点,广度优先搜索实际上就是按照层次完成图的遍历访问
哦呀,这不就是树的层次遍历吗!说起这个来你应该就很熟悉了,没错,树的层次遍历实际上也就是一种BFS
BFS同样也只是一种思想,这里给出一个基本的基于邻接表实现的BFS:
#include
#include
using namespace std;
constexpr int MAXN = 5e3 + 20;
vector<int> g[MAXN];
bool visited[MAXN]{ 0 };
void bfs(int u)
{
queue<int> q;
cout << u << " "; // 从u开始访问
visited[u] = true;
q.push(u);
while (!q.empty()) {
int t{ q.front() };
q.pop();
for (const auto& v : g[t]) {
if (!visited[v]) {
cout << v << " ";
visited[v] = true;
q.push(v);
}
}
}
}
这样就好了,我们从起点开始,每次把它邻接的未访问顶点加入队列中,然后一直循环这个过程,直到所有的顶点都已经被访问过一次,BFS就结束了
同理,基于邻接表的广度优先搜索时间复杂度仍然是 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣),而邻接矩阵为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
如果一个无向图是连通图,那么无论从哪个顶点开始遍历(BFS/DFS均可),都可以访问所有顶点;但如果不连通,那么从任何顶点 v v v出发通过遍历**只能访问到 v v v的极大连通子图(即连通分量)**的所有顶点
因此要求出所有的连通分量,可以对所有顶点进行检验,如果已经被访问,则该顶点已经出现在某个连通分量中,如果没有被访问,则从这个顶点开始遍历,就可以得到另一个连通分量
图这一篇我还是分成了两个部分,上半部分主要是介绍一些基本的图的概念,而下半部分则主要会集中于几个图的算法,最小生成树、最短路径和拓扑排序三个关键的问题