第59章 图的定义及基本图论算法
图(Graph)是一种比线性表和树更复杂的非线性数据结构,由**顶点(Vertex)和边(Edge)**组成,能够描述多对多的复杂关系,在社交网络、路线规划、电路设计、图像处理和迷宫求解等领域都有广泛应用。
本章先建立图的基本概念(定义、种类、度、存储结构),再讲解两种最基础的图遍历算法——深度优先搜索(DFS)与广度优先搜索(BFS),最后重点展开泛洪填充 Flood Fill:它是图遍历在二维网格上的直接应用,也是 GESP 七级考查的图论核心算法之一。
59.1 图的定义
在数学中,图通常表示为:
其中:
- (Vertex set)是顶点集,一个非空的有限集合,每个元素称为一个顶点(或节点);
- (Edge set)是边集,由顶点之间的连接关系构成,每条边表示两个顶点之间的关联。
例如,一个具有 5 个顶点、5 条边的无向图可表示为 ,,其结构如下:
59.2 图的种类
59.2.1 无向图
无向图中,边是无方向的。连接顶点 和 的边写作 ,且 与 代表同一条边。
示例:社交好友关系——若 A 是 B 的好友,则 B 也是 A 的好友,属于无向关联。
59.2.2 有向图
有向图的边带有方向。从 指向 的有向边记作 ,其中 为起点(尾), 为终点(头)。有向边 与 不是同一条边。
示例:网页超链接——A 链接到 B,并不代表 B 一定链接到 A。
59.2.3 加权图与无权图
- 无权图:边只表示"是否相连",没有额外数值。
- 加权图:每条边带有一个权值(如距离、费用、时间),常用于最短路径等问题。权重通常用一个数值表示,例如
graph[u][v] = w表示边 的权值为 。
在 GESP 七级的图遍历与 Flood Fill 考查中,主要接触无权图和**网格(隐含边权为 1)**两种形态。
59.3 图节点的度的概念
"度"(Degree)刻画了一个顶点与其他顶点的连接紧密程度。
59.3.1 无向图的度
顶点 的度 :与该顶点相连的边的总数。
握手定理(Handshaking Lemma):无向图中,所有顶点度数之和等于边数的两倍,即:
推论:无向图所有顶点的度数之和必为偶数,因此"奇度顶点"的个数一定是偶数。
59.3.2 有向图的度
有向图把度拆分为"入度"和"出度":
- 入度 :以该顶点为终点的有向边数量;
- 出度 :以该顶点为起点的有向边数量;
- 总度:。
性质:所有顶点入度总和 = 所有顶点出度总和 = 图的总边数,即:
59.4 图的数据结构表示
在程序中表示图,常见两种存储方式:邻接矩阵和邻接表。
59.4.1 邻接矩阵
使用二维数组 graph[n][n] 存储图, 为顶点总数。
- 无向图:
graph[i][j] = graph[j][i](对称); - 有向图:仅当 存在时
graph[i][j]标记为 1(或权重),否则为 0(或无穷大); - 优点:判断两点是否相连为 ;
- 缺点:空间复杂度 ,稀疏图浪费严重。
#include <cstdio>
#define MAX_VERTEX 5
// 初始化无向图邻接矩阵
void InitUndirectedGraph(int graph[MAX_VERTEX][MAX_VERTEX]) {
for (int i = 0; i < MAX_VERTEX; i++) {
for (int j = 0; j < MAX_VERTEX; j++) {
graph[i][j] = 0;
}
}
// 添加无向边(每条边存两次)
graph[0][1] = graph[1][0] = 1;
graph[0][2] = graph[2][0] = 1;
graph[1][3] = graph[3][1] = 1;
graph[1][4] = graph[4][1] = 1;
graph[2][4] = graph[4][2] = 1;
}
int main() {
int graph[MAX_VERTEX][MAX_VERTEX];
InitUndirectedGraph(graph);
printf("无向图邻接矩阵:\n");
for (int i = 0; i < MAX_VERTEX; i++) {
for (int j = 0; j < MAX_VERTEX; j++) {
printf("%d ", graph[i][j]);
}
printf("\n");
}
return 0;
}
59.4.2 邻接表
用"数组 + 链表"组合存储:数组下标代表顶点,每个顶点挂载一条链表,存储与它相邻的顶点。
- 无向图:每条边存两次;有向图:仅起点的链表存储该边;
- 优点:稀疏图节省空间,空间复杂度 ;
- 缺点:判断两点是否连边需要遍历链表,效率偏低(平均 )。
#include <cstdio>
#include <cstdlib>
// 邻接表节点
typedef struct Node {
int vertex;
struct Node* next;
} Node;
// 邻接表头结点
typedef struct AdjList {
Node* head;
} AdjList;
// 图结构
typedef struct Graph {
int numVertices;
AdjList* adjLists;
} Graph;
// 创建链表节点
Node* createNode(int v) {
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->vertex = v;
newNode->next = NULL;
return newNode;
}
// 创建空图
Graph* createGraph(int vertices) {
Graph* graph = (Graph*)malloc(sizeof(Graph));
graph->numVertices = vertices;
graph->adjLists = (AdjList*)malloc(vertices * sizeof(AdjList));
for (int i = 0; i < vertices; i++) {
graph->adjLists[i].head = NULL;
}
return graph;
}
// 添加无向边
void addUndirectedEdge(Graph* graph, int u, int v) {
Node* newNode = createNode(v);
newNode->next = graph->adjLists[u].head;
graph->adjLists[u].head = newNode;
newNode = createNode(u);
newNode->next = graph->adjLists[v].head;
graph->adjLists[v].head = newNode;
}
// 打印邻接表
void printGraph(Graph* graph) {
for (int v = 0; v < graph->numVertices; v++) {
printf("顶点%d的邻接表: ", v);
Node* temp = graph->adjLists[v].head;
while (temp) {
printf("%d -> ", temp->vertex);
temp = temp->next;
}
printf("NULL\n");
}
}
int main() {
Graph* graph = createGraph(5);
addUndirectedEdge(graph, 0, 1);
addUndirectedEdge(graph, 0, 2);
addUndirectedEdge(graph, 1, 3);
addUndirectedEdge(graph, 1, 4);
addUndirectedEdge(graph, 2, 4);
printGraph(graph);
return 0;
}
59.4.3 两种表示方式的选择
| 表示方式 | 空间复杂度 | 查边效率 | 遍历邻点 | 适用场景 |
|---|---|---|---|---|
| 邻接矩阵 | 顶点少、边稠密 | |||
| 邻接表 | 顶点多、边稀疏(更常用) |
经验法则:当边数 接近 (稠密图)用邻接矩阵;当 远小于 (稀疏图)用邻接表。在 DFS/BFS/Flood Fill 中,邻接表或"网格的隐式邻居"是主流选择。
59.5 深度优先搜索 DFS
59.5.1 遍历逻辑
从起点出发,尽可能向深处走:访问一个顶点后,递归地去访问它未访问过的邻居;当当前顶点没有未访问的邻居(走到死胡同)时,回溯到上一个顶点继续探索。需要一个 visited 数组标记已访问顶点,防止在环上重复访问、陷入死循环。
59.5.2 递归实现
下面基于 59.4.2 的邻接表结构给出 DFS 递归遍历:
void DFSRecursive(int v, int visited[], Graph* graph) {
visited[v] = 1; // 标记已访问
printf("%d ", v);
Node* temp = graph->adjLists[v].head;
while (temp) {
int u = temp->vertex;
if (!visited[u]) { // 只递归未访问的邻居
DFSRecursive(u, visited, graph);
}
temp = temp->next;
}
}
59.5.3 非递归(栈)实现
当图很深时,递归可能栈溢出,可用显式栈模拟:
void DFSNonRecursive(int start, Graph* graph) {
int numVertices = graph->numVertices;
int visited[numVertices];
for (int i = 0; i < numVertices; i++) visited[i] = 0;
int stack[numVertices];
int top = -1;
visited[start] = 1;
stack[++top] = start;
printf("DFS非递归遍历顺序:");
while (top != -1) {
int v = stack[top--];
printf("%d ", v);
Node* temp = graph->adjLists[v].head;
// 先把未访问邻居暂存,再逆序压栈,保证与递归顺序一致
int tempStack[numVertices];
int tempTop = -1;
while (temp) {
int u = temp->vertex;
if (!visited[u]) {
visited[u] = 1;
tempStack[++tempTop] = u;
}
temp = temp->next;
}
while (tempTop != -1) stack[++top] = tempStack[tempTop--];
}
printf("\n");
}
59.5.4 适用场景
DFS 擅长"一条路走到底",常用于:连通性/连通块判断、环检测、拓扑排序、迷宫一条路径查找。注意:DFS 不能保证求出无权图最短路径(它先深后广,路径长度无保证)。
59.6 广度优先搜索 BFS
59.6.1 遍历逻辑
从起点出发逐层扩散:使用队列存储"待访问顶点",先访问起点,再把起点的所有未访问邻居入队;之后每次从队首取出一个顶点并访问它,同时把它未访问的邻居入队。如此保证"同一层全部访问完才进入下一层",按"由近及远"的顺序遍历。
59.6.2 代码实现
void BFS(int start, Graph* graph) {
int numVertices = graph->numVertices;
int visited[numVertices];
for (int i = 0; i < numVertices; i++) visited[i] = 0;
int queue[numVertices];
int front = 0, rear = 0;
visited[start] = 1;
queue[rear++] = start;
printf("BFS遍历顺序:");
while (front < rear) {
int v = queue[front++];
printf("%d ", v);
Node* temp = graph->adjLists[v].head;
while (temp) {
int u = temp->vertex;
if (!visited[u]) {
visited[u] = 1;
queue[rear++] = u;
}
temp = temp->next;
}
}
printf("\n");
}
59.6.3 适用场景(无权图最短路径)
BFS 按层扩散的特性,使其天然适合求无权图(或边权均为 1 的图)的最短路径——第一次到达某个顶点时的步数即为最短距离。也常用于:连通区域统计、层级遍历、Flood Fill(见 59.7)。
59.7 泛洪填充 Flood Fill
59.7.1 什么是 Flood Fill(定义与本质)
Flood Fill(泛洪填充) 是图遍历在**二维网格(Grid)**上的应用。把网格看作一张图:
- 每个单元格(格点)是一个顶点;
- 两个单元格若上下左右相邻,则它们之间有一条边(即四邻域连接);
- 从一个"种子单元格"出发,沿着边访问所有连通且满足条件(如颜色相同)的单元格,并对它们执行某种操作(最常见的是"重新着色")。
因此,Flood Fill 的本质就是:在网格图上求"与种子点连通的同色连通块",并对该连通块统一处理。它是 BFS/DFS 的直接套用,区别只是"邻居"由"邻接表"变成了"上下左右四个方向"。
59.7.2 二维网格建模(单元格=顶点,四邻域=边)
网格中位置 的合法邻居只有四个方向:上 、下 、左 、右 ,统称四邻域(4-neighbor)。Flood Fill 每次只向这四个方向扩散,斜对角不算相邻。
59.7.3 DFS 实现(完整示例)
下面给出一个可独立运行的 Flood Fill 程序:从起点 出发,把与起点连通、且颜色等于 oldColor 的所有单元格重新染成 newColor。为方便演示,用二维数组 int grid[ROW][COL] 表示网格,0 表示阻隔、1 表示待填充区域。
#include <cstdio>
#define ROW 4
#define COL 5
// 四邻域方向:上、下、左、右
int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
// DFS 版 Flood Fill:把从 (x,y) 出发、颜色为 oldColor 的连通块染成 newColor
void floodFillDFS(int grid[ROW][COL], int x, int y, int oldColor, int newColor) {
// 1. 越界检查
if (x < 0 || x >= ROW || y < 0 || y >= COL) return;
// 2. 颜色不符(已不是目标连通块)则停止
if (grid[x][y] != oldColor) return;
// 3. 染色当前格
grid[x][y] = newColor;
// 4. 向四个方向递归扩散
for (int i = 0; i < 4; i++) {
floodFillDFS(grid, x + dirs[i][0], y + dirs[i][1], oldColor, newColor);
}
}
void printGrid(int grid[ROW][COL]) {
for (int i = 0; i < ROW; i++) {
for (int j = 0; j < COL; j++) {
printf("%d ", grid[i][j]);
}
printf("\n");
}
}
int main() {
int grid[ROW][COL] = {
{1, 1, 0, 0, 0},
{1, 0, 1, 1, 0},
{1, 0, 0, 1, 0},
{0, 0, 0, 0, 0}
};
printf("填充前网格:\n");
printGrid(grid);
floodFillDFS(grid, 0, 0, 1, 2); // 从(0,0)把连通的 1 染成 2
printf("填充后网格:\n");
printGrid(grid);
return 0;
}
运行结果示意(左为填充前,右为填充后;注意右侧那些不连通的 1 没有被染色):
59.7.4 BFS 实现(完整示例)
用队列代替递归,避免深层递归栈溢出,更适合大规模网格:
#include <cstdio>
#define ROW 4
#define COL 5
typedef struct Point { int x; int y; } Point;
int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
// BFS 版 Flood Fill
void floodFillBFS(int grid[ROW][COL], int startX, int startY, int oldColor, int newColor) {
if (startX < 0 || startX >= ROW || startY < 0 || startY >= COL) return;
if (grid[startX][startY] != oldColor) return;
Point queue[ROW * COL];
int front = 0, rear = 0;
queue[rear++] = {startX, startY};
grid[startX][startY] = newColor; // 入队即染色,防止重复入队
while (front < rear) {
Point cur = queue[front++];
for (int i = 0; i < 4; i++) {
int x = cur.x + dirs[i][0];
int y = cur.y + dirs[i][1];
if (x >= 0 && x < ROW && y >= 0 && y < COL && grid[x][y] == oldColor) {
grid[x][y] = newColor;
queue[rear++] = {x, y};
}
}
}
}
59.7.5 DFS 与 BFS 对比(优缺点、如何选择)
| 对比项 | DFS(递归) | BFS(队列) |
|---|---|---|
| 代码复杂度 | 简洁,几行递归 | 较长,需维护队列与方向数组 |
| 空间占用 | 递归深度 = 栈深度,深网格易栈溢出 | 队列最大约 ,更安全 |
| 访问顺序 | 一条路走到底 | 由近及远、按层扩散 |
| 适用规模 | 小规模网格/连通块 | 大规模网格、求最短路/最小步数 |
| 典型用途 | 图像油漆桶、连通块标记 | 迷宫最短路径、岛屿扩展 |
选择建议:网格不大、追求代码简短用 DFS;网格很大或需要"最短步数/最少操作"时用 BFS。注意 BFS 必须在入队时立即标记已访问(如本例染色),否则同一格可能被多次入队导致超时。
59.7.6 应用一:连通块 / 岛屿数量(经典真题风格示例)
问题描述(典型竞赛/考试题型):给定一个由 '1'(陆地)和 '0'(水)组成的二维网格,计算岛屿的数量。岛屿总是被水包围,且水平/竖直相邻的陆地连成同一个岛屿(斜对角不算)。
思路:每遇到一个 '1',就说明发现了一个新岛屿;用一次 Flood Fill 把与它连通的所有 '1' 全部"沉掉"(改为 '0' 标记已访问),这样该岛屿就不会被重复计数。最终数出进行了几次 Flood Fill,即为岛屿数。
#include <vector>
using namespace std;
class Solution {
private:
int rows, cols;
int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
// Flood Fill:把与 (x,y) 连通的陆地整块沉掉
void dfs(vector<vector<char>>& grid, int x, int y) {
if (x < 0 || x >= rows || y < 0 || y >= cols || grid[x][y] != '1')
return;
grid[x][y] = '0'; // 标记已访问(沉岛)
for (int i = 0; i < 4; i++) {
dfs(grid, x + dirs[i][0], y + dirs[i][1]);
}
}
public:
int numIslands(vector<vector<char>>& grid) {
rows = grid.size();
if (rows == 0) return 0;
cols = grid[0].size();
int count = 0;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '1') { // 发现新岛屿
count++;
dfs(grid, i, j); // 用 Flood Fill 沉掉整座岛
}
}
}
return count;
}
};
小例子:对下方网格,左上两块 '1' 互不连通,因此岛屿数为 2:
1 1 0 0 0
0 0 1 0 0
0 0 0 0 0
0 0 0 1 1
59.7.7 应用二:图像填充、迷宫、地图连通区域
- 画图软件"油漆桶":点击某像素,把同色连通区域换成新颜色——正是 59.7.3 的
floodFillDFS原型。 - 迷宫连通性 / 最短路:把迷宫格点当网格,墙为阻隔、路为可走,用 Flood Fill 判断两格是否连通,用 BFS 求最少步数。
- 地图连通区域统计:如统计一片海域中有多少个独立湖面、多少块大陆,本质都是"数连通块数量",与 59.7.6 完全相同。
59.7.8 时间与空间复杂度分析
设网格大小为 ,每个单元格最多被访问一次:
- 时间复杂度:(最坏遍历整个网格一次);
- 空间复杂度:
- DFS:递归调用栈深度最坏为 (一条蛇形长连通块),易栈溢出;
- BFS:队列最多同时存 个点。
若网格非常大(如 ),优先用 BFS 或将 DFS 改为显式栈,避免递归爆栈。
59.8 选择题练习
参考答案与解析(点击展开)
-
B —— 无向图中每条边贡献 2 个度,因此所有顶点度数之和 = ,是边数的两倍。
-
B —— 邻接矩阵用 的二维数组,空间复杂度固定为 ,与边数无关。
-
B —— Flood Fill 核心是在网格上找出"同色连通块"并统一处理,最典型的用途是图像/区域的连通填充与统计。
-
A —— BFS 按层由近及远扩散,第一次到达某点时的步数即最短距离,因此能求无权图最短路径;拓扑排序、环检测更依赖 DFS。
-
A —— 二维网格的 Flood Fill 只向**上下左右(四邻域)**扩散;八方向需额外加 4 个斜对角,不属于标准四邻域。
-
B —— DFS 用递归/栈"一条路走到底",代码短但递归深度过大时可能栈溢出;BFS 用队列更稳,适合大规模网格。
-
无向图中,所有顶点的度数之和等于( )。
- A. 边数
- B. (边数的两倍)
- C. 顶点数
- D. 0
-
用邻接矩阵存储有 个顶点的图,其空间复杂度为( )。
- A.
- B.
- C.
- D.
-
Flood Fill(泛洪填充)算法主要用于解决( )。
- A. 单源最短路径
- B. 二维网格的连通区域填充与统计
- C. 数组排序
- D. 二分查找
-
关于 BFS 与最短路径,下列说法正确的是( )。
- A. BFS 可求无权图(边权均为 1)的最短路径
- B. BFS 只能用于有向图
- C. DFS 比 BFS 更适合求最短路
- D. BFS 无法统计连通块
-
在二维网格上做标准 Flood Fill 时,一个单元格的"邻居"通常指( )。
- A. 上下左右四个方向
- B. 周围八个方向(含对角)
- C. 仅右边一格
- D. 仅对角线方向
-
下列关于 DFS 实现 Flood Fill 的说法,正确的是( )。
- A. DFS 一定比 BFS 更快
- B. 递归版在网格很大、连通块很深时可能栈溢出
- C. DFS 不能用于连通块统计
- D. DFS 不需要 visited 标记