力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 12图论入门:BFS、DFS 与拓扑排序

这是基础十二个专题的最后一站。好消息是:图论题的表面花样很多,但底层只需要三块积木—— 访问标记、遍历方式(BFS 或 DFS)、以及承载遍历的队列/递归。绝大多数「矩阵/网格」题本质上是隐式图, 你甚至不需要真的建一个图的数据结构。本章用 3 道经典题把 BFS、DFS、拓扑排序各讲透一道,最后给你一张「BFS 还是 DFS」的选择表。 学完这一章,你的力扣 Hot 100 主干就通了。

学习目标
掌握网格 BFS/DFS 与拓扑排序
前置要求
递归(专题 08)、队列基础
本章产出
3 道图论题(岛屿/橘子/课程表)
建议学时
6–8 小时

12.1图题只需要三块积木

很多人被「图论」二字吓到,以为要学邻接矩阵、最短路、并查集一大堆。其实力扣里 90% 的图题,骨架都一样:

积木作用
visited 标记记录哪些点已经处理过,防止重复访问、死循环
遍历(BFS / DFS)从某点出发,按规则走到相邻点
队列 / 递归栈BFS 用队列(一层层扩散);DFS 用递归(一条路走到底)

网格题 = 隐式图

最常见的一类是「二维字符网格」题(岛屿、橘子、迷宫)。这时每个格子就是图的一个节点,相邻格子(上下左右)就是边。 你不需要 new 一个 Graph 类——grid[i][j] 本身就是节点,四方向偏移就是邻边:

// 四方向偏移:上、右、下、左(顺序随意,统一即可)
int[][] dirs = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};

// 从 (x, y) 出发,枚举四个邻居
for (int[] d : dirs) {
    int nx = x + d[0];
    int ny = y + d[1];
    // 先判断越界,再判断是否可走 / 未访问
    if (nx >= 0 && nx < m && ny >= 0 && ny < n) {
        // (nx, ny) 是一个合法邻居
    }
}
记住这两行:int[][] dirs = {{-1,0},{0,1},{1,0},{0,-1}}; 是网格题的万能起手式。 每遇到一个新格子,就套这个循环枚举邻居,再判越界、判是否该访问。几乎每道网格题都从它开始。

visited 的两种存法

下面三道题,就是这三块积木的不同组合:DFS 洪水填充(岛屿)、多源 BFS(橘子)、入度表 + 队列(课程表拓扑排序)。


12.2岛屿数量:DFS 洪水填充(中等,力扣 200)

中等 核心:遍历格点 + 沉岛

题意:给你一个 '1'(陆地)和 '0'(水)的二维网格,相连的陆地(上下左右)组成一个岛。求岛屿个数。 例如下方有 3 个岛:

1 1 0 0 0
1 0 0 1 1
0 0 0 1 1
(左上的 1 是一个岛,右下的四个 1 是另一个岛,共 2 个)

思路推导

岛屿是「连成一片的陆地」。思考方式:每发现一块没访问过的陆地,它一定属于某个岛;从这个点出发,把所有和它相连的陆地都「标记掉」,岛数 +1。

怎么把「相连的陆地都标记掉」?这就是洪水填充(flood fill)——从一个陆地格出发,DFS 四个方向:碰到陆地就继续深入,碰到水/边界/已访问就停。一次 DFS 能「淹掉」一整座岛(把所有陆地改成 '0',即「沉岛」,顺便充当 visited)。 整张网格扫一遍,每遇到一个仍是 '1' 的格,就是一座新岛的起点,触发一次 DFS。

为什么用 DFS 而不是 BFS?这里只关心「连通性」(一座岛算一个),不关心距离/层数,DFS 用递归写最简洁。换成 BFS 也完全正确,只是代码稍长。

参考代码

class Solution {
    // 四方向:上、右、下、左
    int[][] dirs = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};

    public int numIslands(char[][] grid) {
        int m = grid.length, n = grid[0].length;
        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;
    }

    // 洪水填充:把 (i,j) 连通的所有陆地都改成 '0'(沉岛=标记已访问)
    private void dfs(char[][] grid, int i, int j) {
        // 越界 或 不是陆地(已是水/已沉),直接返回
        if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length
                || grid[i][j] != '1') {
            return;
        }
        grid[i][j] = '0';                    // 沉岛:标记当前陆地已访问
        for (int[] d : dirs) {               // 向四个方向扩散
            dfs(grid, i + d[0], j + d[1]);
        }
    }
}

复杂度:时间 O(m×n),每个格子最多被访问一次(沉岛后不会再进 DFS);空间 O(m×n)(最坏递归深度等于格子总数,如全陆地时一条斜线递归)。

亲手走一遍:grid = [[1,1,0],[1,0,0],[0,0,1]]

初始扫描:
(0,0)='1' -> 触发 DFS,开始沉岛
  dfs(0,0): 沉(0,0);邻居(0,1),(1,0) 是'1'继续沉
    dfs(0,1): 沉(0,1);邻居(0,0)已沉、(0,2)='0'停、(1,1)='0'停
    dfs(1,0): 沉(1,0);邻居(0,0)已沉、(2,0)='0'停、(1,1)='0'停
  这次 DFS 把左上角三块陆地全沉了 -> count=1
继续扫描:
(0,1)(1,0) 已是'0'跳过;(2,2)='1' -> 触发第二次 DFS,沉掉它 -> count=2
最终 count = 2(左上岛 + 右下角单独一块)。

可以看到:一次从 (0,0) 出发的 DFS,顺着连通性把整座左上岛「淹」光了,count 只 +1; 之后扫描遇到右下角孤立的 (2,2) 是另一座岛,再 +1。这正是「每发现一个未访问陆地就数一座岛、并沉掉整座」的体现。

关键点:「沉岛」是这道题最妙的地方——直接改原网格当 visited,既省空间又不用额外布尔数组。 但前提是你不再需要原网格的值;若题目要求保留原网格,就改用一个 boolean[][] visited。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 岛屿的最大面积(力扣 695,中等)——与本题共用「DFS 洪水填充 + 沉岛/visited」技巧;差异在数的是「一座岛包含多少陆地格」而非岛的个数,DFS 时累加面积(提示:把 dfs 改成返回连通陆地格数,每次递归 +1,主循环里对每个新岛累加其面积)。
  • 岛屿的周长(力扣 463,简单)——与本题共用「网格遍历 + 四方向邻居」框架;差异在求的是陆地格「临水/临边」的边数之和,不用沉岛,遇到越界或水就给周长 +1(提示:DFS/BFS 走到每个陆地格,对每个方向判「出界或格子是水」就计一条边)。

12.3腐烂的橘子:多源 BFS(中等,力扣 994)

中等 核心:多个起点同时入队,层数 = 分钟数

题意:网格里 2 表示腐烂橘子、1 表示新鲜橘子、0 表示空格。每分钟,每个腐烂橘子会把它上下左右相邻的新鲜橘子传染腐烂。 求多少分钟后所有橘子都烂;若不可能(有新鲜橘子永远接触不到腐烂橘子),返回 -1。 例如 [[2,1,1],[1,1,0],[0,1,1]],需要 4 分钟。

思路推导

这道题和岛屿的区别:岛屿只数「连通块个数」,不关心距离;这里关心「几分钟」,也就是距离/层数。 一提到「扩散 + 层数/最短时间」就该想到 BFS——BFS 天然一层层扩散,层数就是时间。

关键在「多个起点」:初始时可能不止一个腐烂橘子。如果只从一个腐烂橘子 BFS,会漏掉别的源头。 正确做法:把所有初始腐烂橘子同时入队(多源 BFS),第 0 层就是它们全体。 然后正常 BFS:每出队一个腐烂橘子,把它相邻的新鲜橘子变腐烂并入队,同时记录「这一层处理完代表过了 1 分钟」。

怎么数分钟?用一个「层」的概念:每开始处理新一层前,记下当前队列大小 sz,把这 sz 个节点(同一分钟的)全处理完,分钟数 +1。 或者用 int[] 存坐标+分钟 入队也行。处理完后,再扫一遍网格:若还有新鲜橘子('1')残留,说明有橘子永远烂不到,返回 -1。

参考代码

class Solution {
    int[][] dirs = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};

    public int orangesRotting(int[][] grid) {
        int m = grid.length, n = grid[0].length;
        Queue<int[]> q = new LinkedList<>();
        int fresh = 0;                       // 统计新鲜橘子总数
        // 把所有初始腐烂橘子入队(多源起点),并数新鲜橘子
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (grid[i][j] == 2) q.offer(new int[]{i, j});
                if (grid[i][j] == 1) fresh++;
            }
        }
        if (fresh == 0) return 0;            // 本来就没新鲜橘子,0 分钟

        int minutes = 0;
        while (!q.isEmpty()) {
            int sz = q.size();               // 当前层的节点数(同一分钟的腐烂橘子)
            boolean changed = false;         // 这一层是否真的传染了
            for (int k = 0; k < sz; k++) {
                int[] cur = q.poll();
                for (int[] d : dirs) {
                    int nx = cur[0] + d[0], ny = cur[1] + d[1];
                    if (nx >= 0 && nx < m && ny >= 0 && ny < n
                            && grid[nx][ny] == 1) {      // 相邻的新鲜橘子
                        grid[nx][ny] = 2;               // 传染腐烂
                        fresh--;                        // 新鲜数 -1
                        changed = true;
                        q.offer(new int[]{nx, ny});     // 新腐烂的入队,下一层再扩散
                    }
                }
            }
            if (changed) minutes++;          // 这一层确实传染了,才计 1 分钟
        }
        // 若还有新鲜橘子残留,说明永远烂不到
        return fresh == 0 ? minutes : -1;
    }
}

复杂度:时间 O(m×n)(每个橘子最多入队一次),空间 O(m×n)(队列最坏存所有橘子)。

亲手走一遍:grid = [[2,1,1],[1,1,0],[0,1,1]]

初始:腐烂点 (0,0) 入队;新鲜橘子共 6 个((0,1)(0,2)(1,0)(1,1)(2,1)(2,2),fresh=6)。

第 0 层(mins=0):队列=[(0,0)],sz=1
  处理(0,0):传染(0,1)、(1,0) -> 变2,fresh=4,入队
  changed=true -> minutes=1

第 1 层(mins=1):队列=[(0,1),(1,0)],sz=2
  处理(0,1):传染(0,2) -> 变2,fresh=3
  处理(1,0):邻居(1,1)仍是新鲜 -> 变2,fresh=2
  入队(0,2),(1,1);changed=true -> minutes=2

第 2 层(mins=2):队列=[(0,2),(1,1)],sz=2
  处理(0,2):四周皆0/已烂,无新传染
  处理(1,1):传染(2,1) -> 变2,fresh=1,入队(2,1)
  changed=true -> minutes=3

第 3 层(mins=3):队列=[(2,1)],sz=1
  处理(2,1):传染(2,2) -> 变2,fresh=0,入队(2,2)
  changed=true -> minutes=4

第 4 层(mins=4):队列=[(2,2)],sz=1
  处理(2,2):四周无新鲜,无传染;changed=false(分钟不+1)
队列空。fresh=0 == 0 -> 返回 4 分钟。

逐层数下来,新鲜橘子 6 个被分 4 层全部传染完,返回 4。注意「changed 才加分钟」:最后一层若没真传染,不会多算。 若把某个新鲜橘子改成永远接触不到腐烂点(比如左上角孤立一块 1),fresh 就减不到 0,最终返回 -1。

多源 BFS 的两个易错点:(1) 必须一开始把所有源点都入队,不能只入第一个腐烂橘子,否则别的源头扩散不到; (2) 最后要扫一遍检查是否还有新鲜橘子,否则「有橘子烂不到」的情况会误报成正常分钟数。 另外 minutes 只在「这一层真传染了」时才 +1,避免空转多算。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 二进制矩阵中的最短路径(力扣 1091,中等)——与本题共用「网格 BFS + 每步扩散求最短距离」技巧;差异在每步走的是八方向(含斜向)且 0 可走 1 不可走,求左上到右下的最少步(提示:把 dirs 扩成 8 个方向,BFS 层数即步数,首次到达终点即答案)。
  • 地图中的最高点(力扣 1765,中等)——与本题共用「多源 BFS」框架;差异在初始所有水域(值 0)同时入队,BFS 逐层向上抬高相邻陆地高度(提示:水格全部初始入队,每层把邻居高度设为「当前高度+1」,等价于从所有水源向外一圈圈涨)。

12.4课程表:拓扑排序(中等,力扣 207)

中等 核心:入度表 + 邻接表 + 队列

题意:numCourses 门课,prerequisites[i] = [a, b] 表示要选 a 必须先选 b(b 是 a 的先修)。问能否学完所有课(即是否存在一个合法选课顺序,无环)。 例如 numCourses=2, prerequisites=[[1,0]],先修 0 再选 1,可以,返回 true;若有 [1,0] 和 [0,1] 互相先修,则 false。

思路推导

这是标准的拓扑排序问题:把课看成节点,先修关系看成有向边 b → a(b 指向 a,表示 b 要先完成)。 能学完所有课 ⇔ 图里没有环。拓扑排序就是「把节点排成一条线,让所有边都从前指向后」;若存在环,就排不出。

怎么做?用入度表 + 队列的 BFS 写法(Kahn 算法):

「入度归零 = 先修课都完成」是这题的核心直觉:一门课的入度就是它还没完成的先修数量,归零意味着前置全部就绪,可以学了。

参考代码

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // indegree[i]:第 i 门课还有几门先修没完成
        int[] indegree = new int[numCourses];
        // graph[i]:学完 i 后,能解锁哪些课(i 是它们的先修)
        List<Integer>[] graph = new List[numCourses];
        for (int i = 0; i < numCourses; i++) graph[i] = new ArrayList<>();

        for (int[] p : prerequisites) {
            int a = p[0], b = p[1];          // 学 a 要先学 b
            graph[b].add(a);                 // b 完成后解锁 a
            indegree[a]++;                   // a 的先修数 +1
        }

        // 所有入度为 0 的课(无先修)先入队
        Queue<Integer> q = new LinkedList<>();
        for (int i = 0; i < numCourses; i++) {
            if (indegree[i] == 0) q.offer(i);
        }

        int learned = 0;                     // 已学完的课数
        while (!q.isEmpty()) {
            int u = q.poll();
            learned++;                       // 学完 u
            for (int v : graph[u]) {         // u 解锁的每门课 v
                indegree[v]--;               // v 的一个先修已完成
                if (indegree[v] == 0) {      // v 的先修全部完成
                    q.offer(v);
                }
            }
        }
        // 学完的课数 == 总课数 才说明无环、能全部学完
        return learned == numCourses;
    }
}

复杂度:时间 O(V + E)(V 门课、E 条先修边,每点每边各处理一次),空间 O(V + E)(邻接表 + 入度数组)。

亲手走一遍:numCourses=4, prerequisites=[[1,0],[2,0],[3,1],[3,2]]

含义:1 需先 0、2 需先 0、3 需先 1、3 需先 2。即 0 是 1 和 2 的先修,1 和 2 是 3 的先修。

建表:
  graph[0]=[1,2]   indegree[1]=1
  graph[1]=[3]     indegree[2]=1
  graph[2]=[3]     indegree[3]=2
  graph[3]=[]      indegree[0]=0
入度为 0 的:只有 0 -> 队列=[0]

出队 0:learned=1;解锁 graph[0]=[1,2]
  indegree[1]:1->0 -> 入队;indegree[2]:1->0 -> 入队。队列=[1,2]
出队 1:learned=2;解锁 graph[1]=[3]
  indegree[3]:2->1(还没到0,不入队)。队列=[2]
出队 2:learned=3;解锁 graph[2]=[3]
  indegree[3]:1->0 -> 入队。队列=[3]
出队 3:learned=4;graph[3]=[] 无解锁。队列空。

learned(4) == numCourses(4) -> 无环,返回 true。
合法顺序之一:0 -> 1 -> 2 -> 3(或 0 -> 2 -> 1 -> 3)。

对比有环的情形:prerequisites=[[1,0],[0,1]]。建表后 indegree[0]=1、indegree[1]=1,没有任何入度为 0 的课能入队, 队列一开始就空,learned=0 ≠ 2,返回 false——环被准确识别。这就是「入度归零 = 先修全完成」的威力。

关键点:拓扑排序的「判环」本质就是「能否把所有点都入队并出队一次」。 有环时,环上的课入度永远减不到 0,永远进不了队,learned 就小于总数。 若题目还要你输出一个合法顺序(力扣 210),只要把每次出队的 u 加进结果列表即可。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 课程表 II(力扣 210,中等)——与本题共用「入度表 + 邻接表 + 队列的拓扑排序」技巧;差异在不仅判能否学完,还要输出一种合法顺序(提示:每次出队的课加入结果列表,最后若结果长度 == 总课数则返回该顺序,否则返回空列表)。
  • 找到最终的安全状态(力扣 802,中等)——与本题共用「拓扑排序判环」思想;差异在要找所有「不会走到环里」的节点,需反向建图从出度为 0 的点反向拓扑(提示:把边方向反过来,从原出度为 0 的节点入队,能一路减度到 0 的节点即安全,收集这些节点)。

补充:拓扑排序还有「DFS 三色标记」写法

除了上面的 BFS(Kahn)写法,拓扑排序也能用 DFS 做:给每个节点标三种颜色——

DFS 时若遇到灰色节点,说明「从它出发又走回它自己」,即存在后向边 = 有环,直接返回 false;遇到黑色说明之前已安全处理过,跳过;白色则继续递归。 这种写法在「判断有向图是否 DAG」时很常用,但面试里 BFS(Kahn)因为「顺手能输出顺序、入度表直观」而更推荐首选。 两种方法本质等价,知道有这一支即可,不必强背。


12.5BFS / DFS 怎么选

这两兄弟都是遍历,但适用场景不同。一张表记清:

需求选哪个原因
最短路径 / 最少步数 / 层数BFS一层层扩散,第一次到达即最短
多源同时扩散(如腐烂橘子)BFS所有源点同时入队,天然按层
连通块 / 连通性(岛屿)两者皆可,DFS 更简不关心距离,DFS 递归最省代码
枚举所有路径 / 回溯DFS递归天然适合「一条路走到底再回退」
环检测 / 拓扑排序BFS(Kahn)入度队列写法直观;DFS 三色标记也可
图很深、怕栈溢出BFSDFS 递归深度大时可能 StackOverflow
速记:「问距离用 BFS,问连通用 DFS,问顺序/环用拓扑(BFS)」。 一句话判断:答案和「第几层 / 走几步」有关 → BFS;只关心「在不在一起 / 达不达得到」→ DFS 也行。

12.6本专题常见坑

  • 坑一:visited 忘标记导致死循环 / 重复入队。 不管是 BFS 还是 DFS,进入一个节点第一件事就是标记已访问(或沉岛)。漏了就会在相邻节点间来回跳,TLE 或栈溢出。
  • 坑二:BFS 在「入队时」就标记,而不是「出队时」标记。 这是本题最高频的一个坑:应在节点入队那一刻就把它标为 visited。否则同一节点可能被多个邻居先后发现、重复入队多次, 队列爆炸。理由:入队即代表「已安排处理」,出队才标记会让它又被别的邻居加进来。
  • 坑三:多源 BFS 忘了把所有源点先入队。 像腐烂橘子,初始有多个腐烂点,必须一次性全部入队。只入第一个,其他源头的扩散范围就漏了,答案偏小。
  • 坑四:越界判断顺序错。 先判 nx>=0 && nx=0 && ny 再访问 grid[nx][ny],否则数组越界。别写成先访问再判。
  • 坑五:拓扑排序入度/邻接表建反。 prerequisites=[a,b] 是「b→a」(b 先),adjacency 里应是 graph[b].add(a)、indegree[a]++。建反了(a→b)整个拓扑就错。
  • 坑六:腐烂橘子漏了「最后检查残留新鲜」。 有新鲜橘子永远烂不到时,要返回 -1 而不是分钟数;另外新鲜橘子为 0 时直接返回 0,别进 BFS。
关于坑二再强调:「入队即标记」和「出队才标记」在单源最短路里结果可能一样,但在多源 / 会重复发现同一节点的场景下差异巨大。 养成习惯:if (未访问) { 标记已访问; 入队; } —— 标记和入队写在一起,永远不出错。

附带提醒:坐标偏移的方向顺序无关紧要

前面所有题我都用 {{-1,0},{0,1},{1,0},{0,-1}}(上右下左)。其实四个方向的顺序完全无所谓—— BFS/DFS 只要把四个邻居都枚举到就行,顺序只影响遍历的「先后」,不影响最终正确性(岛屿数、分钟数、能否学完都不变)。 唯一要小心的:写偏移时别把 d[0] 和 d[1] 搞反(行偏移加在 x/行上,列偏移加在 y/列上),以及别漏掉某个方向(只写两个就会漏边)。 新人常犯的错误是把方向数组写成 {{0,1},{1,0}} 只有两个方向,导致只往右和下扩散,连通性判断出错。


12.7自测题

Q1.【动手题】岛屿数量那题,如果不允许修改原网格(题目还要保留地图),该怎么改代码?写出关键差异。

另开一个 boolean[][] visited = new boolean[m][n]。DFS 开头判断条件改成 if (越界 || visited[i][j] || grid[i][j]!='1') return;,进入后先 visited[i][j]=true; 再扩散。 主循环里也要先判 grid[i][j]=='1' && !visited[i][j] 才触发 DFS。沉岛那行删掉,逻辑完全等价。

Q2. 腐烂橘子为什么「入队时标记」比「出队时标记」更安全?用两个腐烂橘子相邻的情形推一下。

假设腐烂橘子 A、B 相邻且都初始入队。若出队才标记:处理 A 时把相邻 B 发现并(因 B 还没出队、未标记)再次入队, B 被重复入队一次,队列里多一份。虽然本题 B 已是腐烂值、不会重复传染,但如果是「求最短距离」类题, 重复入队会让同一节点被多次松弛、甚至多算层数。入队即标记能保证每个节点最多进队一次,最稳。

Q3.【动手题】力扣 207 若要求输出一种合法选课顺序(即力扣 210),在本题代码上改哪几行?

把 learned 换成结果列表 List order;每次 int u = q.poll(); 后执行 order.add(u);。 最后若 order.size() == numCourses 返回 order,否则返回空列表(有环)。其余入度/邻接/减度逻辑一字不改。

学完这章,你应该能回答:「网格题本质是什么?BFS 和 DFS 分别什么时候用?拓扑排序怎么判环?」 答案:网格题是隐式图,每个格子是节点、四方向是边,靠 dirs 遍历;求最短/层数/多源用 BFS(队列、入队即标记), 求连通性用 DFS(递归、沉岛当 visited);拓扑排序用入度表 + 邻接表 + 队列,能全部出队即无环。 这十二个基础专题到此收官——13~24 号进阶专题按面试方向选学;回到 总览 的复杂度表与 90 天路线,开始你的刷题循环吧。