深度优先搜索、递归、栈
递归的思想
递归的基本思想是,把规模较大的一个问题,分解成规模较小的多个子问题去解决,而每一个子问题又可以继续拆分成多个更小的子问题。最重要的一点就是假设子问题已经解决了,现在要基于已经解决的子问题来解决当前问题;或者说,必须先解决子问题,再基于子问题来解决当前问题。
递归解决的是有依赖顺序关系的多个问题:假设一个抽象问题有两个时间点要素:开始处理,结束处理,那么递归处理的顺序就是,先开始处理的问题,最后才能结束处理。递归对问题的处理顺序,是遵循了先入后出(也就是先开始的问题最后结束)的规律。
内容源引自:理解递归的本质:递归与栈
深度优先搜索
深度优先搜索 算法(Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。这个算法会 尽可能深 的搜索树的分支。当结点 v 的所在边都己被探寻过,搜索将 回溯 到发现结点 v 的那条边的起始结点。这一过程一直进行到已发现从源结点可达的所有结点为止。如果还存在未被发现的结点,则选择其中一个作为源结点并重复以上过程,整个进程反复进行直到所有结点都被访问为止。
使用递归实现深度优先搜索
- 递归对问题的处理顺序,是遵循了先入后出(也就是先开始的问题最后结束)的规律。
def DFS(graph,visit,dfs,s):
dfs.append(s)
visit.add(s)
for node in graph[s]:
if node not in visit:
visit.add(node)
DFS(graph,visit,dfs,node)
graph={
"A":["B","C"],
"B":["A","C","D"],
"C":["A","B","D","E"],
"D":["B","C","E","F"],
"E":["C","D"],
"F":["D"]
}
visit=set()
dfs=[]
DFS(graph,visit,dfs,"A")
print(dfs)
使用栈实现深度优先搜索
- 栈:后进先出。
def DFS(graph,s):
stack=[]
stack.append(s)
visit=set()
dfs=[]
while len(stack)>0:
vertex=stack.pop()
dfs.append(vertex)
for node in graph[vertex]:
if node not in visit:
stack.append(node)
visit.add(node)
return dfs
graph={
"A":["B","C"],
"B":["A","C","D"],
"C":["A","B","D","E"],
"D":["B","C","E","F"],
"E":["C","D"],
"F":["D"]
}
dfs=DFS(graph,"A")
print(dfs)
回溯
回溯法 采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。
回溯法是一个既带有系统性又带有跳跃性的搜索算法:
- 系统性:在包含问题的所有解的解空间树中,按照深度优先的策略,从根结点出发搜索解空间树;
- 跳跃性:算法搜索至解空间树的任一结点时,判断该结点为根的子树是否包含问题的解,如果肯定不包含,则跳过以该结点为根的子树的搜索,逐层向其祖先点回溯,否则,进入该子树,继续深度优先的策略进行搜索。
算法框架
由于回溯法以深度优先的方式系统地搜索问题的解,通常用最简单的递归方法来实现。
两类常见的解空间树
- 子集树:当所给的问题是从 n n n 个元素的集合 S S S 中找出满足某种性质的子集时,相应的解空间树称为子集树。子集树通常有 2 n 2^n 2n 个叶子结点,其总结点个数为 2 n + 1 − 1 2^{n+1}-1 2n+1−1,遍历子集树的时间为 O ( 2 n ) O(2^n) O(2n)。
- 排列树:当所给问题是确定 n n n 个元素满足某种性质的排列时,相应的解空间树称为排列树。排列树通常有 n ! n! n! 个叶子结点,因此,遍历排列树需要 O ( n ! ) O(n!) O(n!) 的计算时间。
统一套路
Python编程注意事项
- 可变对象(比如字典或者列表)通过“传引用”来传递对象,因此在结算时要做一个拷贝,在回溯时要重置现场;
- 不可变对象(比如数字、字符或者元组)通过“传值’来传递对象,每次都用一个新的,因此无需状态重置。
LeetCode题目
46.全排列【中等】
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
思路1:
- 目标:选择 n = l e n ( n u m s ) n=len(nums) n=len(nums) 个元素,构成全排列。
- 函数 dfs(depth) 选择第 d e p t h depth depth 个元素,每个元素可选择的范围为:当前未选择的元素,使用数组 u s e d used used 标记。
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
def dfs(depth):
if depth == n:
res.append(path[:])
return
for i in range(n):
if not used[i]:
used[i] = True
path.append(nums[i])
dfs(depth + 1)
used[i] = False
path.pop()
n=len(nums)
if n == 0:
return []
used = [False for _ in range(n)]
res,path = [],[]
dfs(0)
return res
思路2:
- 传入当前可选数字,不需要标记数组。
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
res = []
# 第二个参数nums在变化,存储当前可选数字
def backtrack(nums):
if not nums:
res.append(path[:])
for i in range(len(nums)):
path.append(nums[i])
backtrack(nums[:i]+nums[i+1:])
path.pop()
path=[]
backtrack(nums)
return res
思路3:
将题目给定的
n
n
n 个数的数组
n
u
m
s
[
]
nums[]
nums[] 划分成左右两个部分,左边的表示已经填过的数,右边表示待填的数,在递归搜索的时候只要动态维护这个数组:假设已经填到第
f
i
r
s
t
first
first 个位置,那么
n
u
m
s
[
]
nums[]
nums[] 数组中
[
0
,
f
i
r
s
t
−
1
]
[0,first−1]
[0,first−1] 是已填过的数的集合
[
f
i
r
s
t
,
n
−
1
]
[first,n−1]
[first,n−1] 是待填的数的集合,尝试用
[
f
i
r
s
t
,
n
−
1
]
[first,n−1]
[first,n−1] 里的数去填第
f
i
r
s
t
first
first 个数,假设待填的数的下标为
i
i
i ,那么填完以后将第
i
i
i 个数和第
f
i
r
s
t
first
first 个数交换,即能使得在填第
f
i
r
s
t
+
1
first+1
first+1个数的时候
n
u
m
s
[
]
nums[]
nums[] 数组的
[
0
,
f
i
r
s
t
]
[0,first]
[0,first] 部分为已填过的数,
[
f
i
r
s
t
+
1
,
n
−
1
]
[first+1,n−1]
[first+1,n−1] 为待填的数,回溯的时候交换回来即能完成撤销操作。
代码:
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
def backtrack(first = 0):
if first == n:
res.append(nums[:])
for i in range(first, n):
nums[first], nums[i] = nums[i], nums[first]
backtrack(first + 1)
nums[first], nums[i] = nums[i], nums[first]
n = len(nums)
res = []
backtrack()
return res
47.全排列2【中等】
LeetCode传送门
给定一个可包含重复数字的序列,返回所有不重复的全排列。
思路:
在搜索之前就对候选数组排序,一旦发现某个分支搜索下去可能搜索到重复的元素就停止搜索,这样结果集中不会包含重复列表。
- 在图中 ② 处,搜索的数也和上一次一样,但是上一次的 1 还在使用中,不需要剪枝;
- 在图中 ① 处,搜索的数也和上一次一样,但是上一次的 1 刚刚被撤销,正是因为刚被撤销,下面的搜索中还会使用到,因此会产生重复,剪掉的就应该是这样的分支。
代码:
class Solution:
def permuteUnique(self, nums: List[int]) -> List[List[int]]:
def dfs(depth):
if depth == n:
res.append(path[:])
return
for i in range(n):
if not used[i]:
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]: # 剪枝
continue
used[i] = True
path.append(nums[i])
dfs(depth + 1)
used[i] = False
path.pop()
n = len(nums)
if n == 0:
return []
nums.sort()
used = [False] * n
res,path = [],[]
dfs(0)
return res
39.组合总和【中等】
LeetCode传送门
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复被选取。
说明:
- 所有数字(包括 target)都是正整数。
- 解集不能包含重复的组合。
思路:
已经组合的列表为 combine,定义递归函数 dfs(target, idx) 表示还剩 target 要组合,当前在确定选不选 candidates 数组的第 idx 位。
- 递归的终止条件为 target <= 0 或者 candidates 数组被全部用完。
- 在当前的函数中,每次我们可以选择跳过不用第 idx 个数,即执行 dfs(target, idx + 1),也可以选择使用第 idx 个数,即执行 dfs(target - candidates[idx], idx),注意到每个数字可以被无限制重复选取,因此搜索的下标仍为 idx。
代码:
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
def dfs(target,idx):
if idx==len(candidates):
return
if target==0:
ans.append(combine[:])
return
dfs(target,idx+1)
if target-candidates[idx]>=0:
combine.append(candidates[idx])
dfs(target-candidates[idx],idx)
combine.pop()
ans,combine=[],[]
dfs(target,0)
return ans
40.组合总和2【中等】
LeetCode传送门
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
说明:
- 所有数字(包括目标数)都是正整数。
- 解集不能包含重复的组合。
思路:
题目要求解集不能包含重复的组合,考虑将相同的数放在一起进行处理,也就是说,如果数
x
x
x 出现了
y
y
y 次,那么在递归时一次性地处理它们,即分别调用选择
0
,
1
,
⋯
,
y
0, 1, \cdots, y
0,1,⋯,y 次
x
x
x 的递归函数,这样我们就不会得到重复的组合。具体地:
- 统计数组 c a n d i d a t e s candidates candidates 中每个数出现的次数,在统计完成之后,我们将结果放入一个列表 f r e q freq freq 中:列表 f r e q freq freq 的长度即为数组 c a n d i d a t e s candidates candidates 中不同数的个数,其中的每一项对应着某个数以及它出现的次数。
- 在递归时,对于当前的第
p
o
s
pos
pos 个数,它的值为
f
r
e
q
[
p
o
s
]
[
0
]
freq[pos][0]
freq[pos][0],出现的次数为
f
r
e
q
[
p
o
s
]
[
1
]
freq[pos][1]
freq[pos][1],那么我们可以调用
d f s ( p o s + 1 , r e s t − i × f r e q [ p o s ] [ 0 ] ) dfs(pos+1,rest-i\times freq[pos][0]) dfs(pos+1,rest−i×freq[pos][0])
即我们选择了这个数 i i i 次,这里 i i i 不能大于这个数出现的次数,并且 i × f r e q [ p o s ] [ 0 ] i \times freq[pos][0] i×freq[pos][0] 也不能大于 r e s t rest rest。同时,我们需要将 i i i 个 f r e q [ p o s ] [ 0 ] freq[pos][0] freq[pos][0] 放入列表中。
优化(剪枝):将 f r e q freq freq 根据数从小到大排序,这样在递归时会先选择小的数,再选择大的数。这样做的好处是,当递归到 d f s ( p o s , r e s t ) dfs(pos,rest) dfs(pos,rest) 时,如果 f r e q [ p o s ] [ 0 ] freq[pos][0] freq[pos][0] 已经大于 r e s t rest rest,那么后面还没有递归到的数也都大于 r e s t rest rest,这就说明不可能再选择若干个和为 r e s t rest rest 的数放入列表了,此时就可以直接回溯。
代码:
class Solution:
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
def dfs(pos,rest):
nonlocal sequence
if rest==0:
ans.append(sequence[:])
return
if pos==len(freq) or rest<freq[pos][0]:
return
dfs(pos+1,rest)
most=min(rest//freq[pos][0],freq[pos][1])
for i in range(1,most+1):
sequence.append(freq[pos][0])
dfs(pos+1,rest-i*freq[pos][0])
sequence=sequence[:-most]
freq=sorted(collections.Counter(candidates).items())
ans=list()
sequence=list()
dfs(0,target)
return ans
216.组合总和3【中等】
LeetCode传送门
找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
说明:
- 所有数字都是正整数。
- 解集不能包含重复的组合。
思路:
- 尝试做减法,减到 0 就说明可能找到了一个符合题意的组合,但是题目对组合里元素的个数有限制,因此还需要对元素个数做判断;
- 如果减到负数,没有必要继续搜索下去;
- 由于结果集里的元素互不相同,因此下一层搜索的起点应该是上一层搜索的起点值 + 1;
根据画出的递归树设计递归方法的参数:
- 剩下的数的和是多少。初始时传入 n。
- 还需要搜索多少个数。每递归一层,需要找到的个数就减1,到0的时候搜索结束。
- 搜索的起点:最小时1,最大是9.
- path:从根结点到叶子结点的路径。
代码:
class Solution:
def combinationSum3(self, k: int, n: int) -> List[List[int]]:
def dfs(n,k,start):
if n==0 and k==0:
ans.append(path[:])
return
for i in range(start,10):
if n-i<0 or k<=0:
return
path.append(i)
dfs(n-i,k-1,i+1)
path.pop()
ans,path=[],[]
dfs(n,k,1)
return ans
78.子集【中等】
LeetCode传送门
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。说明:解集不能包含重复的子集。
思路1:
定义递归函数 dfs(i) ,对于 n u m s nums nums 中的第 i 个元素,都有放入集合和不放入集合两个选择:
- 选择不放入该元素,即执行 dfs(i+1),由于没有放入,不需要状态重置;
- 选择放入该元素,当前子集中加入 n u m s [ i ] nums[i] nums[i]后,执行 dfs(i+1),之后进行状态重置。
代码:
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
def dfs(i):
if i==n:
ans.append(subset[:])
return
dfs(i+1)
subset.append(nums[i])
dfs(i+1)
subset.pop()
n=len(nums)
subset,ans=[],[]
dfs(0)
return ans
思路2:
子集中最多可以放 n n n 位,定义递归函数dfs(i) ,
- 已经考虑了前 i-1 位的放置情况;
- i i i 为可选元素的起始下标,可从 n u m s [ i : ] nums[i:] nums[i:]中任意选择一个。
代码:
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
def dfs(i):
ans.append(tmp[:])
for j in range(i, n):
tmp.append(nums[j])
dfs(j + 1)
tmp.pop()
ans,tmp = [],[]
n = len(nums)
dfs(0)
return ans
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
def helper(i, tmp):
ans.append(tmp)
for j in range(i, n):
helper(j + 1,tmp + [nums[j]] )
ans = []
n = len(nums)
helper(0, [])
return ans
90.子集2【中等】
LeetCode传送门
给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
思路:
- 基本思路和【78.子集】一样;
- 去重:类似于【47.全排列2】,使用 used[i] 数组标记当前子集 subset 是否使用元素 nums[i]。
代码:
class Solution:
def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
def dfs(i):
if i==n:
ans.append(subset[:])
return
dfs(i+1)
if i>0 and nums[i]==nums[i-1] and not used[i-1]: # 剪枝
return
used[i]=True
subset.append(nums[i])
dfs(i+1)
used[i]=False
subset.pop()
n=len(nums)
subset,ans=[],[]
nums.sort()
used=[False]*n
dfs(0)
return ans
class Solution:
def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
def helper(i):
res.append(tmp[:])
for j in range(i, n):
if j > i and nums[j] == nums[j-1]:
continue
tmp.append(nums[j])
helper(j+1)
tmp.pop()
res,tmp = [],[]
n = len(nums)
nums.sort()
helper(0)
return res
class Solution:
def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
def helper(i, tmp):
res.append(tmp)
for j in range(i, n):
if j > i and nums[j] == nums[j-1]:
continue
helper(j+1, tmp + [nums[j]])
res = []
n = len(nums)
nums.sort()
helper(0, [])
return res
22.括号生成【中等】
LeetCode传送门
数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
示例:
- 输入:n = 3
- 输出:[
“((()))”,
“(()())”,
“(())()”,
“()(())”,
“()()()”
]
思路:
- 当左右括号使用次数都小于 n 个时,可以产生分支;
- 产生左分支时,只看当前左括号的使用次数是否小于n;
- 产生右分支时,还受左分支的限制,左括号出现次数严格大于有括号出现次数时才可以产生分支;
- 在左括号、有括号使用次数都达到 n 时结算。
代码:
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
def dfs(cur_str, left, right):
if left == n and right == n:
res.append(cur_str)
return
if left < n:
dfs(cur_str + '(', left + 1, right)
if right<left:
dfs(cur_str + ')', left, right + 1)
res = []
cur_str = ''
dfs(cur_str, 0, 0)
return res
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
ans = []
def backtrack(S, left, right):
if len(S) == 2 * n:
ans.append(''.join(S))
return
if left < n:
S.append('(')
backtrack(S, left+1, right)
S.pop()
if right < left:
S.append(')')
backtrack(S, left, right+1)
S.pop()
backtrack([], 0, 0)
return ans
51.N皇后【困难】
LeetCode传送门
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击:任何两个皇后都不能处于同一条横行、纵行或斜线上。下图为 8 皇后问题的一种解法。
给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。
每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
思路:
4皇后递归树:
对每一行,尝试在某一列放置皇后。
为了判断一个位置所在的列和两条斜线上是否已经有皇后,使用三个集合
c
o
l
col
col、
d
g
dg
dg 和
u
d
g
udg
udg 分别记录每一列以及两个方向的每条斜线上是否有皇后:
- c o l col col:表示第 i i i 列有无皇后;
- 从左上到右下方向的斜线 u d g udg udg:同一条斜线上的每个位置满足行下标与列下标之差相等,即 k − i k-i k−i相同,但 − n + 1 ≤ k − i ≤ n − 1 -n+1\leq k-i\leq n-1 −n+1≤k−i≤n−1,加上 n n n 之后有 1 ≤ n − k + i ≤ 2 n − 1 1\leq n-k+i\leq 2n-1 1≤n−k+i≤2n−1;
- 从右上到左下方向的斜线 d g dg dg:同一条斜线上的每个位置满足行下标与列下标之和相等,即 k + i k+i k+i相同,且 0 ≤ k + i ≤ 2 n − 2 0\leq k+i\leq 2n-2 0≤k+i≤2n−2。
代码:
class Solution:
def solveNQueens(self, n: int) -> List[List[str]]:
def dfs(k):
board=[]
if k==n:
for i in range(n):
board.append(''.join(location[i]))
ans.append(board)
return
# 对于第k行,尝试在第i列放置皇后
for i in range(n):
if not col[i] and not dg[k+i] and not udg[n-k+i]:
location[k][i]='Q'
col[i]=dg[k+i]=udg[n-k+i]=True
dfs(k+1)
location[k][i]='.'
col[i]=dg[k+i]=udg[n-k+i]=False
ans=[]
location=[['.']*n for i in range(n)]
col,dg,udg=[False]*2*n,[False]*2*n,[False]*2*n
dfs(0)
return ans
源引文章
LeetCode题解
理解回溯算法的深度优先搜索策略
深度优先搜索、回溯概念
回溯模板