【力扣hot100】图论专题+模板|DFS、BFS、拓扑排序...

前言

图论是计算机科学中一个极其重要的领域,它研究的是由节点(顶点)和边组成的图结构。无论是在算法竞赛、面试准备,还是在实际的软件开发中,图论算法都扮演着至关重要的角色。

本文旨在为 Java 开发者提供一个实用的图论算法指南,涵盖了从基础的 DFS/BFS 遍历,到经典的拓扑排序、最短路径、最小生成树等核心算法。每个算法都配有清晰的 Java 实现代码和关键思路解析,力求让你能够:

  1. 理解原理:掌握每个算法的核心思想和适用场景
  2. 快速上手:获得可直接使用的 Java 代码模板
  3. 灵活应用:学会根据实际问题选择合适的图论算法

文章结构从易到难,先通过 LeetCode 经典题目(如岛屿数量、腐烂的橘子)引入基础概念,再深入讲解更复杂的算法模板。最后,我们还提供了一个算法选择速查表,帮助你在面对具体问题时快速定位合适的算法。

无论你是正在准备技术面试,还是希望在项目中应用图论算法,相信这篇文章都能为你提供有价值的参考。让我们开始探索图论的奇妙世界吧!

图论

文章目录

DFS

java 复制代码
void dfs(int[][] grid, int r, int c) {
    // 判断 base case
    // 如果坐标 (r, c) 超出了网格范围,直接返回
    if (!inArea(grid, r, c)) {
        return;
    }
    // 访问上、下、左、右四个相邻结点
    dfs(grid, r - 1, c);
    dfs(grid, r + 1, c);
    dfs(grid, r, c - 1);
    dfs(grid, r, c + 1);
}

// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
    return 0 <= r && r < grid.length 
        	&& 0 <= c && c < grid[0].length;
}

200.岛屿数量

200. 岛屿数量

  • 0 ------ 海洋格子
  • 1 ------ 陆地格子(未遍历过)
  • 2 ------ 陆地格子(已遍历过)
java 复制代码
class Solution {
    
    public int numIslands(char[][] grid) {
        int count = 0;
        //来遍历整张二维表格中所有的陆地
        for(int i = 0; i<grid.length;i++){
            for(int j =0;j<grid[0].length;j++){
                //取出所有的陆地
                if(grid[i][j] == '1'){
                    //深度递归,遍历所有的陆地
                    dfs(grid,i,j);
                    count++;
                }
            }
        }
        return count;
    }
    public void dfs(char[][] grid,int i, int j){
        
        if(i<0||j<0||i>=grid.length||j>=grid[0].length||grid[i][j]=='0') 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);
    }
}

994. 腐烂的橘子

994. 腐烂的橘子

  • DFS 是一条线,适合探索未知;
  • BFS 是一圈圈的水波纹,适合计算"最短时间/最少步数

所以这题应该用多源BFS

怎么判断所有橘子是否全部腐烂?

开始定义一个变量记录新鲜橘子的数量,结束后如果新鲜橘子数量不为0就返回-1

java 复制代码
class Solution {
    public int orangesRotting(int[][] grid) {
        Queue<int[]> q = new ArrayDeque<>();
        int time = 0,fresh = 0;

        //初始化
        for(int i = 0;i<grid.length;i++){
            for(int j =0;j<grid[0].length;j++){
                if(grid[i][j] == 1){
                    fresh++;//得到新鲜橘子的个数
                }else if(grid[i][j] == 2){
                    q.add(new int[]{i,j});//让腐烂的橘子进队
                }
            }
        }

        int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
        //队列不为空,并且新鲜橘子数量大于0的情况下
        //访问队列中的所有元素,把相邻的新鲜橘子入队
        while(!q.isEmpty() && fresh > 0){
            int qLen = q.size();
            for(int i=0;i<qLen;i++){
                int[] cur = q.poll();
                //判断相邻是否有新鲜橘子 有就入队
                for(int[] dir : dirs){
                    int r = cur[0] + dir[0];
                    int c = cur[1] + dir[1];
                    if(r<0||r>=grid.length||c<0||c>=grid[0].length||grid[r][c]!=1){
                        //不满足条件
                        continue;
                    }
                    grid[r][c] = 2;
                    q.add(new int[]{r,c});
                    fresh--;
                }
            }
            time++;
        }

        if(fresh > 0)return -1;
        else return time;

    }
}

拓扑排序

必须在有向无环图中才能进行下去

每次选入度为0 的点,然后删除这个点和它的出边

用一个数组记录每个节点的入度,再通过BFS对有向图进行拓扑排序

207. 课程表

207. 课程表

本题用的就是拓扑排序

count用于记录遍历过的节点数(或者直接用numCourses--),数组ingree用于标记每个节点的入度数,列表preCourse标记每个节点的关联,队列q用于操作每个入度为0的节点

java 复制代码
class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        int[] ingree = new int[numCourses];
        List<List<Integer>> preCourse = new ArrayList<>();
        //初始化列表里的子列表
        for (int i = 0; i < numCourses; i++) {
            preCourse.add(new ArrayList<>());
        }
        //初始化ingree和preCourse 记录每个节点的入度
        for(int[] temp : prerequisites){
            preCourse.get(temp[1]).add(temp[0]);
            ingree[temp[0]]++;
        }
        Queue<Integer> q = new ArrayDeque<>();
        //初始化队列
        for(int i= 0;i<numCourses;i++){
            if(ingree[i] == 0){
                q.add(i);
            }
        }
        while(!q.isEmpty()){
            int cur = q.poll();
            numCourses--;
            for(int temp : preCourse.get(cur)){
                ingree[temp]--;
                if(ingree[temp] == 0){
                    q.add(temp);
                }
            }
        }

        return numCourses == 0;
    }
}

208. 实现 Trie (前缀树)

208. 实现 Trie (前缀树)

1.核心数据结构:26 叉树

每个 Node 节点包含:

  • son[26] :一个长度为 26 的数组,分别对应 a~z 26 个字母。如果某个字母存在,对应位置就指向下一个节点;如果不存在,就是 null
  • end :标记从根节点到当前节点的路径是否构成一个完整的单词

2.三个核心操作的逻辑

insert(String word) --- 插入单词

从根节点出发,逐个字符往下走:

  • 如果当前字符对应的子节点不存在son[c] == null),就 new 一个新节点。
  • 如果已存在,直接走过去。
  • 走到最后一个字符时,把 end 标记为 true,表示这里是一个完整单词的结尾。

find(String word) --- 统一查找(核心设计)

从根节点出发,逐个字符往下走:

  • 如果中途某个字符对应的子节点为 null,说明这条路根本不存在,直接返回 0(未找到)。
  • 如果顺利走完了所有字符,说明至少前缀匹配 成功,返回 1
  • 如果走完后发现 end == true,说明这是一个完整单词 ,返回 2

3.search startsWith --- 调用 find 做判断

  • search :必须返回 2(完全匹配),即这个单词确实被插入过。
  • startsWith :只要返回 12 都行(!= 0),即只要前缀存在即可
java 复制代码
class Trie {
    private static class Node {
        Node[] son = new Node[26];
        boolean end = false;
    }

    private final Node root = new Node();

    public Trie() {

    }

    public void insert(String word) {
        Node cur = root;
        for (char c : word.toCharArray()) {
            c -= 'a';
            if (cur.son[c] == null) { // 无路可走
                cur.son[c] = new Node(); // new一个新的
            }
            cur = cur.son[c];
        }
        cur.end = true;
    }

    public boolean search(String word) {
        return find(word) == 2 ? true : false; 
    }

    public boolean startsWith(String prefix) {
        //完全匹配也算前缀匹配
        return find(prefix) != 0 ? true : false;
    }

    public int find(String word){
        Node cur = root;
        for(char c : word.toCharArray()){
            c -= 'a';
            if(cur.son[c] == null){
                return 0;
            }
            cur = cur.son[c];
        }
        //2:完全匹配,1:前缀匹配
        return cur.end ? 2 : 1;
    }
}

/**
 * Your Trie object will be instantiated and called as such:
 * Trie obj = new Trie();
 * obj.insert(word);
 * boolean param_2 = obj.search(word);
 * boolean param_3 = obj.startsWith(prefix);
 */

总结

下面把之前 C++ 的图论模板全部翻译成 Java 版 ,统一使用 List<List<int[]>> 作为图的存储结构,和你之前写课程表时用的风格完全一致,拿来就能用。


一、建图模板
java 复制代码
// 建图:graph.get(u) 存的是 u 的所有出边,每条边是 int[]{终点, 边权}
List<List<int[]>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) {
    graph.add(new ArrayList<>());
}

// 添加有向边 u -> v,权值为 w
graph.get(u).add(new int[]{v, w});

// 添加无向边 u <-> v,权值为 w(加两条)
graph.get(u).add(new int[]{v, w});
graph.get(v).add(new int[]{u, w});

二、堆优化 Dijkstra

适用场景 :求一个起点到所有点的最短距离,边权非负。时间复杂度 O ( m log ⁡ n ) O(m \log n) O(mlogn)。

java 复制代码
public int[] dijkstra(List<List<int[]>> graph, int start) {
    int n = graph.size();
    int[] dist = new int[n];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[start] = 0;

    boolean[] visited = new boolean[n];

    // 小根堆:存 {距离, 节点}
    PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]);
    pq.offer(new int[]{0, start});

    while (!pq.isEmpty()) {
        int[] cur = pq.poll();
        int d = cur[0], u = cur[1];

        if (visited[u]) continue; // 已确定最短路,跳过
        visited[u] = true;

        for (int[] edge : graph.get(u)) {
            int v = edge[0], w = edge[1];
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.offer(new int[]{dist[v], v});
            }
        }
    }
    return dist;
}

三、SPFA

适用场景:有负权边但无负环。平均 O(m),最坏O(nm)

java 复制代码
public int[] spfa(List<List<int[]>> graph, int start) {
    int n = graph.size();
    int[] dist = new int[n];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[start] = 0;

    boolean[] inQueue = new boolean[n];
    Queue<Integer> q = new ArrayDeque<>();
    q.offer(start);
    inQueue[start] = true;

    while (!q.isEmpty()) {
        int u = q.poll();
        inQueue[u] = false;

        for (int[] edge : graph.get(u)) {
            int v = edge[0], w = edge[1];
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (!inQueue[v]) {
                    q.offer(v);
                    inQueue[v] = true;
                }
            }
        }
    }
    return dist;
}

四、Floyd
java 复制代码
public int[][] floyd(int[][] g) {
    int n = g.length;
    // g[i][j] 初始为 i 到 j 的直接边权,无直接边则为 INF
    for (int k = 0; k < n; k++) {
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (g[i][k] != Integer.MAX_VALUE && g[k][j] != Integer.MAX_VALUE) {
                    g[i][j] = Math.min(g[i][j], g[i][k] + g[k][j]);
                }
            }
        }
    }
    return g;
}

⚠️ 注意k 必须在最外层!这是 Floyd 的核心。


五、Kruskal

适用场景:连接所有点且总边权最小。时间复杂度 O(m \log m)

java 复制代码
// 并查集(Kruskal 的搭档)
class UnionFind {
    int[] parent;

    public UnionFind(int n) {
        parent = new int[n];
        for (int i = 0; i < n; i++) parent[i] = i;
    }

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

    public boolean union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false; // 已在同一集合,会形成环
        parent[ra] = rb;
        return true;
    }
}

// Kruskal 主逻辑
public int kruskal(int n, int[][] edges) {
    // edges 每行是 {u, v, weight}
    Arrays.sort(edges, (a, b) -> a[2] - b[2]); // 按边权升序排序

    UnionFind uf = new UnionFind(n);
    int totalWeight = 0, edgeCount = 0;

    for (int[] edge : edges) {
        int u = edge[0], v = edge[1], w = edge[2];
        if (uf.union(u, v)) { // 不形成环才加入
            totalWeight += w;
            if (++edgeCount == n - 1) break; // 凑够 n-1 条边即停
        }
    }
    return edgeCount == n - 1 ? totalWeight : -1; // -1 表示无法连通
}

六、染色法判定二分图

适用场景:判断图能否被分成两组,使得同组内无边。时间复杂度O(n + m)

java 复制代码
public boolean isBipartite(List<List<int[]>> graph) {
    int n = graph.size();
    int[] color = new int[n]; // 0: 未染色, 1: 颜色A, 2: 颜色B

    for (int i = 0; i < n; i++) {
        if (color[i] == 0) { // 未访问过的连通块
            Queue<Integer> q = new ArrayDeque<>();
            q.offer(i);
            color[i] = 1;

            while (!q.isEmpty()) {
                int u = q.poll();
                for (int[] edge : graph.get(u)) {
                    int v = edge[0];
                    if (color[v] == 0) {
                        color[v] = 3 - color[u]; // 染相反颜色
                        q.offer(v);
                    } else if (color[v] == color[u]) {
                        return false; // 相邻节点颜色相同,不是二分图
                    }
                }
            }
        }
    }
    return true;
}

算法选择速查表

题目特征 优先算法 关键判断点
无权图最短路 / 最少步数 BFS 队列,分层
连通块 / 路径存在性 / 回溯 DFS 递归栈
任务依赖 / 判断环 / 拓扑序 拓扑排序 入度为0入队
单源最短路,边权 ≥ 0 Dijkstra (堆) 优先队列 + visited[]
单源最短路,有负权边 SPFA 队列 + inQueue[]
任意两点最短路,n ≤ 500 Floyd 三重循环,k 在最外层
最小生成树,稀疏图 Kruskal 边排序 + 并查集
二分图判定 染色法 相邻点颜色不同
相关推荐
mmsx1 小时前
置灰按钮为什么自己又“亮“了?一次状态缓存“双写冲突“的排查记录
java·缓存·bug·livedata
用户3126874877201 小时前
ReentrantLock 到底怎么排队的?AQS 源码拆解
java
Zane19941 小时前
ArrayList 插入慢,LinkedList 一定快吗
java·后端
_Narcissus_1 小时前
B树概念及操作笔记(含完整代码实现)
c语言·数据结构·数据库·c++·笔记·b树·算法
l1258651 小时前
# RAG多轮对话检索设计:Query重写如何让“那它呢“变成完整问题
前端·数据库·人工智能·python·算法·fastapi·milvus
吃饱了得干活1 小时前
从类爆炸到协作——DDD战略设计登场
java·后端·架构
wno7042 小时前
Spring Boot异常处理
java·spring boot·后端
疯狂打码的少年2 小时前
【数据结构】图的应用:最短路径(Dijkstra / Floyd)
数据结构·笔记·算法
linux-hzh2 小时前
百日算法修炼 · Day 13
算法