优良笔记链接分享:
《谷歌高畅Leetcode刷题笔记》
《BAT霜神Leetcode刷题笔记》
《labuladong的算法小抄官方完整版》
链接:https://pan.baidu.com/s/1Y-PmNM-OIkRCwKztdaeSfg
提取码:i6qu
不要再点那么多乱七八糟的公众号,每天一堆广告、推送、同行吹捧不烦嘛?
题目和图片来源
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems
著作权归领扣网络所有。
什么是回溯法?
学这个东西,首先要有dfs的基础,明白凡是涉及回溯法的各种情况本质就是一个树。
回溯法是优先搜索的一种特殊情况,又称为试探法,常用于需要记录节点状态的深度优先搜索。通常来说,排列、组合、选择类问题使用回溯法比较方便。
回溯法的核心是回溯。在搜索到某一节点的时候,如果我们发现目前的节点(及其子节点)并不是需求目标时,我们回退到原来的节点继续搜索,并且把在目前节点修改的状态还原。
好处:
- 始终只对图的总状态进行修改,而非每次遍历时新建一个图来储存状态。在具体的写法上,它在普通的深度优先搜索基础上的修改的步骤为[修改当前节点状态]→[递归子节点]→[回改当前节点状态]。
缺点:
- 如果对那些遍历搜素“撞车”情况非常多,时间复杂度会很高。
46. 全排列
解答:
class Solution(object):
def permute(self, nums):
# 这题我们使用回溯法去处理,要想排列,首先要有树的概念,如果是三个数的话,那么我们就有三层的计算,两个数的话就二层
def back(nums,level):
# 如果我搜索到最深层:
if level==len(nums)-1:
# 这里我用python的话,需要写成nums[:]的形式,这样才可令当前nums的值被改变后的结果可以正常输出
ans.append(nums[:])
# print(id(nums[:]),id(nums))
# return list(nums)
# 如果没到最后一层就一直搜索,怎么表示排列呢,由于只用排列而不是组合,那么通过每次的数的交换就可以模拟
for i in range(level,len(nums)):
nums[i],nums[level]=nums[level],nums[i]
# 接着往下一层搜索就可以了
back(nums,level+1)
# 回溯
nums[i],nums[level]=nums[level],nums[i]
# answer是要求二维的注意
ans=[]
back(nums,0)
return ans
python语言的踩坑点
nums与nums[:]的区别
- nums[:] ,因为它有[:],是复制的意思, 传值调用,修改的是仅仅是堆中的内容,用id()函数显示的话,一开始指针还会指向nums的原地址(子对象与原对象相同),后面我要更改我的排序值,id(nums[:])显示的地址就会发生变换。
- nums表示的是数组,所以是引用的意思,传址调用,所以在leetcode中由于它都把nums的地址给固定了,我要想在树的叶节点处把结果添加到我的结果数组中。如果我在这题的输入为[1,0]的话,最终结果应该为[[1,0],[1,0]]。而不是答案的[[1,0],[0,1]]
77.组合
建议观看B站代码随想录的视频,讲的很好:
https://www.bilibili.com/video/BV1ti4y1L7cv#reply3733925949
以下的不同写法,也只是通过不同的方式来表示回溯这个情况,并不是真正意义上的各种解法。
这题跟排列类似,排列回溯的是交换的位置,而组合回溯的是否把当前的数字加入结果中。
写法一:
这里通过栈的入栈和出栈来表示回溯。
class Solution(object):
def combine(self, n, k):
def back_comb(n,k,startIndex):
if len(path)==k:
result.append(path[:])
return
for i in range(startIndex,n - (k - len(path)) + 1 +1):
path.append(i)
back_comb(n,k,i+1)# 注意这里不是startIndex+1,是i+1
path.pop()
path=[]
result=[]
back_comb(n,k,1)
return result
写法二:
其实这题除了写法一可以通过栈来表示回溯,其实还可以通过下标来表示。
class Solution(object):
def combine(self, n, k):
def back_comb(n,k,count,startIndex):
if count==k:
result.append(path[:])
return
for i in range(startIndex,n - (k - len(path)) + 1):
path[count]=i
count+=1
# 注意这里不是startIndex+1,是i+1
# 用来表示在不再遍历之前的已经出现过的节点
# 也就不会出现[4,3]类似这种情况了
back_comb(n,k,count,i+1)
# 回溯
count-=1
path=[0 for _ in range(k)]
result=[]
count=0
back_comb(n,k,count,1)
return result
写法三:
回溯法的模板
来源:https://leetcode-cn.com/problems/combinations/solution/mo-ban-tao-yong-by-suneng-hhfb/
res = []
def backtrack(未探索区域, res, path):
if 满足条件:
res.append(path)
# return # 如果不用继续搜索需要 return
for 选择 in 未探索区域当前可能的选择:
if 当前选择符合要求:
backtrack(未探索区域, res, path+已探索的区域)
怎么表示回溯?
如果是在python可以通过切片表示,同时也可以做到要求需要的单调递增。
class Solution(object):
def combine(self, n, k):
def back_comb(nums,result,path):
if len(path)==k:
result.append(path[:])
return
# 组合需要的剩余的字符数要大于还未探索的字符数,所以不用写到range(len(nums))
for i in range(len(nums)-(k-len(path))+1):
#根据题意,只需寻找比nums[i]大的数组合即可,nums单调递增,nums[i+1:]为未探索的区域
back_comb(nums[i+1:],result,path+[nums[i]])
result=[]
nums=[i for i in range(1,n+1)]
path=[]
back_comb(nums,result,path)
return result
79.单词搜索
这题其实可以说跟leetcode的417题类似,我们都是四个方向不停寻找,可是跟417题不同的是,在417题你要是真用了回溯法还不剪枝,时间一定会超时,不过思路是一样的,我把它全部变换成一个二维bool型,来表示是否查找过。
为什么这题可以用回溯法,是瞎猜的?
在我搜索的过程中,是为了改回当前位置为未访问,防止干扰其它位置搜索到当前位置。
现在以代码随想录的总结,开始递归三问题:
参数是什么,返回的结果形式是什么?
- 我这个字符串是否真能从二维数组中搜索出来,返回一个布尔型
结束条件是什么?
- 越界、当前的字母不匹配我在二维数组中的字母、
- 我已经搜索过了这个二维布尔的为True、这个最终结果的字符串确实能够被匹配出来
递归的形式是什么?
- 就如题目一样,上下左右搜索,同时更新是否要查找字符串下一个字母
解答:
class Solution(object):
def exist(self, board, word):
def back_word(i,j,pos,visited,word,board,find):
# 越界
if i<0 or i>=len(board) or j<0 or j>=len(board[0]):
return
# 查过没
if visited[i][j] or self.find or board[i][j]!=word[pos]:
# print(id(find))
return
# 找到结果的话
if len(word)-1==pos:
# 注意python这里就这样修改是没有意义的,它又不是C++那样可以引用
self.find=True
# print(id(find))
return find
visited[i][j]=True
# 递归的形式如下
back_word(i+1,j,pos+1,visited,word,board,find)
back_word(i-1,j,pos+1,visited,word,board,find)
back_word(i,j+1,pos+1,visited,word,board,find)
back_word(i,j-1,pos+1,visited,word,board,find)
# 回溯
visited[i][j]=False
n,m=len(board),len(board[0])
# 表示是否匹配字符串的结果
self.find=False
# print(id(find))
# 创建一个二维布尔型
visited=[[False for j in range(m)]for i in range(n)]
for i in range(n):
for j in range(m):
back_word(i,j,0,visited,word,board,find)
return self.find
Python语言踩坑点2:
递归调用无法返回特定参数
我在第79题中要返回的是我是否确实找到了在这个二维数组中匹配我的字符串,可是尽管代码的思路一开始是对的,我发现最终结果返回值都是为Flase,后面我使用id()函数,发现除了返回结果为True的情况,其余的find 的地址都是一致的,那么可以很明显知道这是跟上面一样的类似问题,都是不可变对象,由于python不区分传值还是传址,这题也没法加个[:]就能解决,后面我发现可以通过self实例我find对象,就能解决这个问题,更加深刻的概念和含义情况在以下的博客链接:
全面理解python中self的用法
看完相关概念后,也就明白,其实根本问题是:
因为我每次的调用都不是指向对象的实例,而只是指向find这个不可变对象,尽管后面返回结果确实是因为改为True后,地址也发生了改变,可是最终结果是不可能被dfs函数外的find对象引用的。
51.N皇后
解题开始:
判断条件
左对角线或右对角线不能有有相邻的皇后,行与列不能有皇后,否则就跳过这一次的分析判断,而不是返回结束不寻找一行中的下一列
结束条件
行到达最底层就结束
参数
行
返回形式
字符串数组画出皇后应该要排哪个位置
这题为了不影响搜索结果,所以要回溯
难点:怎么表示左对角线或右对角线确实有皇后?
目前我所知有二种:
-
一种就是通过二维数组描述的方式来描述皇后的进攻方向,不过这样复杂度太高了,不可行,可以稍微改进一下,做一个暂时存放的一维布尔数组,那么皇后的进攻位置又应该怎么表示?
- 有一个数学公式恰好能够表示:
- 总行数-当前行+当前列-1=左对角线数组的下标
- 当前行+当前列+1=右对角线数组的下标
-
一种是对角线的距离来表示
写法一:
class Solution(object):
def solveNQueens(self, n):
def back_track(row,n,result,board,column,ldiag,rdiag):
# 如果已经到最下面的一行
if row==n:
#--------下面之所以这样写是因为字符串类型为不可变的
r=[]
for _ in range(n):
a=''.join(board[_])
r.append(a)
result.append(r[:])
print(result)
return
# 在每行搜索,并记得回溯
for i in range(n):
if column[i] or ldiag[n-row+i-1] or rdiag[row+i+1]:
continue
board[row][i]='Q'
column[i]=ldiag[n-row+i-1]=rdiag[row+i+1]=True
back_track(row+1,n,result,board,column,ldiag,rdiag)
board[row][i]='.'
column[i]=ldiag[n-row+i-1]=rdiag[row+i+1]=False
result=[]
board=[['.' for j in range(n)]for i in range(n)]
# 判断当前列、当前对角线是否有皇后,先初始化
column,ldiag,rdiag=[False]*n,[False]*(2*n),[False]*(2*n)
# 调用函数
back_track(0,n,result,board,column,ldiag,rdiag)
return result
但是由于我为了解决string类型为不可变的情况,我多次改变了数组空间,这无疑拖慢了不少时间,现在要开始改进。
写法二:
class Solution(object):
def solveNQueens(self, n):
def back_track(row,board):
# 如果已经到最下面的一行
if row==n:
result.append(board)
return
# 在每行搜索,并记得回溯
# 每次都弄一个n列的初始行,后面再改就行了,
# 也不用当下字符串不可变这种情况
cur_row = ['.']*n
for i in range(n):
if column[i] or ldiag[n-row+i-1] or rdiag[row+i+1]:
continue
cur_row[i]='Q'
column[i]=ldiag[n-row+i-1]=rdiag[row+i+1]=True
back_track(row+1,board+[''.join(cur_row)])
cur_row[i]='.'
column[i]=ldiag[n-row+i-1]=rdiag[row+i+1]=False
result=[]
# 判断当前列、当前对角线是否有皇后,先初始化
column,ldiag,rdiag=[False]*n,[False]*(2*n),[False]*(2*n)
# 调用函数
back_track(0,[])
return result
什么是bfs,他与dfs的区别
广度优先搜索(breadth-first search,BFS)不同于深度优先搜索,它是一层层进行遍历的,因此需要用先入先出的队列而非先入后出的栈进行遍历。
深度优先搜索和广度优先搜索都可以处理可达性问题,即从一个节点开始是否
能达到另一个节点。在《谷歌高畅Leetcode刷题笔记》中,作者就表示用栈实现的深度优先搜索和用队列实现的广度优先搜索在写法上并没有太大差异,因此使用哪一种搜索方式需要根据实际的功能需求来判断。
而我们本人在上一期的[Leetcode实战]回溯法中的第79题是三种写法里就尝试过使用栈操作的dfs,可以发现它的效率相较于指针与Python的切片操作是很高的,也不用担心栈溢出的问题。
那么现在下面开始正题。
934.最短的桥
在给定的二维二进制数组 A 中,存在两座岛。(岛是由四面相连的 1 形成的一个最大组。)
现在,我们可以将 0 变为 1,以使两座岛连接起来,变成一座岛。
返回必须翻转的 0 的最小数目。(可以保证答案至少是 1 。)
示例 :
输入:A = [[1,1,1,1,1],[1,0,0,0,1],[1,0,1,0,1],[1,0,0,0,1],[1,1,1,1,1]]
输出:1
解答思路
dfs+bfs
根据题目的意思,其实可以知道通过dfs搜索其中一座岛屿,在搜索完第一座岛屿的基础上,我们在它的外围(即没有被搜索过的位置上)通过bfs搜索直到我们的第二座岛屿,这期间搜索的层数就算我需要的建桥数。
class Solution(object):
def shortestBridge(self, grid):
# 初始化
m,n=len(grid),len(grid[0])
poins=collections.deque()
# 本题实际上是求两个岛屿间的最短距离,
# 因此我们可以先通过任意搜索方法找到其中一个岛屿,
def dfs(i,j):
if i<0 or j<0 or i>=m or j>=n or grid[i][j]==0:
return
#标记为已访问,并且标记为0,其实标记成什么都可以。
grid[i][j]=0
poins.append((i,j))
# print(poins)
# 四个方向搜索
# dfs(i-1,j);dfs(i+1,j);dfs(i,j-1);dfs(i,j+1)
for (r,c) in [(i+1,j),(i-1,j),(i,j+1),(i,j-1)]:
dfs(r,c)
# dfs寻找第一个岛屿。
self.flipped=False
for i in range(m):
for j in range(n):
# 如果是陆地的话,那么还没有搜索,没有翻转,则dfs搜索第一座岛屿
if grid[i][j]==1 and not self.flipped:
dfs(i,j)
self.flipped=True
# 然后利用广度优先搜索,查找其与另一个岛屿的最短距离。
# bfs寻找第二个岛屿
seen=set(poins)# 这里这样写,纯粹只是因为deque不能被正常识别
self.level=0
while poins:# 有些人会写成!queue.empty(),但是在python的deque函数没必要多此一举
n_point=len(poins)
#遍历
for k in range(n_point):
i,j=poins.popleft()# 相当于C++queue的front函数,python是没有front函数的
# 在第一个岛屿的外围的四个方向搜索
for (x,y) in [(i+1,j),(i-1,j),(i,j+1),(i,j-1)]:
# 没有越界,并且能够保证我现在还没搜索除第一座岛的位置
if x>=0 and y>=0 and x<m and y<m and (x,y) not in seen:
# 如果此刻的位置为0,那么说明该处有可能就是要建桥的位置,
# 先记录下表示已经搜索过了
if grid[x][y]==0:
poins.append((x,y))
seen.add((x,y))
# 如果此处为1,说明就是已经搜索到第2座岛的位置
else:
return self.level
# 建桥数量
self.level+=1
return level
课后练习题
130. 被围绕的区域
解题思路
这题的思路很像417题的pacific-atlantic-water-flow,我们没有办法通过回溯法来一个一个判断O是否被包围,它相连的位置又是谁,但是我们可以从查找边界开始,以边界的O为起点一个一个的查,这样也能说明直接相连,可以被标记出来
解法一:DFS
class Solution(object):
def solve(self, board):
# 初始化
n,m=len(board),len(board[0])
# 先用dfs试一下
def dfs(i,j):
# 不能越界,也不能为O,否则就结束查找
if i<0 or i>=n or j<0 or j>=m or board[i][j]!='O':
return
# 现在我们开始标记那些边界或与之相连的为S
board[i][j]='S'
# 四个方向搜索
for (r,c) in [(i+1,j),(i-1,j),(i,j+1),(i,j-1)]:
dfs(r,c)
# 这题就不用像417题一样需要考虑水往高处流的问题了,也就不需要创建两个洋流向的一个二维的判断矩阵了
# 矩阵的左右边界
for i in range(n):
dfs(i,0)
dfs(i,m-1)
# 矩阵的上下边界
for i in range(m):
dfs(0,i)
dfs(n-1,i)
# 现在就开始判断
for i in range(n):
for j in range(m):
# 还原之前标记过的S点
if board[i][j]=='S':
board[i][j]='O'
# 如果没有被标记,则说明被包围,那么就需要改为X
elif board[i][j]=='O':
board[i][j]='X'
return board
257. 二叉树的所有路径
dfs
就直接搜索就是了,没什么复杂的内容
# Definition for a binary tree node.
# class TreeNode(object):
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution(object):
def binaryTreePaths(self, root):
def dfs(root,path):
if root:
path+=str(root.val)
if not root.left and not root.right:#当前节点没有左右节点,就是叶子节点
# 就代表加入到结尾里了
paths.append(path)
else:# 只要有任一下面可以遍历的节点,就继续深度搜索下去
path+='->'
dfs(root.left,path)
dfs(root.right,path)
paths=[]
dfs(root,'')
return paths
47. 全排列 II
写法一
在回溯法排序的基础上加个去重,不过我这个去重的效率有点低,还是说因为用了回溯法,接下来优化。
class Solution(object):
def permuteUnique(self, nums):
def back_track(level):
if level==len(nums)-1:
ans.append(nums[:])
return
for i in range(level,len(nums)):
nums[i],nums[level]=nums[level],nums[i]
back_track(level+1)
nums[i],nums[level]=nums[level],nums[i]
ans=[]
back_track(0)
b=[]
for i in ans:
if i not in b:
b.append(i)
return b
问题的原因是因为,重复的元素越多,剪枝就越多。
写法二
这一次就在搜索里面剪枝,判断条件应该是当i大于0的时候,前面搜索到的数组和后面搜索到的数组不应该相同,不过首先要把nums给排一下顺序才行,不然无法做到前后判断。
同时我也可以利用切片做到改变顺序,没必要每次用都用交换。
class Solution(object):
def permuteUnique(self, nums):
def back_track(nums,path):
if len(path)==n:
ans.append(path)
for i in range(len(nums)):# 这里不明白为什么用n就越界了?
if i>0 and nums[i]==nums[i-1]:# 这里实现剪枝
continue
back_track(nums[:i]+nums[i+1:],path+[nums[i]])
ans=[]
n=len(nums)
nums.sort()
back_track(nums,[])
return ans
40. 组合总和 II
class Solution(object):
def combinationSum2(self, candidates, target):
# 这题是77题组合题的进阶,我们怎么处理回溯法?
# 即我们该如何剪枝?达到处理重复数组的目标:通过一个累减的值residue来表示
#重复的值就不需要判断,直接加到数组中,累减到一定程度后发现没减到0,说明没必要减下去了,行不通。
def back_track(startIndex,residue):
if residue==0:
result.append(path[:])
return
# 千万不要像我下面这样写处理重复值,一定会超时的,老实想清楚剪枝的条件
# if sum(path)==target and path not in result:
for i in range(startIndex,len(candidates)):
# 如果我在考虑不断累减的情况下,后面突然
# 出现一个大于我残余值的数,就没必要算了
if candidates[i]>residue:
break
# 剪枝,被组合值如果是重复出现就没必要每次都去判断,浪费时间
if i>startIndex and candidates[i]==candidates[i-1]:
continue
path.append(candidates[i])
back_track(i+1,residue-candidates[i])
# 回溯
path.pop()
# 先排序
candidates.sort()
path=[]
result=[]
back_track(0,target)
return result
踩坑点:
老实想明白哪些是可以剪枝,不要填完所有结果再判断重复值。
参考内容
https://blog.youkuaiyun.com/Sunshine_Java_L/article/details/79123972
https://blog.youkuaiyun.com/qq_42053453/article/details/105875813
https://blog.youkuaiyun.com/qq_43230540/article/details/84788151
刷题建议
少看答案多写题,多看答案多总结。