DAY22: LeetCode 733:图像渲染——从二维网格开始理解 DFS 和 BFS

LeetCode 733:图像渲染 Flood Fill------从二维网格开始理解 DFS 和 BFS

目录

一、准备做 200,可以先做这道题

LeetCode 733:图像渲染

我原本准备做 LeetCode 200「岛屿数量」,但刚开始接触二维网格搜索时,对"从一个点向四周搜索"这件事还不是特别熟悉。

所以可以先做 733。

这道题可以帮助理解一个非常重要的模型:

从一个起点开始,把所有和它上下左右连通,并且数值相同的格子全部找出来。

这个模型其实就是后面 200「岛屿数量」的基础。

733 已经直接告诉了我们:

text 复制代码
从哪里开始

我们只需要解决:

text 复制代码
怎么从这个起点出发
把和它连起来的一整块区域全部找出来

而 200 则是在这个基础上,再增加一步:

text 复制代码
自己去寻找每一座岛的起点

二、先理解二维网格中的"四个方向"

刚开始我以为:

从初始点出发,分别往上、下、左、右一直遍历。

但实际上并不是沿着四条直线一直扫。

假设当前格子的坐标是:

text 复制代码
(row, col)

那么它紧挨着的四个格子分别是:

text 复制代码
上:(row - 1, col)
下:(row + 1, col)
左:(row, col - 1)
右:(row, col + 1)

也就是说,我们每一次只检查当前格子周围的一格。

如果某个相邻格子符合要求,就进入这个格子,然后再从这个新格子继续检查它自己的上、下、左、右。

例如:

text 复制代码
1 1 0
0 1 1
0 0 1

从左上角 (0,0) 开始,可以一路找到:

text 复制代码
(0,0) → (0,1)
           ↓
         (1,1) → (1,2)
                   ↓
                 (2,2)

所以真正的思路是:

每到达一个新的格子,都重新检查它自己的四个邻居。


1、怎么保证四个方向一个都不会漏?

可以先把四个方向统一保存下来:

python 复制代码
directions = [
    (-1, 0),  # 上
    (1, 0),   # 下
    (0, -1),  # 左
    (0, 1)    # 右
]

之后每到达一个格子,就执行:

python 复制代码
for dr, dc in directions:
    new_row = row + dr
    new_col = col + dc

例如当前在:

text 复制代码
(row, col)

四轮循环分别会得到:

text 复制代码
row - 1, col
row + 1, col
row, col - 1
row, col + 1

所以可以把:

text 复制代码
dr
dc

理解成:

text 复制代码
这一轮,行和列分别要变化多少

例如:

text 复制代码
(-1, 0)

表示:

text 复制代码
行 -1
列不变

也就是向上移动一格。

只要每一个到达的格子都执行一次这个 for,它的四个方向就不会漏掉。


2、首先要记住起点原来的颜色

题目要求修改的是:

与初始位置上下左右连通,并且和初始位置颜色相同的格子。

所以最开始应该先保存起点原来的颜色:

python 复制代码
original = image[sr][sc]

例如:

text 复制代码
image[sr][sc] = 1

那么:

text 复制代码
original = 1

之后我们寻找邻居时,就可以判断:

python 复制代码
image[new_row][new_col] == original

只有颜色仍然等于起点原来的颜色,才属于我们需要继续寻找的区域。


3、还要判断有没有越界

二维数组不是无限大的。

假设:

python 复制代码
rows = len(image)
cols = len(image[0])

分别表示:

text 复制代码
rows = 总行数
cols = 总列数

那么合法坐标必须满足:

python 复制代码
0 <= new_row < rows
0 <= new_col < cols

为什么是这样?

比如总共有 3 行:

text 复制代码
第 0 行
第 1 行
第 2 行

合法行下标其实就是:

text 复制代码
0 ~ rows - 1

所以写成:

python 复制代码
0 <= new_row < rows

列也是同样的道理。

PS: 这里判断的是整张图像的边界


三、先不管 DFS 和 BFS,这道题到底应该怎么想?

现在已经知道:

text 复制代码
当前格子 → 有上、下、左、右四个邻居

但真正的问题是:

从起点 (sr, sc) 开始,怎么把所有和它连通、并且颜色相同的格子全部找出来?

假设:

text 复制代码
1 1 0
0 1 1
0 0 1

起点是:

text 复制代码
(0,0)

起点颜色是:

text 复制代码
1

所以我们的目标就是:

text 复制代码
从 (0,0) 出发
通过上下左右移动
把所有能够到达的 1 找出来

1、先处理起点

起点本身肯定属于需要修改的区域。

假设新颜色是:

text 复制代码
2

那么可以先把:

text 复制代码
(0,0)

修改成:

text 复制代码
2 1 0
0 1 1
0 0 1

现在 (0,0) 已经处理好了。

接下来应该干什么?

自然就是检查:

它周围有没有其他和它连在一起的 1?


2、检查当前格子的四个邻居

当前位置:

text 复制代码
(0,0)

它的四个邻居是:

text 复制代码
上:(-1,0)
下:(1,0)
左:(0,-1)
右:(0,1)

但不是每个位置都可以进入。

一个邻居至少要满足:

text 复制代码
1. 没有越界
2. 颜色还是 original

这里:

text 复制代码
上:(-1,0) → 越界
左:(0,-1) → 越界
下:(1,0) → 颜色是 0
右:(0,1) → 颜色是 1

所以真正能够继续进入的是:

text 复制代码
(0,1)

3、进入 (0,1) 以后怎么办?

来到:

text 复制代码
(0,1)

以后,问题又变成了:

(0,1) 周围还有没有和它连通的 1?

也就是说,我们又要做一模一样的事情:

text 复制代码
处理当前格子
↓
检查四个方向
↓
找到符合条件的邻居
↓
进入邻居
↓
再检查邻居自己的四周

例如可能形成:

text 复制代码
(0,0)
  ↓
(0,1)
  ↓
(1,1)
  ↓
(1,2)
  ↓
(2,2)

这时候会发现一个很重要的特点:

每进入一个新格子,我们面对的其实还是同一个问题。

都是:

text 复制代码
处理当前格子
+
寻找它符合要求的邻居
+
继续处理邻居

而这种"一个大问题里面,又出现了完全相同的小问题"的结构,就非常适合递归。


4、还要解决一个问题:不能一直走回来

例如有两个相邻的格子:

text 复制代码
A B

A 可以走到 B。

但是来到 B 以后,B 左边又是 A。

如果完全不记录哪些格子已经处理过,就可能出现:

text 复制代码
A → B → A → B → A...

一直来回走。

所以一个格子访问以后,必须留下一个:

text 复制代码
我已经处理过

的标记。

这道题其实正好可以利用"修改颜色"来做这件事。

例如:

text 复制代码
original = 1
color = 2

访问一个格子以后:

text 复制代码
1 → 2

之后我们只允许进入:

python 复制代码
image[new_row][new_col] == original

已经变成 2 的格子,自然不会再次满足:

text 复制代码
2 == 1

所以:

修改颜色不仅完成了题目的要求,同时也相当于给这个格子做了 visited 标记。

不过, 通常情况下,如果题目不能直接修改原网格,就会额外准备一个 visited 来记录哪些格子已经访问过。

最常见有两种写法。

方法一:二维布尔数组

假设:

python 复制代码
rows = len(image)
cols = len(image[0])

可以创建:

python 复制代码
visited = [
    [False] * cols
    for _ in range(rows)
]

一开始所有位置都是:

text 复制代码
False

表示:

text 复制代码
还没有访问过

例如:

text 复制代码
False False False
False False False
False False False

如果现在访问了:

text 复制代码
(0,1)

就可以:

python 复制代码
visited[0][1] = True

变成:

text 复制代码
False True  False
False False False
False False False

以后再准备进入一个邻居时,除了判断:

text 复制代码
有没有越界

以及:

text 复制代码
这个格子本身是否满足题目条件

还要多判断:

python 复制代码
not visited[new_row][new_col]

比如:

python 复制代码
if (
    0 <= new_row < rows
    and 0 <= new_col < cols
    and not visited[new_row][new_col]
    and image[new_row][new_col] == original
):

一旦决定进入这个格子,就先:

python 复制代码
visited[new_row][new_col] = True

表示:

这个格子已经被发现过了,之后不要再重复进入。


方法二:使用 set 保存坐标

也可以写:

python 复制代码
visited = set()

访问一个格子以后:

python 复制代码
visited.add((row, col))

例如:

python 复制代码
visited.add((0, 1))

那么:

python 复制代码
visited

里面可能保存:

text 复制代码
{(0,0), (0,1), (1,1)}

之后判断一个格子有没有访问过:

python 复制代码
(new_row, new_col) not in visited

就可以了。


所以一般的网格搜索其实是:

text 复制代码
当前位置
↓
检查四个邻居
↓
判断有没有越界
↓
判断是否满足题目条件
↓
判断有没有访问过
↓
如果可以进入
↓
先标记 visited
↓
再继续搜索

而 733 比较特殊,因为:

python 复制代码
image[row][col] = color

本身就已经能看出:

text 复制代码
这个格子访问过了

所以不需要再额外创建 visited。

可以把两种情况简单记成:

text 复制代码
如果可以安全修改原数组 → 经常直接修改原数组当 visited

如果不能修改原数组 → 单独建立 visited

5、一个特殊情况:新颜色和原颜色一样

假设:

text 复制代码
original = 1
color = 1

那么修改:

text 复制代码
1 → 1

其实什么都没有发生。

这样"修改颜色作为访问标记"的方法就失效了。

例如:

text 复制代码
A → B → A → B...

仍然可能不断重复访问。

而且既然:

text 复制代码
原颜色 == 新颜色

最终整张图片本来也不会发生任何变化,所以可以一开始直接:

python 复制代码
if original == color:
    return image

不用继续搜索。


四、方法一:DFS

前面已经自己推出了这样一个过程:

text 复制代码
进入当前格子
↓
处理当前格子
↓
检查四个邻居
↓
发现符合要求的邻居
↓
进入邻居
↓
邻居继续做同样的事情

这其实就是 DFS。

DFS:

Depth First Search,深度优先搜索。

可以简单理解成:

找到一个能继续走的邻居以后,马上进入这个邻居,然后继续往更深处找。


1、为什么这里可以使用递归?

我们可以写一个函数:

python 复制代码
dfs(row, col)

它只负责一件事:

处理当前位置 (row, col),然后寻找它能够继续进入的邻居。

进入当前格子以后:

python 复制代码
image[row][col] = color

然后检查四个方向:

python 复制代码
for dr, dc in directions:
    new_row = row + dr
    new_col = col + dc

如果新的位置:

text 复制代码
① 没有越界
② 颜色仍然等于 original

说明它也是这块连通区域的一部分。

那么接下来怎么办?

其实还是做和当前格子完全一样的事情:

python 复制代码
dfs(new_row, new_col)

所以递归并不是突然冒出来的。

而是因为:

处理邻居的问题,和处理当前格子的问题完全一样。


2、DFS 大概是怎么走的?

例如:

text 复制代码
1 1 0
0 1 1
0 0 1

从:

text 复制代码
(0,0)

开始。

可能会一路:

text 复制代码
(0,0)
→ (0,1)
→ (1,1)
→ (1,2)
→ (2,2)

DFS 的感觉就是:

text 复制代码
发现能继续走
↓
马上进去
↓
再发现能继续走
↓
继续进去
↓
直到这条路走不下去

之后递归才会一层一层返回,继续检查之前还没有检查完的其他方向。


3、DFS 完整代码

python 复制代码
class Solution:
    def floodFill(
        self,
        image: list[list[int]],
        sr: int,
        sc: int,
        color: int
    ) -> list[list[int]]:

        original = image[sr][sc]

        if original == color:
            return image

        rows = len(image)
        cols = len(image[0])

        directions = [
            (-1, 0),
            (1, 0),
            (0, -1),
            (0, 1)
        ]

        def dfs(row, col):
            image[row][col] = color

            for dr, dc in directions:
                new_row = row + dr
                new_col = col + dc

                if (
                    0 <= new_row < rows
                    and 0 <= new_col < cols
                    and image[new_row][new_col] == original
                ):
                    dfs(new_row, new_col)

        dfs(sr, sc)

        return image

整个 DFS 的核心其实就是:

text 复制代码
dfs(当前格子)
↓
先处理当前格子
↓
检查四个方向
↓
找到符合要求的邻居
↓
dfs(邻居)

也就是:

进入一个格子 → 做标记 → 检查四个邻居 → 符合条件就继续进入。


五、方法二:BFS

理解 DFS 以后,再来看 BFS 就会自然很多。

DFS 的做法是:

text 复制代码
发现一个邻居
↓
马上进入这个邻居
↓
继续往深处寻找

那么我也可以换一种方式:

发现一个邻居以后,我先不马上进去,而是先把它记下来,等之后再处理。

也就是说,我们需要一个地方保存:

已经发现,但是还没有检查它四周的格子。

这就是 BFS 中的队列 queue。


1、为什么要使用队列?

假设当前处理 A:

text 复制代码
A

然后发现两个邻居:

text 复制代码
B
C

我们暂时不马上进入 B 或 C,而是先记录:

text 复制代码
待处理:
B
C

之后:

text 复制代码
先处理 B
再处理 C

而 B 在处理过程中又可能发现:

text 复制代码
D
E

那么队列可能变成:

text 复制代码
C
D
E

也就是说:

先发现的格子先处理。

这正好符合队列的:

text 复制代码
先进先出 First In First Out(FIFO)

队列正常使用需要:

python 复制代码
from collections import deque

2、初始化队列

python 复制代码
queue = deque([(sr, sc)])

其中:

python 复制代码
(sr, sc)

表示一个坐标。

例如 (1, 2) 表示:

text 复制代码
第 1 行,第 2 列

外面的:

python 复制代码
[(sr, sc)]

表示一个列表,作用是:把这个坐标作为一个整体,放进 deque 里。

因为 deque() 接收的是一个可以遍历的对象。

如果直接写:deque((sr, sc))

那么 (sr, sc) 会被拆开,变成:sr sc 两个元素。
而我们希望队列里保存的是:(sr, sc) 这样一个完整的坐标,所以外面需要再套一层列表。


3、popleft() 是什么意思?

BFS 每次从队列最前面取出一个待处理格子:

python 复制代码
row, col = queue.popleft()

例如:

text 复制代码
queue = [(1,2), (1,3), (2,2)]

执行:

python 复制代码
row, col = queue.popleft()

会取出:

text 复制代码
(1,2)

于是:

text 复制代码
row = 1
col = 2

队列剩下:

text 复制代码
[(1,3), (2,2)]

接下来就开始检查:

text 复制代码
(1,2)

自己的四个方向。


4、为什么找到邻居以后要加入队列?

假设处理 (1,2) 时,发现:

text 复制代码
(1,3)

也是合法邻居。

这时候:

python 复制代码
queue.append((1, 3))

并不是说:

text 复制代码
(1,3) 已经处理完了

而是表示:

我已经发现了 (1,3),但还没有检查它自己的四个方向,所以先放进待处理队列。

因此 BFS 的 queue 可以理解成:

已经发现,但还没有继续检查四周的格子。


5、为什么加入队列之前就要修改颜色?

BFS 中一般会这样写:

python 复制代码
image[new_row][new_col] = color
queue.append((new_row, new_col))

而不是等这个格子以后被:

python 复制代码
popleft()

取出来的时候才修改颜色。

原因是:

一个格子可能同时被多个邻居发现。

例如:

text 复制代码
A → C
B → C

A 先发现 C。

如果只是:

python 复制代码
queue.append(C)

但是没有立刻给 C 做标记,那么之后 B 检查邻居时,也会发现:

text 复制代码
C 还是 original

于是又会:

python 复制代码
queue.append(C)

最终:

text 复制代码
C

被重复加入队列。

所以正确顺序应该是:

text 复制代码
发现一个合法邻居
↓
立即修改颜色,表示已经发现过
↓
再放进 queue

也就是:

python 复制代码
image[new_row][new_col] = color
queue.append((new_row, new_col))

这里可以注意一个区别:

DFS

DFS 中:

python 复制代码
dfs(new_row, new_col)

一进入函数,就马上:

python 复制代码
image[row][col] = color

所以访问标记是在"进入 DFS 时"完成。

BFS

BFS 中邻居不会马上处理,而是先进入队列。

所以应该在:

text 复制代码
加入队列之前

就做好标记,防止它在等待处理期间再次被其他格子加入。


6、BFS 完整代码

python 复制代码
from collections import deque


class Solution:
    def floodFill(
        self,
        image: list[list[int]],
        sr: int,
        sc: int,
        color: int
    ) -> list[list[int]]:

        original = image[sr][sc]

        if original == color:
            return image

        rows = len(image)
        cols = len(image[0])

        directions = [
            (-1, 0),
            (1, 0),
            (0, -1),
            (0, 1)
        ]

        queue = deque([(sr, sc)])

        image[sr][sc] = color

        while queue:
            row, col = queue.popleft()

            for dr, dc in directions:
                new_row = row + dr
                new_col = col + dc

                if (
                    0 <= new_row < rows
                    and 0 <= new_col < cols
                    and image[new_row][new_col] == original
                ):
                    image[new_row][new_col] = color
                    queue.append((new_row, new_col))

        return image

BFS 的整体过程就是:

text 复制代码
起点放进 queue
↓
给起点做标记
↓
从 queue 取出一个格子
↓
检查四个方向
↓
发现合法邻居
↓
立刻做标记
↓
把邻居加入 queue
↓
继续处理 queue 中剩下的格子

六、DFS 和 BFS 到底是什么关系?

刚开始可能会觉得:

DFS 做完以后,是不是还可以继续优化成 BFS?

但其实不是。

DFS 和 BFS 并不是:

text 复制代码
普通方法
↓
优化方法

而是:

两种不同的搜索顺序。

它们解决的都是:

text 复制代码
从一个起点出发
把能够到达的所有位置找出来

区别主要在于:

text 复制代码
下一步先处理谁?

1、DFS:一条路先走深

DFS:

text 复制代码
发现邻居
↓
马上进入邻居
↓
继续寻找邻居
↓
一条路一直往深处走

递归 DFS 实际上借助的是:

text 复制代码
函数调用栈

所以可以简单记成:

text 复制代码
DFS
→ stack / 递归调用栈
→ 一条路先走深

2、BFS:一层一层往外扩

BFS:

text 复制代码
发现邻居
↓
先放进 queue
↓
先把当前附近的格子处理掉
↓
再继续往外扩散

它使用的是:

text 复制代码
队列 queue

所以可以记成:

text 复制代码
BFS
→ queue
→ 一层一层往外扩

3、用同一张图来看区别

例如:

text 复制代码
    A
   / \
  B   C
 / \
D   E

DFS 可能是:

text 复制代码
A
→ B
→ D
→ 回到 B
→ E
→ 回到 A
→ C

因为:

text 复制代码
找到一条路以后先一直走深

而 BFS 更像:

text 复制代码
A
→ B、C
→ D、E

先处理离 A 一步的,再处理离 A 两步的。

所以:

DFS 和 BFS 找到的连通区域可能完全一样,只是访问顺序不同。


七、复杂度

假设图像大小为:

text 复制代码
rows × cols

也就是一共有:

text 复制代码
rows × cols

个格子。

最坏情况下,整张图所有格子都和起点连通。

那么每个格子最多被真正访问一次。

所以 DFS 和 BFS 的时间复杂度都是:

text 复制代码
O(rows × cols)

1、DFS 的空间复杂度

递归 DFS 需要使用函数调用栈。

如果连通区域特别大,最坏情况下递归深度也可能达到:

text 复制代码
rows × cols

所以空间复杂度最坏为:

text 复制代码
O(rows × cols)

2、BFS 的空间复杂度

BFS 需要使用:

text 复制代码
queue

最坏情况下队列中也可能同时保存大量格子。

所以最坏空间复杂度同样为:

text 复制代码
O(rows × cols)

因此在这道题里:

BFS 并不是 DFS 的时间复杂度优化版本。

两者主要只是搜索方式不同。

不过 Python 中,递归 DFS 如果连通区域特别大,有可能因为递归层数过深出现:

text 复制代码
RecursionError

这种情况下,可以考虑:

text 复制代码
BFS

或者:

text 复制代码
自己使用 stack 写迭代 DFS

避免依赖 Python 的递归调用栈。


八、这道题要学啥?

真正需要建立的是一个二维网格搜索模型:

text 复制代码
从一个起点开始
↓
处理当前格子
↓
检查上、下、左、右
↓
判断有没有越界
↓
判断这个邻居能不能进入
↓
能进入就做访问标记
↓
继续从这个邻居向四周搜索

也就是:

从一个点出发,把与它连通的一整块区域全部找出来。

这里还有几个非常重要的思想。

第一,二维网格其实也可以理解成"点和点之间的连接"

每一个格子都可以看成一个点。

如果两个格子:

text 复制代码
上下左右相邻

并且满足题目的移动条件,就可以认为:

text 复制代码
两个点之间可以连接

所以 DFS 和 BFS 并不只是"树的算法"。

二维网格同样可以使用。

第二,要有 visited 的意识

搜索过程中最容易出现的问题就是:

text 复制代码
A → B → A → B...

所以必须有办法判断:

text 复制代码
这个位置我是不是已经处理过了?

733 比较特殊,可以直接用修改颜色代替:

text 复制代码
visited

以后其他题不一定能修改原数组,就可能需要单独写:

python 复制代码
visited = set()

或者:

python 复制代码
visited = [[False] * cols for _ in range(rows)]

第三,DFS 和 BFS 的核心问题其实一样

它们都在解决:

从当前点还能到哪里?

真正不同的是:

text 复制代码
DFS:发现以后马上处理

BFS:发现以后先存起来,之后按照队列顺序处理
相关推荐
Pniubi1 小时前
力扣41缺失的第一个正数(哈希表法)
算法·leetcode·职场和发展
地平线开发者1 小时前
MQBench QAT量化实践指南
人工智能·算法·自动驾驶
Είναι η κοπέλα2 小时前
llama.cpp 与 GGUF 格式:本地大模型的“裸引擎“
开发语言·人工智能·pytorch·python·conda
今夜有雨.2 小时前
图像运算、掩膜/ROI 与绘制交互
c语言·数据结构·c++·qt·算法·计算机视觉
UIU1142 小时前
P1028 [NOIP 2001 普及组] 数的计算
c++·算法
sbjdhjd2 小时前
智能体开始“动手”之后:OpenAI越权事件、Anthropic算力资本化与开放权重模型竞逐 | AI与SI行业日报整理(9月29日—10月6日)
大数据·人工智能·经验分享·笔记·ai·chatgpt·开源
上位机妹子2 小时前
从零理解 EF Core:用 Minimal API 给「工作项看板」建模(小白笔记)
笔记
Keven_112 小时前
BUG:UVA116
算法·bug·图论
Henry-SAP2 小时前
Beam大模型开源引爆AI热潮
算法