数据结构与算法|第十二章:图

数据结构与算法|第十二章:图

  • [第十二章 图(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-FordSPFA(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)

下一章我们将进入第三篇:算法设计 ,首先学习递归与分治------这是所有高级算法策略的基石。从汉诺塔到归并排序,从主定理到快速幂,递归思维将贯穿后续每一章。


上篇:第十一章、跳表

下篇:第十三章、递归与分治

相关推荐
洛水水5 分钟前
【数据结构】红黑树详解
数据结构·红黑树
炸膛坦客5 分钟前
嵌入式 - 数据结构与算法:(1-9)数据结构 - 队列(Queue)
c语言·数据结构
~|Bernard|32 分钟前
二.go语言中map的底层原理(2026-5-8)
算法·golang·哈希算法
AbandonForce40 分钟前
哈希表(HashTable,散列表)个人理解
开发语言·数据结构·c++·散列表
mask哥1 小时前
力扣算法java实现汇总整理(下)
java·算法·leetcode
代码中介商1 小时前
栈结构完全指南:顺序栈实现精讲
c语言·开发语言·数据结构
样例过了就是过了1 小时前
LeetCode热题100 编辑距离
数据结构·c++·算法·leetcode·动态规划
wearegogog1231 小时前
MATLAB椭圆参数检测算法实现
数据库·算法·matlab
secondyoung1 小时前
Markdown数学公式语法速查手册
算法·编辑器·markdown·latex
君义_noip1 小时前
CSP-S 2025 提高级 第一轮(初赛) 阅读程序(1)
算法·深度优先·信息学奥赛·初赛