当前位置: 首页 > article >正文

LeetCode 热题100之图论

1.岛屿数量

在这里插入图片描述
思路分析:要计算一个由 ‘1’(陆地)和 ‘0’(水)组成的二维网格中的岛屿数量,我们可以使用深度优先搜索(DFS)或广度优先搜索(BFS)来遍历每个岛屿。下面将展示使用 DFS 的方法。

  • 遍历网格:遍历整个网络,检查每个位置;
  • 发现岛屿:当找到一个‘1’陆地时,表示发现了一个新的岛屿。这时候用DFS来标记这个岛屿的所有部分。
  • 标记已访问的部分:在DFS中,遍历所有相连的‘1’,并将它们标记为‘0’(已访问),以防止重复计算;
  • 统计岛屿数量:每当我们找到一个新的’1’,就增加岛屿数量。

具体实现代码(详解版):

class Solution {
public:
    int numIslands(vector<vector<char>>& grid) {
        if (grid.empty()) return 0; // 如果网格为空,返回 0

        int numIslands = 0; // 岛屿数量
        int rows = grid.size(); // 行数
        int cols = grid[0].size(); // 列数

        // 遍历整个网格
        for (int r = 0; r < rows; ++r) {
            for (int c = 0; c < cols; ++c) {
                // 当找到一个岛屿
                if (grid[r][c] == '1') {
                    numIslands++; // 岛屿数量增加
                    dfs(grid, r, c); // 使用 DFS 标记整个岛屿
                }
            }
        }

        return numIslands; // 返回岛屿数量
    }

private:
    // 深度优先搜索,标记岛屿
    void dfs(vector<vector<char>>& grid, int r, int c) {
        // 检查边界条件
        if (r < 0 || r >= grid.size() || c < 0 || c >= grid[0].size() || 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); // 向左
    }
};
  • 时间复杂度:O(mn),其中m和n分别是网格的行数和列数;
  • 空间复杂度:O(mn).

2.腐烂的橘子

在这里插入图片描述
思路分析:这是一个典型的 多源广度优先搜索(BFS) 问题。在网格中,多源 BFS 可以帮助我们找到从多个起点到所有目标点的最短路径。因此,这个问题的核心思路是模拟腐烂橘子对周围新鲜橘子的传播过程,并统计所需的时间。

  • 初始化与多源起点
    • 首先遍历整个网络,扎到所有腐烂橘子的初始位置,并将它们加到BFS队列中作为起点。每一个腐烂橘子会在接下来的分钟内使周围的新鲜橘子腐烂;
    • 同时统计网格中所有新鲜橘子的数量。如果没有新鲜橘子,则直接返回0,因为不需要任何传播。
  • BFS传播腐烂
    • 使用队列进行BFS,从所有橘子的初始位置开始逐层扩散。每一轮BFS表示一分钟,每一轮当中当前腐烂的橘子会使其周围的四个方向的新鲜橘子腐烂;
    • 每当新鲜橘子被腐烂时,新鲜橘子数减一,并将腐烂的新橘子位置加入队列,以便在下一分钟继续传播;
    • 每轮BFS完成后,计时器加1表示过去了一分钟。
  • 终止条件
    • 当BFS结束时,由两种可能的情况:
      • 新鲜橘子全部腐烂:这时freshOranges为0,返回经过的分钟数,minutes;
      • 剩余橘子无法别腐烂:BFS 结束后,如果仍然有新鲜橘子(freshOranges > 0),则说明存在无法到达的新鲜橘子(如被空格隔开),返回 -1 表示不可能全部腐烂。

为什么选择 BFS?

  • BFS 的特性是逐层扩散,先将距离起点近的节点(橘子)处理完,再扩展到更远的节点,因此适合这种求最短传播路径的问题。
    因为腐烂橘子可以同时腐烂周围新鲜橘子,BFS 可以在同一时间处理多个腐烂橘子的传播过程,非常适合这种多源扩展的情景。

具体实现代码(详解版):

class Solution {
public:
    int orangesRotting(vector<vector<int>>& grid) {
        int m = grid.size();//行数
        int n = grid[0].size();//列数

        queue<pair<int, int>> rotten; // 队列,用于存储当前腐烂橘子的坐标
        int freshOranges = 0; // 记录新鲜橘子的总数

        // 初始化:将所有腐烂橘子的位置加入队列,同时统计新鲜橘子的数量
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == 2) {
                    rotten.push({i, j}); // 腐烂橘子加入队列
                } else if (grid[i][j] == 1) {
                    freshOranges++; // 统计新鲜橘子的数量
                }
            }
        }
        // 边界情况:如果没有腐烂橘子,直接返回0
        if (freshOranges == 0)
            return 0;

        int minutes = 0; // 记录分钟数
        vector<pair<int, int>> directions = {
            {1, 0}, {-1, 0}, {0, 1}, {0, -1}}; // 四个方向

        // 开始进行BFS,每一轮扩散代表一分钟
        while (!rotten.empty()) {
            int size = rotten.size(); // 当前层的腐烂橘子的数量
            bool foundFresh = false; // 标记是否有橘子在这一轮被腐烂
            // 遍历当前层的腐烂橘子
            for (int i = 0; i < size; i++) {
                auto [x, y] = rotten.front(); // 当前腐烂橘子的坐标
                rotten.pop(); // 将当前腐烂橘子从队列中移除
                // 尝试向四个方向扩散腐烂
                for (auto [dx, dy] : directions) {
                    int nx = x + dx;
                    int ny = y + dy;

                    // 检查新位置是否在边界内并且是否为新鲜橘子
                    if (nx >= 0 && nx < m && ny >= 0 && ny < n &&
                        grid[nx][ny] == 1) {
                        grid[nx][ny] = 2;      // 将新鲜橘子腐烂
                        rotten.push({nx, ny}); // 新腐烂的橘子加入队列
                        freshOranges--;        // 新鲜橘子减少
                        foundFresh = true; // 标记为找到新腐烂的橘子
                    }
                }
            }
            //如果这一轮中有新鲜橘子腐烂,则时间加1
            if (foundFresh)
                minutes++;
        }
        //检查是否还有剩余的新鲜橘子,如果有返回-1,否则返回所需要的分钟数
        return freshOranges == 0 ? minutes : -1;
    }
};
  • 时间复杂度:O(mn),其中m,n分别是网格的行数和列数
  • 空间复杂度:O(mn).

3.课程表

在这里插入图片描述
这个问题可以用 拓扑排序 或 图的环检测 来解决。问题的核心在于确定是否存在一个课程学习顺序,使得可以满足所有的先修课程要求。如果出现环(即某个课程的先修要求出现了循环依赖),那么这个顺序就不可能存在,从而课程无法全部完成。

思路分析1(DFS):在有向图中,我们可以通过 DFS 来检测是否存在环。如果存在环,则无法完成所有课程的学习;如果没有环,则可以完成所有将课程。

  • 建模为有向图:将课程和先修关系建模为有向图,其中每个课程是一个节点,先修关系表示一条从前置课程到后续课程的有向边。
  • DFS检测环
    • 使用 DFS 遍历图中的每个节点。
    • 每个节点可以有3种状态:
      • 未访问(0):节点还未被访问;
      • 正在访问(1):节点在当前的DFS路径上。若在DFS过程中再次遇到此状态的节点,说明存在环;
      • 已访问(2):节点已经被完全处理,不再在当前的路径中。
    • 当我们从一个节点开始DFS时,把它标记为”正在访问“。如果在DFS种再次访问到一个”正在访问“的节点,说明存在环;
    • DFS完成后,将节点标记未”已访问:,表示这个节点的所有邻居节点都已经处理完毕,不会再引起环
  • 初始化每个节点的状态为“未访问”。
  • 对每个节点调用 DFS。如果 DFS 中检测到环,直接返回 false;如果所有节点都遍历完成且无环,返回 true

具体实现代码(详解版):

class Solution {
public:
    bool dfs(int course, vector<vector<int>>& adjList, vector<int>& status) {
        // 表示在当前 DFS 路径中再次遇到 course,说明存在环,返回 true。
        if (status[course] == 1)
            return true;

        // course 已经处理完毕,不再需要检查,返回 false
        if (status[course] == 2)
            return false;

        status[course] = 1; // 标记当前节点为正在访问
        // 遍历当前课程的所有后续课程
        for (int nextCoures : adjList[course]) {
            if (dfs(nextCoures, adjList, status)) {
                return true;
            }
        }
        status[course] = 2; // 标记为已访问
        return false;
    }
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        // 构建邻接表
        vector<vector<int>> adjList(numCourses);
        for (const auto& pre : prerequisites) {
            int course = pre[0];
            int preCourse = pre[1];
            adjList[preCourse].push_back(course);
        }
        // 状态数组:0未访问;1-正在访问;2已访问
        vector<int> status(numCourses, 0);
        // 对每个课程进行DFS,如果检测到环,则无法完成所有课程
        for (int course = 0; course < numCourses; course++) {
            if (status[course] == 0) {
                if (dfs(course, adjList, status)) {
                    return false; // 存在环,返回false
                }
            }
        }
        return true; // 无环,表示可以完成所有课程
    }
};

思路分析2(拓扑排序):基于入度(进入某节点的边数),在入度为零的节点中依次取出节点并删除边,直到无法删除新的节点。如果最后所有节点都被删除,则无环,否则有环。入度可以理解为一个课程在学习之前必须完成的先修课程数。如果课程的入度为 0,说明它可以直接学习,不需要其他课程作为前置条件。拓扑排序通过不断消除入度为 0 的节点来确保学习顺序,类似“逐层剥洋葱”。如果最终还能剩下课程无法剥离,说明存在环。

  • 构建入度和邻接表
    • indegree 数组用于记录每门课程的入度(即需要的先修课程数量)。
    • adjList 是邻接表,用于记录每门课程指向的后续课程。
  • 初始化队列:将所有入度为 0 的课程加入队列,这些课程可以直接开始学习,不依赖其他课程
  • 拓扑排序
    • 每次从队列中取出一个入度为 0 的课程,计数已完成的课程数。
    • 对于该课程的后续课程,减少其入度,如果入度变为 0,则加入队列,表示该课程的前置课程已完成,可以开始学习。
    • 结果判断:最后,如果处理过的课程数量等于总课程数,则说明无环,返回 true。否则,返回 false,表示有环,无法完成所有课程。

具体实现代码(详解版):

class Solution {
public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        //构建图的入度表和邻接表
        vector<int> indegree(numCourses,0);//入度表,记录没门课程的前置课程数量
        vector<vector<int>> adjList(numCourses);//邻接表,记录每门课程指向的后续课程

        //填充入读表和邻接表
        for(const auto& pre : prerequisites){
            int course = pre[0];
            int preCourse = pre[1];
            adjList[preCourse].push_back(course);//建立前置课程到后续课程的有向边
            indegree[course] ++;//该课程的入度加1
        }

        //初始化队列,将所有入度为0的课程加入队列
        queue<int> q;
        for(int i = 0 ; i < numCourses ; i ++){
            if(indegree[i] == 0){
                q.push(i);//入度为0的课程可以直接学习
            }
        }
        int completeCourses = 0;//记录已完成课程的数量

        //拓扑排序过程
        while(!q.empty()){
            int course = q.front();//取出一个入度为0的课程
            q.pop();
            completeCourses ++;//完成该课程

            //遍历该课程的后续课程,减少后续课程的入度
            for(int nextCourse : adjList[course]){
                indegree[nextCourse] --;//减少后续课程的入度
                if(indegree[nextCourse] == 0){//后续课程的入度变为0
                    q.push(nextCourse);//将其加入队列,以便在下一轮可以直接学习
                }
            }
        }
        return completeCourses == numCourses;

    }
};
  • 时间复杂度:DFS 和 拓扑排序 的时间复杂度都是 O(V+E),其中 V 是课程的数量,即 numCourses,而 E 是先修课程对的数量,即 prerequisites.size()。适合稀疏图的情况(即边数远小于节点数的平方)。
  • 空间复杂度: 两种方法的空间复杂度也都是 O(V+E)。
    这两种方法在时间和空间复杂度上几乎相同,选择方法时可以根据代码实现的简单性和使用的图遍历方式来决定。DFS 更适合递归结构,而拓扑排序使用了入度表和队列的迭代结构。

4.实现Trie(前缀树)

在这里插入图片描述
思路分析:

  • 初始化 Trie 对象时,会创建一个根节点
  • insert 方法:遍历单词的每个字符,依次检查每个字符是否在当前节点的 children 中。若不存在,则创建一个新节点并加入 children。遍历完成后,将最后一个字符对应的节点标记为单词结尾。
  • search 方法:遍历单词的每个字符,逐级检查是否存在相应的字符路径。如果路径存在且最后节点标记为单词结尾,则返回 true,否则返回 false。
  • startsWith 方法:与 search 方法类似,但不要求最后一个节点为单词结尾,只需检查是否存在该前缀的路径。

具体实现代码(详解版):

class Trie {
private:
    // 子节点数组,大小为 26,用于表示每个小写字母的子节点
    vector<Trie*> children;
    // 标记当前节点是否为某个单词的结束节点
    bool isEnd;

    // 辅助函数:查找指定前缀的节点
    Trie* searchPrefix(string prefix) {
        // 从当前节点开始查找
        Trie* node = this; 
        // 遍历前缀中的每个字符
        for (char ch : prefix) {
            ch -= 'a'; // 将字符转换为对应的索引(0-25)
            // 如果当前节点的子节点中没有对应的字符,返回 nullptr
            if (node->children[ch] == nullptr) {
                return nullptr;
            }
            // 移动到下一个节点
            node = node->children[ch];
        }
        // 返回找到的节点
        return node;
    }

public:
    // 构造函数:初始化子节点和结束标志
    Trie() : children(26, nullptr), isEnd(false) {}

    // 插入单词
    void insert(string word) {
        Trie* node = this; // 从根节点开始
        // 遍历单词中的每个字符
        for (char ch : word) {
            ch -= 'a'; // 将字符转换为对应的索引(0-25)
            // 如果当前节点的子节点中没有对应的字符,创建新节点
            if (node->children[ch] == nullptr) {
                node->children[ch] = new Trie();
            }
            // 移动到下一个节点
            node = node->children[ch];
        }
        // 将最后一个字符的节点标记为单词的结束
        node->isEnd = true;
    }

    // 搜索完整单词
    bool search(string word) {
        Trie* node = this->searchPrefix(word); // 查找单词对应的节点
        // 返回节点是否存在且为单词结束节点
        return node != nullptr && node->isEnd;
    }

    // 检查是否存在以指定前缀开头的单词
    bool startsWith(string prefix) {
        // 只需查找前缀对应的节点是否存在即可
        return this->searchPrefix(prefix) != nullptr;
    }
};

/**
 * 例子使用:
 * Trie* obj = new Trie();
 * obj->insert(word);       // 插入单词
 * bool param_2 = obj->search(word);     // 搜索完整单词
 * bool param_3 = obj->startsWith(prefix); // 检查前缀
 */

这个题目在很多大厂面试的时候都会被问到,应该重点关注~


http://www.kler.cn/a/383096.html

相关文章:

  • MySQL 8.0:explain analyze 分析 SQL 执行过程
  • 封装(2)
  • Spring Security 6 系列之七 - 自定义异常管理
  • 常见数据结构
  • 问题解决:发现Excel中的部分内容有问题。是否让我们尽量尝试恢复? 如果您信任此工作簿的源,请单击“是”。
  • Android 代码模式的理解
  • Hive 2.x 的安装与配置
  • GPU架构概述
  • python数据分析笔记
  • 如何选择适合TikTok创作者的高性价比专线网络:全方位指南
  • 【算法篇】--重温算法题
  • Pulsargeist:恐怖类型的 XR 大空间项目创新玩法
  • SQL练习专场--01
  • 【glm4-voice-9b 本地运行并测试 gradio+notebook】
  • 探索空间计算与 VR 设备的未来:4K4DGen 高分辨率全景 4D 内容生成系统
  • ssm061基于SSM框架的个人博客网站的设计与实现+vue(论文+源码)_kaic
  • AI 搜索来势汹汹,互联网将被颠覆还是进化?
  • Openlayers高级交互(20/20):超级数据聚合,页面不再混乱
  • 使用 Let’s Encrypt 获取免费SSL证书
  • 城镇住房保障系统:SpringBoot开发要点
  • TLU - Net:一种用于钢材表面缺陷自动检测的深度学习方法
  • c语言架构的一点构想
  • 挂钩图像分割安全状态与危险状态识别系统:更新创新流程
  • 推荐一款可视化和检查原始数据的工具:RawDigger
  • Midjourney从入门到精通教程,10分钟让你从小白变大神!【珍藏版】
  • Bert完形填空