专栏文章
专栏文章
经典算法专栏
1. 经典算法专栏 #01:图算法 2. 经典算法专栏 #02:排序与搜索 3. 经典算法专栏 #03:动态规划 4. 经典算法专栏 #04:业务场景算法 5. 经典算法专栏 #05:算法复杂度分析 6. 经典算法专栏 #06:基础数据结构 7. 经典算法专栏 #07:分治与贪心 8. 经典算法专栏 #08:字符串算法 9. 经典算法专栏 #09:网络流与匹配

经典算法专栏 #01:图算法

发布于 2026-06-10 07:48 👁 20 次阅读
#算法#data-structure#graph

图由顶点和边组成,是描述"关系"最自然的数据结构。本文覆盖图的存储、BFS/DFS 搜索、Dijkstra/Bellman-Ford 最短路径、Prim/Kruskal 最小生成树和拓扑排序,每个算法配有过程图辅助理解。


目录

章节 说明
图的基本概念 顶点、边、有向/无向/带权图
图的存储 邻接矩阵 vs 邻接表
BFS 广度优先搜索 逐层扩散,保证最短路径
DFS 深度优先搜索 沿路走到底,回溯换路
最短路径 Dijkstra / Bellman-Ford
最小生成树 Prim / Kruskal
拓扑排序 有向无环图的线性排序

图的基本概念

图(Graph)顶点(Vertex)边(Edge) 组成,顶点可以与任意其他顶点建立连接关系。

概念 说明 示例
无向图 边没有方向,A-B 等价于 B-A 微信好友关系
有向图 边有方向,A→B 不等于 B→A 微博关注关系
带权图 每条边有权重 地图路程、网络延迟
度(Degree) 与某顶点相连的边数 无向图
入度(In-degree) 指向该顶点的边数 有向图
出度(Out-degree) 从该顶点出发的边数 有向图
稀疏图 顶点多,边少(E ≪ V²) 社交网络
稠密图 边数接近 V² 全连接网络

图的存储

同一张图可以用邻接矩阵或邻接表存储,二者的空间和时间取舍截然不同:

graph storage

邻接矩阵

用二维数组 A[V][V] 存储,A[i][j] 表示顶点 i 到顶点 j 的边权(无边时为 0 或 ∞):

int[][] graph = new int[V][V];
// 无向图:双向赋值
graph[i][j] = 1;
graph[j][i] = 1;
// 带权图:存权重
graph[i][j] = weight;
优点 缺点
查询两点关系 O(1) 空间 O(V²),稀疏图严重浪费
矩阵运算方便(Floyd-Warshall 等) 无向图存储冗余(天然对称矩阵)

邻接表

每个顶点对应一个链表,只存储实际存在的邻居:

public class Graph {
    private final int v;
    private final LinkedList<Integer>[] adj;

    public Graph(int v) {
        this.v = v;
        adj = new LinkedList[v];
        for (int i = 0; i < v; i++) adj[i] = new LinkedList<>();
    }

    public void addEdge(int s, int t) { // 无向图
        adj[s].add(t);
        adj[t].add(s);
    }
}
优点 缺点
空间 O(V+E),稀疏图节省 查询两点关系需遍历链表 O(度数)
适合大规模稀疏图 随机访问对 CPU 缓存不友好

升级版邻接表:将链表替换为红黑树、跳表或散列表,提升查询效率。如微博关注关系用跳表还能支持按用户名排序分页。

选型对比

维度 邻接矩阵 邻接表
空间复杂度 O(V²) O(V+E)
查询两点关系 O(1) O(度数)
遍历所有邻居 O(V) O(度数)
适用场景 稠密图、矩阵运算 稀疏图(社交网络)

BFS 广度优先搜索

广度优先搜索(BFS) 用队列驱动,从起点向外逐层扩散,先访问距离为 1 的邻居,再访问距离为 2 的……

关键性质:BFS 找到的路径是边数最少的路径(最短路径,无权图)。

graph bfs

实现

public void bfs(int s, int t) {
    if (s == t) return;
    boolean[] visited = new boolean[v];
    int[] prev = new int[v];         // prev[i] = 到达 i 的前驱节点
    Arrays.fill(prev, -1);

    Queue<Integer> queue = new LinkedList<>();
    visited[s] = true;
    queue.add(s);

    while (!queue.isEmpty()) {
        int w = queue.poll();
        for (int q : adj[w]) {
            if (!visited[q]) {
                prev[q] = w;
                if (q == t) {
                    printPath(prev, s, t); // 找到目标,打印路径
                    return;
                }
                visited[q] = true;
                queue.add(q);
            }
        }
    }
}

private void printPath(int[] prev, int s, int t) {
    if (prev[t] != -1 && t != s) printPath(prev, s, prev[t]);
    System.out.print(t + " ");
}

三个辅助变量的作用

变量 作用
visited[] 标记已访问,防止重复入队
queue FIFO 保证按层处理
prev[] 反向记录路径,最后递归还原

复杂度

维度 复杂度 原因
时间 O(V+E) 每个顶点和每条边最多访问一次
空间 O(V) visited、queue、prev 均为线性

DFS 深度优先搜索

深度优先搜索(DFS) 沿一条路走到底,走不通则回溯换路,本质上是递归调用栈的深度优先遍历。

关键性质:DFS 找到的路径不保证是最短路径,但能遍历图中所有可达顶点。

graph dfs

实现

private boolean found = false;

public void dfs(int s, int t) {
    boolean[] visited = new boolean[v];
    int[] prev = new int[v];
    Arrays.fill(prev, -1);
    recurDfs(s, t, visited, prev);
    if (found) printPath(prev, s, t);
}

private void recurDfs(int w, int t, boolean[] visited, int[] prev) {
    if (found) return;
    visited[w] = true;
    if (w == t) { found = true; return; }
    for (int q : adj[w]) {
        if (!visited[q]) {
            prev[q] = w;
            recurDfs(q, t, visited, prev);
        }
    }
    // 此处隐式回溯:从 w 的所有邻居返回后,w 标记已访问但不影响其他路径
}

复杂度

维度 复杂度
时间 O(E)(每条边最多访问两次)
空间 O(V)(visited 数组 + 递归调用栈)

BFS vs DFS 对比

维度 BFS DFS
驱动结构 队列(显式) 调用栈(隐式递归)
路径保证 最短路径(边数最少) 不保证最短
内存占用 较大(层宽可能很大) 较小(只存当前路径)
典型应用 最短路径、层序遍历、社交网络N度好友 拓扑排序、连通分量、回溯搜索

最短路径

Dijkstra 算法

适用于无负权边的带权图,求单源最短路径。

核心思路:贪心。维护 dist[] 数组记录起点到每个顶点的当前最短距离,每次从未处理的顶点中取 dist 最小的,用它松弛所有邻居。

graph dijkstra

松弛操作:若 dist[u] + w(u,v) < dist[v],则更新 dist[v]

public int[] dijkstra(int src) {
    int[] dist = new int[v];
    boolean[] processed = new boolean[v];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[src] = 0;

    // 优先队列:(distance, vertex)
    PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
    pq.offer(new int[]{0, src});

    while (!pq.isEmpty()) {
        int[] curr = pq.poll();
        int u = curr[1];
        if (processed[u]) continue;
        processed[u] = true;

        for (int[] edge : adjWeighted[u]) { // [neighbor, weight]
            int neighbor = edge[0], weight = edge[1];
            if (dist[u] + weight < dist[neighbor]) {
                dist[neighbor] = dist[u] + weight;
                pq.offer(new int[]{dist[neighbor], neighbor});
            }
        }
    }
    return dist;
}
实现 时间复杂度 说明
朴素实现(线性扫描) O(V²) 适合稠密图
优先队列优化 O((V+E) log V) 适合稀疏图

Bellman-Ford 算法

适用于有负权边的图,可检测负权环

核心思路:对所有边做 V−1 轮松弛。第 k 轮结束后,dist[v] 是从源点经过至多 k 条边的最短距离。若第 V 轮仍能松弛,说明存在负权环。

public int[] bellmanFord(int src) {
    int[] dist = new int[v];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[src] = 0;

    for (int i = 0; i < v - 1; i++) {        // V-1 轮
        for (int[] edge : edges) {            // 遍历所有边 (u, v, w)
            int u = edge[0], nv = edge[1], w = edge[2];
            if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[nv])
                dist[nv] = dist[u] + w;
        }
    }
    // 检测负权环:若第 V 轮仍能松弛则有负权环
    for (int[] edge : edges) {
        if (dist[edge[0]] + edge[2] < dist[edge[1]])
            throw new RuntimeException("存在负权环");
    }
    return dist;
}

最短路径算法对比

算法 负权边 负权环检测 时间复杂度 适用场景
Dijkstra O((V+E) log V) 地图导航、无负权
Bellman-Ford O(VE) 货币套利检测、有负权
Floyd-Warshall O(V³) 全源最短路径(V 较小)

最小生成树

最小生成树(MST):在连通无向带权图中,找一棵包含所有 V 个顶点、仅用 V−1 条边、且边权之和最小的树。

graph mst

Prim 算法

思路:从任意顶点出发,维护"已选集合 S",每次选择连接 S 与非 S 的最小权边,将新顶点并入 S,重复 V−1 次。

S = {start}
重复 V-1 次:
  在所有 (u, v) 边中,u ∈ S,v ∉ S,选权重最小的边
  将 v 加入 S,记录边 (u, v)

Kruskal 算法

思路:将所有边按权重从小到大排序,依次考察每条边——若加入后不形成环,则纳入 MST。用并查集判断成环。

public List<int[]> kruskal(int v, int[][] edges) {
    // edges: [u, v, weight],按 weight 排序
    Arrays.sort(edges, Comparator.comparingInt(e -> e[2]));
    int[] parent = new int[v];
    for (int i = 0; i < v; i++) parent[i] = i;

    List<int[]> mst = new ArrayList<>();
    for (int[] edge : edges) {
        int pu = find(parent, edge[0]);
        int pv = find(parent, edge[1]);
        if (pu != pv) {           // 不在同一连通分量,不成环
            mst.add(edge);
            parent[pu] = pv;      // 合并并查集
        }
        if (mst.size() == v - 1) break;
    }
    return mst;
}

private int find(int[] parent, int x) {
    if (parent[x] != x) parent[x] = find(parent, parent[x]); // 路径压缩
    return parent[x];
}

Prim vs Kruskal

维度 Prim Kruskal
出发点 从某一顶点扩张 从全局最小边开始
核心数据结构 优先队列 排序 + 并查集
时间复杂度 O((V+E) log V) O(E log E)
适合图类型 稠密图 稀疏图

拓扑排序

拓扑排序:对有向无环图(DAG)的顶点进行线性排序,使每条边 (u→v) 中 u 都排在 v 之前。

典型应用:编译依赖、任务调度、课程先修关系、Makefile 构建顺序。

graph topo

Kahn 算法(BFS + 入度)

public List<Integer> topoSort(int v, int[][] edges) {
    int[] inDegree = new int[v];
    List<List<Integer>> adj = new ArrayList<>();
    for (int i = 0; i < v; i++) adj.add(new ArrayList<>());

    for (int[] e : edges) {
        adj.get(e[0]).add(e[1]);
        inDegree[e[1]]++;
    }

    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < v; i++)
        if (inDegree[i] == 0) queue.add(i); // 所有入度为 0 的顶点入队

    List<Integer> result = new ArrayList<>();
    while (!queue.isEmpty()) {
        int u = queue.poll();
        result.add(u);
        for (int neighbor : adj.get(u)) {
            if (--inDegree[neighbor] == 0) // 入度归零,可以入队
                queue.add(neighbor);
        }
    }

    // 若结果不包含所有顶点,说明图中存在环(无法完成拓扑排序)
    if (result.size() < v) throw new RuntimeException("图中存在环,不是 DAG");
    return result;
}

步骤

  1. 统计所有顶点的入度
  2. 将入度为 0 的顶点加入队列
  3. 出队一个顶点 u,将其所有邻居的入度减 1;若某邻居入度变为 0,入队
  4. 重复直到队列为空
维度
时间复杂度 O(V+E)
空间复杂度 O(V)
检测环 result.size() < V 即有环

DFS 拓扑排序

DFS 完成后逆序输出即为拓扑序(后处理时间越大的顶点排越前),适合递归场景:

public List<Integer> topoSortDFS(int v, List<List<Integer>> adj) {
    boolean[] visited = new boolean[v];
    Deque<Integer> stack = new ArrayDeque<>();
    for (int i = 0; i < v; i++)
        if (!visited[i]) dfsHelper(i, adj, visited, stack);
    List<Integer> result = new ArrayList<>(stack); // 栈顶 = 拓扑序第一个
    return result;
}

private void dfsHelper(int u, List<List<Integer>> adj, boolean[] visited, Deque<Integer> stack) {
    visited[u] = true;
    for (int neighbor : adj.get(u))
        if (!visited[neighbor]) dfsHelper(neighbor, adj, visited, stack);
    stack.push(u); // 所有后继处理完后才入栈
}

参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论