图论专题(上)

图论专题(上)------ DFS / BFS 与拓扑排序

Hot100 图论专题共 13 题,本篇覆盖前 5 题:岛屿数量、腐烂的橘子、课程表、实现 Trie 前缀树、岛屿的最大面积。这几题分别考察 DFS flood fill、BFS 多源扩散、拓扑排序、前缀树四个核心图论技巧,是竞赛和面试的高频考点。


一、前置知识:图的 DFS 与 BFS 框架

图和树的区别在于图可能有环,所以 DFS/BFS 时需要额外的 visited 数组标记已访问节点,防止重复访问。

矩阵(网格图)是图的一种特殊形式,每个格子是一个节点,上下左右四个方向是边。

DFS 框架(网格图)

cpp 复制代码
int dirs[4][2] = {{0,1},{0,-1},{1,0},{-1,0}}; // 右左下上

void dfs(vector<vector<char>>& grid, int i, int j) {
    int m = grid.size(), n = grid[0].size();
    if (i < 0 || i >= m || j < 0 || j >= n) return; // 越界
    if (grid[i][j] == '0') return; // 已访问或不可达
    grid[i][j] = '0'; // 标记为已访问(直接修改原数组)
    for (auto& d : dirs) dfs(grid, i + d[0], j + d[1]);
}

BFS 框架(网格图)

cpp 复制代码
int dirs[4][2] = {{0,1},{0,-1},{1,0},{-1,0}};

void bfs(vector<vector<int>>& grid, int si, int sj) {
    queue<pair<int,int>> q;
    q.push({si, sj});
    grid[si][sj] = 0; // 入队时标记,防止重复入队
    while (!q.empty()) {
        auto [i, j] = q.front(); q.pop();
        for (auto& d : dirs) {
            int ni = i + d[0], nj = j + d[1];
            if (ni < 0 || ni >= m || nj < 0 || nj >= n) continue;
            if (grid[ni][nj] == 0) continue;
            grid[ni][nj] = 0;
            q.push({ni, nj});
        }
    }
}

注意:标记已访问要在入队时做,而不是出队时。如果出队时才标记,同一个节点可能被多次入队,导致重复处理甚至死循环。


二、岛屿数量(#200)

题意

给一个由 '1'(陆地)和 '0'(水)组成的二维网格,计算岛屿的数量。岛屿由上下左右相邻的陆地连接而成,四周都是水。

复制代码
输入:
11110
11010
11000
00000

输出:1(整个连通块是一个岛屿)

输入:
11000
11000
00100
00011

输出:3

思路

经典 flood fill 问题。

遍历整个网格,每遇到一个 '1',就说明发现了一个新岛屿,计数加一,然后用 DFS 把这个岛屿所有相连的 '1' 全部标记为 '0'(沉岛),避免重复计数。

这样遍历结束后,DFS 被触发的次数就等于岛屿数量。

复制代码
网格:
1 1 0 0 0
1 1 0 0 0
0 0 1 0 0
0 0 0 1 1

(0,0)=1,发现新岛屿,count=1
DFS从(0,0)出发,把(0,0)(0,1)(1,0)(1,1)全部标记为0
  网格变为:
    0 0 0 0 0
    0 0 0 0 0
    0 0 1 0 0
    0 0 0 1 1

继续扫描,(2,2)=1,发现新岛屿,count=2
DFS把(2,2)标记为0

继续扫描,(3,3)=1,发现新岛屿,count=3
DFS把(3,3)(3,4)标记为0

最终count=3

DFS 递归时,先判断越界和当前格子是否为 '0',满足任一条件就返回,否则标记并向四个方向递归。

代码

cpp 复制代码
class Solution {
public:
    void dfs(vector<vector<char>>& grid, int i, int j) {
        int m = grid.size(), n = grid[0].size();
        // 越界或已经是水,直接返回
        if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] == '0') 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);
    }

    int numIslands(vector<vector<char>>& grid) {
        int m = grid.size(), n = grid[0].size();
        int count = 0;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == '1') {
                    count++;         // 发现新岛屿
                    dfs(grid, i, j); // 把整个岛屿沉掉
                }
            }
        }
        return count;
    }
};

复杂度

  • 时间 :O(mn)O(mn)O(mn),每个格子最多被访问一次(DFS 时标记,之后不会再进入)
  • 空间 :O(mn)O(mn)O(mn),递归栈最深为网格大小(全是陆地时)

三、腐烂的橘子(#994)

题意

网格中 0 表示空格,1 表示新鲜橘子,2 表示腐烂橘子。每过一分钟,腐烂橘子上下左右相邻的新鲜橘子会被感染变腐烂。返回所有橘子腐烂所需的最少分钟数,如果不可能全部腐烂则返回 -1。

复制代码
输入:
2 1 1
1 1 0
0 1 1

第0分钟:2 1 1 / 1 1 0 / 0 1 1
第1分钟:2 2 1 / 2 1 0 / 0 1 1
第2分钟:2 2 2 / 2 2 0 / 0 1 1
第3分钟:2 2 2 / 2 2 0 / 0 2 1
第4分钟:2 2 2 / 2 2 0 / 0 2 2

输出:4

思路

这是多源 BFS 问题。腐烂橘子会同时向四周扩散,不是从单一起点出发,而是从所有腐烂橘子同时开始。

把所有初始腐烂橘子一次性全部入队,然后做 BFS,每一轮(处理完当前队列中的所有节点)代表过了一分钟。

流程

  1. 遍历网格,把所有初始腐烂橘子入队,同时统计新鲜橘子数量 fresh
  2. BFS 逐层扩散,每次处理完一整层(一分钟),把感染到的新鲜橘子入队,fresh--
  3. BFS 结束后,如果 fresh > 0,说明有孤立的新鲜橘子无法被感染,返回 -1
  4. 否则返回经过的分钟数

注意:如果初始就没有新鲜橘子,直接返回 0,不需要进入 BFS。

复制代码
初始:腐烂橘子(0,0)入队,fresh=7

第1分钟:处理(0,0),感染(0,1)和(1,0)
  fresh=5,队列:[(0,1),(1,0)]

第2分钟:处理(0,1)(1,0),感染(0,2)(1,1)
  fresh=3,队列:[(0,2),(1,1)]

第3分钟:处理(0,2)(1,1),感染(2,1)
  fresh=2,队列:[(2,1)](注意(1,2)是空格,无法感染)

第4分钟:处理(2,1),感染(2,2)
  fresh=1?

重新数:3×3网格,空格(1,2),橘子8个,初始腐烂1个,新鲜7个
最终fresh=0,minutes=4,返回4 ✓

代码

cpp 复制代码
class Solution {
public:
    int orangesRotting(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        queue<pair<int,int>> q;
        int fresh = 0;

        // 初始化:所有腐烂橘子入队,统计新鲜橘子
        for (int i = 0; i < m; i++)
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == 2) q.push({i, j});
                else if (grid[i][j] == 1) fresh++;
            }

        if (fresh == 0) return 0; // 没有新鲜橘子,直接返回

        int dirs[4][2] = {{0,1},{0,-1},{1,0},{-1,0}};
        int minutes = 0;

        while (!q.empty() && fresh > 0) {
            minutes++;
            int size = q.size(); // 这一分钟内要处理的腐烂橘子数
            for (int k = 0; k < size; k++) {
                auto [i, j] = q.front(); q.pop();
                for (auto& d : dirs) {
                    int ni = i + d[0], nj = j + d[1];
                    if (ni < 0 || ni >= m || nj < 0 || nj >= n) continue;
                    if (grid[ni][nj] != 1) continue; // 不是新鲜橘子
                    grid[ni][nj] = 2; // 感染,同时标记已访问
                    fresh--;
                    q.push({ni, nj});
                }
            }
        }

        return fresh == 0 ? minutes : -1;
    }
};

复杂度

  • 时间 :O(mn)O(mn)O(mn),每个格子最多入队一次
  • 空间 :O(mn)O(mn)O(mn),队列最多存所有格子

四、课程表(#207)

题意

共有 numCourses 门课,编号 0 到 n-1。给一个先决条件数组 prerequisitesprerequisites[i] = [a, b] 表示学课 a 之前必须先学课 b。判断是否能完成所有课程的学习(即判断有向图中是否有环)。

复制代码
输入:numCourses=2, prerequisites=[[1,0]]
输出:true(先学0,再学1,可以完成)

输入:numCourses=2, prerequisites=[[1,0],[0,1]]
输出:false(0依赖1,1依赖0,形成环,无法完成)

思路

这是拓扑排序的经典应用,判断有向图中是否存在环。

拓扑排序(Kahn 算法,BFS 版)

核心思路:入度为 0 的节点(没有先修课的课程)可以直接开始学。学完一门课后,把它的所有后继课程的入度减 1,如果某门课的入度变为 0,说明它的所有先修课都学完了,可以加入队列继续学。

如果最终处理的课程数等于总课程数,说明图中无环,可以完成所有课程;如果图中有环,环上的节点入度永远无法降为 0,最终处理数量小于总课程数。

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

建图(邻接表,b→a 表示b的后继是a):
  0 → [1, 2]
  1 → [3]
  2 → [3]
  3 → []

入度统计:
  课程0: 0(没有先修课)
  课程1: 1(先修课0)
  课程2: 1(先修课0)
  课程3: 2(先修课1和2)

初始队列(入度为0):[0]

处理课程0:
  后继1入度 1→0,入队
  后继2入度 1→0,入队
  count=1,队列[1,2]

处理课程1:
  后继3入度 2→1
  count=2,队列[2]

处理课程2:
  后继3入度 1→0,入队
  count=3,队列[3]

处理课程3:
  count=4,队列空

count=4 == numCourses=4 → 无环,返回true ✓

代码

cpp 复制代码
class Solution {
public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        // 建邻接表和入度数组
        vector<vector<int>> graph(numCourses);
        vector<int> inDegree(numCourses, 0);
        for (auto& pre : prerequisites) {
            graph[pre[1]].push_back(pre[0]); // pre[1] → pre[0]
            inDegree[pre[0]]++;
        }

        // 入度为0的节点入队
        queue<int> q;
        for (int i = 0; i < numCourses; i++)
            if (inDegree[i] == 0) q.push(i);

        int count = 0; // 已处理的课程数
        while (!q.empty()) {
            int cur = q.front(); q.pop();
            count++;
            for (int next : graph[cur]) {
                if (--inDegree[next] == 0) q.push(next);
            }
        }

        return count == numCourses;
    }
};

复杂度

  • 时间 :O(V+E)O(V + E)O(V+E),VVV 为课程数,EEE 为先修关系数
  • 空间 :O(V+E)O(V + E)O(V+E),邻接表 + 入度数组 + 队列

五、实现 Trie(前缀树)(#208)

题意

实现一个前缀树,支持以下操作:

  • insert(word):插入字符串
  • search(word):返回字符串是否在前缀树中
  • startsWith(prefix):返回是否存在以 prefix 为前缀的字符串

前置知识:Trie 的结构

Trie(前缀树 / 字典树)是一种专门处理字符串的树形数据结构。每个节点代表一个字符,从根节点到某个节点的路径代表一个字符串前缀。

复制代码
插入 "app"、"apple"、"apply" 后的 Trie:

root
└─ a
   └─ p
      └─ p [isEnd=true]    ← "app" 在这里结束
         └─ l
            ├─ e [isEnd=true]  ← "apple" 在这里结束
            └─ y [isEnd=true]  ← "apply" 在这里结束

每个节点包含:

  • children[26]:26 个字母对应的子节点指针,初始全为 nullptr
  • isEnd:标记当前节点是否是某个单词的结尾

插入 :从根开始,按字符逐层向下,节点不存在就创建,最后把终止节点的 isEnd 置为 true

查询 :从根开始,按字符逐层向下,某字符对应的子节点不存在就返回 false,走完所有字符后检查 isEnd

前缀查询 :和查询完全一样,只是最后不需要检查 isEnd,路径存在就说明有以该前缀开头的单词。

复制代码
查询 "app":
  root→a→p→p,节点存在,isEnd=true → 返回true ✓

查询 "ap":
  root→a→p,节点存在,但 isEnd=false → 返回false
  ("ap"没有被插入过,只是路径经过这里)

前缀查询 "ap":
  root→a→p,路径存在 → 返回true ✓
  (存在以"ap"为前缀的单词,如"app"、"apple"等)

代码

cpp 复制代码
class Trie {
    struct TrieNode {
        TrieNode* children[26];
        bool isEnd;
        TrieNode() : isEnd(false) {
            for (int i = 0; i < 26; i++) children[i] = nullptr;
        }
    };

    TrieNode* root;

    // 从根出发,沿 word 路径走到底,返回最后一个节点(路径不存在返回 nullptr)
    TrieNode* searchPrefix(const string& word) {
        TrieNode* cur = root;
        for (char c : word) {
            int idx = c - 'a';
            if (!cur->children[idx]) return nullptr; // 路径断了
            cur = cur->children[idx];
        }
        return cur;
    }

public:
    Trie() { root = new TrieNode(); }

    void insert(string word) {
        TrieNode* cur = root;
        for (char c : word) {
            int idx = c - 'a';
            if (!cur->children[idx])
                cur->children[idx] = new TrieNode(); // 节点不存在则创建
            cur = cur->children[idx];
        }
        cur->isEnd = true; // 标记单词结尾
    }

    bool search(string word) {
        TrieNode* node = searchPrefix(word);
        return node != nullptr && node->isEnd; // 路径存在且是完整单词
    }

    bool startsWith(string prefix) {
        return searchPrefix(prefix) != nullptr; // 路径存在即可
    }
};

复杂度

  • 插入 / 查询 :O(L)O(L)O(L),LLL 为字符串长度,每个字符走一步
  • 空间 :O(Σ⋅L⋅N)O(\Sigma \cdot L \cdot N)O(Σ⋅L⋅N),Σ=26\Sigma=26Σ=26,NNN 为插入的字符串数量,最坏情况每个字符都要新建节点

六、岛屿的最大面积(#695)

题意

给一个二进制网格,1 表示陆地,0 表示海洋,求最大岛屿的面积(面积 = 格子数)。

复制代码
输入:
0 0 1 0 0 0 0 1 0 0
0 0 0 0 0 0 0 1 1 1
0 1 1 0 1 0 0 0 0 0
0 1 0 0 1 1 0 0 1 0
0 1 0 0 1 1 0 0 1 1
0 0 0 0 0 0 0 0 0 1

输出:6(右侧连通块面积为6)

思路

和岛屿数量几乎完全一样,DFS 时额外返回当前连通块的格子数,遍历过程中取最大值。

DFS 函数:越界或为 0 返回 0;否则标记为 0,返回 1+1 +1+ 四个方向 DFS 结果之和,这个和就是以当前格子为起点的连通块大小。

复制代码
以某个面积为4的连通块为例:

  1 1
  1 0
  1

DFS(0,0):标记,返回 1+DFS(0,1)+DFS(1,0)+...
  DFS(0,1):标记,返回 1(无更多相邻1)
  DFS(1,0):标记,返回 1+DFS(2,0)
    DFS(2,0):标记,返回 1
  = 1+1+(1+1) = 4

代码

cpp 复制代码
class Solution {
public:
    int dfs(vector<vector<int>>& grid, int i, int j) {
        int m = grid.size(), n = grid[0].size();
        if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] == 0) return 0;
        grid[i][j] = 0; // 标记已访问
        return 1 + dfs(grid, i+1, j) + dfs(grid, i-1, j)
                 + dfs(grid, i, j+1) + dfs(grid, i, j-1);
    }

    int maxAreaOfIsland(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        int res = 0;
        for (int i = 0; i < m; i++)
            for (int j = 0; j < n; j++)
                if (grid[i][j] == 1)
                    res = max(res, dfs(grid, i, j));
        return res;
    }
};

复杂度

  • 时间 :O(mn)O(mn)O(mn),每个格子最多访问一次
  • 空间 :O(mn)O(mn)O(mn),递归栈

七、本篇小结

题目 核心算法 关键点 时间 空间
岛屿数量 DFS flood fill 每发现新连通块计数,DFS 沉岛避免重复 O(mn)O(mn)O(mn) O(mn)O(mn)O(mn)
腐烂的橘子 多源 BFS 所有腐烂橘子同时入队,按层扩散计时 O(mn)O(mn)O(mn) O(mn)O(mn)O(mn)
课程表 拓扑排序 Kahn 入度为0入队,处理总数==课程总数则无环 O(V+E)O(V+E)O(V+E) O(V+E)O(V+E)O(V+E)
实现 Trie 前缀树 26叉树,isEnd 标记单词结尾,路径即前缀 O(L)O(L)O(L) O(ΣLN)O(\Sigma LN)O(ΣLN)
岛屿最大面积 DFS flood fill DFS 返回连通块大小,取所有连通块的最大值 O(mn)O(mn)O(mn) O(mn)O(mn)O(mn)

DFS 和 BFS 怎么选:

  • 求最短路径、最少步数、最快时间 → BFS(按层扩散,天然保证最短)
  • 判断连通性、统计连通块大小、枚举所有路径 → DFS 和 BFS 均可,DFS 代码更简洁
相关推荐
大大杰哥1 小时前
leetcode hot100(2)双指针,滑动窗口
数据结构·算法·leetcode
风筝在晴天搁浅1 小时前
LeetCode CodeTop 113.路径总和Ⅱ
算法·leetcode
张赫轩(不重名)1 小时前
加权重心(换根DP)
c++·算法·动态规划·图论
水木流年追梦1 小时前
【python因果库实战26】逆概率加权模型1
开发语言·python·算法·leetcode
2401_840105201 小时前
题解: [GESP202409 八级] 美丽路径
数据结构·c++·算法·动态规划
今儿敲了吗2 小时前
链表篇(五)——链表中间结点
数据结构·笔记·算法·链表
码农的神经元2 小时前
2026 年数维杯A 题:抱轨式磁浮列车的悬浮电磁铁故障检测问题
人工智能·算法·数学建模
小新同学^O^2 小时前
算法学习 --> 快速输入和输出
java·学习·算法
脑子加油站2 小时前
K8S-Ingress资源对象
算法·贪心算法·k8s