8.7 KiB
C++ 算法:图论(二)- 图的存储结构 讲义
一、 引言
1.1 问题引入
我们已经知道,一张校园地图可以被抽象为一个由顶点(建筑)和边(道路)组成的图。但计算机不认识“点”和“边”,我们必须找到一种方式,用代码来描述“哪个顶点和哪个顶点相连?”以及“连接它们的道路有多长?”这个问题。
换言之,我们需要一个数据结构,来充当理论模型与算法实现之间的桥梁。
而存储图有多种办法,不同的方法会影响后续图算法的时间复杂度和空间复杂度。
二、 存储方法一:邻接矩阵 (Adjacency Matrix)
2.1 核心思想:关系地图
邻接矩阵:我们创建一个二维数组,用它来记录每一对顶点之间是否存在边。
例如我们有一张城市间的航班距离表:
| 北京 | 上海 | 广州 | |
|---|---|---|---|
| 北京 | 0 | 1200km | 2100km |
| 上海 | 1200km | 0 | 1400km |
| 广州 | 2100km | 1400km | 0 |
这个表格就是一个邻接矩阵。行和列都代表图的顶点,表格中的[行, 列]位置的数值,就代表了对应两个顶点间的关系(边的信息)。
2.2 实现细节
在C++中,我们通常使用一个二维数组 G[N][N] 来表示邻接矩阵,其中 N 是顶点的最大数量。G[u][v] 的值代表了从顶点 u 到顶点 v 的边的信息。
-
对于无权图:
G[u][v] = 1表示从u到v存在一条边。G[u][v] = 0表示从u到v不存在边。
-
对于带权图:
G[u][v] = w表示从u到v存在一条边,且其权重为w。- 如果边不存在,我们可以用一个特殊值来表示,通常是 0, -1 或 无穷大。如果用一个很大的整数(可以用
0x3f3f3f3f)来模拟无穷大,这样如果需要进行一些计算的时候的时候不易溢出。
-
对于无向图:
- 图中的边是双向的,所以矩阵是对称的。即
G[u][v]和G[v][u]的值总是相等。我们在存边时,需要同时设置这两个值。
- 图中的边是双向的,所以矩阵是对称的。即
2.3 代码实现
我们实现储存一个顶点数量为 n , 边的数量为 m 的一个有向带权图(权重定义为顶点之间的距离)。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m; // n个顶点, m条边
cin >> n >> m;
// vector的大小为 n x n,初始全部设置为 0,代表不存在任何边
vector<vector<int> > G(n, vecotr<int>(n, 0));
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w; // 读入一条从u到v,权重为w的边
// 如果是无向图,需要存两条边
G[u][v] = w;
G[v][u] = w; // 若是有向图,则没有这一行
}
// 查询从顶点2到顶点3的距离(权值)
if (G[2][3] == 0) {
cout << "顶点2和3没有边相连" << endl;
} else {
cout << "顶点2到3的距离是: " << G[2][3] << endl;
}
return 0;
}
2.4 优劣分析
- 优点:
- 实现简单:非常直观,易于理解和编写。
- 查询效率高:判断两个顶点
u和v是否有边只需访问G[u][v]即可,时间复杂度为 O(1)。
- 缺点:
- 空间复杂度高:无论图中有多少条边,都需要一个
N*N的空间,空间复杂度为 O(N²)。 - 对于稀疏图(边数远小于顶点数的平方)来说,空间浪费巨大。例如,1万个点但只有100条边的图,矩阵中绝大部分空间都是储存的 0,代表没有边相连,被浪费了。
- 遍历邻居效率低:要找到一个顶点的所有邻居,必须遍历一整行,时间复杂度为 O(N)。
- 空间复杂度高:无论图中有多少条边,都需要一个
三、 存储方法二:邻接表 (Adjacency List)
3.1 核心思想:记录我的邻居是谁
邻接矩阵像一张全国地图,记录了所有城市两两之间的关系,信息很全但很占地方。而邻接表则更像每个人的通讯录,只记录与自己有直接关系的人。
具体来说,我们为图中的每一个顶点,都维护一个列表,这个列表里只存放与该顶点直接相连的其他顶点(以及边的权重,如果是带权图)。
3.2 通过struct封装实现邻接表
为了更好地组织和管理图的数据,我们可以使用一个自定义的结构体 Graph 来表示和存储一个图。这种方式将图的所有关键信息打包在一起,让代码结构更清晰。
1. 定义图的结构体
// 定义图的结构体
struct Graph {
int V = -1, E = -1; // V:顶点数量, E:边的数量
vector<vector<int>> adjList; // 邻接表
};
V和E分别存储图的顶点数和边数。adjList是一个vector的vector。一个动态的二维数组。adjList[u]本身就是一个vector,它将作为顶点u的“邻居通讯录”,里面存放所有与u直接相连的顶点编号。
2. 初始化图的结构 在使用这个图之前,我们需要一个初始化函数来设置其规模,并准备好存放邻居列表的空间。
struct Graph {
int V = -1, E = -1; // V:顶点数量, E:边的数量
vector<vector<int>> adjList; // 邻接表
// 初始化函数,设置顶点和边的数量
void setCount(int new_V, int new_E) {
V = new_V; E = new_E;
// 一共有 V 个顶点,故需要 V 个对应顶点的通讯录来储存直接相连的顶点编号
adjList.resize(new_V);
}
};
setCount函数接收顶点的数量new_V,然后调用resize方法,将adjList变成一个包含new_V个空vector。
3. 添加边 图的结构准备好后,我们需要一个方法来添加边,以描述顶点间的连通情况。
struct Graph {
// 省略之前的结构体内容...以下为添加边的函数
void addEdge(int src, int dest) {
if (V == -1) return; // 确保图已被初始化
// 代表存在一条边src->sest, 在起点 src 的相连顶点数组中添加顶点 dest
// 注意如果是无向图,还需要添加从 dest 到 src 的反向边
adjList[src].push_back(dest);
}
};
addEdge函数就是在起点src的相连顶点列表adjList[src]的末尾,添加上终点dest。- 注意:对于无向图,由于边是双向的,添加一条
(u, v)的边,意味着v是u的邻居,同时u也是v的邻居。因此需要双向添加。
3.3 代码实现:使用Graph结构体
下面我们通过一个完整的例子,演示如何使用这个 Graph 结构体来存储一个有向无权图。
int main() {
// 创建一个图对象
Graph G;
int V, E;
cin >> V >> E;
// 调用初始化函数,准备好邻接表的结构
G.setCount(V, E);
// 读取 E 条边,并添加到图中
for (int i = 0; i < E; ++i) {
int src, dest;
cin >> src >> dest;
G.addEdge(src, dest);
}
// 此时,图G中已经存好了所有的顶点和边信息,可以进行操作,例:打印顶点3的所有邻居
for (int neighbor : G.adjList[3]) {
cout << neighbor << " ";
}
return 0;
}
3.4 优劣分析
- 优点:
- 空间效率高:邻接表只存储实际存在的边,空间复杂度为 O(N + M)(N为顶点数,M为边数)。对于使用邻接矩阵的的 O(N²),节省了大量空间。
- 遍历邻居效率高:邻接表要找到一个顶点的所有邻居,只需遍历其对应的
vector即可,时间复杂度为 O(出度(u)),其中出度(u)是顶点u的度。优于邻接矩阵的遍历邻居时间复杂度O(N) 。
- 缺点:
- 查询效率较低:邻接表判断两个顶点
u和v是否有边,需要遍历adj[u]这个列表,时间复杂度为 O(出度(u)),不如邻接矩阵直接查询的 O(1) 快。
- 查询效率较低:邻接表判断两个顶点
四、 总结
- 我们学习了两种主流的图存储方法:邻接矩阵和邻接表。
- 邻接矩阵是一个二维数组,用
G[u][v]存储边的信息,它像一张完整的地图,优点是查询快,缺点是浪费空间。 - 邻接表是一个
vector数组,adj[u]存储了u的所有邻居,它像一本通讯录,优点是节省空间,缺点是查询稍慢。选择主要在于分析图的疏密程度:稠密用矩阵,稀疏用表。 - 在信息学竞赛和实际应用中,由于图的稀疏性,其实邻接表的使用频率会稍高于邻接矩阵。