前言
对回溯的一类题型做一个系统的总结
回溯算法有这么几种大类题型摘录自卡尔老师的代码随想录,本人准备蓝桥杯主要就是刷的老师博客上的题目,忠实粉丝嘿嘿。
而我要总结的就是前四种类别,因为我认为其中有许多的共同之处。
一、回顾回溯的基本思想以及操作步骤
回溯算法实际上就是一种暴力搜索,但是能尽力的进行剪枝操作减小时间复杂度。回溯法解决的问题都可以抽象为树形结构。例如1,2,3,4的所有组合就可这样抽象:
回溯三部曲:
1.回溯函数模板返回值以及参数
2.回溯函数终止条件
3.回溯搜索的遍历过程以及回溯操作
二、组合类:
1.基本组合
例题如下:
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
先看代码
res=[]
path=[]
def backtrack(n,k,index):
if(len(path)==k):
res.append(path[:])
return
for i in range(index,n-(k-len(path))+2):
path.append(i)
backtrack(n,k,i+1)
path.pop()
backtrack(n,k,1)
return res
此题很容易,根据三部曲,由于只要规定长度的子串,所以终止条件显然是
if(len(path)==k):
res.append(path[:])
return
而对于函数参数显然要传递数组,字串长度以及遍历起点。
当回到上一层递归时就需要进行回溯操作:把上一次填进的数取出。
而这里最重要的应该是在遍历时end的剪枝操作,因为如果后面的如果满足不了要求的字串长度那么就不需要再往下搜索直接返回。
例如需要长度为3的字串但是1,2,3,4,5中如果已经遍历到了4,但是path中却只有一个数那还有必要往下遍历吗?
2.组合总和
示例:
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。
示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。
先丢代码
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
path=[]
paths=[]
def backtack(candidates,target,sum_,index):
if sum_==target:
paths.append(path[:])
return
if sum_> target:
return
for i in range(index,len(candidates)):
sum_+=candidates[i]
path.append(candidates[i])
backtack(candidates,target,sum_,i)
path.pop()
sum_-=candidates[i]
backtack(candidates,target,0,0)
return paths
为什么说这些题都类似,我完全可以根据上一个题改一改,这一题就结束了。
首先:看题目,终止条件不一样了,那就改一改改成当数组和等于需求时返回,以及当和大于了需求时也没必要继续搜索了。那第一步更改就完成了
再看:每个数字可以无限制使用,那改一下递归的起始点不就行了吗?把起始点i+1改成i那就可以一直用了。
最后就是看回溯操作就是多了个将path的和减去这一层加进去就行了。
3.组合总和2:
示例:
- 组合总和 II
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用 一次 。
注意:解集不能包含重复的组合。
示例 1:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
依旧如此,只需要根据第一种情况烧烧烧烧加修改就完事儿了,做的时候开始我是这么想的,结果当然是没过了呜呜呜,看代码:
class Solution:
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
path=[]
pahs=[]
def back(candidates,target,index,sum_):
if sum_==target:
pahs.append(path[:])
return
for i in range(index,len(candidates)):
if i>index and candidates[i]==candidates[i-1]:
continue
if sum_ + candidates[i] > target:
return
path.append(candidates[i])
sum_+=candidates[i]
back(candidates,target,i+1,sum_)
path.pop()
sum_-=candidates[i]
candidates.sort()
back(candidates,target,0,0)
return pahs
因为有一点就是答案中不能有重复的组合,那么问题就来了,即便是在递归的时候起始点设置为下一个数仍然是会存在重复的答案,那么我们就需要进行先对数组排序再进行去重操作:
if i>index and candidates[i]==candidates[i-1]:
continue
这去重操作感觉还是比较常用的,就是说
当这一次取得数跟上一次相同时并且该数不是第一次遍历的数就可以直接跳过了,因为是排了顺序的,所以前后两个数如果相等,想一想得到的答案肯定也是一样的。
当然这题我也可以在最后的答案里面去重.比如:
if sum_==target:
if path not in pahs:
pahs.append(path[:])
return
如果这样,复杂度就嘎嘎增高了老铁,直接超时了呜呜。
还就是这一题我跟上一题有一个写的差别,if sum_ + candidates[i] > target: return
我将这一步操作放在了上一层的递归中,这也是我那一次学到的,能降低复杂度。
三:分割类:
例题:
有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。
例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "192.168@1.1" 是 无效 IP 地址。
给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 '.' 来形成。你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。
示例 1:
输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]
此题在我看来依旧是如出一辙:
老样子先看终止条件,看到这一题的终止条件比较多,所以我选择了封装一个函数判断是否符合要求:
def is_ok(start,end,s):
if start > end:
return False
if s[start] == '0' and start != end:
return False
if not 0 <= int(s[start:end+1]) <= 255:
return False
return True
也是比较好理解的就是题目所说的那些要求。
而回溯的递归函数参数也是比较容易的,比较麻烦的应该是函数中遍历里面怎样对数组进行操作以及怎样进行回溯,因为其实这题不适合建立一个新数组去装答案。
答案:
for i in range(index,len(s)):
if is_ok(index,i,s):
s=s[:i+1] + "." + s[i+1:]
backtack(s,i+2,point_num+1)
s=s[:i+1]+s[i+2:]
答案非常容易理解,基本是一看就会呀,但是感觉还是不太容易想到
四、子集问题:
经典子集:
示例:
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
代码如下:
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
path=[]
paths=[]
def back(nums,startindex):
paths.append(path[:])
if startindex == len(nums):
return
for i in range(startindex,len(nums)):
path.append(nums[i])
back(nums,i+1)
path.pop()
back(nums,0)
return paths
感觉不用多说了,都比较简单了。三部曲都可以很轻松的实现
子集2:
示例:
给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
示例 1:
输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]
此题与上一题比较来讲不同之处在于
数组中含有重复元素。很简单只需要在遍历里的操作里面加我之前所说到的一个去重的操作即可
if i>startindex and nums[i]==nums[i-1]:
continue
五、全排列问题:
不含重复数字
示例:
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
代码如下:
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
path=[]
paths=[]
def back(nums):
if path not in paths and len(path)==len(nums):
paths.append(path[:])
return
for i in range(0,len(nums)):
if nums[i] in path:
continue
path.append(nums[i])
back(nums)
path.pop()
back(nums)
return paths
全排列的话跟前面几种类型其实还是存在较大的差别的,递归函数中传入的参数并不需要遍历起始点,而是每一次都从第一个开始搜索,这也是我第一次做把我困扰了的一个问题。
由于数组不含重复数字,所以我们可以以此操作避免取到同一个数字
if nums[i] in path:
continue
含重复数字
这一个区别就在于有重复数字,所以在遍历中的操作还需要一个去重操作
代码如下:
class Solution:
def permuteUnique(self, nums: List[int]) -> List[List[int]]:
if not nums: return []
res = []
used = [0] * len(nums)
def backtracking(nums, used, path):
if len(path) == len(nums):
res.append(path.copy())
return
for i in range(len(nums)):
if not used[i]:
if i>0 and nums[i] == nums[i-1] and not used[i-1]:
continue
used[i] = 1
path.append(nums[i])
backtracking(nums, used, path)
path.pop()
used[i] = 0
backtracking(sorted(nums),used,[])
return res
六、总结
在这些题型中都有共通点:
如果能重复取数字,那么递归时起始点仍然是i,
如果数组中有重复数字,结果子集又不能重复,那么就需要去重,基本都是通用的那一个去重操作
总之此类题都跑不出回溯三部曲的紧箍咒,只要理清思路什么时候终止,抓住细节,怎么进行函数遍历,怎么剪枝,怎么进行遍历中的操作,回溯就通关了啦。。