数据结构与算法|第十二章:图
- [第十二章 图(Graph)](#第十二章 图(Graph))
-
- [12.1 图的基本概念](#12.1 图的基本概念)
-
- [12.1.1 什么是图](#12.1.1 什么是图)
- [12.1.2 基本术语](#12.1.2 基本术语)
- [12.1.3 图的分类](#12.1.3 图的分类)
- [12.2 图的遍历](#12.2 图的遍历)
-
- [12.2.1 深度优先搜索(DFS)](#12.2.1 深度优先搜索(DFS))
- [12.2.2 广度优先搜索(BFS)](#12.2.2 广度优先搜索(BFS))
- [12.2.3 DFS vs BFS 对比](#12.2.3 DFS vs BFS 对比)
- [12.3 图的应用场景](#12.3 图的应用场景)
-
- [12.3.1 最小生成树(MST)](#12.3.1 最小生成树(MST))
- [12.3.2 最短路径](#12.3.2 最短路径)
- [12.3.3 有向无环图(DAG)及其应用](#12.3.3 有向无环图(DAG)及其应用)
- [12.3.4 关键路径(AOE 网)](#12.3.4 关键路径(AOE 网))
- [12.4 图的存储方式](#12.4 图的存储方式)
-
- [12.4.1 邻接矩阵](#12.4.1 邻接矩阵)
- [12.4.2 邻接表](#12.4.2 邻接表)
- [12.4.3 两种存储方式对比](#12.4.3 两种存储方式对比)
- [12.5 图的数据结构](#12.5 图的数据结构)
-
- [12.5.1 定义 Graph 接口](#12.5.1 定义 Graph 接口)
- [12.5.2 定义 Vertex 类](#12.5.2 定义 Vertex 类)
- [12.6 经典算法](#12.6 经典算法)
-
- [12.6.1 最短路径算法](#12.6.1 最短路径算法)
-
- [12.6.1.1 Dijkstra 算法](#12.6.1.1 Dijkstra 算法)
- [12.6.1.2 Floyd-Warshall 算法](#12.6.1.2 Floyd-Warshall 算法)
- [12.6.1.3 Bellman-Ford 算法](#12.6.1.3 Bellman-Ford 算法)
- [12.6.1.4 三种最短路径算法对比](#12.6.1.4 三种最短路径算法对比)
- [12.6.2 最小生成树(MST)](#12.6.2 最小生成树(MST))
-
- [12.6.2.1 Prim 算法](#12.6.2.1 Prim 算法)
- [12.6.2.2 Kruskal 算法](#12.6.2.2 Kruskal 算法)
- [12.6.2.3 Prim vs Kruskal 对比](#12.6.2.3 Prim vs Kruskal 对比)
- [12.6.3 拓扑排序](#12.6.3 拓扑排序)
-
- [12.6.3.1 Kahn 算法(BFS 实现)](#12.6.3.1 Kahn 算法(BFS 实现))
- [12.6.3.2 DFS 实现](#12.6.3.2 DFS 实现)
- [12.6.4 关键路径(AOE 网)](#12.6.4 关键路径(AOE 网))
- [12.7 经典实战](#12.7 经典实战)
-
- [12.7.1 岛屿数量(LeetCode 200)](#12.7.1 岛屿数量(LeetCode 200))
- [12.7.2 课程表(LeetCode 207)](#12.7.2 课程表(LeetCode 207))
- [12.7.3 网络延迟时间(LeetCode 743)](#12.7.3 网络延迟时间(LeetCode 743))
- 总结与预告
上篇:第十一章、跳表
下篇:第十三章、递归与分治
第十二章 图(Graph)
在之前的章节中,我们学习了各种线性结构(数组、链表、栈、队列)和树形结构(二叉树、BST、AVL、红黑树、堆)。它们共同的特征是元素之间遵循某种"层级"或"顺序"关系。
但现实世界中,事物之间的关系远比"一对多"的树更复杂:社交网络中人与人之间互相认识;地铁线路中站点之间四通八达;互联网中路由器之间彼此连接。这些场景的共同点是------任意两个元素之间都可能存在关联。
这就是图(Graph)------数据结构中最通用、最强大的模型。从 GPS 导航的最短路径到编译器中的依赖分析,图算法贯穿了计算机科学的方方面面。
12.1 图的基本概念
12.1.1 什么是图
图(Graph):由**顶点(Vertex)的有限非空集合和边(Edge)**的集合组成。通常表示为:
G = ( V , E ) G = (V, E) G=(V,E)
其中 V = { v 1 , v 2 , ... , v n } V = \{v_1, v_2, \dots, v_n\} V={v1,v2,...,vn} 是顶点集, E = { ( v i , v j ) ∣ v i , v j ∈ V } E = \{(v_i, v_j) \mid v_i, v_j \in V\} E={(vi,vj)∣vi,vj∈V} 是边集。
0
1
2
3
4
上图中: V = { 0 , 1 , 2 , 3 , 4 } V = \{0, 1, 2, 3, 4\} V={0,1,2,3,4}, E = { ( 0 , 1 ) , ( 0 , 2 ) , ( 1 , 3 ) , ( 2 , 3 ) , ( 2 , 4 ) , ( 3 , 4 ) } E = \{(0,1), (0,2), (1,3), (2,3), (2,4), (3,4)\} E={(0,1),(0,2),(1,3),(2,3),(2,4),(3,4)}
图的抽象数据类型(ADT)定义如下:
ADT Graph {
数据对象:
D = { V , E ∣ V = { v i } , E = { e i j = ( v i , v j ) } } D=\left \{ V, E \;\;|\;\; V=\{v_i\}, \; E=\{e_{ij}=(v_i, v_j)\} \right \} D={V,E∣V={vi},E={eij=(vi,vj)}}
数据关系:
e i j e_{ij} eij 表示顶点 v i v_i vi 到 v j v_j vj 的一条边
基本运算(6个):Graph(int n):创建含 n 个顶点的图
void addEdge(int u, int v):添加边
void addEdge(int u, int v, int w):添加带权边
List<Integer> neighbors(int v):返回顶点 v 的所有邻居
int degree(int v):返回顶点 v 的度
int vertexCount() / edgeCount():返回顶点数 / 边数
}
12.1.2 基本术语
| 术语 | 定义 | 说明 |
|---|---|---|
| 顶点(Vertex) | 图中的结点 | 城市、路由器、用户 |
| 边(Edge) | 顶点之间的连线 | 道路、网线、好友关系 |
| 邻接点(Adjacent Vertex) | 由一条边直接相连的两个顶点 | 若 ( u , v ) ∈ E (u,v) \in E (u,v)∈E,则 u 与 v 互为邻接点 |
| 简单图(Simple Graph) | 不含平行边 (多重边)和自环的图 | 绝大多数算法默认处理简单图 |
| 度(Degree) | 与该顶点相连的边数;有向图中分入度(In-degree)和出度(Out-degree) | 顶点 2 的度为 3 |
| 完全图(Complete Graph) | 任意两个顶点之间都有边相连 | n 个顶点的无向完全图有 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1) 条边 |
| 稠密图 / 稀疏图 | 边数接近 V 2 V^2 V2 为稠密图;边数远小于 V 2 V^2 V2 为稀疏图 | 指导存储方式的选择 |
| 子图(Subgraph) | 顶点集 V ′ ⊆ V V' \subseteq V V′⊆V 且边集 E ′ ⊆ E E' \subseteq E E′⊆E 的图 | 原图的一部分 |
| 路径(Path) | 顶点序列 v 1 → v 2 → ⋯ → v k v_1→v_2→\dots→v_k v1→v2→⋯→vk,相邻顶点间有边 | 0→2→3→4 |
| 简单路径 | 路径中顶点不重复 | 0→2→4 |
| 环 / 环路(Cycle) | 起点 = 终点的路径 | 0→1→3→2→0 |
| 可达分量 | 从某顶点出发能到达的所有顶点构成的子图 | 有向图中的可达性分析 |
| 连通图(Connected Graph) | 任意两顶点间都存在路径的无向图 | --- |
| 连通分量(Connected Component) | 无向图中的极大连通子图 | 社交网络中的不同社群 |
| 强连通分量(SCC) | 有向图中任意两点双向可达的极大子图 | 网页群组分析 |
| 生成树(Spanning Tree) | 包含所有顶点的极小连通子图(无环) | n 个顶点的生成树恰有 n−1 条边 |
| 权(Weight)与网(Network) | 边上附带的数值称为权;带权图称为网 | 距离、耗时、费用 |
| 有向无环图(DAG) | 有向且不存在环路 | 课程依赖、任务调度 |
12.1.3 图的分类
| 分类维度 | 类型 | 说明 |
|---|---|---|
| 边是否有方向 | 无向图(Undirected Graph) | 边 (v,u) 和 (u,v) 等价,如社交好友关系 |
| 有向图(Directed Graph / Digraph) | 边 <v→u> 有方向性,如网页链接、任务依赖 | |
| 边是否有权重 | 无权图(Unweighted Graph) | 边只有存在/不存在两种状态 |
| 带权图 / 网(Weighted Graph) | 边带有数值权重,如距离、耗时、费用 |
12.2 图的遍历
12.2.1 深度优先搜索(DFS)
DFS 的核心思想是**"一条路走到黑"**------从起点出发,沿一条路径不断深入,直到无路可走再回溯。
java
/**
* 图的 DFS 遍历(递归实现)
* @param graph 图(邻接表)
* @param start 起点
*/
public void dfs(AdjListGraph graph, int start) {
boolean[] visited = new boolean[graph.V()];
dfsRec(graph, start, visited);
}
private void dfsRec(AdjListGraph graph, int v, boolean[] visited) {
visited[v] = true;
System.out.print(v + " "); // 访问顶点
for (int neighbor : graph.neighbors(v)) {
if (!visited[neighbor]) {
dfsRec(graph, neighbor, visited);
}
}
}
DFS 的时间复杂度 O(V + E),空间复杂度 O(V)(递归栈深度)。DFS 天然适合解决**连通分量、环检测、拓扑排序(DFS 版)**等问题。
DFS 迭代版(显式使用栈):
java
public void dfsIterative(AdjListGraph graph, int start) {
boolean[] visited = new boolean[graph.V()];
Deque<Integer> stack = new ArrayDeque<>();
stack.push(start);
while (!stack.isEmpty()) {
int v = stack.pop();
if (visited[v]) continue;
visited[v] = true;
System.out.print(v + " ");
// 将邻居逆序入栈(保证与递归版顺序一致)
List<Integer> neighbors = graph.neighbors(v);
for (int i = neighbors.size() - 1; i >= 0; i--) {
int neighbor = neighbors.get(i);
if (!visited[neighbor]) stack.push(neighbor);
}
}
}
12.2.2 广度优先搜索(BFS)
BFS 的核心思想是**"一圈圈向外扩展"**------先访问距离起点为 1 的顶点,再访问距离为 2 的,依此类推。
java
/**
* 图的 BFS 遍历
* @param graph 图(邻接表)
* @param start 起点
*/
public void bfs(AdjListGraph graph, int start) {
boolean[] visited = new boolean[graph.V()];
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
visited[start] = true;
while (!queue.isEmpty()) {
int v = queue.poll();
System.out.print(v + " "); // 访问顶点
for (int neighbor : graph.neighbors(v)) {
if (!visited[neighbor]) {
visited[neighbor] = true;
queue.offer(neighbor);
}
}
}
}
BFS 的时间复杂度 O(V + E),空间复杂度 O(V)。BFS 天然适合解决最短路径(无权图)、层序遍历等问题。
12.2.3 DFS vs BFS 对比
| 维度 | DFS | BFS |
|---|---|---|
| 数据结构 | 栈(递归/显式) | 队列 |
| 路径特性 | 不保证最短 | 保证最短(无权图) |
| 内存消耗 | O(h),h 为深度 | O(w),w 为最大宽度 |
| 典型应用 | 连通分量、环检测、回溯 | 最短路径、拓扑排序(Kahn) |
12.3 图的应用场景
在学习具体算法之前,先了解图在现实世界中的四大经典应用场景。
12.3.1 最小生成树(MST)
场景:铺设光缆连接 n 个城市,要求总成本最低。
最小生成树要解决的问题是:在连通带权无向图中,找出一棵连接所有顶点的树,使得边权之和最小 。典型算法有 Prim (贪心 + 优先队列)和 Kruskal(排序 + 并查集)。
12.3.2 最短路径
场景:GPS 导航从 A 地到 B 地的最短路线。
最短路径问题分多种变体:单源最短路径 (Dijkstra、Bellman-Ford)、全源最短路径 (Floyd-Warshall)、无权图最短路径(BFS 即可)。
12.3.3 有向无环图(DAG)及其应用
场景:大学课程有先修关系------"高等数学"必须在"机器学习"之前修完。
DAG 的核心应用是拓扑排序------将顶点排成线性序列,使得每条有向边 u→v 中 u 出现在 v 之前。常用于构建系统(Makefile)、任务调度(Airflow DAG)、数据处理管道。
12.3.4 关键路径(AOE 网)
场景:建筑工程项目管理,哪些工序延误会导致总工期推迟?
AOE 网(Activity On Edge)用顶点表示里程碑、有向边表示活动、边权表示耗时。关键路径是从起点到终点的最长路径,决定了整个工程的最早完成时间。
12.4 图的存储方式
12.4.1 邻接矩阵
邻接矩阵(Adjacency Matrix):使用一个 n × n n \times n n×n 的二维数组 matrix[u][v] 表示顶点 u 到 v 的边。
-
无权图:
matrix[u][v] = 1表示有边,0表示无边 -
带权图:
matrix[u][v] = w表示边权为 w w w,∞或0表示无边有向无权图: 邻接矩阵:
0 → 1 [0][1][0][0]
↓ ↓ [0][0][1][0]
2 → 3 [0][0][0][1]
[0][0][0][0]
java
/**
* 邻接矩阵存储的无向无权图
*/
public class AdjMatrixGraph {
private int[][] matrix; // 邻接矩阵
private int V; // 顶点数
private int E; // 边数
public AdjMatrixGraph(int V) {
this.V = V;
matrix = new int[V][V];
}
/** 添加无向边 */
public void addEdge(int u, int v) {
if (matrix[u][v] == 0) {
matrix[u][v] = 1;
matrix[v][u] = 1; // 无向图,双向设置
E++;
}
}
/** 获取顶点 v 的所有邻居 */
public List<Integer> neighbors(int v) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < V; i++) {
if (matrix[v][i] == 1) list.add(i);
}
return list;
}
public boolean hasEdge(int u, int v) {
return matrix[u][v] == 1;
}
public int degree(int v) {
int d = 0;
for (int i = 0; i < V; i++) {
if (matrix[v][i] == 1) d++;
}
return d;
}
public int V() { return V; }
public int E() { return E; }
}
邻接矩阵的特点 :判断两点是否相邻 O(1);空间 O(V²),在边稀疏时浪费严重。
12.4.2 邻接表
邻接表(Adjacency List):为每个顶点维护一个链表(或 ArrayList),存储它的所有邻居。
java
/**
* 邻接表存储的无向无权图
*/
public class AdjListGraph {
private List<Integer>[] adj; // adj[v] = 顶点 v 的邻居列表
private int V;
private int E;
@SuppressWarnings("unchecked")
public AdjListGraph(int V) {
this.V = V;
adj = new ArrayList[V];
for (int i = 0; i < V; i++) {
adj[i] = new ArrayList<>();
}
}
/** 添加无向边 */
public void addEdge(int u, int v) {
adj[u].add(v);
adj[v].add(u);
E++;
}
/** 获取顶点 v 的所有邻居 */
public List<Integer> neighbors(int v) {
return adj[v];
}
public int degree(int v) {
return adj[v].size();
}
public int V() { return V; }
public int E() { return E; }
}
邻接表的特点 :空间 O(V + E),适合稀疏图;判断两点是否相邻需要遍历邻居列表 O(degree(v))。
12.4.3 两种存储方式对比
| 维度 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间 | O(V²) | O(V + E) |
| 判断 (u,v) 是否相邻 | O(1) | O(degree(u)) |
| 遍历邻居 | O(V) | O(degree(v)) |
| 添加边 | O(1) | O(1) |
| 适合场景 | 稠密图(E ≈ V²) | 稀疏图(E ≪ V²) |
| 适合算法 | Floyd-Warshall | DFS / BFS / Dijkstra |
选型建议:绝大多数图算法默认使用邻接表。只有在 Floyd-Warshall 等需要频繁判断任意两点间关系的场景下,邻接矩阵才有优势。
12.5 图的数据结构
在正式编写图算法之前,先统一数据结构。良好的抽象可以让算法代码更清晰、更易复用。
12.5.1 定义 Graph 接口
java
/**
* 图接口:统一有向/无向、带权/无权图的抽象
*/
public interface Graph {
/** 顶点数 */
int V();
/** 边数 */
int E();
/** 添加无权边 */
void addEdge(int u, int v);
/** 添加带权边 */
void addEdge(int u, int v, int weight);
/** 返回顶点 v 的所有邻居 */
Iterable<Integer> neighbors(int v);
/** 返回顶点 v 的度 */
int degree(int v);
/** 判断 u 到 v 是否有边 */
boolean hasEdge(int u, int v);
/** 获取边 (u, v) 的权值(无权图返回 1) */
int getWeight(int u, int v);
}
12.5.2 定义 Vertex 类
java
/**
* 顶点类:封装顶点的标识与数据
*/
public class Vertex<T> {
public int id; // 顶点编号(唯一标识)
public T data; // 顶点携带的数据(可选)
public Vertex(int id) {
this.id = id;
}
public Vertex(int id, T data) {
this.id = id;
this.data = data;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Vertex)) return false;
Vertex<?> vertex = (Vertex<?>) o;
return id == vertex.id;
}
@Override
public int hashCode() {
return Integer.hashCode(id);
}
@Override
public String toString() {
return data != null ? data.toString() : "V" + id;
}
}
设计说明 :在图算法中,顶点通常用
int编号即可满足需求(数组下标天然映射)。实际项目中如需携带业务数据(如城市名称、坐标),可使用Map<Integer, Vertex<T>>映射。
12.6 经典算法
12.6.1 最短路径算法
12.6.1.1 Dijkstra 算法
问题:给定带权图(权值 ≥ 0),求从起点 s 到所有其他顶点的最短路径。
贪心思想 :每次从"未确定最短距离"的顶点中选出距离起点最近 的那个,用它去松弛它的邻居。
java
import java.util.*;
/**
* Dijkstra 最短路径算法(邻接表 + 优先队列)
* @param graph 邻接表表示的带权图,graph[u] = [(v, w), ...]
* @param s 起点
* @return dist[v] = 起点 s 到顶点 v 的最短距离
*/
public int[] dijkstra(List<int[]>[] graph, int s) {
int n = graph.length;
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[s] = 0;
// 小顶堆:按距离排序 (dist, vertex)
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]);
pq.offer(new int[]{0, s});
boolean[] settled = new boolean[n]; // 已确定最短距离的顶点
while (!pq.isEmpty()) {
int[] cur = pq.poll();
int d = cur[0];
int u = cur[1];
if (settled[u]) continue; // 懒惰删除:跳过旧记录
settled[u] = true;
// 松弛邻居
for (int[] edge : graph[u]) {
int v = edge[0];
int w = edge[1]; // 边权
if (!settled[v] && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.offer(new int[]{dist[v], v});
}
}
}
return dist;
}
算法分析 :优先队列每次 poll O(log V),每个顶点最多入队一次、每条边最多松弛一次,总复杂度 O((V + E) log V) 。Dijkstra 要求权值非负。
12.6.1.2 Floyd-Warshall 算法
问题 :给定带权图(可有负权,但无负环),求所有顶点对之间的最短路径。
动态规划思想 : d p [ k ] [ i ] [ j ] dp[k][i][j] dp[k][i][j] 表示"只经过前 k 个顶点中转时,i 到 j 的最短距离"。状态压缩后:
java
/**
* Floyd-Warshall 全源最短路径
* @param graph 邻接矩阵,graph[i][j] = 边权,无边用 INF
* @return 全源最短距离矩阵
*/
public int[][] floydWarshall(int[][] graph) {
int n = graph.length;
int[][] dist = new int[n][n];
// 初始化
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
dist[i][j] = graph[i][j];
}
}
// 三重循环:中转点 k 必须在最外层
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dist[i][k] != Integer.MAX_VALUE
&& dist[k][j] != Integer.MAX_VALUE
&& dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
return dist;
}
算法分析 :三重循环,时间复杂度 O(V³),空间 O(V²)。优势是实现极其简洁,且能处理负权边。
12.6.1.3 Bellman-Ford 算法
问题 :给定带权图(可含负权边 ),求从起点 s 到所有顶点的最短路径,并能检测负权环。
核心操作 :对所有边 进行 V−1 轮松弛。第 i 轮结束后,最多经过 i 条边的最短路径已确定。
java
/**
* Bellman-Ford 最短路径算法(可处理负权边,可检测负环)
* @param edges 边列表,每条边为 (u, v, w)
* @param V 顶点数
* @param s 起点
* @return dist[v] = 起点到 v 的最短距离;若存在负环则返回 null
*/
public int[] bellmanFord(int[][] edges, int V, int s) {
int[] dist = new int[V];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[s] = 0;
// 进行 V-1 轮松弛
for (int i = 0; i < V - 1; i++) {
boolean updated = false;
for (int[] edge : edges) {
int u = edge[0], v = edge[1], w = edge[2];
if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
updated = true;
}
}
if (!updated) break; // 提前退出:本轮无松弛
}
// 第 V 轮检测负环
for (int[] edge : edges) {
int u = edge[0], v = edge[1], w = edge[2];
if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) {
return null; // 存在负权环
}
}
return dist;
}
算法分析 :每轮 O(E),共 V−1 轮,时间复杂度 O(V·E)。可以处理负权边并检测负环,但效率低于 Dijkstra。
12.6.1.4 三种最短路径算法对比
| 算法 | 适用范围 | 时间复杂度 | 空间 | 能否处理负权 | 全源? |
|---|---|---|---|---|---|
| Dijkstra(堆优化) | 非负权 | O((V+E)log V) | O(V) | ❌ | 单源 |
| Floyd-Warshall | 无负环 | O(V³) | O(V²) | ✅ | ✅ 全源 |
| Bellman-Ford | 无负环 | O(V·E) | O(V) | ✅ 且可检测负环 | 单源 |
选型建议:
- 正权 + 单源 → Dijkstra
- 正权 + 全源 → 跑 V 次 Dijkstra 或 Floyd(前者的 O(V·(V+E)log V) 在稀疏图中更优)
- 含负权 → Bellman-Ford 或 SPFA(Bellman-Ford 的队列优化版)
- 稠密图全源 → Floyd-Warshall(常数因子小,实现简单)
12.6.2 最小生成树(MST)
问题 :给定连通无向带权图,找出一棵连接所有顶点的树,使得边权之和最小。
12.6.2.1 Prim 算法
贪心思想 :从一个顶点开始,每次选择当前连通集合到外部的最小权边,将外部顶点纳入集合。非常类似 Dijkstra!
java
/**
* Prim 最小生成树算法
* @param graph 邻接表,graph[u] = [(v, w), ...]
* @return 最小生成树的边权和
*/
public int prim(List<int[]>[] graph) {
int n = graph.length;
boolean[] inMST = new boolean[n];
int[] minEdge = new int[n]; // minEdge[v] = 连接 v 到 MST 的最小边权
Arrays.fill(minEdge, Integer.MAX_VALUE);
minEdge[0] = 0;
int totalWeight = 0;
for (int i = 0; i < n; i++) {
// 找出 minEdge 最小的未加入顶点
int u = -1;
for (int v = 0; v < n; v++) {
if (!inMST[v] && (u == -1 || minEdge[v] < minEdge[u])) {
u = v;
}
}
inMST[u] = true;
totalWeight += minEdge[u];
// 用 u 的邻居更新 minEdge
for (int[] edge : graph[u]) {
int v = edge[0], w = edge[1];
if (!inMST[v] && w < minEdge[v]) {
minEdge[v] = w;
}
}
}
return totalWeight;
}
复杂度:朴素 O(V²),堆优化 O((V+E)log V)。稠密图中朴素版反而更优(无堆的开销)。
Prim vs Dijkstra 关键区别:
| Prim | Dijkstra | |
|---|---|---|
minEdge[v] 含义 |
顶点 v 到 MST 集合的最小边权 | 起点 s 到顶点 v 的最短距离 |
| 更新条件 | w < minEdge[v] |
dist[u] + w < dist[v] |
12.6.2.2 Kruskal 算法
贪心思想:按边权从小到大排序,依次检查每条边------如果该边的两个端点尚未连通,则将其加入 MST。
核心在于**并查集(Union-Find)**判定连通性。
java
/**
* Kruskal 最小生成树算法
* @param edges 边列表 (u, v, w)
* @param V 顶点数
* @return 最小生成树的边权和
*/
public int kruskal(int[][] edges, int V) {
// 1. 按边权升序排列
Arrays.sort(edges, (a, b) -> a[2] - b[2]);
UnionFind uf = new UnionFind(V);
int totalWeight = 0;
int edgeCount = 0;
// 2. 依次检查每条边
for (int[] edge : edges) {
int u = edge[0], v = edge[1], w = edge[2];
if (uf.find(u) != uf.find(v)) {
uf.union(u, v);
totalWeight += w;
edgeCount++;
if (edgeCount == V - 1) break; // MST 已形成
}
}
return totalWeight;
}
/** 并查集辅助类 */
class UnionFind {
int[] parent, rank;
UnionFind(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // 路径压缩
return parent[x];
}
void union(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return;
if (rank[rx] < rank[ry]) parent[rx] = ry;
else if (rank[rx] > rank[ry]) parent[ry] = rx;
else { parent[ry] = rx; rank[rx]++; }
}
}
复杂度 :排序 O(E log E),并查集操作接近 O(1),总 O(E log E)。Kruskal 在稀疏图中效率极高。
12.6.2.3 Prim vs Kruskal 对比
| Prim | Kruskal | |
|---|---|---|
| 核心操作 | 找最小边权顶点 | 按边权排序 + 并查集 |
| 时间复杂度 | O(V²) 或 O((V+E)log V) | O(E log E) |
| 适合场景 | 稠密图 | 稀疏图 |
| 数据结构 | 优先队列 | 并查集 |
12.6.3 拓扑排序
问题:给定有向无环图(DAG),将顶点排成线性序列,使得对于每条有向边 u→v,u 在序列中出现在 v 之前。
典型场景:课程依赖(先修课 → 后续课)、构建系统的编译顺序。
12.6.3.1 Kahn 算法(BFS 实现)
核心思路 :每次选择入度为 0 的顶点输出,并将它所有邻接点的入度减 1。
java
/**
* Kahn 拓扑排序(BFS)
* @param graph 有向图的邻接表
* @return 拓扑序列;若存在环则返回空数组
*/
public int[] topologicalSort(List<Integer>[] graph) {
int n = graph.length;
int[] indegree = new int[n];
// 计算所有顶点的入度
for (int u = 0; u < n; u++) {
for (int v : graph[u]) {
indegree[v]++;
}
}
// 所有入度为 0 的顶点入队
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < n; i++) {
if (indegree[i] == 0) queue.offer(i);
}
int[] result = new int[n];
int idx = 0;
while (!queue.isEmpty()) {
int u = queue.poll();
result[idx++] = u;
for (int v : graph[u]) {
indegree[v]--;
if (indegree[v] == 0) {
queue.offer(v);
}
}
}
// idx < n 说明存在环(有些顶点入度始终不为 0)
return (idx == n) ? result : new int[0];
}
12.6.3.2 DFS 实现
核心思路 :对每个未访问的顶点进行 DFS,回溯时将顶点加入结果列表(逆序)。
java
public int[] topologicalSortDFS(List<Integer>[] graph) {
int n = graph.length;
int[] state = new int[n]; // 0=未访问, 1=访问中, 2=已完成
List<Integer> order = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (state[i] == 0) {
if (!dfsTopo(graph, i, state, order)) {
return new int[0]; // 检测到环
}
}
}
// 逆序即为拓扑序
Collections.reverse(order);
int[] result = new int[n];
for (int i = 0; i < n; i++) result[i] = order.get(i);
return result;
}
private boolean dfsTopo(List<Integer>[] graph, int u,
int[] state, List<Integer> order) {
state[u] = 1; // 访问中
for (int v : graph[u]) {
if (state[v] == 1) return false; // 发现环(遇到访问中的顶点)
if (state[v] == 0 && !dfsTopo(graph, v, state, order)) {
return false;
}
}
state[u] = 2; // 已完成
order.add(u); // 回溯时记录
return true;
}
复杂度:两种实现均为 O(V + E)。Kahn 算法更直观,DFS 实现更简洁且能检测环。
12.6.4 关键路径(AOE 网)
AOE 网(Activity On Edge):带权有向无环图,顶点表示事件(里程碑),有向边表示活动,边权表示活动耗时。
关键路径:从起点到终点的最长路径(决定了整个工程的最早完成时间)。
核心概念:
| 量 | 符号 | 含义 | 计算方向 |
|---|---|---|---|
| 最早发生时间 | v e ( k ) ve(k) ve(k) | 事件 k 最早何时发生 | 正向拓扑序 |
| 最迟发生时间 | v l ( k ) vl(k) vl(k) | 事件 k 最迟何时发生(不延误总工期) | 逆向拓扑序 |
| 关键活动 | --- | v e ( i ) = v l ( i ) ve(i) = vl(i) ve(i)=vl(i) 的顶点 i 上的活动 | --- |
java
/**
* 关键路径算法
* @param graph 邻接表,graph[u] = [(v, w), ...](w 为活动耗时)
* @param n 顶点数
* @return 关键路径上的顶点序列
*/
public List<Integer> criticalPath(List<int[]>[] graph, int n) {
// 1. 拓扑排序,计算 ve(最早发生时间)
int[] indegree = new int[n];
for (int u = 0; u < n; u++) {
for (int[] edge : graph[u]) indegree[edge[0]]++;
}
Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < n; i++) {
if (indegree[i] == 0) q.offer(i);
}
int[] ve = new int[n];
List<Integer> topo = new ArrayList<>();
while (!q.isEmpty()) {
int u = q.poll();
topo.add(u);
for (int[] edge : graph[u]) {
int v = edge[0], w = edge[1];
ve[v] = Math.max(ve[v], ve[u] + w); // 正推:取最大值
if (--indegree[v] == 0) q.offer(v);
}
}
if (topo.size() < n) return Collections.emptyList(); // 有环
// 2. 逆序计算 vl(最迟发生时间)
int[] vl = new int[n];
Arrays.fill(vl, ve[topo.get(topo.size() - 1)]); // 初始化为总工期
for (int i = topo.size() - 1; i >= 0; i--) {
int u = topo.get(i);
for (int[] edge : graph[u]) {
int v = edge[0], w = edge[1];
vl[u] = Math.min(vl[u], vl[v] - w); // 逆推:取最小值
}
}
// 3. ve[i] == vl[i] 的顶点在关键路径上
List<Integer> result = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (ve[i] == vl[i]) result.add(i);
}
return result;
}
算法分析:两次拓扑遍历 O(V + E)。关键路径分析广泛应用于项目管理(PERT 图)、游戏任务依赖链分析。
12.7 经典实战
12.7.1 岛屿数量(LeetCode 200)
问题 :给定
m × n的网格grid,'1'表示陆地,'0'表示水域。求岛屿数量(上下左右相连的陆地属于同一岛屿)。
DFS 解法(沉岛法):
java
public int numIslands(char[][] grid) {
int m = grid.length, n = grid[0].length;
int count = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '1') {
count++;
dfs(grid, i, j); // 将整座岛沉没
}
}
}
return count;
}
private void dfs(char[][] grid, int i, int j) {
if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length
|| grid[i][j] != '1') return;
grid[i][j] = '0'; // 沉没:标记为已访问
dfs(grid, i + 1, j);
dfs(grid, i - 1, j);
dfs(grid, i, j + 1);
dfs(grid, i, j - 1);
}
BFS 解法:
java
public int numIslandsBFS(char[][] grid) {
int m = grid.length, n = grid[0].length;
int count = 0;
int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '1') {
count++;
Queue<int[]> q = new LinkedList<>();
q.offer(new int[]{i, j});
grid[i][j] = '0';
while (!q.isEmpty()) {
int[] cur = q.poll();
for (int[] d : dirs) {
int x = cur[0] + d[0], y = cur[1] + d[1];
if (x >= 0 && x < m && y >= 0 && y < n
&& grid[x][y] == '1') {
grid[x][y] = '0';
q.offer(new int[]{x, y});
}
}
}
}
}
}
return count;
}
分析:网格可以看作隐式图(每个格子是一个顶点,上下左右相邻则有边)。DFS/BFS 时间复杂度均为 O(m × n)。
12.7.2 课程表(LeetCode 207)
问题 :给定课程数
numCourses和先修关系prerequisites[i] = [a, b](必须先修 b 才能修 a),判断能否完成所有课程。
这就是拓扑排序的判环应用:
java
public boolean canFinish(int numCourses, int[][] prerequisites) {
List<Integer>[] graph = new ArrayList[numCourses];
for (int i = 0; i < numCourses; i++) graph[i] = new ArrayList<>();
int[] indegree = new int[numCourses];
for (int[] pre : prerequisites) {
graph[pre[1]].add(pre[0]); // b → a,修完 b 才能修 a
indegree[pre[0]]++;
}
Queue<Integer> q = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (indegree[i] == 0) q.offer(i);
}
int taken = 0;
while (!q.isEmpty()) {
int course = q.poll();
taken++;
for (int next : graph[course]) {
if (--indegree[next] == 0) q.offer(next);
}
}
return taken == numCourses; // 能修完所有课 = 无环
}
12.7.3 网络延迟时间(LeetCode 743)
问题 :有 n 个网络节点,给定有向边
(u, v, w)表示 u 到 v 的信号传输时间 w。从起点 k 发送信号,求所有节点都收到信号的最短时间。
这就是Dijkstra 单源最短路径 + 取最大值:
java
public int networkDelayTime(int[][] times, int n, int k) {
// 构建邻接表
List<int[]>[] graph = new ArrayList[n + 1];
for (int i = 1; i <= n; i++) graph[i] = new ArrayList<>();
for (int[] t : times) {
graph[t[0]].add(new int[]{t[1], t[2]}); // (v, w)
}
int[] dist = new int[n + 1];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[k] = 0;
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[1] - b[1]);
pq.offer(new int[]{k, 0});
while (!pq.isEmpty()) {
int[] cur = pq.poll();
int u = cur[0], d = cur[1];
if (d > dist[u]) continue;
for (int[] edge : graph[u]) {
int v = edge[0], w = edge[1];
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.offer(new int[]{v, dist[v]});
}
}
}
// 所有节点的最短到达时间的最大值
int maxTime = 0;
for (int i = 1; i <= n; i++) {
if (dist[i] == Integer.MAX_VALUE) return -1; // 不可达
maxTime = Math.max(maxTime, dist[i]);
}
return maxTime;
}
总结与预告
本章是数据结构系列中算法密度最高的一章,我们系统学习了图------这一最通用的数据结构:
- 12.1 基本概念:顶点/边/度/路径/连通分量,简单图、完全图、子图、生成树、权与网等核心术语
- 12.2 图的遍历:DFS(栈/递归,一条路走到黑) vs BFS(队列,一圈圈扩展)
- 12.3 应用场景:最小生成树(光缆铺设)、最短路径(GPS 导航)、DAG(任务调度)、关键路径(项目管理)
- 12.4 存储方式:邻接矩阵(O(V²),适合稠密图) vs 邻接表(O(V+E),适合稀疏图)
- 12.5 数据结构:Graph 接口与 Vertex 类的抽象设计
- 12.6 经典算法 :
- 最短路径:Dijkstra(贪心+堆)/ Floyd(DP,全源)/ Bellman-Ford(负权+负环检测)
- 最小生成树:Prim(贪心+优先队列) / Kruskal(排序+并查集)
- 拓扑排序:Kahn(BFS+入度) / DFS(后序遍历逆序)
- 关键路径:AOE 网,正向算 ve、逆向算 vl
- 12.7 经典实战:岛屿数量(DFS 沉岛/BFS)、课程表(拓扑判环)、网络延迟时间(Dijkstra)
图算法速查表:
| 算法 | 解决的问题 | 核心技巧 | 复杂度 |
|---|---|---|---|
| DFS / BFS | 遍历、连通分量 | 栈/队列 | O(V+E) |
| Dijkstra | 单源最短路径(正权) | 贪心 + 优先队列 | O((V+E)log V) |
| Floyd | 全源最短路径 | DP | O(V³) |
| Bellman-Ford | 单源最短(负权)+ 负环检测 | V−1 轮松弛 | O(VE) |
| Prim | MST(稠密图) | 贪心 + 优先队列 | O((V+E)log V) |
| Kruskal | MST(稀疏图) | 排序 + 并查集 | O(E log E) |
| Kahn | 拓扑排序 + 判环 | BFS + 入度 | O(V+E) |
| 关键路径 | 工程工期分析 | 正向 ve + 逆向 vl | O(V+E) |
下一章我们将进入第三篇:算法设计 ,首先学习递归与分治------这是所有高级算法策略的基石。从汉诺塔到归并排序,从主定理到快速幂,递归思维将贯穿后续每一章。
上篇:第十一章、跳表
下篇:第十三章、递归与分治