力扣Hot100系列22(Java)——[图论]总结(岛屿数量,腐烂的橘子,课程表,实现Trie(前缀树))

文章目录


前言

本文记录力扣Hot100里面关于图论的四道题,包括常见解法和一些关键步骤理解,也有例子便于大家理解


一、岛屿数量

1.题目

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。

示例 1:

输入:grid = [

'1','1','1','1','0'\], \['1','1','0','1','0'\], \['1','1','0','0','0'\], \['0','0','0','0','0'

]

输出:1

示例 2:

输入:grid = [

'1','1','0','0','0'\], \['1','1','0','0','0'\], \['0','0','1','0','0'\], \['0','0','0','1','1'

]

输出:3

2.代码

java 复制代码
class Solution {
    // DFS 函数:从 (r,c) 开始,把整片相连的陆地全部变成 0
    void dfs(char[][] grid, int r, int c) {
        int nr = grid.length;          // 网格一共有多少行
        int nc = grid[0].length;       // 网格一共有多少列

        // 如果越界 或者 当前是水,直接返回
        if (r < 0 || c < 0 || r >= nr || c >= nc || grid[r][c] == '0') {
            return;
        }

        grid[r][c] = '0';              // 把当前陆地淹没,标记为已访问
        dfs(grid, r - 1, c);           // 往上搜索
        dfs(grid, r + 1, c);           // 往下搜索
        dfs(grid, r, c - 1);           // 往左搜索
        dfs(grid, r, c + 1);           // 往右搜索
    }

    // 主函数:计算岛屿数量
    public int numIslands(char[][] grid) {
        // 如果网格为空,直接返回 0
        if (grid == null || grid.length == 0) {
            return 0;
        }

        int nr = grid.length;          // 行数
        int nc = grid[0].length;       // 列数
        int num_islands = 0;           // 岛屿数量,初始 0

        // 遍历整个网格的每一个格子
        for (int r = 0; r < nr; ++r) {
            for (int c = 0; c < nc; ++c) {
                // 发现一块没被访问过的陆地
                if (grid[r][c] == '1') {
                    num_islands++;     // 岛屿数量 +1
                    dfs(grid, r, c);   // DFS 淹没整个岛屿
                }
            }
        }

        return num_islands;            // 返回最终岛屿总数
    }
}

3.例子

复制代码
['1','1','0','0','0']
['1','1','0','0','0']
['0','0','1','0','0']
['0','0','0','1','1']

第一步:初始化

  • 行数 nr = 4
  • 列数 nc = 5
  • 岛屿数量 num_islands = 0

开始双重循环遍历每一格

第 1 个格子:(0,0) → 值是 '1'

  1. num_islands 从 0 → 1
  2. 调用 dfs(0,0)
  3. DFS 开始淹没整片相连的 1

DFS 淹没过程(自动把相连 1 全变 0)

  • (0,0) → 变 0
  • (0,1) → 变 0
  • (1,0) → 变 0
  • (1,1) → 变 0

淹没后网格变成:

复制代码
['0','0','0','0','0']
['0','0','0','0','0']
['0','0','1','0','0']
['0','0','0','1','1']

继续遍历:

(0,2)=0、(0,3)=0、(0,4)=0

(1,0)=0、(1,1)=0、(1,2)=0、(1,3)=0、(1,4)=0


走到 (2,2) → 值是 '1'

  1. num_islands 从 1 → 2
  2. 调用 dfs(2,2)
  3. 淹没这个单独的 1 → 变 0

网格现在:

复制代码
['0','0','0','0','0']
['0','0','0','0','0']
['0','0','0','0','0']
['0','0','0','1','1']

继续遍历:

(2,3)=0、(2,4)=0

(3,0)=0、(3,1)=0、(3,2)=0


走到 (3,3) → 值是 '1'

  1. num_islands 从 2 → 3
  2. 调用 dfs(3,3)
  3. 淹没:
    • (3,3) → 0
    • (3,4) → 0

最终网格全是 0:

复制代码
['0','0','0','0','0']
['0','0','0','0','0']
['0','0','0','0','0']
['0','0','0','0','0']

遍历结束

返回 num_islands = 3


二、腐烂的橘子

1.题目

在给定的 m x n 网格 grid 中,每个单元格可以有以下三个值之一:

  • 值 0 代表空单元格;
  • 值 1 代表新鲜橘子;
  • 值 2 代表腐烂的橘子。
    每分钟,腐烂的橘子 周围 4 个方向上相邻 的新鲜橘子都会腐烂。
    返回 直到单元格中没有新鲜橘子为止所必须经过的最小分钟数 。如果不可能,返回 -1

示例 1:

输入:grid = [[2,1,1],[1,1,0],[0,1,1]]

输出:4

示例 2:

输入:grid = [[2,1,1],[0,1,1],[1,0,1]]

输出:-1

解释:左下角的橘子(第 2 行, 第 0 列)永远不会腐烂,因为腐烂只会发生在 4 个方向上。

示例 3:

输入:grid = [[0,2]]

输出:0

解释:因为 0 分钟时已经没有新鲜橘子了,所以答案就是 0 。

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 10
  • grid[i][j] 仅为 0、1 或 2

2.代码

java 复制代码
class Solution {
    // 上下左右四个方向的坐标偏移量
    private static final int[][] DIRECTIONS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

    public int orangesRotting(int[][] grid) {
        int m = grid.length;    // 网格行数
        int n = grid[0].length; // 网格列数
        int fresh = 0;          // 新鲜橘子数量
        List<int[]> badList = new ArrayList<>(); // 存放当前腐烂的橘子

        // 第一步:遍历网格,统计初始数据
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == 1) {
                    fresh++; // 遇到新鲜橘子,计数+1
                } else if (grid[i][j] == 2) {
                    badList.add(new int[]{i, j}); // 遇到腐烂橘子,加入队列
                }
            }
        }

        int ans = 0; // 记录总共需要的分钟数

        // 第二步:BFS 扩散腐烂(循环直到没有新鲜橘子 或 没有可扩散的腐烂橘子)
        while (fresh > 0 && !badList.isEmpty()) {
            ans++; // 每轮循环 = 过去1分钟
            List<int[]> tmp = badList;   // 取出这一分钟要扩散的所有橘子
            badList = new ArrayList<>(); // 清空,准备存下一分钟新腐烂的橘子

            // 遍历当前所有腐烂橘子,让它们同时向四周扩散
            for (int[] pos : tmp) {
                for (int[] dir : DIRECTIONS) { // 遍历上下左右
                    int i = pos[0] + dir[0];
                    int j = pos[1] + dir[1];

                    // 判断坐标是否合法 + 是新鲜橘子
                    if (0 <= i && i < m && 0 <= j && j < n && grid[i][j] == 1) {
                        fresh--;            // 新鲜橘子变少
                        grid[i][j] = 2;     // 变成腐烂橘子
                        badList.add(new int[]{i, j}); // 加入下一轮扩散
                    }
                }
            }
        }
     // 最后:如果还有新鲜橘子剩下,返回-1,否则返回时间
        return fresh > 0 ? -1 : ans;
    }
}

3.例子

我用你给的** exact 例子**,一步一步、逐分钟带你走一遍代码,让你彻底看懂!

输入网格

复制代码
grid = [
  [2, 1, 1],
  [1, 1, 0],
  [0, 1, 1]
]

数字含义:

  • 2 = 腐烂橘子
  • 1 = 新鲜橘子
  • 0 = 空

第一步:代码先遍历整个网格

复制代码
[2,1,1]
[1,1,0]
[0,1,1]

代码统计结果:

  • fresh = 6 个新鲜橘子
  • badList = [ (0,0) ] 1 个腐烂橘子
  • ans = 0

第二步:开始 BFS 扩散(按分钟走)

第 1 分钟开始

ans 从 0 → 1

当前要扩散的腐烂橘子:(0,0)

上下左右:

  • 上:越界
  • 下:(1,0) = 1 → 变 2
  • 左:越界
  • 右:(0,1) = 1 → 变 2

新腐烂橘子:(1,0)、(0,1)
fresh = 6 - 2 = 4

网格现在:

复制代码
[2, 2, 1]
[2, 1, 0]
[0, 1, 1]

第 2 分钟

ans 从 1 → 2

当前扩散:(0,1)、(1,0)

(0,1) 扩散:

  • 右 (0,2) = 1 → 变 2

(1,0) 扩散:

  • 下 (2,0) = 0 不变
  • 右 (1,1) = 1 → 变 2

新腐烂:(0,2)、(1,1)
fresh = 4 - 2 = 2

网格:

复制代码
[2, 2, 2]
[2, 2, 0]
[0, 1, 1]

第 3 分钟

ans 从 2 → 3

当前扩散:(0,2)、(1,1)

(0,2) 扩散:周围无新鲜橘子

(1,1) 扩散:

  • 下 (2,1) = 1 → 变 2

新腐烂:(2,1)
fresh = 2 - 1 = 1

网格:

复制代码
[2, 2, 2]
[2, 2, 0]
[0, 2, 1]

第 4 分钟
ans 从 3 → 4

当前扩散:(2,1)

(2,1) 扩散:

  • 右 (2,2) = 1 → 变 2

新腐烂:(2,2)
fresh = 1 - 1 = 0

网格:

复制代码
[2, 2, 2]
[2, 2, 0]
[0, 2, 2]

第三步:结束循环

  • fresh = 0 → 没有新鲜橘子了
  • 返回 ans = 4

三、课程表

1.题目

你这个学期必须选修 numCourses 门课程 ,记为 0 到 numCourses - 1 。

在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [a(i), b(i)] ,表示如果要学习课程 a(i) 则 必须 先学习课程 b(i)( )。

  • 例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1 。

请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。

示例 1:

输入:numCourses = 2, prerequisites = [[1,0]]

输出:true

解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。
示例 2:

输入:numCourses = 2, prerequisites = [[1,0],[0,1]]

输出:false

解释:总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的。

2.代码

java 复制代码
class Solution {
    // 全局变量:存储图的邻接表(课程依赖关系)
    List<List<Integer>> edges;
    // 标记每个课程的访问状态:0=未访问 1=访问中 2=已访问完
    int[] visited;
    // 标记是否无环,默认无环(true),发现环改成false
    boolean valid = true;

    // 主方法:输入课程总数、课程依赖数组,返回能否修完课
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 初始化邻接表,给每一门课程创建一个空的依赖列表
        edges = new ArrayList<List<Integer>>();
        for (int i = 0; i < numCourses; ++i) {
            edges.add(new ArrayList<Integer>());
        }

        // 初始化状态数组,所有课程默认未访问(0)
        visited = new int[numCourses];

        // 构建有向图:[后修课, 先修课] → 存为 先修课 → 后修课 的边
        for (int[] info : prerequisites) {
            edges.get(info[1]).add(info[0]);
        }

        // 遍历所有课程,只要没发现环,就对未访问的课程做DFS
        for (int i = 0; i < numCourses && valid; ++i) {
            if (visited[i] == 0) {
                dfs(i);
            }
        }

        // 最终返回:无环=true,有环=false
        return valid;
    }

    // DFS核心:深度遍历,检测是否有环
    public void dfs(int u) {
        // 标记当前课程:正在遍历中(状态1)
        visited[u] = 1;

        // 遍历当前课程的所有后续课程
        for (int v: edges.get(u)) {
            if (visited[v] == 0) {
                // 后续课程没访问过 → 继续DFS
                dfs(v);
                // 子递归发现环,直接退出,不用继续遍历
                if (!valid) return;
            } else if (visited[v] == 1) {
                // 遇到了【正在遍历中】的节点 → 找到环!
                valid = false;
                return;
            }
        }

        // 当前课程所有后续都遍历完了 → 标记为已完成(状态2)
        visited[u] = 2;
    }
}

3.例子

例 1:无环 → 可以修完课(返回 true)

输入

复制代码
numCourses = 2
prerequisites = [[1,0]]

含义 :想学课程 1,必须先学课程 0
:0 → 1


代码执行步骤

1. 初始化

  • edges = [[], []]
  • visited = [0, 0]
  • valid = true

2. 建图
info = [1,0]
edges.get(0).add(1)

edges = [ [1], [] ]

3. 遍历课程

复制代码
i=0,visited[0]=0 → 调用 dfs(0)

** 执行 dfs(0)**

  1. visited[0] = 1(标记正在访问)
  2. 遍历邻居 v=1
    • visited[1] = 0 → 调用 dfs(1)

执行 dfs(1)

  1. visited[1] = 1
  2. 没有邻居
  3. visited[1] = 2(标记完成)
  4. 返回

回到 dfs(0)

  • 循环结束
  • visited[0] = 2
  • 返回

4. 结束
valid = true返回 true


例 2:有环 → 不能修完课(返回 false)

输入

复制代码
numCourses = 2
prerequisites = [[1,0], [0,1]]

含义

学 1 先学 0,学 0 先学 1 → 死循环(环)
:0 ↔ 1


代码执行步骤

1. 建图
edges.get(0).add(1)
edges.get(1).add(0)

edges = [ [1], [0] ]

2. 开始 dfs(0)

  1. visited[0] = 1
  2. 遍历邻居 v=1
    • visited[1]=0 → dfs(1)

执行 dfs(1)

  1. visited[1] = 1
  2. 遍历邻居 v=0
    • visited[0] == 1 (正在访问!)
    • 发现环!
    • valid = false
    • return

回到 dfs(0)

  • 发现 valid=false → return

3. 结束
valid = false返回 false


四、实现Trie(前缀树)

1.题目

Trie(发音类似 "try")或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补全和拼写检查。

请你实现 Trie 类:

Trie() 初始化前缀树对象。

void insert(String word) 向前缀树中插入字符串 word 。

boolean search(String word) 如果字符串 word 在前缀树中,返回 true(即,在检索之前已经插入);否则,返回 false 。

boolean startsWith(String prefix) 如果之前已经插入的字符串 word 的前缀之一为 prefix ,返回 true ;否则,返回 false 。

示例:

输入

"Trie", "insert", "search", "search", "startsWith", "insert", "search"

\[\], \["apple"\], \["apple"\], \["app"\], \["app"\], \["app"\], \["app"\]

输出

null, null, true, false, true, null, true

解释

Trie trie = new Trie();

trie.insert("apple");

trie.search("apple"); // 返回 True

trie.search("app"); // 返回 False

trie.startsWith("app"); // 返回 True

trie.insert("app");

trie.search("app"); // 返回 True

提示:

1 <= word.length, prefix.length <= 2000

word 和 prefix 仅由小写英文字母组成

insert、search 和 startsWith 调用次数 总计 不超过 3 * 104 次

2.代码

java 复制代码
class Trie {
    // 子节点数组,每个节点最多有26个孩子,对应小写字母a-z
    private Trie[] children;
    // 标记当前节点是否是一个单词的结尾
    private boolean isEnd;

    // 构造方法:初始化字典树节点
    public Trie() {
        children = new Trie[26]; // 26个字母,所以数组长度26
        isEnd = false; // 默认不是单词结尾
    }
    
    // 插入一个单词到字典树
    public void insert(String word) {
        Trie node = this; // 从根节点开始
        for (int i = 0; i < word.length(); i++) { // 遍历单词的每一个字符
            char ch = word.charAt(i); // 取出当前字符
            int index = ch - 'a'; // 转成数组下标 0~25
            if (node.children[index] == null) { // 如果该子节点不存在
                node.children[index] = new Trie(); // 新建节点
            }
            node = node.children[index]; // 移动到子节点,继续处理下一个字符
        }
        node.isEnd = true; // 单词全部插入完成,标记最后一个节点为单词结尾
    }
    
    // 查找单词是否存在(必须完全匹配)
    public boolean search(String word) {
        Trie node = searchPrefix(word); // 查找前缀
        return node != null && node.isEnd; // 找到且是单词结尾才返回true
    }
    
    // 判断是否存在以 prefix 为前缀的单词
    public boolean startsWith(String prefix) {
        return searchPrefix(prefix) != null; // 只要能找到前缀就返回true
    }

    // 私有工具方法:查找前缀,返回最后一个节点
    private Trie searchPrefix(String prefix) {
        Trie node = this; // 从根节点开始
        for (int i = 0; i < prefix.length(); i++) { // 遍历前缀字符
            char ch = prefix.charAt(i);
            int index = ch - 'a';
            if (node.children[index] == null) { // 某个字符不存在
                return null; // 前缀不存在,返回null
            }
            node = node.children[index]; // 继续往下走
        }
        return node; // 遍历完成,返回当前节点
    }
}

3.例子

例:

复制代码
操作序列:
["Trie", "insert", "search", "search", "startsWith", "insert", "search"]

参数:
[[], ["apple"], ["apple"], ["app"], ["app"], ["app"], ["app"]]

输出:
[null, null, true, false, true, null, true]

第1步:执行 Trie() ------ 创建字典树

  • 操作:新建一棵空字典树
  • 此时只有根节点
  • 没有任何字母,没有任何单词
  • 输出:null

第2步:执行 insert("apple") ------ 插入单词 apple

开始逐字符往下建路径:

  1. 从根节点出发
  2. 字符 a → 没有,新建节点
  3. 字符 p → 没有,新建节点
  4. 字符 p → 没有,新建节点
  5. 字符 l → 没有,新建节点
  6. 字符 e → 没有,新建节点
  7. 到达最后一个字符 e,把它标记 isEnd = true
    • 表示:到这里是一个完整单词

现在树里的路径:
根 → a → p → p → l → e(标记结束)

  • 输出:null

第3步:执行 search("apple") ------ 查找完整单词 apple

逐字符查找:

  1. a 存在
  2. p 存在
  3. p 存在
  4. l 存在
  5. e 存在
  6. 最后一个节点 e 被标记为结束(isEnd = true)

→ 两个条件都满足:路径存在 + 是单词结尾

→ 结果:true


第4步:执行 search("app") ------ 查找完整单词 app

逐字符查找:

  1. a 存在
  2. p 存在
  3. p 存在

路径走完了

但是 :最后这个 p 节点 没有标记 isEnd = true

它只是 apple 中间的一个节点,不是单词结尾。

search 要求:必须是完整单词才返回 true

→ 结果:false


第5步:执行 startsWith("app") ------ 判断是否有以 app 开头的单词

startsWith 规则:只要路径能走完,就返回 true

不管是不是单词结尾。

  1. a 存在
  2. p 存在
  3. p 存在

路径完全能走通 → 结果:true


第6步:执行 insert("app") ------ 插入单词 app

现在插入 app:

  1. 走到 a → p → p
  2. 把最后这个 p 节点 标记 isEnd = true

现在树里有两个单词:

  1. app (p标记结束)
  2. apple (e标记结束)

路径变成:
根 → a → p → p(结束)→ l → e(结束)

  • 输出:null

第7步:执行 search("app") ------ 再次查找完整单词 app

现在:

  1. 路径 a→p→p 存在
  2. 最后 p 节点 已经被标记 isEnd = true

→ 满足完整单词条件

→ 结果:true


最终输出结果

复制代码
[null, null, true, false, true, null, true]

如果本篇文章对您有帮助,可以点赞,收藏或评论哦!!!关注主包不迷路,让我们一起向前进步吧!!

相关推荐
1104.北光c°2 小时前
深入浅出 Elasticsearch:从搜索框到精准排序的架构实战
java·开发语言·elasticsearch·缓存·架构·全文检索·es
MSTcheng.2 小时前
【优选算法必修篇——位运算】『面试题 01.01. 判定字符是否唯一&面试题 17.19. 消失的两个数字』
java·算法·面试
蹦哒2 小时前
Kotlin 与 Java 语法差异
java·python·kotlin
左左右右左右摇晃2 小时前
Java并发——并发编程底层原理
java·开发语言
一个有温度的技术博主2 小时前
Redis系列八:Jedis连接池在java中的使用
java·redis·bootstrap
cyforkk2 小时前
Java 并发编程教科书级范例:深入解析 computeIfAbsent 与方法引用
java·开发语言
后青春期的诗go2 小时前
泛微OA-E9与第三方系统集成开发企业级实战记录(八)
java·接口·金蝶·泛微·oa·集成开发·对接
dreamxian2 小时前
苍穹外卖day09
java·spring boot·tomcat·log4j·maven
剑海风云2 小时前
JDK 26之安全增强
java·开发语言·安全·jdk26