1. 力扣Hot100图论专题概览
最近在准备面试时,我发现力扣Hot100中的图论题目是高频考点,尤其是岛屿数量、腐烂的橘子、课程表和Trie(前缀树)这几道经典题目。作为Java开发者,掌握这些题目的解法不仅能帮助我们应对技术面试,更能提升解决实际问题的能力。本文将结合我的刷题经验,详细解析这四道图论相关的题目,从问题分析到代码实现,再到优化思路,手把手带你攻克这些经典问题。
图论在算法面试中占据重要地位,它考察的是我们对复杂数据结构的处理能力和算法设计思维。这四道题目分别代表了图论中的不同应用场景:岛屿数量考察图的遍历,腐烂的橘子涉及广度优先搜索的应用,课程表是拓扑排序的经典案例,而Trie则是一种特殊的树形结构,常用于字符串处理。掌握它们,你就能在图论类题目中游刃有余。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 岛屿数量问题解析与实现
2.1 问题描述与理解
岛屿数量(LeetCode 200)的题目描述很简单:给定一个由'1'(陆地)和'0'(水)组成的二维网格,计算岛屿的数量。岛屿被水包围,并且通过水平或垂直方向上相邻的陆地连接形成。这道题本质上是在寻找图中连通分量的数量。
我第一次看到这道题时,觉得应该用深度优先搜索(DFS)来解决,因为需要探索所有相连的陆地。但后来发现广度优先搜索(BFS)同样适用,甚至在某些情况下效率更高。关键在于如何高效地标记已经访问过的陆地,避免重复计算。
2.2 DFS解法实现
DFS解法直观且易于实现。基本思路是遍历整个网格,当遇到'1'时,启动DFS将与之相连的所有'1'都标记为已访问,这样它们就不会被重复计数。以下是Java实现:
java复制class Solution {
public int numIslands(char[][] grid) {
if (grid == null || grid.length == 0) return 0;
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;
}
private void dfs(char[][] grid, int i, int j) {
if (i < 0 || j < 0 || i >= grid.length || 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);
}
}
这个解法的时间复杂度是O(M×N),其中M和N分别是网格的行数和列数,因为每个网格最多被访问一次。空间复杂度在最坏情况下也是O(M×N),当网格全是陆地时,递归栈的深度可能达到M×N。
2.3 BFS解法与优化
BFS解法使用队列来替代递归栈,适合处理大规模数据时避免栈溢出。实现思路类似,只是将DFS的递归调用改为队列操作:
java复制class Solution {
public int numIslands(char[][] grid) {
if (grid == null || grid.length == 0) return 0;
int count = 0;
int[][] directions = {{1,0},{-1,0},{0,1},{0,-1}};
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
if (grid[i][j] == '1') {
count++;
Queue<int[]> queue = new LinkedList<>();
queue.offer(new int[]{i, j});
grid[i][j] = '0';
while (!queue.isEmpty()) {
int[] current = queue.poll();
for (int[] dir : directions) {
int x = current[0] + dir[0];
int y = current[1] + dir[1];
if (x >= 0 && y >= 0 && x < grid.length && y < grid[0].length && grid[x][y] == '1') {
queue.offer(new int[]{x, y});
grid[x][y] = '0';
}
}
}
}
}
}
return count;
}
}
在实际测试中,我发现对于某些特定形状的岛屿(如长条形的岛屿),BFS的性能可能优于DFS,因为它不需要深入递归。但两者在最坏情况下的时间复杂度相同。
提示:面试时可以先提出DFS解法,然后主动提到也可以使用BFS实现,并比较两者的优缺点,这会展示你对问题的全面理解。
3. 腐烂的橘子问题详解
3.1 问题分析与建模
腐烂的橘子(LeetCode 994)描述了一个网格,其中每个单元格可能有三种状态:0代表空单元格,1代表新鲜橘子,2代表腐烂橘子。每分钟,与腐烂橘子相邻的新鲜橘子都会腐烂。我们需要计算所有橘子腐烂所需的最少时间,或者返回-1表示不可能使所有橘子腐烂。
这道题实际上是图论中多源点BFS的典型应用。每个腐烂的橘子可以看作是一个源点,它们同时开始"感染"周围的新鲜橘子。我们需要计算这种"感染"传播到所有可达新鲜橘子所需的时间。
3.2 多源BFS实现
实现这个算法的关键在于使用队列来同时处理所有腐烂橘子,并在每一轮处理中记录时间。以下是Java实现:
java复制class Solution {
public int orangesRotting(int[][] grid) {
if (grid == null || grid.length == 0) return 0;
int rows = grid.length;
int cols = grid[0].length;
Queue<int[]> queue = new LinkedList<>();
int fresh = 0;
int time = 0;
// 统计新鲜橘子数量,并将所有腐烂橘子加入队列
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == 1) {
fresh++;
} else if (grid[i][j] == 2) {
queue.offer(new int[]{i, j});
}
}
}
// 如果没有新鲜橘子,直接返回0
if (fresh == 0) return 0;
int[][] directions = {{1,0},{-1,0},{0,1},{0,-1}};
while (!queue.isEmpty() && fresh > 0) {
int size = queue.size();
time++;
for (int i = 0; i < size; i++) {
int[] point = queue.poll();
for (int[] dir : directions) {
int x = point[0] + dir[0];
int y = point[1] + dir[1];
if (x >= 0 && y >= 0 && x < rows && y < cols && grid[x][y] == 1) {
grid[x][y] = 2;
queue.offer(new int[]{x, y});
fresh--;
}
}
}
}
return fresh == 0 ? time : -1;
}
}
这个解法的时间复杂度是O(M×N),因为每个格子最多被处理一次。空间复杂度也是O(M×N),在最坏情况下队列可能存储所有格子。
3.3 边界条件与测试案例
在实现过程中,有几个边界条件需要特别注意:
- 网格中一开始就没有新鲜橘子,应该直接返回0
- 网格中有无法被腐烂的新鲜橘子(被0包围),应该返回-1
- 网格为空的情况
我建议在面试中主动讨论这些边界条件,并给出相应的测试案例。例如:
java复制// 测试案例1:没有新鲜橘子
int[][] grid1 = {{0,2}};
// 期望输出:0
// 测试案例2:有无法腐烂的橘子
int[][] grid2 = {{1,1,0,0},{0,1,2,0},{0,0,1,1}};
// 期望输出:-1
// 测试案例3:正常情况
int[][] grid3 = {{2,1,1},{1,1,0},{0,1,1}};
// 期望输出:4
4. 课程表问题与拓扑排序
4.1 问题理解与抽象
课程表(LeetCode 207)是拓扑排序的经典应用。题目描述为:总共有numCourses门课程需要选,记为0到numCourses-1。给定一个先修课程数组prerequisites,其中prerequisites[i] = [a, b]表示要学习课程a必须先完成课程b。判断是否可能完成所有课程的学习。
这个问题可以抽象为判断有向图是否存在环。如果存在环,则说明有循环依赖,无法完成所有课程;否则可以找到一种拓扑排序来完成所有课程。
4.2 拓扑排序的两种实现
4.2.1 Kahn算法(入度表+BFS)
Kahn算法通过维护一个入度表和一个队列来实现拓扑排序:
java复制class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 构建邻接表和入度数组
List<List<Integer>> adj = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
adj.add(new ArrayList<>());
}
int[] inDegree = new int[numCourses];
for (int[] edge : prerequisites) {
adj.get(edge[1]).add(edge[0]);
inDegree[edge[0]]++;
}
// 初始化队列,将所有入度为0的节点加入队列
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) {
queue.offer(i);
}
}
int visited = 0;
while (!queue.isEmpty()) {
int course = queue.poll();
visited++;
for (int neighbor : adj.get(course)) {
inDegree[neighbor]--;
if (inDegree[neighbor] == 0) {
queue.offer(neighbor);
}
}
}
return visited == numCourses;
}
}
4.2.2 DFS检测环
另一种方法是使用DFS来检测图中是否存在环:
java复制class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 构建邻接表
List<List<Integer>> adj = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
adj.add(new ArrayList<>());
}
for (int[] edge : prerequisites) {
adj.get(edge[1]).add(edge[0]);
}
// 0=未访问, 1=正在访问, 2=已访问
int[] visited = new int[numCourses];
for (int i = 0; i < numCourses; i++) {
if (visited[i] == 0 && hasCycle(adj, visited, i)) {
return false;
}
}
return true;
}
private boolean hasCycle(List<List<Integer>> adj, int[] visited, int course) {
if (visited[course] == 1) return true;
if (visited[course] == 2) return false;
visited[course] = 1;
for (int neighbor : adj.get(course)) {
if (hasCycle(adj, visited, neighbor)) {
return true;
}
}
visited[course] = 2;
return false;
}
}
两种方法的时间复杂度都是O(V+E),其中V是课程数,E是先修条件数。Kahn算法通常更直观,而DFS方法代码更简洁。
4.3 实际应用与变种
拓扑排序在实际中有广泛应用,如:
- 任务调度
- 编译器的依赖解析
- 软件包管理系统的依赖解决
在面试中,可能会遇到课程表问题的变种,如LeetCode 210(课程表II),要求返回一个可行的学习顺序。解决思路类似,只是在Kahn算法中记录出队的顺序,或在DFS中记录完成顺序的逆序。
5. Trie(前缀树)的设计与实现
5.1 Trie的基本概念
Trie(发音类似"try")或前缀树是一种树形数据结构,用于高效存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补全和拼写检查。
Trie的主要特点:
- 根节点不包含字符,除根节点外每个节点只包含一个字符
- 从根节点到某一节点的路径上的字符连接起来,就是该节点对应的字符串
- 每个节点的所有子节点包含的字符都不相同
5.2 Trie的Java实现
LeetCode 208要求实现一个Trie数据结构,包含insert、search和startsWith操作。以下是完整实现:
java复制class Trie {
private TrieNode root;
/** Initialize your data structure here. */
public Trie() {
root = new TrieNode();
}
/** Inserts a word into the trie. */
public void insert(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
if (!node.containsKey(c)) {
node.put(c, new TrieNode());
}
node = node.get(c);
}
node.setEnd();
}
/** Returns if the word is in the trie. */
public boolean search(String word) {
TrieNode node = searchPrefix(word);
return node != null && node.isEnd();
}
/** Returns if there is any word in the trie that starts with the given prefix. */
public boolean startsWith(String prefix) {
return searchPrefix(prefix) != null;
}
private TrieNode searchPrefix(String prefix) {
TrieNode node = root;
for (char c : prefix.toCharArray()) {
if (node.containsKey(c)) {
node = node.get(c);
} else {
return null;
}
}
return node;
}
}
class TrieNode {
private TrieNode[] links;
private final int R = 26;
private boolean isEnd;
public TrieNode() {
links = new TrieNode[R];
}
public boolean containsKey(char c) {
return links[c - 'a'] != null;
}
public TrieNode get(char c) {
return links[c - 'a'];
}
public void put(char c, TrieNode node) {
links[c - 'a'] = node;
}
public void setEnd() {
isEnd = true;
}
public boolean isEnd() {
return isEnd;
}
}
5.3 Trie的性能分析与应用
Trie的时间复杂度:
- 插入:O(L),其中L是字符串长度
- 搜索:O(L)
- 前缀搜索:O(L)
空间复杂度:最坏情况下O(AL),其中A是字母表大小,L是平均字符串长度。
Trie在实际中的应用非常广泛:
- 自动补全系统
- 拼写检查
- IP路由(最长前缀匹配)
- 解决单词游戏(如Boggle)
在面试中,可能会遇到Trie的变种问题,如:
- 添加通配符支持
- 实现删除操作
- 统计以某前缀开头的所有单词
- 设计一个支持正则表达式搜索的Trie
提示:在实现Trie时,可以考虑使用Map来存储子节点,这样能节省空间(特别是当字符集很大时),但会稍微降低访问速度。这是一个典型的时空权衡问题,面试时可以讨论这种设计选择。
6. 图论题目解题技巧总结
通过分析这四道题目,我总结出一些图论题目的通用解题技巧:
-
识别问题类型:首先要判断题目是否属于图论问题,以及具体是哪类图论问题(遍历、最短路径、拓扑排序、连通性等)
-
选择合适的数据结构:
- 邻接表:适合稀疏图
- 邻接矩阵:适合稠密图或需要快速判断两节点是否相邻
- 对于网格类问题,可以直接使用二维数组表示图
-
算法选择:
- 连通性问题:DFS/BFS/Union-Find
- 最短路径:Dijkstra/Bellman-Ford/Floyd-Warshall
- 拓扑排序:Kahn算法/DFS
- 字符串处理:Trie
-
优化思路:
- 使用标记数组避免重复访问
- 对于多源点问题,可以先将所有源点加入队列
- 考虑迭代和递归两种实现方式
- 在适当情况下使用记忆化或动态规划优化
-
测试用例设计:
- 空输入
- 最小规模输入
- 最大规模输入(测试性能)
- 特殊形状的图(如链状、星状、环状)
在实际面试中,建议先明确问题类型,然后讨论可能的解决方案和它们的时间/空间复杂度,最后选择最合适的实现。同时要主动考虑边界条件和测试案例,这能展示你的全面思考能力。
