1. 引言
图(Graph)是数据结构中非常重要且应用广泛的一种非线性结构。与线性表、树不同,图中的元素之间是多对多的关系,任意两个顶点之间都可能存在联系。无论是社交网络中的好友关系、地图导航中的路径规划,还是计算机网络中的拓扑结构,都可以用图来建模。本文将从图的基本概念出发,逐步介绍图的存储结构、遍历算法以及最小生成树等核心内容,并给出完整的 C 语言实现。
2. 图的基本概念
图由顶点集合和边集合两部分组成,通常记作 G = (V, E),其中 V 是顶点的有穷非空集合,E 是顶点之间边的有穷集合。
- 顶点(Vertex):图中的数据元素,例如社交网络中的每个用户。
- 边(Edge):顶点与顶点之间的连线,表示两个顶点之间存在某种关系。
- 无向图(Undirected Graph):边没有方向,例如好友关系,A 认识 B 也就意味着 B 认识 A。
- 有向图(Directed Graph):边有方向,例如微博的关注关系,A 关注 B 不代表 B 关注 A。
- 权(Weight):边上的数值,可以表示距离、花费、时间等实际含义,带权的图称为网(Network)。
- 度(Degree):与顶点相关联的边的数目。在有向图中,还分为入度和出度。
- 路径与回路:从一个顶点到另一个顶点经过的顶点序列称为路径;起点和终点相同的路径称为回路或环。
3. 图的存储结构
图的存储方式有多种,最常用的是邻接矩阵和邻接表两种。下面分别介绍它们的原理,并给出 C 语言实现。
3.1 邻接矩阵
邻接矩阵使用一个二维数组来存储顶点之间的关系。对于包含 n 个顶点的图,定义一个 n × n 的矩阵,矩阵中第 i 行第 j 列的元素表示顶点 i 与顶点 j 之间是否存在边。对于无向图,邻接矩阵是对称的;对于带权图,矩阵元素存储权值,不存在的边可以用 0 或无穷大表示。
邻接矩阵的优点是直观、易于判断任意两个顶点之间是否有边,缺点是当图的边数较少时,会浪费大量存储空间。
c
#include <stdio.h>
#include <stdlib.h>
#define MAX_VERTEX_NUM 100
#define INFINITY 65535
typedef struct {
int vertex[MAX_VERTEX_NUM]; // 顶点数组
int edge[MAX_VERTEX_NUM][MAX_VERTEX_NUM]; // 邻接矩阵
int vertex_num; // 顶点数
int edge_num; // 边数
} MGraph;
// 初始化图
void init_graph(MGraph *g, int vertex_num) {
g->vertex_num = vertex_num;
g->edge_num = 0;
for (int i = 0; i < vertex_num; i++) {
g->vertex[i] = i;
for (int j = 0; j < vertex_num; j++) {
g->edge[i][j] = 0;
}
}
}
// 添加无向边
void add_undirected_edge(MGraph *g, int i, int j) {
g->edge[i][j] = 1;
g->edge[j][i] = 1;
g->edge_num++;
}
// 打印邻接矩阵
void print_matrix(MGraph *g) {
for (int i = 0; i < g->vertex_num; i++) {
for (int j = 0; j < g->vertex_num; j++) {
printf("%d ", g->edge[i][j]);
}
printf("\n");
}
}
int main() {
MGraph g;
init_graph(&g, 5);
add_undirected_edge(&g, 0, 1);
add_undirected_edge(&g, 0, 2);
add_undirected_edge(&g, 1, 3);
add_undirected_edge(&g, 2, 4);
print_matrix(&g);
return 0;
}
3.2 邻接表
邻接表是一种链式存储结构,它把每个顶点的所有邻接点组织成一条链表。对于每个顶点,用一个头结点记录顶点信息,后面挂接一条链表,链表中的每个结点表示与该顶点相邻的一个顶点。邻接表适合存储边数较少的稀疏图,能够有效节省空间。
c
#include <stdio.h>
#include <stdlib.h>
#define MAX_VERTEX_NUM 100
typedef struct EdgeNode {
int adjvex; // 邻接点下标
struct EdgeNode *next; // 指向下一条边
} EdgeNode;
typedef struct VertexNode {
int data; // 顶点数据
EdgeNode *first_edge; // 指向第一条边
} VertexNode;
typedef struct {
VertexNode adjlist[MAX_VERTEX_NUM];
int vertex_num;
int edge_num;
} ALGraph;
// 初始化图
void init_graph(ALGraph *g, int vertex_num) {
g->vertex_num = vertex_num;
g->edge_num = 0;
for (int i = 0; i < vertex_num; i++) {
g->adjlist[i].data = i;
g->adjlist[i].first_edge = NULL;
}
}
// 添加无向边(头插法)
void add_undirected_edge(ALGraph *g, int i, int j) {
EdgeNode *node = (EdgeNode *)malloc(sizeof(EdgeNode));
node->adjvex = j;
node->next = g->adjlist[i].first_edge;
g->adjlist[i].first_edge = node;
node = (EdgeNode *)malloc(sizeof(EdgeNode));
node->adjvex = i;
node->next = g->adjlist[j].first_edge;
g->adjlist[j].first_edge = node;
g->edge_num++;
}
// 打印邻接表
void print_adjlist(ALGraph *g) {
for (int i = 0; i < g->vertex_num; i++) {
printf("顶点 %d: ", g->adjlist[i].data);
EdgeNode *p = g->adjlist[i].first_edge;
while (p != NULL) {
printf("%d ", p->adjvex);
p = p->next;
}
printf("\n");
}
}
int main() {
ALGraph g;
init_graph(&g, 5);
add_undirected_edge(&g, 0, 1);
add_undirected_edge(&g, 0, 2);
add_undirected_edge(&g, 1, 3);
add_undirected_edge(&g, 2, 4);
print_adjlist(&g);
return 0;
}
4. 图的遍历
图的遍历是指从图中某个顶点出发,按照某种规则访问图中所有顶点,且每个顶点仅被访问一次。常用的遍历方式有深度优先搜索(DFS)和广度优先搜索(BFS)两种。
4.1 深度优先搜索(DFS)
深度优先搜索类似于树的先序遍历。它从起始顶点出发,沿着一条路径一直访问下去,直到无法继续时再回退,换一条路径继续访问,直到所有顶点都被访问过。DFS 通常借助递归或栈来实现。
c
#include <stdio.h>
#include <stdlib.h>
#define MAX_VERTEX_NUM 100
typedef struct EdgeNode {
int adjvex;
struct EdgeNode *next;
} EdgeNode;
typedef struct VertexNode {
int data;
EdgeNode *first_edge;
} VertexNode;
typedef struct {
VertexNode adjlist[MAX_VERTEX_NUM];
int vertex_num;
int edge_num;
} ALGraph;
int visited[MAX_VERTEX_NUM];
// 初始化图(省略,与邻接表一致)
// 深度优先搜索
void dfs(ALGraph *g, int v) {
visited[v] = 1;
printf("%d ", g->adjlist[v].data);
EdgeNode *p = g->adjlist[v].first_edge;
while (p != NULL) {
if (!visited[p->adjvex]) {
dfs(g, p->adjvex);
}
p = p->next;
}
}
// 遍历所有连通分量
void dfs_traverse(ALGraph *g) {
for (int i = 0; i < g->vertex_num; i++) {
visited[i] = 0;
}
for (int i = 0; i < g->vertex_num; i++) {
if (!visited[i]) {
dfs(g, i);
}
}
}
4.2 广度优先搜索(BFS)
广度优先搜索类似于树的层序遍历。它从起始顶点出发,先访问其所有邻接点,再依次访问这些邻接点的邻接点,逐层向外扩展。BFS 通常借助队列来实现。
c
#include <stdio.h>
#include <stdlib.h>
#define MAX_VERTEX_NUM 100
typedef struct EdgeNode {
int adjvex;
struct EdgeNode *next;
} EdgeNode;
typedef struct VertexNode {
int data;
EdgeNode *first_edge;
} VertexNode;
typedef struct {
VertexNode adjlist[MAX_VERTEX_NUM];
int vertex_num;
int edge_num;
} ALGraph;
int visited[MAX_VERTEX_NUM];
// 简单队列实现
typedef struct {
int data[MAX_VERTEX_NUM];
int front;
int rear;
} Queue;
void init_queue(Queue *q) {
q->front = 0;
q->rear = 0;
}
void enqueue(Queue *q, int v) {
q->data[q->rear++] = v;
}
int dequeue(Queue *q) {
return q->data[q->front++];
}
int is_empty(Queue *q) {
return q->front == q->rear;
}
// 广度优先搜索
void bfs(ALGraph *g, int v) {
Queue q;
init_queue(&q);
visited[v] = 1;
printf("%d ", g->adjlist[v].data);
enqueue(&q, v);
while (!is_empty(&q)) {
int cur = dequeue(&q);
EdgeNode *p = g->adjlist[cur].first_edge;
while (p != NULL) {
if (!visited[p->adjvex]) {
visited[p->adjvex] = 1;
printf("%d ", g->adjlist[p->adjvex].data);
enqueue(&q, p->adjvex);
}
p = p->next;
}
}
}
// 遍历所有连通分量
void bfs_traverse(ALGraph *g) {
for (int i = 0; i < g->vertex_num; i++) {
visited[i] = 0;
}
for (int i = 0; i < g->vertex_num; i++) {
if (!visited[i]) {
bfs(g, i);
}
}
}
5. 最小生成树
对于一个带权的连通无向图,生成树是包含图中全部顶点且边数最少的连通子图。最小生成树(Minimum Spanning Tree,MST)则是所有生成树中边的权值之和最小的一棵。最小生成树在网络布线、交通规划等场景中有广泛应用。常用的算法有 Prim 算法和 Kruskal 算法。
5.1 Prim 算法
Prim 算法的基本思想是从一个顶点开始,每次选择一条连接已选顶点集合与未选顶点集合的最小权值边,将对应的新顶点加入集合,重复直到所有顶点都被选中。Prim 算法适合边稠密的图。
c
#include <stdio.h>
#include <stdlib.h>
#define MAX_VERTEX_NUM 100
#define INFINITY 65535
typedef struct {
int vertex[MAX_VERTEX_NUM];
int edge[MAX_VERTEX_NUM][MAX_VERTEX_NUM];
int vertex_num;
int edge_num;
} MGraph;
// Prim 算法求最小生成树
void prim(MGraph *g, int start) {
int lowcost[MAX_VERTEX_NUM]; // 记录顶点到已选集合的最小权值
int adjvex[MAX_VERTEX_NUM]; // 记录该最小权值对应的顶点
for (int i = 0; i < g->vertex_num; i++) {
lowcost[i] = g->edge[start][i];
adjvex[i] = start;
}
for (int k = 1; k < g->vertex_num; k++) {
int min = INFINITY;
int j = -1;
for (int i = 0; i < g->vertex_num; i++) {
if (lowcost[i] != 0 && lowcost[i] < min) {
min = lowcost[i];
j = i;
}
}
if (j == -1) break;
printf("边 (%d, %d) 权值 %d\n", adjvex[j], j, lowcost[j]);
lowcost[j] = 0;
for (int i = 0; i < g->vertex_num; i++) {
if (lowcost[i] != 0 && g->edge[j][i] < lowcost[i]) {
lowcost[i] = g->edge[j][i];
adjvex[i] = j;
}
}
}
}
5.2 Kruskal 算法
Kruskal 算法的基本思想是先把所有边按权值从小到大排序,然后依次选取权值最小的边,只要该边不会与已选边构成回路,就将其加入生成树,直到选出 n-1 条边为止。Kruskal 算法适合边稀疏的图。
c
#include <stdio.h>
#include <stdlib.h>
#define MAX_EDGE_NUM 100
#define MAX_VERTEX_NUM 100
typedef struct {
int begin;
int end;
int weight;
} Edge;
// 并查集查找根结点
int find_root(int parent[], int f) {
while (parent[f] > 0) {
f = parent[f];
}
return f;
}
// Kruskal 算法求最小生成树
void kruskal(Edge edges[], int edge_num, int vertex_num) {
// 按权值从小到大排序(冒泡排序)
for (int i = 0; i < edge_num - 1; i++) {
for (int j = 0; j < edge_num - 1 - i; j++) {
if (edges[j].weight > edges[j + 1].weight) {
Edge temp = edges[j];
edges[j] = edges[j + 1];
edges[j + 1] = temp;
}
}
}
int parent[MAX_VERTEX_NUM] = {0};
int count = 0;
for (int i = 0; i < edge_num && count < vertex_num - 1; i++) {
int m = find_root(parent, edges[i].begin);
int n = find_root(parent, edges[i].end);
if (m != n) {
parent[m] = n;
printf("边 (%d, %d) 权值 %d\n", edges[i].begin, edges[i].end, edges[i].weight);
count++;
}
}
}
6. 最短路径
最短路径问题是图论中的经典问题,目标是找到两个顶点之间权值之和最小的路径。常用的算法有 Dijkstra 算法(单源最短路径)和 Floyd 算法(多源最短路径)。
6.1 Dijkstra 算法
Dijkstra 算法用于求解从某个源点到其余各顶点的最短路径,要求图中边的权值非负。算法通过逐步扩展已确定最短路径的顶点集合来求解。
7. 总结
通过本文的学习,应重点掌握以下内容:
一是理解图的基本概念,包括顶点、边、有向图与无向图、权、度以及路径与回路等术语;二是掌握邻接矩阵和邻接表两种存储结构的原理与适用场景,并能够用 C 语言实现图的建立与基本操作;三是熟练运用深度优先搜索(DFS)和广度优先搜索(BFS)两种遍历算法,理解递归与队列在其中的作用;四是掌握最小生成树的 Prim 算法和 Kruskal 算法,以及最短路径的 Dijkstra 算法,能够分析它们的时间复杂度和适用条件。
建议读者在理解算法思想的基础上,动手编写并调试代码,通过实际运行加深对图这一数据结构的理解。