1. 写在前面
今天复习深度优先和广度优先遍历,这两个也是非常重要的思想,应用最多的就是对树和图的相关遍历了。
- 深度优先遍历DFS解决的是连通性的问题,即给定两个点,一个是起始点,一个是终止点,判断是不是有一条路径从起点到终点(这里的起点和终点也可以指起始状态和最终状态)。问题的要求并不在乎是长还是短,只在乎有没有,有时候题目也会要求把找到的路径完整的打印出来
- 广度优先遍历BFS一般用来解决最短路径的问题。和深度优先搜索不同,广度优先的搜索是从起始点出发,一层一层地进行,每层当中的点距离起始点的步数都是相同的,当找到了目的地之后就可以立即结束。广度优先的搜索可以同时从起始点和终点开始进行,称之为双端 BFS。这种算法往往可以大大地提高搜索的效率。
关于深度优先遍历和广度优先遍历,这里又可以使用框架式的思维了, 下面的三个代码框架要学会默写了哈哈。
首先,深度优先遍历的两个框架(递归和非递归):
visited = set()
def dfs(node, visited):
if node in visited: # terminator
# already visited
return
visited.add(node)
# process currrent node here.
for next_node in node.children():
if not next_code in visited:
dfs(next_code, visited)
非递归写法需要手动维护一个栈:
def dfs(self, tree):
if not tree.root:
return
visited, stack = [], [tree.root]
while stack:
node = stack.pop()
visited.add(node)
process(node)
nodes = generated_related_nodes(node)
stack.push(nodes)
广度优先遍历的一个框架,之前树那里其实也见识过了:
def bfs(graph, start, end):
queue = []
queue.append([start])
visited.add(start)
while queue:
node = queue.popleft()
visited.add(node)
process(node)
nodes = generate_related_nodes(node)
queue.addend(nodes)
这三个框架式的代码默写完毕, 当然, 这里的dfs,bfs其实并不能完全用上面的这种框架解题,上面这两个框架更像是一种思路框架,而不是具体的解题框架,下面针对了具体的BFS和DFS整理了个解题框架。比如网格化的DFS和图上的BFS。下面就学习具体题目进行体会了。
PS: 下面的第二部分是我目前做过的题目整理(思路和代码),第三部分是第二部分的题目汇总,只有题目,这个方便复习用,如果是为了回顾思路和复习,直接看第三部分,如果想不起来思路和代码了,再瞟一眼第二部分哈哈,尽量不要从头开始读,那样会非常难受,并且失去了刷题复习的趣味性了!
2. 题目思路整理
2.1 DFS系列
DFS这一块, 除去树那边的那一系列, 发现最常考的就是网格类的DFS算法了, 也就是岛屿的相关问题了。四个经典岛屿题目:
我们所熟悉的DFS问题通常是在树或者图结构上进行的, 而岛屿问题的解题思路, 采用的DFS是从网络结构中进行的。下面是岛屿问题解题汇总,参考的这篇题解
网格问题的基本概念:
网格问题是由 m × n m×n m×n个小方格组成一个网格,每个小方格与其上下左右四个方格认为是相邻的,要在这样的网格上进行某种搜索。
岛屿问题是一类典型的网格问题。每个格子中的数字可能是 0 或者 1。我们把数字为 0 的格子看成海洋格子,数字为 1 的格子看成陆地格子,这样相邻的陆地格子就连接成一个岛屿。
在这样一个设定下,就出现了各种岛屿问题的变种,包括岛屿的数量、面积、周长等。不过这些问题,基本都可以用 DFS 遍历来解决。 当然有些也可以用BFS来解决, 但现在的情况是哪个能记住就用哪个了哈哈。
DFS的基本结构:
网格结构要比二叉树结构稍微复杂一些,它其实是一种简化版的图结构。要写好网格上的 DFS 遍历,我们首先要理解二叉树上的 DFS 遍历方法,再类比写出网格结构上的 DFS 遍历。我们写的二叉树 DFS 遍历一般是这样的:
def traverse(root):
if root == NULL:
return
# 访问相邻节点: 左子节点和右子节点
traverse(root.left)
traverse(root.right)
可以看到,二叉树的DFS有两个要素: 访问相邻节点和判断base case:
- 第一个要素是访问相邻结点。二叉树的相邻结点非常简单,只有左子结点和右子结点两个。二叉树本身就是一个递归定义的结构:一棵二叉树,它的左子树和右子树也是一棵二叉树。那么我们的 DFS 遍历只需要递归调用左子树和右子树即可。
- 第二个要素是 判断base case。一般来说,二叉树遍历的 base case 是
root == null
。这样一个条件判断其实有两个含义:一方面,这表示 root 指向的子树为空,不需要再往下遍历了。另一方面,在root == null
的时候及时返回,可以让后面的root.left
和root.right
操作不会出现空指针异常。
对于网格上的DFS, 我们可以参考二叉树的, 写出网格DFS的两个要素:
- 首先, 网格结构中的格子有多少相邻节点? 答案是上下左右四个。 对于格子
(r,c)
来说(r和c分别代表行坐标和列坐标), 四个相邻格子分别是(r-1,c), (r+1, c), (r, c-1), (r, c+1)
。
- 网格中的base case是什么? 从二叉树的 base case 对应过来,应该是网格中不需要继续遍历、
grid[r][c]
会出现数组下标越界异常的格子,也就是那些超出网格范围的格子。
这一点稍微有些反直觉,坐标竟然可以临时超出网格的范围?这种方法称为「先污染后治理」—— 甭管当前是在哪个格子,先往四个方向走一步再说,如果发现走出了网格范围再赶紧返回。这跟二叉树的遍历方法是一样的,先递归调用,发现root == null
再返回。
这样, 就得到了网格DFS遍历的框架代码:
# 判断是否越界
def InArea(self, grid, r, c):
return r >= 0 and r < len(grid) and c >= 0 and c < len(grid[0])
# 网格内的DFS算法
def dfs(self, visited, grid, r, c):
# 结束条件
if not self.InArea(grid, r, c):
return
# 访问相邻陆地
self.dfs(visited, grid, r-1, c) # 上
self.dfs(visited, grid, r+1, c) # 下
self.dfs(visited, grid, r, c-1) # 左
self.dfs(visited, grid, r, c+1) # 右
但上面有个问题就是还没有处理重复遍历,所以还需要标记已经遍历过的格子。以岛屿问题为例,我们需要在所有值为 1 的陆地格子上做 DFS 遍历。每走过一个陆地格子,就把格子的值改为 2,这样当我们遇到 2 的时候,就知道这是遍历过的格子了。也就是说,每个格子可能取三个值:
- 0 —— 海洋格子
- 1 —— 陆地格子(未遍历过)
- 2 —— 陆地格子(已遍历过)
当然, 我们这里不想直接改里面的数, 所以采用了一个visited来标记是否已经被访问过。 这里一定要慎重改人家原来的数组。 所以最后的框架代码如下(这个也是应该会默写)
# 网格内的DFS算法
def dfs(self, visited, grid, r, c):
# 结束条件
if not self.InArea(grid, r, c):
return
# 如果是海洋 后者是已经被访问过的陆地, 那么直接返回
if grid[r][c] == "0" or visited[r][c]:
return
# 访问当前陆地
visited[r][c] = True
# 访问相邻陆地
self.dfs(visited, grid, r-1, c) # 上
self.dfs(visited, grid, r+1, c) # 下
self.dfs(visited, grid, r, c-1) # 左
self.dfs(visited, grid, r, c+1) # 右
铺垫结束, 下面开始干上面的四个题目了。
-
LeetCode200: 岛屿数量: 有了上面的DFS网格遍历框架,这个题目就比较简单了, 思路就是遍历网格内的每一块, 如果发现是陆地且没有被访问过它, 那么就dfs这块陆地,得到一块岛屿,岛屿数量加1,这样最后就能统计出岛屿的数量来了。
-
LeetCode463: 岛屿周长: 这个题目可以用上面的这个代码框架解决,这里要明确个问题,就是岛屿的周长计算方式:岛屿的周长是计算岛屿全部的「边缘」,而这些边缘就是我们在 DFS 遍历中,dfs函数返回的位置
- 当我们的 dfs 函数因为「坐标
(r, c)
超出网格范围」返回的时候,实际上就经过了一条黄色的边; - 而当函数因为「当前格子是海洋格子」返回的时1候,实际上就经过了一条蓝色的边。
所以上面的代码拿过来,修改三个地方就可以啦,框架式思维的强大之处:
当然,这个方法并不是解决这个题的最快方式,因为该岛屿是封闭的,所以每一条上边必定对应一条下边, 每一条左边必定对应一条右边。 因此只需要关注上边和左边即可。而上面和左边的条件是:- 遍历到的块本身得是陆地
- 在上面的条件下, 它上面是grid的边界或者是水, 或者它左边是grid的边界或者是水
所以代码可以是这样:
def islandPerimeter(self, grid: List[List[int]]) -> int: # 记录row和cols rows = len(grid) if rows == 0: return 0 cols = len(grid[0]) circum = 0 # 遍历一遍 for i in range(rows): for j in range(cols): # 如果遇到了陆地 land++ if grid[i][j] == 1: # 上面的判断: 边界或者他上面是水 if i == 0 or grid[i-1][j] == 0: circum += 2 # 左边判断: 边界或者左边是水 if j == 0 or grid[i][j-1] == 0: circum += 2 return circum
拓展思路可以,但通用性不强。
- 当我们的 dfs 函数因为「坐标
-
LeetCode695: 岛屿的最大面积: 这个题目依然是网格化的dfs遍历框架, 对于每一块岛屿,要返回面积了,然后更新最大面积即可。
先看个变形题目。 -
剑指offer13: 机器人的运动范围: 这个题目和上面这个求面积的非常像,这里机器人也是上下左右移动,求的是从(0,0)点出发可以达到的最大格子数,其实就是求从(0,0)出发,围成的区域的最大面积,所以上面的代码稍微改一下就可以搞定。 首先,起止点只有(0,0),所以主函数里面的格子双重遍历不用。其次是这里的可行范围,加了一个数位坐标之和不能大于给定的k,这个需要判断。
-
LeetCode827: 最大人工岛: 这个题目要分为两步:
-
对格子中的每个块进行遍历, 用dfs算法找到所有的岛屿, 并在dfs的同时, 对每个格子用岛屿的索引下标进行标记, 且还需要计算每个岛屿的面积, 建立索引 --> 面积的映射, 这个用上面的dfs框架就可以搞定
-
对格子中的每个块再进行遍历一遍, 对于每个块,尝试填充, 然后计算可以获得的最大面积, 返回最大的那个。 这里面涉及到了计算获得的最大面积, 这个的思路也很简单,对于当前的海洋块,看他的上下左右, 是否是陆地, 如果是的话, 把陆地的面积加入到set集合里面, 最后算出填上它之后的大陆总面积。
这个代码看起来挺多,其实理解起来并不是多么困难。主要是填海的这个思路比较巧妙。
-
再进一步, DFS+回溯的经典题目还有,要和上面的岛屿问题进行对比。
- LeetCode79: 单词搜索: 这个题对应剑指offer的剑指offer12: 矩阵中的路径, 是在一个二维的表格中,看有没有一条字符组成的路径正好组成某个给定的单词序列。 这个题的解题思路需要网格化的DFS+回溯的思路。 网格化的DFS是在某个字符出发,上下左右去查相邻字符,而回溯是为了之前的字符重用。 因为这个题目里面字符的顺序不一致,并且可以从任意位置出发,这就需要保证字符的重用性,也就是换个起始点之后,要保证所有单词依然是都没有使用过才行。 这个和岛屿那个不同。 那个找完了一块岛屿之后,就不用管了,直接找下一块即可。所以那里面并没有回溯。 整理思路依然是要有一个visited数组标记是否已经访问,然后遍历每个格子,看看以当前格子的字符为起始字符最终能不能找到给定单词。 dfs里面的逻辑就是如果当前位置不合法,返回,如果到了word的最后一个字符,看看是否相等。如果当前不匹配返回。 遍历所有可能,然后回溯。
2.2 BFS系列
BFS这一块的大部分经典题目都在二叉树那里总结了,那些都是常考的在二叉树上BFS遍历的题目, 这里整理三道不太一样的广度优先遍历题目。
- LeetCode433: 最小基因变化:给定一个初始串,一个终止串,起始串的每个位置只有四种变化的可能,且针对于一次可能,比如位于基因库, 问能够变化到终止串的最小步数。 这其实是个典型的广度优先遍历。 思路就是从头开始, 每个元素位置从
["A", "G", "C", "T"]
四个里面变化, 每次变化一个字符, 然后去基于库里面找是否有, 如果有的话, 把该元素从基因库里面删除, 把这个新的序列加入到队列当中, 步数加1.当出队的字符串等于了终止字符串, 返回结果即可。
这个题目可能一开始没有往BFS上想,其实DFS+回溯也可以搞定,但感觉那种不太好想。这个题目其实也可以画个图看看的。现在的问题是这个图怎么画出来呢? 就是上面的遍历位置,然后不断尝试了。只要有了这个图,BFS就搞定, 如果这个体会不明显,再看看下面这个就深刻了。
-
LeetCode127: 单词接龙: 这个和上面类似的题目,依然是BFS遍历。只不过这里的尝试可能性从4到了26。所以拿上面的代码稍微改下就能A掉这个题目。
如何构造图是本题的关键。我们把每个单词看成图中的顶点, 如果两个顶点之间相差一个字母, 这时候就可以连一条线构成边,表示这两个节点之间构成一条路,即题目中的改变一个字母就能进行转换。 这时候我们就可以得到像下面这样的无向图:
再往上一层,就是这两个题的BFS遍历的特点:对于当前的每个节点,我们是无法直接获取到其相邻节点的。这个不像普通的bfs遍历,是直接能够找到相邻节点的(比如二叉树的root.left, root.right
,N叉树的root.children
),而这里的这两个题目, 是需要通过遍历的方式去找相邻节点的,也就是一开始我们并不知道当前节点的相邻节点是啥,只知道相邻节点存在于某些可能里面。我们只有遍历这很多种可能才能找到相邻节点,进而套用我们的BFS框架。
所以这里,我又基于前面泛化的那个BFS框架进行了一些补充,形成自己的一个BFS框架:
from collections import deque
def bfs(graph, start, end):
# 定义队列和标记变量
d =deque([start])
visited = set(start)
step = 1
while d:
# 记录当前圈能遍历的个数
cur_size = len(d)
for _ in range(cur_size):
node = d.popleft()
# 处理当前层的逻辑
process(node)
# 寻找与当前节点的相邻节点,也就是下一圈的所有节点,然后入队
# 这里不同的题目就不太一样了 一般也是进行搜索
next_nodes = generate_related_nodes(node) # 这里面要避免重复访问 visited控制
d.append(next_nodes)
visited.add(next_nodes)
当然,这里还有种更为强大的思路叫做双向的BFS搜索。已知目标顶点的情况下,可以分别从起点和目标顶点(终点)执行广度优先遍历,直到遍历的部分有交集。
就拿单词接龙这个题来说,可以从两边进行BFS, 当然这个的实现过程也是交替进行,每次从单词量小的集合进行扩散。 由于双向BFS,这里的数据结构就不能用向队列了,而是换成了两个集合beginVisited和endVisited。当然那个标记是否访问的visited还在,每次扩散也要加入到这个里面去。下面就看下这个代码,里面加了详细的注释:
单向BFS和双向BFS的感觉:
下面再看看单词接龙升级版本,单词接龙II,这个需要在上一个版本的基础上改一些地方。这个题后面的题解中有DFS+BFS的结合思路,但这里我不整理这个了,代码量太大,到时候不一定能写出来,还是看个新的方法。
-
LeetCode126: 单词接龙II: 这里依然是上面的代码框架,但是需要改些地方, 首先,这里的队列里面,存放的并不是某个节点, 而是直接某条路径。 这样的话,在遍历的同时,就能把路径都给存储下来。下面是整体的一个思路:
- 在单词接龙的基础上,需要将找到的最短路径存储下来
- 之前的队列只用来存储每层的元素,那么现在就能存储每层添加元素之后的结果:比如起始点"ab", 终止点"if", 词典
{"cd", "af", "ib", "if"}
,则队列里面的元素是下面这种了:- 第一层:{“ab”}
- 第二层:{“ab”, “af”}, {“ab”, “ib”}
- 第三层: {“ab”, “af”, “if”}, {“ab”, “ib”, “if”}
- 如果该层添加的某个单词符合目标单词,则该路径为最短路径,该层为最短路径所在的层,但此时不能直接返回结果,必须将该层遍历完,将该层符合的结果都添加进结果集
- 每层添加单词的时候,不能直接添加到总的已访问单词集合中,需要每层有一个单独的该层访问的单词集,该层结束之后,再汇合到总的已访问的单词集合中,原因就是因为3。
所以在BFS的时候,不像单词接龙I那样, 如果找到了某个元素rex
的下一层的节点tex
,就把tex
放到visited里面就完事, 这里还涉及到了当前层队列中的其他元素ted
和tex
也可能有联系。 这种路径也需要考虑进去。 所以需要在寻找下一层节点的时候, 要单独的设置一个level层级的visited, 只标记某一层的节点是否被重复访问过。当遍历完当前层的所有节点之后, 再把这个层级的visited合并到总的visited里面去。
梳理完了之后,就是写代码了:
这个要比BFS+DFS的那种慢很多,不是慢在时间复杂度上,而是慢在保存结构的时候, 列表的复制操作上,这个在保存结果的时候,path[:]
是需要将列表进行复制的。 BFS+DFS的思路是在这个的基础上,不是入队列表,然后是入队单词, 但是这个会用BFS把下一层的节点保存到邻接表里,然后再用DFS进行遍历,得到结果。这个好处是可以用双向的BFS,这个就能达到最优解了。但是不想记录了,多了到时候记不住。虽然不是死记硬背,但这种双向BFS的思路,解决这种题目有很多细节很容易忘,尤其是输出所有最短路径这个, 和计算步长还不是一个难度水平,这个为了能够输出最短路径,得记录前驱后继节点,双向BFS这里还两边交替着来,前驱后继还得用标记记录下,所以细节太多了这里,即使弄明白思路,到时候真不能保证A掉,dfs到时候写的写不出来也是问题。所以先框架式思维搞定题目,在这个基础上,尝试优化一点点就好。 有没有发现, 这三个题目我都用了一个招式来写的哈哈? 只不过难度层层递进了些罢了 😉
最后这里还要补充一个图的DFS框架, 因为发现DFS和BFS这里思想都一样,但是不同的数据结构和场景, 写法还是不太一样的, 在图里面, DFS常常用来解决从起始点到终止点的路径情况。
def dfs(begin, end, successors, path, res):
# 递归结束
if begin == end:
res.append(path[:])
return
if begin not in successors: return
# 找到起始点的所有后继节点
successors_nodes = successors[begin]
for next_node in successors_nodes:
path.append(next_node)
dfs(next_node, end, successors, path, res)
path.pop()
其实就是个回溯了。
补充一个迷宫问题的题目:
给定一个
n*m
的二维整数数组,用来表示一个迷宫,数组中只包含 0 或 1,其中0表示可以走的路,1表示不可通过的墙壁。
最初,有一个人位于 start 处,已知该人每次可以向上、下、左、右任意一个方向移动一个位置。
请问,该人从 start 移动至 end 处,至少需要移动多少次。
数据保证 start 处和 end 处的数字为0,且至少存在一条通路。
输入格式
第一行包含两个整数n和m。
接下来n行,每行包含m个整数(0或1),表示完整的二维数组迷宫。
输出格式
输出一个整数,表示从左上角移动至右下角的最少移动次数。
数据范围1≤n,m≤1001≤n,m≤100
输入样例:
5 5
0 1 0 0 0
0 1 0 1 0
0 0 0 0 0
0 1 1 1 0
0 0 0 1 0
输出样例:8
由于这个题目是要求最少移动次数,所以这是一个最短路径的题目。而和上面图里面求最短路径的又有点不太一样, 这个是网格上的一种BFS了,思路是这样:
- 起始点坐标加入队列,设置距离为0
- 当队列不是空的时候, 出队一个节点,去他的上下左右(必须是可访问), 把相应节点加入队列,距离+1
- 如果到达终点,返回0
代码如下:
from collections import deque
def solve(arr, start, end, visited):
""""BFS求最短路径,这里的start是起始坐标,end是终止坐标,(x,y)的形式"""
queue = deque([])
queue.append((start[0], start[1], 0))
visited[start[0]][start[1]] = True
dx = [-1, 0, 1, 0]
dy = [0, -1, 0, 1]
while queue:
x, y, dis = queue.popleft()
# 如果遇到终点了,返回
if x == end[0] and y == end[1]:
return dis
# 遍历上下左右四个点
for i in range(4):
nx = x + dx[i]
ny = y + dy[i]
if 0 <= nx < len(arr) and 0 <= ny < len(arr[0]) and arr[nx][ny] == 0 and not visited[nx][ny]:
queue.append((nx, ny, dis+1))
visited[nx][ny] = True
if __name__ == '__main__':
m, n = input().split(' ')
m, n = int(m), int(n)
arr = []
for i in range(m):
arr.append(list(map(int, input().split(' '))))
# 起始点和终止点 这个代码可以到任意终点
start, end = (0, 0), (m-1, n-1)
visited = [[False] * n for _ in range(m)]
res = solve(arr, start, end, visited)
print(res)
题目要求最短路径,为什么代码中没有 m i n min min的体现呢?
答:因为广度优先搜索其实已经有寻找到最短路径的功能了,可以理解为总部同时派出多路兵马并行搜索(各路兵马在分岔口又再分出多路并进),并且总部发出命令时,每一路兵马都只前进一步,那么哪一路兵马先到达目的地,则该路线肯定是最近的。
3. 小总
dfs和bfs这边的题目变式也挺固定了,有三个非常重要的框架需要会默写, 上面的题目进行汇总如下, 方便后面的复习想思路用,分类整理如下:
dfs这里主要是网格类的dfs,复习了四个岛屿的相关问题:
- LeetCode200: 岛屿数量 ⭐️⭐️⭐️⭐️
- LeetCode463: 岛屿周长
- LeetCode695: 岛屿的最大面积
- 剑指offer13: 机器人的运动范围
- LeetCode827: 最大人工岛
- LeetCode79: 单词搜索
bfs这里复习最短路径为主的经典题目:
DFS和BFS就简单复习这些题目,下一个专题是二分查找,这里有两个代码框架,记好了就能解题, 也快速的过一遍,今天听师兄师姐们说面试重点考动态规划专题,而这个我还一遍也没刷,而是先把前面的专题过了两遍,感觉有点跑偏了,所以过完二分查找之后,集中突击一遍动态规划,然后直接去面试了,这个怎么算也来不及重温一遍了,只能希望遗忘的速度不会那么快了哈哈。 除了动态规划,前面简单的数据结构题目(链表,队列,栈,字符串)和排序这两块, 会在学习动态规划的同时顺便穿插进来, 这样就整体上摸了一遍常考的题目。 只要整体上摸完一遍,后面反复刷这些就完事了哈哈。 继续Rush 😉