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

lc994.png

  1. 统计所有初始就腐烂的橘子的位置,加到列表 q 中,现在 q=[(0,0)]。

  2. 初始化答案 ans=0。模拟橘子腐烂的过程,不断循环,直到没有新鲜橘子,或者 q 为空。

  3. 答案加一,在第 ans=1 分钟,遍历 q 中橘子的四方向相邻的新鲜橘子,把这些橘子腐烂,q 更新为这些橘子的位置,现在 q=[(0,1),(1,0)]。

  4. 答案加一,在第 ans=2 分钟,遍历 q 中橘子的四方向相邻的新鲜橘子,把这些橘子腐烂,q 更新为这些橘子的位置,现在 q=[(0,2),(1,1)]。

  5. 答案加一,在第 ans=3 分钟,遍历 q 中橘子的四方向相邻的新鲜橘子,把这些橘子腐烂,q 更新为这些橘子的位置,现在 q=[(2,1)]。

  6. 答案加一,在第 ans=4 分钟,遍历 q 中橘子的四方向相邻的新鲜橘子,把这些橘子腐烂,q 更新为这些橘子的位置,现在 q=[(2,2)]。

  7. 由于没有新鲜橘子,退出循环。

  8. 为了判断是否有永远不会腐烂的橘子(如示例 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),其中 mn 分别为 grid 的行数和列数。
  • 空间复杂度:O(mn)。

207. 课程表 - 力扣(LeetCode)

题意

给你一个有向图,判断图中是否有环。

核心思路

如果在递归过程中,发现下一个节点在递归栈中(正在访问中),则找到了环。

例如 1→2→3→4→2,走到 4 的时候,发现下一个节点 2 在递归栈中(正在访问中),那么就找到了环。

lc207.png

注:我们说节点 x「正在访问中」,是说我们正在递归处理节点 x 以及它的后续节点,dfs(x) 尚未结束。

具体思路

对于每个节点 x,都定义三种颜色值(状态值):

  • 0:节点 x 尚未被访问到。

  • 1:节点 x 正在访问中,dfs(x) 尚未结束。

  • 2:节点 x 已经完全访问完毕,dfs(x) 已返回。

⚠误区:不能只用两种状态表示节点「没有访问过」和「访问过」。例如上图,我们先 dfs(0),再 dfs(1),此时 1 的邻居 0 已经访问过,但这并不能表示此时就找到了环。

算法流程:

  1. 建图:把每个 prerequisites[i]=[a,b] 看成一条有向边 b→a,构建一个有向图 g。
  2. 创建长为 numCourses 的颜色数组 colors,所有元素值初始化成 0。
  3. 遍历 colors,如果 colors[i]=0,则调用递归函数 dfs(i)。
  4. 执行 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。
  5. 如果 dfs(i) 返回 true,那么找到了环,返回 false。
  6. 如果遍历完所有节点也没有找到环,返回 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)来检测有向图中是否存在环,以判断是否能完成所有课程。以下是关键点的解释:

  1. 图的构建
    • g 是一个邻接表,其中 g[x] 存储了所有以课程 x 为先修条件的课程。例如,若存在先修关系 [a, b](即修完 b 才能修 a),则 ba 的先修课,代码中将 a 添加到 g[b] 的列表中。因此,g[b] 包含所有需要先完成 b 才能学习的课程(可能不止一个)。
  2. DFS遍历与环检测
    • 颜色标记:使用 colors 数组标记节点的访问状态:
      • 0:未访问。
      • 1:正在访问(当前DFS路径中)。
      • 2:已访问完毕(无环)。
    • 遍历逻辑:对于当前节点 x,遍历其所有后继节点 y(即 g[x] 中的课程):
      • y 处于正在访问状态(colors[y] == 1),说明存在环。
      • y 未被访问且递归检测到环(dfs(y) 返回 true),则当前路径存在环。
    • 标记完成:若所有后继节点均无环,标记 x 为已访问完毕(colors[x] = 2)。
  3. 主循环
    • 对每个课程节点执行DFS,若发现环则返回 false(无法完成所有课程),否则返回 true

总结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,相当于查找二叉树中是否存在一条移动方向为「左-左」的路径,无其他要求。
lc208.png

推广到 26 种字母,其实就是一棵 26 叉树,对于 26 叉树的每个节点,可以用哈希表,或者长为 26 的数组来存储子节点。

算法

  • 初始化:创建一棵 26 叉树,一开始只有一个根节点 root。26 叉树的每个节点包含一个长为 26 的儿子节点列表 son,以及一个布尔值 end,表示是否为终止节点。
  • insert:
    1. 遍历字符串 word,同时用一个变量 cur 表示当前在 26 叉树的哪个节点,初始值为 root。
    2. 如果 word[i] 不是 cur 的儿子,那么创建一个新的节点 node 作为 cur 的儿子。如果 word[i]=a,那么把 node 记录到 cur 的 son[0] 中。如果 word[i]=b,那么把 node 记录到 cur 的 son[1] 中。依此类推。
    3. 更新 cur 为儿子列表中的相应节点。
    4. 遍历结束,把 cur 的 end 标记为 true。
  • search 和 startsWith 可以复用同一个函数 find:
    1. 遍历字符串 word,同时用一个变量 cur 表示当前在 26 叉树的哪个节点,初始值为 root。
    2. 如果 word[i] 不是 cur 的儿子,返回 0。search 和 startsWith 收到 0 之后返回 false。
    3. 更新 cur 为儿子列表中的相应节点。
    4. 遍历结束,如果 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 的调用次数。
posted @ 2025-03-05 23:48  七龙猪  阅读(4)  评论(0)    收藏  举报
-->