广度遍历相关题目以及解法

    技术2025-04-13  15

    1:再深入理解BFS之前还是首先先理解BFS到底描述的是什么。BFS是一种搜索算法,主要的就是按照广度范围进行横向搜索,所以我们必须能精确的识别出来合适可以使用BFS。一定要记住它是一种搜索算法,凡是涉及到搜索都可以使用BFS,我们知道BFS的一般模板就是queue,核心的就是里面的规则,即不断扩充queue的那条规则,换句话说就是从一个节点纵深扩展新节点的规则,这一点一定要发现且是非常重要的。同时为了防止进入死循环,有环的"图"一定要设定visited的set,记录每次访问过的节点,防止进入死循环。所以对于BFS一般的套路就是:模板+扩充queue的规则+visited同时要知道BFS可以前向设计算法,也可以前向求解;可以逆向设计算法,可以逆向求解。这一点要注意。

    2:说完了BFS不得不提的就是DP,DP的本质就是一种BFS,DP需要保存前面存储过的值,这个BFS的visited的作用是一致的,所以DP本质上就是特殊的BFS。同时要知道DP是前向设计算法,前向求解算法,后面的状态依赖于前面的状态,从这点上看也能发现DP是BFS的特例。

    3:说到了BFS不得不提是DFS,DFS一般而言我们很少对于有重复的数据去使用,因为DFS是对于重复遍历数据区使用的话往往时间复杂度是O(2**n),因为再递归里面很难保存visited的set区记录访问过的节点,虽然python有装饰器,但是还是不太方便,所以我们一般而言对于节点能多次去处理的一般不用DFS。但是对于每一个节点只遍历一次的我们BFS和DFS都可以,就像二叉树的各种处理。对于有些节点需要多次遍历的我们一般都要去存储该节点是否访问过以及需要保存的值,考虑这一点的话一般是BFS,当然如果能保存的话DFS也行。要明白自己说的这种情况是什么意思,比如给你一个n,需要找到最少的平方和的个数,比如n=12,那么平方和有4+4+4=12或者9+1+1+1=12,显然结论是前面的3个4,这种就是从1,4,9,16...去寻找和为n的最小平方个数,如果采用全局从后往前遍历的话,里面每一个节点可能要遍历多次,采用DFS遍历的话这条路遍历过4这个节点,下一条路可能走过4这个节点,这就是不断的浪费,所以这就是不用DFS原因。但是如果此时采用BFS,我们记录并且保存由根节点到4的值,那么此时再便利到4的时候直接取其结果岂不是美滋滋,但是DFS由于一般而言要使用递归,即使你保存,也不是保存的最好的值,这就是不适用DFS的原因。本质上还是利用了BFS的每一层最短路的特性。所以对于节点需要重复遍历的题目不适用DFS,使用BFS的最短路特性。同时记住DFS获取答案有再递归条件截至的地方获取、还有是利用返回值获取,所以本质上DFS就是逆向设算法,可以你想求解答案(再递归截至处),也可以正向利用返回值获取答案。所以就是逆向设计,逆向或者正向求解,记住这个和BFS以及DP的区别

    总之一句话:无重复节点遍历BFS,DFS都可以看哪种方便;有重复节点遍历,使用BFS保存记录,使用BFS最短路特性(逆向求解),有些也可以利用DFS记录节点,不能记录的就用BFS记录,当然正向求解的话也可能使用DP。

    所以我们发现对于DFS的题目:列出全部的全排列无重复遍历节点的树的相关题目,这种居多,因为树就是left,right存储,DFS方便一些还有就是可以记录节点是否访问过的例如岛屿数量

    BFS类题目的话一般主要是以下几类:

    1:二叉树的BFS,什么层次输出,锯齿形状输出,不适用数组每一层翻转(而是每一层使用一个collection.deque()存储,appendleft和append使用),还有什么从下层到上层输出,那就是二叉树层次遍历的递归版本,递归版本就是每一层获取这个节点list传入下一层以便于扩展下一层的节点,从下往上输出的话那就最后再ds完后再print,即递归后在输出,从上往下的话就是先print再递归,仅此区别而已。

    2:基于图的题目的变形(图的存储数据结构就是:邻接矩阵、邻接表;遍历方式就是BFS(一般的和拓扑排序),DFS,DP;辅助方式度)。如有向图中检测是否有环(使用图的二种数据结构),拓扑排序、无向树的最小高度节点(本质上是无向图的拓扑排序)等题目。这里面比较重要的就是拓扑排序、图的初始化即creat_graph(邻接矩阵以及邻接表,有向图无向图的区别均可),度_list的构造(有向图无向图的区别),详细的区别下面会介绍到。

    3:一般性质的BFS。岛屿数量、矩阵填充。

    第一类题目:关于二叉树的题目已经非常熟练了,说实话没有必要再介绍了。核心就是记忆BFS的模板在二叉树层次遍历以及层次递归遍历的模板,这两点记住了,上述那几个题目就相对比较简单了,这里就不在介绍了。

    第二类题目:关于BFS在图方面的使用。这里主要讲两个题目:一个是课程排序安排,一个是关于最小高度树。

    在讲述这个题目之前我们首先介绍一下关于有向图的寻找环的方法。有向图寻找环有2中方法,一种就是对于每一个节点采用DFS,使用这种方法首先需要定义节点的一个状态,每一个节点态都有3种状态,[0, 1, 2],如果值等于0,表示当前节点还未遍历,如果值等于1,表示当前节点在当前这条路上遍历过了,在遇到1就表示有环,如果当前这个节点的状态等于-1,表示这个节点在以前遍历其它节点的路中遍历过,那么后续节点就不用遍历了,直接continue即可换句话说:你在当前路中不可能遇到其它路中的节点状态是1,遇到1的节点只能是当前路中,因为如果前面没环,那么这个状态为1的节点就变成了-1,所以在其它路中不会遇到状态为1的节点,切记这一点。所以遍历每一个节点有一个for循环,遍历每一个节点的邻接节点也有一个for循环,这个循环在函数dfs内部。按照以上的3种状态判断就好了。另外的一种就是采用拓扑排序,所谓的拓扑排序就是一个图的一种排序方式,我们利用的是获取拓扑排序的思路,即去节点法,看链接:拓扑排序吗,所以利用拓扑排序的核心就是度列表,有向图是每一个节点的入度列表,无向图是边数量链表。

    至于数据结构的使用也是两种,一种是邻接矩阵,一种是邻接表,邻接矩阵我们需要在使用的时候去判断位置是不是1,是1才是当前点的邻接点,不是1就不是邻接点,跳过,所以邻接点的确定需要不断的搜索去获取,又因为邻接矩阵里面其实0的位置很多,1其实相对稀疏,所以这个搜索是比较费时间的。然而邻接表就不是这样了,每一个节点的邻接点已经找好了,直接存储起来了,所以检索就比较快,所以速度是邻接矩阵的好多倍。所以一般而然使用的是邻接表。

    课程的安排,是力扣的207,210号题目,这里给出 2种数据结构的2种解法,总共4种解法。 # 依据邻接表、拓扑排序判定有向图的环是否存在 import collections class Solution: def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool: if not prerequisites: return True du_list = [0 for _ in range(numCourses)] linjie_table = [[] for _ in range(numCourses)] # 构建图,即构建邻接表 for start, end in prerequisites: linjie_table[start].append(end) # 初始化入度 for i in range(numCourses): for end in linjie_table[i]: du_list[end] += 1 # 获取最开始0度节点 queue = collections.deque() for i in range(numCourses): if du_list[i] == 0: queue.append(i) # 不断循环获取度为空的节点 while queue: i = queue.popleft() for j in linjie_table[i]: du_list[j] -= 1 if du_list[j] == 0: queue.append(j) return True if sum(du_list) == 0 else False # 依据邻接矩阵、拓扑排序判定有向图的环是否存在 import collections class Solution: def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool: if not prerequisites: return True du_list = [0 for _ in range(numCourses)] linjie_juzhen = [[0 for _ in range(numCourses)] for _ in range(numCourses)] # 构建图,即构建邻接矩阵 for start, end in prerequisites: linjie_juzhen[start][end] = 1 # 初始化入度 每一列的和就是当前列索引节点的入度,行和就是出度 for i in range(numCourses): for j in range(numCourses): if linjie_juzhen[j][i] == 1: du_list[i] += 1 # 获取最开始0度节点 queue = collections.deque() for i in range(numCourses): if du_list[i] == 0: queue.append(i) # 不断循环获取度为空的节点 while queue: i = queue.popleft() for j in range(numCourses): if linjie_juzhen[i][j] == 1: du_list[j] -= 1 if du_list[j] == 0: queue.append(j) return True if sum(du_list) == 0 else False # 通过对比发现基于邻接表的时间明显比邻接矩阵的时间降低的多 # 依据邻接矩阵、拓扑排序判定有向图的环是否存在 import collections class Solution: def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool: if not prerequisites: return True state_list = [0 for _ in range(numCourses)] linjie_juzhen = [[0 for _ in range(numCourses)] for _ in range(numCourses)] # 构建图,即构建邻接矩阵 for start, end in prerequisites: linjie_juzhen[start][end] = 1 # DFS深度遍历节点 self.huan = True def dfs(start): # 循环遍历以start节点为开始节点的 state_list[start] = 1 for j in range(numCourses): if linjie_juzhen[start][j] == 1: if state_list[j] == 0: # 等于0表示未遍历过 dfs(j) elif state_list[j] == 1: # 等于1表示再当前这条路遍历过了,即有环 self.huan = False return else: # 等于-1表示当前节点后面的节点在以前的其他路都遍历过了,就没必要继续便利了 continue # start节点后面的节点都遍历过了,则start标记为全部遍历过了 state_list[start] = -1 # 循环处理每一个start for i in range(numCourses): dfs(i) if self.huan == False: return False return True # 依据邻接表、DFS判定有向图的环是否存在 import collections class Solution: def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool: if not prerequisites: return True state_list = [0 for _ in range(numCourses)] linjie_table = [[] for _ in range(numCourses)] # 构建图,即构建邻接表 for start, end in prerequisites: linjie_table[start].append(end) # DFS深度遍历节点 self.huan = True def dfs(start): # 循环遍历以start节点为开始节点的 state_list[start] = 1 for j in linjie_table[start]: if state_list[j] == 0: # 等于0表示未遍历过 dfs(j) elif state_list[j] == 1: # 等于1表示再当前这条路遍历过了,即有环 self.huan = False return else: # 等于-1表示当前节点后面的节点在以前的其他路都遍历过了,就没必要继续便利了 continue # start节点后面的节点都遍历过了,则start标记为全部遍历过了 state_list[start] = -1 # 循环处理每一个start for i in range(numCourses): dfs(i) if self.huan == False: return False return True 明显发现采用邻接表快很多,就是因为没有for那个矩阵的搜多过程(去发现相连的边),所以二者时间的 差距主要就是因为矩阵还还需要手动去发现相连的边,而表是已经将有效的边存储起来了,这就是时间的差距。 class Solution: def numSquares(self, n: int) -> int: # 下面的是dfs,可以实现,但是时间复杂度太高了 # 该方法本质就是暴力枚举法,一般的dfs的使用我们在前面也看到了 """ 一般是列出全部的序列使用;同时对于矩阵的dfs一般是由于节点遍历过可以及时的结束,在前面的岛屿类问题里面, 所以直接使用dfs的情况不是很多。但是这类题目是一种很经典的题目,我们看一下到底如何去处理。 """ # self.result = float('inf') # def dfs(n, much, result): # if n == 0: # self.result = min(self.result, result) # return # if n < 0: # return # for index in range(much, 0, -1): # dfs(n - index ** 2, index, result + 1) # dfs(n, int(n ** 0.5), 0) # return self.result """ 暴力法其实本质pow(2, n),那么下一步其实就是一般采用dp降低时间复杂度,降成幂函数级别。 一维dp+循环的本质就是bfs,所以对于dfs不能直接发现bfs的我们可以转化为dp去使用bfs。 """ # dp[i] = min(numSquares(i-k))+1, 其中 k <= i and k 属于平方数 # 先初始化squares列表 squares_list = [i**2 for i in range(1, int(n**0.5)+1)] dp = [0 if i == 0 else float('inf') for i in range(n+1)] for i in range(1, n+1): for sq in squares_list: if sq > i: break dp[i] = min(dp[i], dp[i-sq]+1) return dp[-1]

     

    无向图的拓扑排序 力扣 310,可以看到一般方法:就是 for + bfs 超时间;优化版本就是无向图拓扑排序的不断去层法。 import collections class Solution: def findMinHeightTrees(self, n: int, edges: List[List[int]]) -> List[int]: # # 一看就是图的BFS,可以采用邻接表存储 # # 由于是统计树,凡是涉及到树的相关内容就是没有环的,即树是无换图,所以不用考虑环 # # 构建邻接表 # linjie_table = [[]for _ in range(n)] # for start, end in edges: # linjie_table[start].append(end) # linjie_table[end].append(start) # dic = {} # # 广度遍历 # def bfs(start): # queue = collections.deque() # queue.append(start) # visited = set() # visited.add(start) # level = 0 # while queue: # lenth = len(queue) # for _ in range(lenth): # node = queue.popleft() # for end in linjie_table[node]: # if end not in visited: # queue.append(end) # visited.add(end) # level += 1 # if level in dic: # dic[level].append(start) # else: # dic[level] = [start] # # 遍历所有节点 # for i in range(n): # bfs(i) # # 返回结果 # return dic[min(dic.keys())] # 运行发现上述代码超时间 """ 开始写了一个解法,犯了一个错误,就是第一次剥离最外层的节点之后露出来的节点不一定是下一层需要剥离的节点, 每次去掉最后一层都要重新去统计新的出度是1的节点,这一点要注意。 所以本质上就是无向图的拓扑排序。 方法就是从du_list里面找到du==1的节点,然后从节点list里面删除,同时和这些du等于1节点邻接的点的度-1,这就是 剥离了最外面那一层,你想么,剥离最外面那一层就是找度等于1的节点,去掉这届节点本身,然后与这些节点相连的节点 的度减1,三步走。 所以数据结构就是一个节点的set,一个du_list,一个邻接表。还是这三个数据结构。 这里面需要注意的一个点就是无向图的拓扑排序和有向图的拓扑排序些许有点不同。有向图因为有方向,一般而言统计的 是每一个节点的入度,而无向图没有方向,所以统计就不涉及入度还是出度了,统计的是和每一个节点连接的边数。可以 发现我们能确定的就是边界节点,因为无向图里面的特点就是每一个节点至少一条边,如果用边的数目来表示度的话,那么 无向图的度至少就是1,所以我们每次通过操作度为1的节点就能不断的操作无向图的边界节点,有向图而言入度就可能为0 了,但是无向图而言入度就是0,至少为1。 """ # 通过分析发现只要从外面一圈一圈往里面删除,最后剩下的节点不是1个就是2个,仔细考虑就是这样的。 # 所以关键的问题就是如何找到只有一条边的节点不断的放入队列去删除,有点拓扑排序无向图的意思。 if not edges: return [i for i in range(n)] # 构建邻接表 linjie_table = [[] for _ in range(n)] for start, end in edges: linjie_table[start].append(end) linjie_table[end].append(start) # 初始化一个度列表 du_list = [0 for _ in range(n)] for i in range(n): du_list[i] = len(linjie_table[i]) # 初始化一个visited的list,便于求解最后的未处理的节点 visited = set(range(n)) queue = collections.deque() # 循环处理圈外每一层 while True: if len(visited) in [1, 2]: return list(visited) for i in range(n): if du_list[i] == 1: du_list[i] -= 1 queue.append(i) visited.remove(i) while queue: start = queue.popleft() end_list = linjie_table[start] for end in end_list: du_list[end] -= 1

     

    第三类题目就是BFS应用

    力扣常见题目是:130, 200 一类题目,就是BFS+visited,套模板就ok了。注意bfs里面的queue的扩充,以及dfs的输入点的扩充。127 是套模板里面相对比较棘手的一个题目。单词接龙主要是核心利用的是图的Bfs遍历方式得到的结果就是最短路,因为图的BFS遍历是按照层状进行遍历,水波方法进行遍历的,所以得到的是最短路。套模板的同时,非常关键的一个点就是如何扩充queue。那就是找和start差一个字母且没有遍历过的节点,改变start的每一个字母去查找是否在list里面且没有出现过,这是耗时最短的,比一个个和list对比时间快多了,因为字母是26个,常数级别的。这一点要注意,这就扩充了queue,然后剩下的就是模板:queue+visited+规则,完事。133的克隆图能告诉我们queue里面append不仅仅是一个元素,可以append一个tuple,这一点要注意。再就是高告诉我们这个【】里面存在死循环依赖的处理办法,那就是利用python的属性,list里面存储的是引用,只要a这个list在后面变了,那么将a存起来的list也就跟着变了,即里面就自动变了,利用的就是python存储引用的办法。279, 这个题目需要好好说一下,因为这个题目是给你一个[i**2 for i in range(1, n)]的一个平方数组,然后求最小的和,看起来一个list直观上就是一个倒序查找的dfs的过程,但是这样时间复杂度太高了,因为存在重复遍历的节点,所以这类型题目就让你往dfs里面本质上是暴力法或者枚举法里面跳坑,所以此时千万别上当,此时需要冷静思考发现可以用dp,那就是找到递推式即可,初始化dp[0]=0,然后就是循环处理,dp[1].........dp[n],其中dp[n]=min(dp[n-1],dp[n-4],dp[n-9]....dp[n-小于n的大平方数])+ 1。这就结束了。然后基于这种思想使用BFS逆推,从n,分别求出每一层,n-1, n-4, n-9...,目标就是找到0,即最先找到0的就是最小的,因为层次就是遍历么,queue的扩充方法就是:当前值p-[1 -------小于当前值的最大平方数],依据这个区扩充。最核心学到的就是能把DFS的横着看过来转化为BFS的去处理。 import collections class Solution: def numSquares(self, n: int) -> int: # 下面的是dfs,可以实现,但是时间复杂度太高了 # 该方法本质就是暴力枚举法,一般的dfs的使用我们在前面也看到了 """ 一般是列出全部的序列使用;同时对于矩阵的dfs一般是由于节点遍历过可以及时的结束,在前面的岛屿类问题里面, 所以直接使用dfs的情况不是很多。但是这类题目是一种很经典的题目,我们看一下到底如何去处理。 """ # dic = set() # self.result = float('inf') # def dfs(n, much, result): # if n == 0: # self.result = min(self.result, result) # return # if n < 0: # return # for index in range(much, 0, -1): # dfs(n - index ** 2, index, result + 1) # dfs(n, int(n ** 0.5), 0) # return self.result """ 暴力法其实本质pow(2, n),那么下一步其实就是一般采用dp降低时间复杂度,降成幂函数级别。 一维dp+循环的本质就是bfs,所以对于dfs不能直接发现bfs的我们可以转化为dp去使用bfs。 """ # dp[i] = min(numSquares(i-k))+1, 其中 k <= i and k 属于平方数 # 先初始化squares列表 # squares_list = [i**2 for i in range(1, int(n**0.5)+1)] # dp = [0 if i == 0 else float('inf') for i in range(n+1)] # for i in range(1, n+1): # for sq in squares_list: # if sq > i: # break # else: # dp[i] = min(dp[i], dp[i-sq]) # dp[i] += 1 # return dp[-1] # 由上述方法需要会使用有DFS全路搜索转化为dp的直接问题的BFS,以后这一点一定要注意。 # BFS算法 # 1:append的一个tuple,不断的暗藏答案的递归,这点开阔思维 2:注意规则就是扩充的做法 # 3:记录访问过的节点是n-sq**2,而不是sq**2本身。 # queue = collections.deque() # queue.append((n, 0)) # visited = set() # visited.add(n) # while queue: # cur, cur_step = queue.popleft() # squares_list = [cur-i**2 for i in range(int(cur**.5)+1)] # for sq in squares_list: # if sq == 0: # return cur_step + 1 # if sq not in visited: # queue.append((sq, cur_step+1)) # visited.add(sq) # return 0

    这就是BFS的几类题目,DFS一般树那块题目巨多,还有就是一般能用BFS的DFS也能上来耍耍,这一点要注意。总之最重要的还是本篇博客最开始的那一段语言记录比较有利于理解,至于题目本身列出来是为了加深理解,反而没有那么重要。

     

     

     

     

     

     

     

     

    Processed: 0.011, SQL: 9