LeetCode · DFS & BFS
理解状态 · 写出不变量 · 解释复杂度
INTERVIEW PREPARATION / GRAPH SEARCH

深度 & 广度优先搜索LeetCode 算法学习手册 · Python 3

从「走完一座岛」到「让所有橘子同时传播」,用一组网格问题建立可迁移的搜索思维:状态是什么,何时标记,是否撤销,答案在哪里产生。

4道不同题目
6个学习单元
4个学习阶段
12份完整 Python 解法

先看路线:同一张网格,三种搜索语义

输入题单分为两组,每组 3 项。岛屿数量、岛屿的最大面积各出现 2 次,因此本手册保留全部 6 个条目,拆成 DFS 与 BFS 独立学习单元,共对应 4 道不同题目。每个单元都提供完整讲解、代码与推演,可以单独复习和标记。

阶段内容与顺序通过标准
01 基础与核心模板网格建图 → visited → 栈与队列 → 路径状态能说明发现时标记的原因;区分全局访问和当前路径
02 DFS 典型应用200 岛屿数量 → 695 岛屿的最大面积独立写出显式栈;解释返回值与连通块统计
03 BFS 与进阶传播200 岛屿数量 → 695 最大面积 → 994 腐烂的橘子独立写出队列遍历、多源初始化、逐层计时
04 回溯与剪枝79 单词搜索 → 算法比较 → 自测解释撤销状态、短路返回与剪枝的正确性

4 天学习计划 + 间隔复习

时间任务可检验的产出
Day 1 · 60–90 分钟基础章节、200 DFS、695 DFS画一遍搜索过程;闭卷写出显式栈和面积返回式
Day 2 · 45–60 分钟200 BFS、695 BFS;比较两种遍历把栈改成队列;说清为什么面积不按层累计
Day 3 · 45–60 分钟994 两种 BFS;手算分钟数通过无新鲜、无源、不可达、多源重叠四类边界
Day 4 · 60–90 分钟79 三种写法、剪枝与面试复述构造需要撤销 visited 的反例;成功返回后网格完整恢复
第 7 / 14 / 30 天 · 各 20–40 分钟随机抽取连通块、多源、路径搜索各一题先口述不变量,再在限定时间内完成代码和复杂度分析

“已掌握”建议标准:不看答案写出代码、独立推演一个反例、准确说明最坏复杂度。6 个单元分别计进度;只有对应单元全部掌握,该题才计入 4 道不同题目的完成数。

第一阶段 · 基础知识与核心模板

1. 网格是隐式图,不需要先建邻接表

把每个可进入的格子看作一个顶点,把上下左右相邻的格子之间的关系看作一条边。坐标 (r, c) 就是节点标识;邻居通过方向数组即时计算。设网格为 m × n,顶点最多 mn 个,每个顶点最多 4 条邻接边,因此一次完整遍历为 O(V + E) = O(mn)。

四方向统一写为 ((1, 0), (-1, 0), (0, 1), (0, -1));先确认 0 ≤ r < m 且 0 ≤ c < n,再读取网格。Python 的负数下标会访问末尾,不能用“没报错”判断边界正确。

上左当前右下
只沿上下左右移动,斜对角没有边。所有示例坐标均从 0 开始。

2. 为什么必须有访问标记?

网格含有环:从 A 走到 B 后,又能从 B 回到 A。不标记会无限递归或反复入队。在发现节点、放入栈或队列时就标记,保证每个节点只被加入一次。若等出队才标记,同一格可能被多个父节点重复加入,面积也容易重复统计。

状态语义何时记录何时撤销本专题应用
全局 visited:此格已归属某个连通块进入递归 / 入栈 / 入队时本次求解不撤销200、695
全局传播状态:该橘子已被安排腐烂发现新鲜橘子并入队时不撤销994
当前路径 path:该格正在拼接这个候选单词匹配成功、搜索后继之前离开当前调用前恢复79

交互实验:同一张岛屿,DFS 与 BFS 怎样走?

固定从 (0, 0) 出发,方向顺序为下、上、右、左。选择算法,点击“下一步”观察待处理容器。为了突出显式栈的 LIFO 机制,DFS 按方向顺序入栈,因此最后加入的右侧邻居会先弹出;递归 DFS 按方向顺序调用时则先向下走。访问顺序不同,连通块不变。

本步处理已发现待处理处理完成水域

本实验仅走完起点所在的连通块;其他岛屿要由外层扫描再次启动搜索。实验不会修改题目掌握进度。

核心模板:先定状态,再定容器

模式状态与过程答案产生位置完整代码位置
Flood fill / 连通块扫描所有格子;遇未访问陆地启动搜索;永久标记相邻陆地启动次数是岛屿数;一次搜索的格数是面积200 DFS / 695 BFS
多源层序 BFS所有源以距离 0 一起入队;每轮固定本层长度;新发现节点下一层处理最后一层时间;若仍有新鲜节点则失败994 分层与时间戳
路径回溯参数包含位置和字符下标;选中 → 搜索 → 撤销;不同分支共享原始棋盘匹配完最后一个字符,逐层返回 True79 三种完整实现
连通块:发现即标记,不撤销 | 路径搜索:进入占用,退出恢复

DFS 与 BFS 的基础差别

维度DFSBFS
容器递归调用栈,或 list + append / popcollections.deque + append / popleft
推进方式优先沿一个分支深入优先处理离起点更近的节点
适合连通性、面积、路径枚举、回溯连通性、等权最短路、同时传播
本专题最坏空间连通块栈 O(mn);路径栈 O(L)一般网格队列保守上界 O(mn)
实现注意长蛇形岛屿递归很深,优先显式栈list.pop(0) 会移动元素,不能代替 deque.popleft()

第二阶段 · DFS 遍历与连通块统计

先用搜索把一个连通块彻底处理完,再讨论外层要统计什么。两道题共享遍历结构,但答案更新的位置不同。

01LeetCode 200 · 中等岛屿数量DFS · 统计连通块

LeetCode 原题 ↗

难度:中等 | 核心知识点:四方向、全局访问标记、递归、显式栈

题意与约束

给定由字符串 "1"(陆地)、"0"(水)组成的矩形网格,返回四方向相连的陆地区域数量。对角接触不连通;网格外视为水,但边界格本身允许是陆地。原题 1 ≤ m,n ≤ 300,下面代码也兼容空网格。

解题思路:外层计数,内层清空一座岛

  1. 按行扫描网格;如果遇到 "0",跳过。
  2. 遇到尚未处理的 "1",说明找到一个新连通块,答案加 1。
  3. 从该格启动 DFS,将所有能连到的陆地改为 "0"。
  4. 继续外层扫描,同一座岛上的后续格子已被清空,不再触发计数。

不变量:每次外层启动 DFS 前,已处理的所有岛屿都已清空。一次 DFS 只沿陆地边行走,所以不会吞掉另一座岛;也会沿连通路径走完本岛所有格子,所以不会重复计数。

解法 A · 递归 DFS:理解进入与终止

Python 3 · 递归 DFS · 可独立提交
from typing import List
import sys

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        if not grid or not grid[0]:
            return 0
        m, n = len(grid), len(grid[0])
        sys.setrecursionlimit(max(sys.getrecursionlimit(), m * n + 50))

        def dfs(r: int, c: int) -> None:
            if not (0 <= r < m and 0 <= c < n):
                return
            if grid[r][c] != "1":
                return
            grid[r][c] = "0"  # 进入时标记,阻止沿边返回
            dfs(r + 1, c)
            dfs(r - 1, c)
            dfs(r, c + 1)
            dfs(r, c - 1)

        count = 0
        for r in range(m):
            for c in range(n):
                if grid[r][c] == "1":
                    count += 1
                    dfs(r, c)
        return count
关键代码含义
if grid[r][c] != "1": return水与已访问陆地都终止;边界检查必须更早
grid[r][c] = "0"必须放在递归邻居前,消除回边循环
count += 1放在外层发现新岛屿时,不在每个递归节点都累加
dfs(...) 无返回值这里关心遍历副作用;岛屿数量由外层维护

解法 B · 显式栈 DFS:推荐提交版本

Python 3 · 显式栈 DFS · 可独立提交
from typing import List

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        if not grid or not grid[0]:
            return 0
        m, n = len(grid), len(grid[0])
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))
        count = 0
        for r in range(m):
            for c in range(n):
                if grid[r][c] != "1":
                    continue
                count += 1
                stack = [(r, c)]
                grid[r][c] = "0"  # 入栈时立即标记
                while stack:
                    x, y = stack.pop()
                    for dx, dy in directions:
                        nx, ny = x + dx, y + dy
                        if (0 <= nx < m and 0 <= ny < n
                                and grid[nx][ny] == "1"):
                            grid[nx][ny] = "0"
                            stack.append((nx, ny))
        return count

stack.pop() 弹出最近加入的节点;每个邻居在入栈前就改为水,防止重复入栈。显式栈模拟待处理任务,不依赖 Python 递归深度。遍历顺序可以与递归不同,但两者找到的连通块完全相同。

示例推演:3 个连通块

输入 [["1","1","0","0","0"],["1","1","0","0","0"],["0","0","1","0","0"],["0","0","0","1","1"]],输出 3。图中 A / B / C 表示三次启动 DFS 所覆盖的格子。

AA000AA00000B00000CC
A 面积 4,B 面积 1,C 面积 2;B 与 C 仅对角相接,仍是两座岛。
扫描位置动作累计岛屿数
(0,0)发现 A;一次 DFS 访问 (0,0)、(1,0)、(1,1)、(0,1)1
(0,1)、(1,0)、(1,1)已被 A 的搜索改为水,跳过1
(2,2)发现 B;四周均为水2
(3,3)发现 C;同时清空 (3,4)3
扫描结束没有残余陆地,返回 33

复杂度与解法比较

时间复杂度:两种解法均为 O(mn)。外层扫描 mn 格,每格最多处理一次、检查四个邻居;两层循环加 DFS 不等于 O((mn)²),因为每次 DFS 的处理范围不会重叠。

空间复杂度:递归版最坏 O(mn) 调用栈;显式栈版最坏 O(mn)。两者都修改输入。递归结构直观,显式栈对深连通块更稳妥。

易错点与边界

  • 这是字符串网格,必须比较 "1",不能写成整数 1。
  • 不要沿八个方向搜索;对角两个 1 应输出 2。
  • 不要在每次递归时都增加岛屿数;一个格子不等于一座岛。
  • 全水返回 0;全陆地返回 1;单格陆地返回 1;单行、单列按连续段计数。
  • 同一个输入不能直接交给第二份原地解法:第一份已把陆地清空。测试时使用 [row[:] for row in grid]。

02LeetCode 695 · 中等岛屿的最大面积DFS · 从遍历到返回值

LeetCode 原题 ↗

难度:中等 | 核心知识点:连通块面积、递归返回值、全局最优、原地标记

题意与约束

给定只含整数 0 和 1 的矩形网格,四方向相连的一片 1 是一座岛,面积为该岛格子数。返回最大面积;没有陆地返回 0。原题 1 ≤ m,n ≤ 50。边界可含陆地,对角不连通。

解题思路:让一次 DFS 返回本次新访问的格子数

先定义函数:dfs(r, c) 返回从该坐标出发,本次递归新发现的陆地数量。越界或非陆地返回 0;首次进入陆地先标记,再把当前 1 格与四个方向新发现的数量相加。外层对每座岛的结果取最大值。

area(r,c) = 1 + 四个方向 DFS 新发现格子数之和

正确性关键:这里的四个方向不是四个互不相交的原始子岛。它们可能通过环互相连通;全局标记使后续分支只统计“尚未被前面分支发现”的格子,从而每块陆地恰好贡献 1。

解法 A · 递归汇总面积

Python 3 · DFS 返回面积 · 可独立提交
from typing import List
import sys

class Solution:
    def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
        if not grid or not grid[0]:
            return 0
        m, n = len(grid), len(grid[0])
        sys.setrecursionlimit(max(sys.getrecursionlimit(), m * n + 50))

        def dfs(r: int, c: int) -> int:
            if not (0 <= r < m and 0 <= c < n):
                return 0
            if grid[r][c] == 0:
                return 0
            grid[r][c] = 0
            return (1 + dfs(r + 1, c) + dfs(r - 1, c)
                    + dfs(r, c + 1) + dfs(r, c - 1))

        best = 0
        for r in range(m):
            for c in range(n):
                if grid[r][c] == 1:
                    best = max(best, dfs(r, c))
        return best

关键代码解释:返回值与标记顺序

水的返回值是 0,因为它不贡献面积;有效格的返回值含自身的 1。必须先 grid[r][c] = 0 再计算四个递归调用。best 跨所有岛屿保留最大值,DFS 的返回值只描述当前一次搜索中新计入的面积。

解法 B · 显式栈:每弹出一格,面积加一

Python 3 · 显式栈统计面积 · 可独立提交
from typing import List

class Solution:
    def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
        if not grid or not grid[0]:
            return 0
        m, n = len(grid), len(grid[0])
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))
        best = 0
        for r in range(m):
            for c in range(n):
                if grid[r][c] != 1:
                    continue
                area = 0
                stack = [(r, c)]
                grid[r][c] = 0
                while stack:
                    x, y = stack.pop()
                    area += 1
                    for dx, dy in directions:
                        nx, ny = x + dx, y + dy
                        if (0 <= nx < m and 0 <= ny < n
                                and grid[nx][ny] == 1):
                            grid[nx][ny] = 0
                            stack.append((nx, ny))
                best = max(best, area)
        return best

area = 0 在每个新岛屿开始时重置;best 只在整座岛遍历完成后更新。由于入栈即标记,出栈节点不重复,所以 area += 1 的次数正好等于岛屿面积。

示例推演:面积 3 与面积 4

教学示例输入 [[1,1,0,0,1],[0,1,0,1,1],[0,0,0,0,1]],输出 4。

AA00B0A0BB0000B
A 有 3 格,B 有 4 格;两块互不连通。
过程当前结果全局最大值
从 (0,0) 启动;新访问 (0,0)、(0,1)、(1,1)33
从 (0,4) 启动;新访问 (0,4)、(1,4)、(2,4)、(1,3)44
其他格已处理或是水,结束扫描—返回 4

递归版 A 岛的返回可以读成:dfs(1,1) = 1,dfs(0,1) = 1 + 1 = 2,dfs(0,0) = 1 + 2 = 3。这里恰好没有环;含环时访问标记仍保证不重算。

复杂度与解法比较

时间复杂度:均为 O(mn),全局每个陆地格仅计入一次。

空间复杂度:递归栈和显式栈最坏均为 O(mn)。两种实现都会修改 grid;递归返回式方便理解“自底向上汇总”,显式栈方便处理大连通块。若要求输入不变,可用全局 visited 集合替代改写,仍为 O(mn) 额外空间。

易错点与边界

  • 输入是整数网格,使用 1 / 0;不要套用 200 的字符串。
  • 返回四方向面积的总和,不是 max;最大值只在不同岛屿之间比较。
  • 不能把多座岛的面积累加为最终答案;每座岛必须重新计数。
  • 全水返回 0;单格陆地返回 1;全陆地返回 mn;2×2 全 1 应为 4,能检查成环时是否重复计算。
  • 递归汇总前标记,不能等递归结束才标记;否则环会反复进入。

第三阶段 · BFS 队列与多源传播

先用队列解决相同的连通块问题,再让队列中的层次代表真实时间。BFS 的价值来自“按距离从近到远”这一顺序。

03LeetCode 200 · 中等岛屿数量BFS · 发现即入队

LeetCode 原题 ↗

难度:中等 | 核心知识点:队列、全局 visited、输入不变、连通块计数

题意与约束

网格元素为字符串 "1" 与 "0",相邻只含上下左右,求岛屿数量。网格外是水,但边界允许陆地;原题 1 ≤ m,n ≤ 300。本单元完整使用 BFS 求解,并提供输入不变的版本。

解题思路:每次 BFS 走完一座岛

  1. 按行扫描;遇到尚未处理的陆地,岛屿数量加 1。
  2. 将起点放入 deque,同时标记为已访问。
  3. 不断从队首弹出坐标;将四周未访问的陆地标记并加入队尾。
  4. 队列为空时,这一岛屿已完全处理,再继续扫描。

队列不变量:容器里只包含已经被发现但尚未展开邻居的陆地,每个格子最多出现一次。沿路径长度归纳,任何与起点相连的陆地都会被逐层发现;水隔开的格子不会进入本次队列。

解法 A · 原地标记 BFS

Python 3 · BFS 原地标记 · 可独立提交
from typing import List
from collections import deque

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        if not grid or not grid[0]:
            return 0
        m, n = len(grid), len(grid[0])
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))
        count = 0
        for r in range(m):
            for c in range(n):
                if grid[r][c] != "1":
                    continue
                count += 1
                queue = deque([(r, c)])
                grid[r][c] = "0"
                while queue:
                    x, y = queue.popleft()
                    for dx, dy in directions:
                        nx, ny = x + dx, y + dy
                        if (0 <= nx < m and 0 <= ny < n
                                and grid[nx][ny] == "1"):
                            grid[nx][ny] = "0"  # 发现时标记
                            queue.append((nx, ny))
        return count
关键代码为什么这样写
deque([(r, c)])每座岛单独创建队列,不把多个岛混成一次启动
queue.popleft()先进先出;deque 队首弹出为 O(1)
grid[nx][ny] = "0" 后 append两个父节点同时发现该格时,只有第一个能入队
不使用 range(len(queue))这里只求连通性,不需要按层统计

解法 B · 保留输入:独立 visited 集合

Python 3 · BFS 保留输入 · 可独立提交
from typing import List
from collections import deque

class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        if not grid or not grid[0]:
            return 0
        m, n = len(grid), len(grid[0])
        visited = set()
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))
        count = 0
        for r in range(m):
            for c in range(n):
                if grid[r][c] != "1" or (r, c) in visited:
                    continue
                count += 1
                queue = deque([(r, c)])
                visited.add((r, c))
                while queue:
                    x, y = queue.popleft()
                    for dx, dy in directions:
                        nx, ny = x + dx, y + dy
                        if (0 <= nx < m and 0 <= ny < n
                                and grid[nx][ny] == "1"
                                and (nx, ny) not in visited):
                            visited.add((nx, ny))
                            queue.append((nx, ny))
        return count

visited 放在外层循环之外,记录整个求解过程中发现过的陆地。每个新起点也必须先检查是否已访问;否则同一个岛会被再次计数。集合添加与队列入队同步,网格本身始终不变。

示例推演:观察发现与出队的区别

教学示例 grid = [["1","1","0"],["1","1","0"],["0","0","1"]],输出 2。使用下、上、右、左的方向顺序,队列左侧为队首。

事件队列(事件后)说明
起点 (0,0) 入队[(0,0)]标记起点,count = 1
弹出 (0,0)[(1,0), (0,1)]发现下、右两个陆地
弹出 (1,0)[(0,1), (1,1)]发现 (1,1) 并立即标记
弹出 (0,1)[(1,1)](1,1) 已发现,不重复加入
弹出 (1,1)[]第一座岛遍历完成,共 4 格
扫描到 (2,2)[(2,2)] → []新岛孤立,count = 2;返回 2

复杂度与优劣比较

时间复杂度:两版均为 O(mn),集合操作按平均 O(1) 计。空间复杂度:原地版队列最坏 O(mn);保留输入版 visited 与队列合计最坏 O(mn)。不能把任意形状网格上的队列空间一概写成 O(min(m,n))。

原地版省去 visited 的额外对象开销;保留输入版便于复用网格。与 DFS 相比,两者时间同阶;BFS 不受递归深度影响,也为后续按层传播提供基础。

易错点与边界

  • 不要用 list.pop(0);反复移动列表元素会破坏线性时间保证。
  • 起点和邻居都在入队时标记,不等出队。
  • visited 不应在每次 BFS 开始时清空,否则外层会再次统计旧岛。
  • 全水返回 0;单格 1 返回 1;对角双陆地返回 2;2×2 全 1 返回 1,且每格只能入队一次。
  • 若改用复制网格保留输入,浅拷贝外层列表不够;必须复制每一行,并计入 O(mn) 空间。

04LeetCode 695 · 中等岛屿的最大面积BFS · 统计节点数量

LeetCode 原题 ↗

难度:中等 | 核心知识点:队列、岛屿面积、局部计数、遍历与层序的区别

题意与约束

给定整数 0 / 1 网格,四方向相连的 1 构成岛屿,返回所有岛屿中最大的格子数量;全水返回 0。原题 1 ≤ m,n ≤ 50,允许边界陆地。

解题思路:队列出队次数就是面积

外层扫描找到未访问陆地,启动一次 BFS。每个格子入队即标记,因此每次出队都对应一个新的陆地格。让局部变量 area 在出队时加一,队列为空后用它更新 best。

正确性:BFS 会访问起点所在连通块的全部格子,且不跨水、不重复访问;因此该次出队总数恰好是岛屿面积。扫描所有起点后取最大值,就覆盖所有岛屿。

完整 Python 3 代码

Python 3 · BFS 最大面积 · 可独立提交
from typing import List
from collections import deque

class Solution:
    def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
        if not grid or not grid[0]:
            return 0
        m, n = len(grid), len(grid[0])
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))
        best = 0
        for r in range(m):
            for c in range(n):
                if grid[r][c] != 1:
                    continue
                queue = deque([(r, c)])
                grid[r][c] = 0
                area = 0
                while queue:
                    x, y = queue.popleft()
                    area += 1
                    for dx, dy in directions:
                        nx, ny = x + dx, y + dy
                        if (0 <= nx < m and 0 <= ny < n
                                and grid[nx][ny] == 1):
                            grid[nx][ny] = 0
                            queue.append((nx, ny))
                best = max(best, area)
        return best

关键代码解释

  • area = 0 属于一座岛;必须放在该岛 BFS 开始处。
  • area += 1 放在出队处。若选择入队处计数,也必须包含起点并保持每格只计一次,不能两处都加。
  • best = max(best, area) 在 BFS 完成后执行,此时 area 才是完整岛屿面积。
  • 这里的 BFS 不需要按层循环:面积统计格子总量,不是最远距离。

示例推演:最大层宽并不是岛屿面积

教学示例 [[1,1,0],[1,0,0],[0,0,1]],输出 3。第一岛屿从 (0,0) 出发,距离 0 的层有 1 格,距离 1 的层有 2 格,面积是 1 + 2 = 3,不是 2。

出队位置发现的邻居当前 areabest 更新
(0,0)(1,0)、(0,1)1尚未完成本岛
(1,0)无2尚未完成本岛
(0,1)无3队列空,best = 3
第二岛 (2,2)无重新从 0 计到 1max(3,1) = 3

复杂度与解法比较

时间复杂度:O(mn),四个方向的常数因子不影响阶数。空间复杂度:队列最坏 O(mn),不额外开 visited。

DFS 递归版用返回值汇总,DFS 显式栈版和 BFS 版用局部计数器;三者都遍历整个连通块,结果一致。BFS 在这里无需利用距离性质,优势主要是实现稳定、便于迁移到多源搜索。若输入必须保留,可增加全局 visited,最坏空间仍是 O(mn)。

易错点与边界

  • 把整数 1 改为整数 0,不要混入字符串。
  • 不能把 len(queue) 当作面积;它只是某个时刻尚未处理的格子数。
  • 每座岛重置 area,但不能重置 best;最后返回 best,不是最后一座岛的面积。
  • 全水 0;单格 1;2×2 全 1 面积为 4;不相连的两个单格岛最大面积仍为 1。
  • 若出队时才标记,成环网格会重复入队并重复计面积。

05LeetCode 994 · 中等腐烂的橘子进阶 BFS · 多源最短传播

LeetCode 原题 ↗

难度:中等 | 核心知识点:多源 BFS、同步层序、最短距离、时间戳、不可达检测

题意与约束

网格中整数 0 为空格,1 为新鲜橘子,2 为腐烂橘子。每过一分钟,已腐烂橘子四周的新鲜橘子同时腐烂。求全部橘子腐烂需要的最少分钟数;有橘子永远无法腐烂则返回 -1;一开始没有新鲜橘子返回 0。原题 1 ≤ m,n ≤ 10。

解题思路:所有源从 0 分钟同时出发

把橘子看作图节点,空格是不可穿过的障碍;每条邻接边传播耗时 1。一个新鲜橘子的最早腐烂时刻,是它到所有初始腐烂源的最短可达距离的最小值。所有橘子都腐烂的时刻,是这些最早时刻的最大值。

完成时间 = max(每个新鲜橘子到最近腐烂源的图距离)

先扫描网格,把所有 2 一起加入队列,并统计新鲜数 fresh。每次只处理当前层已有的节点,将新腐烂节点加入下一层,整层结束才增加一分钟。某格第一次被 BFS 发现时已是最短时间;即时改为 2 可防止多个源对同一格重复计数。

解法 A · 按层计时:推荐版本

Python 3 · 多源 BFS 分层计时 · 可独立提交
from typing import List
from collections import deque

class Solution:
    def orangesRotting(self, grid: List[List[int]]) -> int:
        if not grid or not grid[0]:
            return 0
        m, n = len(grid), len(grid[0])
        queue = deque()
        fresh = 0
        for r in range(m):
            for c in range(n):
                if grid[r][c] == 2:
                    queue.append((r, c))
                elif grid[r][c] == 1:
                    fresh += 1

        minutes = 0
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))
        while queue and fresh > 0:
            for _ in range(len(queue)):  # 固定本分钟开始时的队列长度
                r, c = queue.popleft()
                for dr, dc in directions:
                    nr, nc = r + dr, c + dc
                    if (0 <= nr < m and 0 <= nc < n
                            and grid[nr][nc] == 1):
                        grid[nr][nc] = 2
                        fresh -= 1
                        queue.append((nr, nc))
            minutes += 1
        return minutes if fresh == 0 else -1
关键代码不变量与作用
初始化将所有 2 入队所有源拥有同一时间 0;不是逐个源跑完整个 BFS
while queue and fresh > 0无新鲜时立即结束,初始无新鲜返回 0
for _ in range(len(queue))range 开始前求值一次,固定本层长度;新入队节点不能在本分钟继续传播
改为 2、fresh -= 1、入队发现一次只扣一次,避免重复污染
整层末尾 minutes += 1一轮表示从当前时刻推进到下一分钟
fresh == 0 才返回 minutes队列空不一定成功:孤立新鲜橘子仍可能存在

解法 B · 节点携带时间戳

Python 3 · 多源 BFS 时间戳 · 可独立提交
from typing import List
from collections import deque

class Solution:
    def orangesRotting(self, grid: List[List[int]]) -> int:
        if not grid or not grid[0]:
            return 0
        m, n = len(grid), len(grid[0])
        queue = deque()
        fresh = 0
        for r in range(m):
            for c in range(n):
                if grid[r][c] == 2:
                    queue.append((r, c, 0))
                elif grid[r][c] == 1:
                    fresh += 1
        answer = 0
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))
        while queue:
            r, c, time = queue.popleft()
            answer = max(answer, time)
            for dr, dc in directions:
                nr, nc = r + dr, c + dc
                if (0 <= nr < m and 0 <= nc < n
                        and grid[nr][nc] == 1):
                    grid[nr][nc] = 2
                    fresh -= 1
                    queue.append((nr, nc, time + 1))
        return answer if fresh == 0 else -1

队列存储 (r, c, time),初始源 time 为 0,新邻居为 time + 1。不用手动划分层,最终取最大出队时间。队列按非递减时间出队;同一个新鲜节点只被最早到达的波前加入一次。即使初始只有腐烂橘子,answer 仍为 0。

示例推演:为什么答案是 4 分钟?

输入 [[2,1,1],[1,1,0],[0,1,1]],输出 4。图中文字表示该位置最早腐烂的时刻,× 表示空格。

01212××34
传播到达时间图:每条相邻边增加 1 分钟,空格不传播。
时刻本时刻新腐烂坐标剩余 fresh
0初始源 (0,0)6
1(1,0)、(0,1)4
2(1,1)、(0,2)2
3(2,1)1
4(2,2)0 → 返回 4

复杂度与解法比较

时间复杂度:两版均 O(mn),先扫描、后扩散,每个橘子最多入队一次。空间复杂度:两版队列最坏 O(mn);所有格子都初始腐烂时,初始化就可能入队 mn 个。

分层版每个元素仅存坐标,时间变量集中维护;时间戳版更容易推演各节点到达时刻,队列元素多一个字段。两者都会修改输入。普通单源 DFS 的第一次到达不保证最短时间;为此题反复松弛距离会增加实现和分析负担。

易错点与边界

  • 所有初始腐烂橘子要一起入队;传播可以来自任意源。
  • grid = [[0,2]] → 0;全空格 → 0;[[1]] → -1;[[2,1]] → 1。
  • [[2,1,1],[0,1,1],[1,0,1]] → -1,左下角不可达。
  • 只有 1 格新鲜且没有源时,while 不会启动,但 fresh > 0,必须返回 -1。
  • 每个节点入队就改为 2;否则多源交汇处 fresh 会被多扣。
  • 不能仅依据曼哈顿距离估时:空格障碍会迫使绕路,甚至让目标不可达。

第四阶段 · 路径回溯与安全剪枝

连通块只问能否到达某格;单词搜索还要记录以什么路径到达。状态改变之后,访问标记的生命周期也必须改变。

06LeetCode 79 · 中等单词搜索进阶 DFS · 回溯与剪枝

LeetCode 原题 ↗

难度:中等 | 核心知识点:路径状态、撤销选择、短路恢复、频次剪枝、反向搜索

题意与约束

在字符矩阵 board 中寻找单词 word:从任意格开始,按字符顺序沿上下左右相邻格移动;一次单词路径中不能重复使用同一个格子。存在合法路径返回 True,否则返回 False。字母区分大小写。原题 1 ≤ m,n ≤ 6,1 ≤ L = len(word) ≤ 15,只含大小写英文字母。

解题思路:坐标、字符位置、当前路径缺一不可

定义 dfs(r, c, k):在当前已经占用的路径约束下,能否从 (r,c) 开始匹配 word[k:]。前缀 word[:k] 已匹配且对应格子不可复用。实际状态还包含当前路径占用集合,这个集合用棋盘临时标记或 path 集合隐式传递。

  1. 越界、格子已被当前路径使用、字符不等于 word[k],返回 False。
  2. 若字符匹配且 k 是最后一个下标,返回 True。
  3. 临时占用当前格,对四个邻居搜索 k + 1;任意方向成功即可。
  4. 离开当前函数前撤销占用,再把搜索结果返回给父调用。
  5. 枚举每个格子为起点,直到找到一条完整路径或全部失败。

不变量:每次进入 dfs 时,临时标记的格子恰好对应已匹配的前缀;每次调用返回后,调用前的棋盘状态被完全恢复。由此其他分支可重新使用这些格子。

解法 A · 原地占位,成功与失败都恢复

Python 3 · 回溯 原地恢复 · 可独立提交
from typing import List

class Solution:
    def exist(self, board: List[List[str]], word: str) -> bool:
        if not word:
            return True
        if not board or not board[0]:
            return False
        m, n = len(board), len(board[0])
        if len(word) > m * n:
            return False
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))

        def dfs(r: int, c: int, k: int) -> bool:
            if not (0 <= r < m and 0 <= c < n):
                return False
            if board[r][c] != word[k]:
                return False
            if k == len(word) - 1:
                return True
            char = board[r][c]
            board[r][c] = "#"  # 仅对当前路径禁用
            found = False
            for dr, dc in directions:
                if dfs(r + dr, c + dc, k + 1):
                    found = True
                    break
            board[r][c] = char  # 成功与失败都恢复
            return found

        for r in range(m):
            for c in range(n):
                if dfs(r, c, 0):
                    return True
        return False
关键代码作用
k == len(word) - 1当前字符已匹配,故完成整个单词;最后一格无需再占位
board[r][c] = "#"题目字符只含英文字母,# 不会与正常字符相等
找到后仅设置 found = True 并 break延迟返回,保证本层仍执行恢复语句
board[r][c] = char回到其他方向或起点前恢复,无论是否找到答案
len(word) > m * n一格不能重复用,长度过大必不可能

解法 B · path 集合:不改动棋盘

Python 3 · 回溯 路径集合 · 可独立提交
from typing import List

class Solution:
    def exist(self, board: List[List[str]], word: str) -> bool:
        if not word:
            return True
        if not board or not board[0]:
            return False
        m, n = len(board), len(board[0])
        if len(word) > m * n:
            return False
        path = set()
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))

        def dfs(r: int, c: int, k: int) -> bool:
            if not (0 <= r < m and 0 <= c < n):
                return False
            if (r, c) in path or board[r][c] != word[k]:
                return False
            if k == len(word) - 1:
                return True
            path.add((r, c))
            found = any(dfs(r + dr, c + dc, k + 1)
                        for dr, dc in directions)
            path.remove((r, c))
            return found

        return any(dfs(r, c, 0) for r in range(m) for c in range(n))

path 只记录当前递归链上的坐标,不是整个求解过程的全局 visited。每次 add 后都与 remove 配对,兄弟分支和其他起点可以重新使用该格。any 遇 True 会短路,但短路只是结束子调用枚举,之后的 path.remove 仍会执行。

解法 C · 长度、频次与稀少端点剪枝

Python 3 · 回溯 频次剪枝 · 可独立提交
from typing import List
from collections import Counter

class Solution:
    def exist(self, board: List[List[str]], word: str) -> bool:
        if not word:
            return True
        if not board or not board[0]:
            return False
        m, n = len(board), len(board[0])
        if len(word) > m * n:
            return False
        available = Counter(ch for row in board for ch in row)
        required = Counter(word)
        if any(available[ch] < need for ch, need in required.items()):
            return False
        # 无向邻接中路径可以反转,从更稀少的端点出发
        if available[word[0]] > available[word[-1]]:
            word = word[::-1]
        directions = ((1, 0), (-1, 0), (0, 1), (0, -1))

        def dfs(r: int, c: int, k: int) -> bool:
            if not (0 <= r < m and 0 <= c < n):
                return False
            if board[r][c] != word[k]:
                return False
            if k == len(word) - 1:
                return True
            char = board[r][c]
            board[r][c] = "#"
            found = any(dfs(r + dr, c + dc, k + 1)
                        for dr, dc in directions)
            board[r][c] = char
            return found

        return any(dfs(r, c, 0) for r in range(m) for c in range(n))

三种剪枝分别有清晰的必要条件或等价变换:

  1. 长度检查:L > mn 时格子不够,直接返回 False。
  2. 频次检查:word 所需某个字母数大于棋盘可用数,必定无解。但频次足够仅代表“数量够”,不能保证位置相连。
  3. 反转单词:网格四方向邻接是无向关系;存在 word 路径当且仅当存在 reversed(word) 路径。若末字符在棋盘中更少,就从末端搜起,通常减少候选起点;最坏复杂度不变。

例如棋盘有很多 A、只有一个 B,寻找以 A 开头、B 结尾的词,从 B 反向找通常更快。不要把“频次足够”直接当作 True,也不要对有向移动规则未经证明地使用反转。

示例推演:ABCCED 的一条成功路径

输入 board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]],word = "ABCCED",输出 True。高亮路径只描述成功分支,实际 DFS 还会先尝试一些不匹配的方向。

ABCESFCSADEE
A(0,0) → B(0,1) → C(0,2) → C(1,2) → E(2,2) → D(2,1)
字符下标 k当前格动作与返回
0 / A(0,0)匹配,临时占用 A,继续找 B
1 / B(0,1)匹配,占用 B,继续找 C
2 / C(0,2)匹配,占用 C,继续找下一个 C
3 / C(1,2)匹配,向下找到 E
4 / E(2,2)匹配,向左找到 D
5 / D(2,1)最后字符匹配,返回 True;调用链逐层恢复占用

同一棋盘 word = "SEE" → True,可走 (1,3) → (2,3) → (2,2);word = "ABCB" → False,A → B → C 后所需的 B 只能回到已经使用的 (0,1),不合法。

为什么必须回溯:一个可亲手推演的反例

教学例:board = [["A","A"],["A","B"]],word = "AAA",正确答案是 True,但把 visited 永久保留的写法会错误返回 False。

  1. 先从 (0,0) 的 A 出发,向下走 (1,0) 的 A;它没有可用的第三个 A,分支失败。
  2. 回到 (0,0),尝试向右走 (0,1) 的 A;它同样找不到第三个 A,这个起点整体失败。
  3. 如果不撤销标记,此时三个 A 都已经永久占用;后面的起点会被直接跳过,错误返回 False。
  4. 正确回溯会恢复三个格子。从下一个起点 (0,1) 出发,走 (0,1) → (0,0) → (1,0),恰好得到 AAA。

再看兄弟分支换路:board = [["A","B","C"],["B","X","D"]],word = "ABCD"。从 A 向下走 B(1,0) 后找不到 C,撤销该分支;再向右走 B(0,1) → C(0,2) → D(1,2) 即成功。

访问标记描述的是当前路径占用了什么,不能把失败分支的占用带入后续搜索。也不能仅缓存 (r,c,k) 的失败结果,因为同一位置、同一字符下标,在不同已用路径下拥有不同的可用后继。

复杂度:指数来自路径分支,而非网格扫描

设 L = len(word),代码会先检查 L ≤ mn。每个起点第一步最多 4 个选择,之后刚走来的父格已禁用,通常最多 3 个方向继续扩展;边界和字符不匹配会进一步减枝。

时间复杂度:常用紧一些的上界写作 O(mn · 3ᴸ)(第一层的 4 作为常数处理);更宽松且易推导的 O(mn · 4ᴸ) 也是合法上界。三版最坏均仍为指数级。频次版额外扫描 O(mn + L),不改变总体最坏上界。

空间复杂度:基础原地版为 O(L) 递归栈;path 版集合最多含 L 个格子,加递归栈合计 O(L);剪枝版 O(L + Σ),Σ 为字符种类数,题目只含大小写字母,Σ ≤ 52,可视作 O(L)。反转单词需要 O(L) 临时字符串。所有版本返回后 board 恢复为原始内容。

解法优势代价 / 使用场景
原地占位代码短;无需额外坐标集合中途修改棋盘,必须保证所有返回路径恢复
path 集合不写入棋盘;状态语义直观哈希集合操作有常数开销;add/remove 必须配对
频次 + 反向剪枝无解数量不足时立即终止;减少候选起点额外一次计数;不改善指数级最坏界

易错点与边界

  • 先比较当前字符,再判断是否到达最后一个字符,不能仅因为 k 到末尾就成功。
  • 不能用一个永久 visited 阻止所有路径使用同格;只能禁止当前路径重复。
  • 不要在子调用成功后立即 return True 跳过恢复语句。
  • 单格 [["A"]] 查 A 为 True,查 B / AA 为 False;L > mn 必为 False。
  • 棋盘 [["A","B"],["C","D"]] 查 AD 为 False:对角不算相邻。
  • 代码额外约定空单词返回 True,空棋盘查非空词返回 False;原题约束不会给空输入。
  • 不要复制整个棋盘到每个递归分支,也不要每层切片 word;用 k 传递位置可减少额外开销。

算法对照:用问题目标选择搜索方式

问题答案更新visited 生命周期推荐方案最坏时间 / 空间
200 岛屿数量新启动一次搜索,答案加 1整个求解期间永久有效显式栈 DFS 或 BFSO(mn) / O(mn)
695 最大面积一座岛的格数取最大整个求解期间永久有效DFS 汇总或 BFS 计数O(mn) / O(mn)
994 腐烂的橘子所有新鲜节点最早到达时间取最大发现即腐烂,不撤销多源 BFSO(mn) / O(mn)
79 单词搜索有一条路径完整匹配即成功仅当前候选路径有效DFS 回溯 + 剪枝O(mn·3ᴸ) / O(L)(固定字符集)
要求同步传播 / 等权最短时间?是否多源 BFS · 994当前路径限制格子复用?继续判断是否需要撤销访问状态是否DFS 回溯 · 79DFS / BFS200 / 695
本题组的选型路线。一般带权最短路需要进一步考虑 Dijkstra 等算法,不能直接套普通 BFS。

面试时,用四句话解释你的代码

  1. 状态:200 / 695 的节点是坐标;994 多一个最早到达时间;79 还有单词下标与当前路径占用。
  2. 推进:连通块可用栈或队列;每步耗时相同且求最早到达时,BFS 按距离非递减展开。
  3. 不重复:全局访问在发现时标记;当前路径标记在本层返回前撤销。
  4. 答案:启动次数、单次访问数、最后传播时间、是否存在完整匹配,分别决定答案在何处产生。

三条不能混用的“模板句”

模板句成立原因不能误用到哪里
连通块发现即标记,后续不撤销同一节点被不同路径到达,对连通性没有区别79 中路径已用格子改变了后续可选集合
BFS 首次发现就是最短距离边的代价一致,队列按层推进DFS 首次发现;任意正权边的普通 FIFO BFS
回溯返回前恢复现场兄弟分支应从同一状态开始尝试200 / 695 撤销全局标记会反复统计和成环

边界自测与复盘清单

先猜输出并解释原因,再展开答案。这些自测不执行 Python;所有完整解法均另有自动校验。

200:[["1","0"],["0","1"]] 有几座岛?

2。两格只对角接触,四方向不连通。

695:[[1,1],[1,1]] 的最大面积是多少?

4。网格有环,每个格子只能计一次。

994:[[2,1,1,1,2]] 需要几分钟?

2。两端的源同时扩散,中间在第 2 分钟腐烂。

994:[[1,0,2]] 返回什么?

-1。新鲜橘子被空格隔开,无法到达。

79:[["A","B"]] 查找 ABA 可以吗?

False。长度已超过格子总数,且不能回到 A 重复使用。

79:为什么缓存 (r,c,k) 的失败结果可能错误?

这个键没有包括当前路径已用格子。同位置同字符下标,剩余可用邻居可能不同,不能直接合并状态。

为什么外层双循环 + 每次搜索不是平方复杂度?

200 / 695 每格最多被某一次搜索发现一次;将所有搜索的访问次数相加仍为 O(mn)。79 撤销状态,会在不同路径重复访问,不能用这个论证。

闭卷能力检查(与题目进度独立保存)

最终复习顺序

闭卷写 200 显式栈,随后改成 BFS;从返回岛屿数改成最大面积;把单源改成所有腐烂源一起入队并按层计时;最后重新定义路径状态,写出 79 的占用与恢复。每次改动都说清楚:改了什么状态、为什么正确、最坏会占多少空间。

本地学习记录说明

主题、6 个单元掌握状态、能力自测保存在当前浏览器的 localStorage 中。更换浏览器或清除网站数据不会同步;部分隐私设置可能禁止 file:// 存储,届时页面会提示“仅本次有效”,阅读与交互仍可继续。打印会移除导航和按钮、展开自测答案,并将代码自动换行。