3.4-3.5-图论
200. 岛屿数量 - 力扣(LeetCode)
总体思路:
看示例 2:
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1
假设你是哥伦布。先从左上角开始,把第一个岛全部插上旗子🚩,这里用 2 表示。插满旗子后,把答案(岛屿个数)加一。
⚠注意:在岛上,你只能上下左右走,不能斜方向走。
2 2 0 0 0
2 2 0 0 0
0 0 1 0 0
0 0 0 1 1
- 继续遍历,寻找其他的岛屿,也就是 1。找到 1 意味着发现了一个新的岛,继续插上旗子🚩,把答案加一。
2 2 0 0 0
2 2 0 0 0
0 0 2 0 0
0 0 0 1 1
- 继续遍历,寻找其他的岛屿。找到 1 意味着发现了一个新的岛,插满旗子🚩,把答案加一。
2 2 0 0 0
2 2 0 0 0
0 0 2 0 0
0 0 0 2 2
- 如果没有 1 了,算法就结束了,返回答案。
细节
一旦我们发现 (i,j) 是 1,就从 (i,j) 开始,DFS 这个岛。
每一步可以往左右上下四个方向走,也就是
(i,j−1),(i,j+1),(i−1,j),(i+1,j)这四个格子。每次到达一个新的格子,就插上旗子🚩,把
grid[i][j]改成 2。如果 (i,j) 出界,或者 (i,j) 是水,或者 (i,j) 已经插上了旗子🚩,就不再继续往下递归。
⚠注意:DFS 的过程中,最重要的是不能重复访问之前访问过的格子。
比如从左上角 (0,0) 向右移动到 (0,1),然后从 (0,1) 又向左移动到 (0,0),再从 (0,0) 向右移动到 (0,1),如此往复,就无限递归下去了。
怎么避免重复访问?本题的做法是把访问过的格子都插上旗子🚩。例如从 (0,1) 往左走,发现 (0,0) 是插过旗子的格子,就不继续走了。
问:我可以把访问过的
grid[i][j]改成 0 吗?答:也可以。相当于把访问过的岛摧毁掉。其实,改成除了 1 以外的任何值都行。
class Solution {
public:
int numIslands(vector<vector<char>>& grid) {
int m = grid.size(), n = grid[0].size();
auto dfs = [&](this auto&& dfs, int i, int j) -> void {
// 出界,或者不是 '1',就不再往下递归
if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != '1') {
return;
}
grid[i][j] = '2'; // 插旗!避免来回横跳无限递归
dfs(i, j - 1); // 往左走
dfs(i, j + 1); // 往右走
dfs(i - 1, j); // 往上走
dfs(i + 1, j); // 往下走
};
int ans = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '1') { // 找到了一个新的岛
dfs(i, j); // 把这个岛插满旗子,这样后面遍历到的 '1' 一定是新的岛
ans++;
}
}
}
return ans;
}
};
改进方向向量法:
class Solution {
public:
int numIslands(vector<vector<char>>& grid) {
int m = grid.size(), n = grid[0].size();
vector<int> dirs = {0, 1, 0, -1, 0};//定义四个方向(如果可以斜走,则为8个方向)
auto dfs = [&](this auto&& dfs, int i, int j) -> void {
// 出界,或者不是 '1',就不再往下递归
if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != '1') {
return;
}
grid[i][j] = '2'; // 插旗!避免来回横跳无限递归
for (int k = 0 ; k < 4 ; k ++){//直接改成循环避免重复写
dfs(i + dirs[k] , j + dirs[k + 1]);
}
};
int ans = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '1') { // 找到了一个新的岛
dfs(i, j); // 把这个岛插满旗子,这样后面遍历到的 '1' 一定是新的岛
ans++;
}
}
}
return ans;
}
};
994. 腐烂的橘子 - 力扣(LeetCode)
看示例 1:
输入:grid = [[2,1,1],[1,1,0],[0,1,1]] 输出:4
统计所有初始就腐烂的橘子的位置,加到列表 q 中,现在 q=[(0,0)]。
初始化答案 ans=0。模拟橘子腐烂的过程,不断循环,直到没有新鲜橘子,或者 q 为空。
答案加一,在第 ans=1 分钟,遍历 q 中橘子的四方向相邻的新鲜橘子,把这些橘子腐烂,q 更新为这些橘子的位置,现在 q=[(0,1),(1,0)]。
答案加一,在第 ans=2 分钟,遍历 q 中橘子的四方向相邻的新鲜橘子,把这些橘子腐烂,q 更新为这些橘子的位置,现在 q=[(0,2),(1,1)]。
答案加一,在第 ans=3 分钟,遍历 q 中橘子的四方向相邻的新鲜橘子,把这些橘子腐烂,q 更新为这些橘子的位置,现在 q=[(2,1)]。
答案加一,在第 ans=4 分钟,遍历 q 中橘子的四方向相邻的新鲜橘子,把这些橘子腐烂,q 更新为这些橘子的位置,现在 q=[(2,2)]。
由于没有新鲜橘子,退出循环。
为了判断是否有永远不会腐烂的橘子(如示例 2):
输入:grid = [[2,1,1],[0,1,1],[1,0,1]] 输出:-1 解释:左下角的橘子(第 2 行, 第 0 列)永远不会腐烂,因为腐烂只会发生在 4 个方向上。我们可以统计初始新鲜橘子的个数 fresh。在 BFS 中,每有一个新鲜橘子被腐烂,就把 fresh 减一,这样最后如果发现 fresh>0,就意味着有橘子永远不会腐烂,返回 −1。
代码实现时,在 BFS 中要将
grid[i][j]=1的橘子修改成 2(或者其它不等于 1 的数),这可以保证每个橘子加入 q 中至多一次。如果不修改,我们就无法知道哪些橘子被腐烂过了,比如示例 1 中 (0,1) 去腐烂 (1,1),而 (1,1) 在此之后又重新腐烂 (0,1),如此反复,程序就会陷入死循环。读者可以注释掉下面代码中的grid[i][j]= 2 这行代码试试。问:如果代码不在 while 中判断 fresh>0,会发生什么?
答:会在腐烂完所有新鲜橘子后,多循环一次。这会导致 ans 比实际多 1。
class Solution {
int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; // 四方向
public:
int orangesRotting(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
int fresh = 0;
vector<pair<int, int>> q;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 1) {
fresh++; // 统计新鲜橘子个数
} else if (grid[i][j] == 2) {
q.emplace_back(i, j); // 一开始就腐烂的橘子
}
}
}
int ans = 0;
while (fresh && !q.empty()) {
ans++; // 经过一分钟
vector<pair<int, int>> nxt;
for (auto& [x, y] : q) { // 已经腐烂的橘子
for (auto d : dirs) { // 四方向
int i = x + d[0], j = y + d[1];
if (0 <= i && i < m && 0 <= j && j < n && grid[i][j] == 1) { // 新鲜橘子
fresh--;
grid[i][j] = 2; // 变成腐烂橘子
nxt.emplace_back(i, j);
}
}
}
q = move(nxt);
}
return fresh ? -1 : ans;
}
};
原来的代码中,作者用了两个vector<pair<int,int>>,一个是当前的q,另一个是nxt,用来存储下一层的节点。每次循环处理完当前层的所有节点后,将nxt赋值给q,然后继续下一轮循环。这种方式是层序遍历的典型做法,每次处理完一层后,再处理下一层,同时记录时间ans递增。
为了将原来的双数组实现改为使用单个队列,我们可以利用队列的层序遍历特性,记录每一层的节点数量,并在处理完每一层后增加时间。这样就能确保每一轮处理对应一分钟的时间推移。以下是修改后的代码:
class Solution {
public:
int orangesRotting(vector<vector<int>>& grid) {
int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
int m = grid.size(), n = grid[0].size();
int fresh = 0;
queue<pair<int, int>> q;
// 初始化队列和新鲜橘子计数
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j] == 1) {
++fresh;
} else if (grid[i][j] == 2) {
q.emplace(i, j);
}
}
}
int ans = 0;
// 当还有新鲜橘子且队列不为空时处理
while (fresh > 0 && !q.empty()) {
ans++;
int size = q.size(); // 当前层的节点数
for (int i = 0; i < size; ++i) {
auto [x, y] = q.front();
q.pop();
// 遍历四个方向
for (auto& d : dirs) {
int nx = x + d[0];
int ny = y + d[1];
// 检查边界条件和新鲜橘子
if (nx >= 0 && nx < m && ny >= 0 && ny < n && grid[nx][ny] == 1) {
grid[nx][ny] = 2; // 腐烂
q.emplace(nx, ny);
fresh--;
}
}
}
}
// 如果仍有新鲜橘子则返回-1,否则返回时间
return fresh == 0 ? ans : -1;
}
};
复杂度分析
- 时间复杂度:O(mn),其中 m 和 n 分别为 grid 的行数和列数。
- 空间复杂度:O(mn)。
207. 课程表 - 力扣(LeetCode)
题意
给你一个有向图,判断图中是否有环。
核心思路
如果在递归过程中,发现下一个节点在递归栈中(正在访问中),则找到了环。
例如 1→2→3→4→2,走到 4 的时候,发现下一个节点 2 在递归栈中(正在访问中),那么就找到了环。
![]()
注:我们说节点 x「正在访问中」,是说我们正在递归处理节点 x 以及它的后续节点,dfs(x) 尚未结束。
具体思路
对于每个节点 x,都定义三种颜色值(状态值):
0:节点 x 尚未被访问到。
1:节点 x 正在访问中,dfs(x) 尚未结束。
2:节点 x 已经完全访问完毕,dfs(x) 已返回。
⚠误区:不能只用两种状态表示节点「没有访问过」和「访问过」。例如上图,我们先 dfs(0),再 dfs(1),此时 1 的邻居 0 已经访问过,但这并不能表示此时就找到了环。
算法流程:
- 建图:把每个 prerequisites[i]=[a,b] 看成一条有向边 b→a,构建一个有向图 g。
- 创建长为 numCourses 的颜色数组 colors,所有元素值初始化成 0。
- 遍历 colors,如果 colors[i]=0,则调用递归函数 dfs(i)。
- 执行 dfs(x):
- 首先标记 colors[x]=1,表示节点 x 正在访问中。
- 然后遍历 x 的邻居 y。如果 colors[y]=1,则找到环,返回 true。如果 colors[y]=0(没有访问过)且 dfs(y) 返回了 true,那么 dfs(x) 也返回 true。
- 如果没有找到环,那么先标记 colors[x]=2,表示 x 已经完全访问完毕,然后返回 false。
- 如果 dfs(i) 返回 true,那么找到了环,返回 false。
- 如果遍历完所有节点也没有找到环,返回 true。
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> g(numCourses);
for (auto& p : prerequisites) {
g[p[1]].push_back(p[0]);
}
vector<int> colors(numCourses);
auto dfs = [&](this auto&& dfs, int x) -> bool {
colors[x] = 1; // x 正在访问中
//访问邻接表g,遍历所有以x为先驱的节点
for (int y : g[x]) {
//从左到右判断:
//如果 colors[y]=1,则找到环,返回 true。
//如果 colors[y]=0(没有访问过)且 dfs(y) 返回了 true,那么 dfs(x) 也返回 true。
if (colors[y] == 1 || colors[y] == 0 && dfs(y)) {
return true; // 找到了环
}
}
colors[x] = 2; // x 完全访问完毕
return false; // 没有找到环
};
for (int i = 0; i < numCourses; i++) {
if (colors[i] == 0 && dfs(i)) {
return false; // 有环
}
}
return true; // 没有环
}
};
这段代码使用深度优先搜索(DFS)来检测有向图中是否存在环,以判断是否能完成所有课程。以下是关键点的解释:
- 图的构建:
g是一个邻接表,其中g[x]存储了所有以课程x为先修条件的课程。例如,若存在先修关系[a, b](即修完b才能修a),则b是a的先修课,代码中将a添加到g[b]的列表中。因此,g[b]包含所有需要先完成b才能学习的课程(可能不止一个)。
- DFS遍历与环检测:
- 颜色标记:使用
colors数组标记节点的访问状态:0:未访问。1:正在访问(当前DFS路径中)。2:已访问完毕(无环)。
- 遍历逻辑:对于当前节点
x,遍历其所有后继节点y(即g[x]中的课程):- 若
y处于正在访问状态(colors[y] == 1),说明存在环。 - 若
y未被访问且递归检测到环(dfs(y)返回true),则当前路径存在环。
- 若
- 标记完成:若所有后继节点均无环,标记
x为已访问完毕(colors[x] = 2)。
- 颜色标记:使用
- 主循环:
- 对每个课程节点执行DFS,若发现环则返回
false(无法完成所有课程),否则返回true。
- 对每个课程节点执行DFS,若发现环则返回
总结:for (int y : g[x]) 遍历的是课程 x 的所有直接后继课程,用于检测这些后继是否导致环的存在。邻接表 g 表示了课程间的依赖关系,DFS通过颜色标记有效检测了图中是否存在循环依赖。
复杂度分析
- 时间复杂度:O(n+m),其中 n 是 numCourses,m 是 prerequisites 的长度。每个节点至多递归访问一次,每条边至多遍历一次。
- 空间复杂度:O(n+m)。存储 g 需要 O(n+m) 的空间。
208. 实现 Trie (前缀树) - 力扣(LeetCode)
思路
假设字符串里面只有 a 和 b 两种字符。
- insert:例如插入字符串 aab,相当于生成了一条移动方向为「左-左-右」的路径。标记最后一个节点为终止节点。再插入字符串 aabb,相当于生成了一条移动方向为「左-左-右-右」的路径。标记最后一个节点为终止节点。
- search:例如查找字符串 aab,相当于查找二叉树中是否存在一条移动方向为「左-左-右」的路径,且最后一个节点是终止节点。
- startsWith:例如查找前缀 aa,相当于查找二叉树中是否存在一条移动方向为「左-左」的路径,无其他要求。
![]()
推广到 26 种字母,其实就是一棵 26 叉树,对于 26 叉树的每个节点,可以用哈希表,或者长为 26 的数组来存储子节点。
算法
- 初始化:创建一棵 26 叉树,一开始只有一个根节点 root。26 叉树的每个节点包含一个长为 26 的儿子节点列表 son,以及一个布尔值 end,表示是否为终止节点。
- insert:
- 遍历字符串 word,同时用一个变量 cur 表示当前在 26 叉树的哪个节点,初始值为 root。
- 如果 word[i] 不是 cur 的儿子,那么创建一个新的节点 node 作为 cur 的儿子。如果 word[i]=a,那么把 node 记录到 cur 的 son[0] 中。如果 word[i]=b,那么把 node 记录到 cur 的 son[1] 中。依此类推。
- 更新 cur 为儿子列表中的相应节点。
- 遍历结束,把 cur 的 end 标记为 true。
- search 和 startsWith 可以复用同一个函数 find:
- 遍历字符串 word,同时用一个变量 cur 表示当前在 26 叉树的哪个节点,初始值为 root。
- 如果 word[i] 不是 cur 的儿子,返回 0。search 和 startsWith 收到 0 之后返回 false。
- 更新 cur 为儿子列表中的相应节点。
- 遍历结束,如果 cur 的 end 是 false,返回 1,否则返回 2。search 如果收到的是 2,返回 true,否则返回 false。startsWith 如果收到的是非 0 数字,返回 true,否则返回 false。
struct Node {
Node* son[26]{};
bool end = false;
};
class Trie {
Node* root = new Node();
int find(string word) {
Node* cur = root;
for (char c : word) {
c -= 'a';
if (cur->son[c] == nullptr) {
return 0;
}
cur = cur->son[c];
}
return cur->end ? 2 : 1;
}
public:
void insert(string word) {
Node* cur = root;
for (char c : word) {
c -= 'a';
if (cur->son[c] == nullptr) {
cur->son[c] = new Node();
}
cur = cur->son[c];
}
cur->end = true;
}
bool search(string word) {
return find(word) == 2;
}
bool startsWith(string prefix) {
return find(prefix) != 0;
}
};
复杂度分析
- 时间复杂度:初始化为 O(1),insert 为 O(n∣Σ∣),其余为 O(n),其中 n 是 word 的长度,∣Σ∣=26 是字符集合的大小。注意创建一个节点需要 O(∣Σ∣) 的时间(如果用的是数组)。
- 空间复杂度:O(qn∣Σ∣)。其中 q 是 insert 的调用次数。


浙公网安备 33010602011771号