回溯法专题

回溯法

回溯法(backtracking)是优先搜索的一种特殊情况,又称为试探法,常用于需要记录节点状
态的深度优先搜索。通常来说,排列、组合、选择类问题使用回溯法比较方便。

顾名思义,回溯法的核心是回溯。在搜索到某一节点的时候,如果我们发现目前的节点(及
其子节点)并不是需求目标时,我们回退到原来的节点继续搜索,并且把在目前节点修改的状态
还原。这样的好处是我们可以始终只对图的总状态进行修改,而非每次遍历时新建一个图来储存
状态。
在具体的写法上,它与普通的深度优先搜索一样,都有 [修改当前节点状态]→[递归子节
点] 的步骤,只是多了回溯的步骤,变成了 [修改当前节点状态]→[递归子节点]→[回改当前节点
状态]。

两个小诀窍:一是按引用传状态;二是所有的状态修改在递归完成后回改。
回溯法修改一般有两种情况:

  1. 修改最后一位输出,比如排列组合;
  2. 修改访问标记,比如矩阵里搜字符串。
2.1 全排列(46 Permutations)

题目描述:
给定一个无重复数字的整数数组,求其所有的排列方式。
question_2.1
解题思路:
我们尝试在纸上写 3 个数字、4 个数字、5 个数字的全排列,相信不难找到这样的方法。
以数组 [1, 2, 3] 的全排列为例。

  • 先写以 1 开头的全排列,它们是:[1, 2, 3], [1, 3, 2],即 1 + [2, 3] 的全排列(注意:递归结构体现在这里);
  • 再写以 2 开头的全排列,它们是:[2, 1, 3], [2, 3, 1],即 2 + [1, 3] 的全排列;
  • 最后写以 3 开头的全排列,它们是:[3, 1, 2], [3, 2, 1],即 3 + [1, 2] 的全排列。

总结搜索的方法:按顺序枚举每一位可能出现的情况,已经选择的数字在 当前 要选择的数字中不能出现。按照这种策略搜索就能够做到 不重不漏 这样的思路,可以用一个树形结构表示
backtracking
说明:

  • 每一个结点表示了求解全排列问题的不同的阶段,这些阶段通过变量的「不同的值」体现,这些变量的不同的值,称之为「状态」;
  • 使用深度优先遍历有「回头」的过程,在「回头」以后, 状态变量需要设置成为和先前一样 ,因此在回到上一层结点的过程中,需要撤销上一次的选择,这个操作称之为「状态重置」;
  • 深度优先遍历,借助系统栈空间,保存所需要的状态变量,在编码中只需要注意遍历到相应的结点的时候,状态变量的值是正确的,具体的做法是:往下走一层的时候,path 变量在尾部追加,而往回走的时候,需要撤销上一次的选择(即弹出),也是在尾部操作,因此 path 变量是一个栈;
  • 深度优先遍历通过「回溯」操作,实现了全局使用一份状态变量的效果。

使用编程的方法得到全排列,就是在这样的一个树形结构中完成 遍历,从树的根结点到叶子结点形成的路径就是其中一个全排列。

设计状态变量:

  • 首先这棵树除了根结点和叶子结点以外,每一个结点做的事情其实是一样的,即:在已经选择了一些数的前提下,在剩下的还没有选择的数中,依次选择一个数,这显然是一个 递归 结构;
  • 递归的终止条件是: 一个排列中的数字已经选够了 ,因此我们需要一个变量来表示当前程序递归到第几层,我们把这个变量叫做 depth,或者命名为 index ,表示当前要确定的是某个全排列中下标为 index 的那个数是多少;
  • 布尔数组 used,初始化的时候都为 false 表示这些数还没有被选择,当我们选定一个数的时候,就将这个数组的相应位置设置为 true ,这样在考虑下一个位置的时候,就能够以 O ( 1 ) O(1) O(1)的时间复杂度判断这个数是否被选择过,这是一种「以空间换时间」的思想。

这些变量称为「状态变量」,它们表示了在求解一个问题的时候所处的阶段。需要根据问题的场景设计合适的状态变量。

 def permute(self, nums: List[int]) -> List[List[int]]:
 	ans = []
 	visited = [0]*len(nums)
 	def backtracking(path,depth):
 		if depth == len(nums):
 			ans.append(path[:])
 			return
 		for i in range(len(nums)):
 			if not visited[i]:
 				path.append(i)
 				visited[i] = 1
 				backtracking(path,depth+1)
 				path.pop()
 				visited[i] = 0
 	backtracking([],0)
 	return ans

# 栈实现
def permutations(nums):
    n = len(nums)
    used = [0]*n
    stack = []
    depth,k = 0,0
    while 1:
        while depth<n and k<n:
            if used[k]==0:
                stack.append(k)
                used[k] = 1
                depth+=1
                k=0
            else:
                k+=1 
        if depth==n:
            print(stack)
        elif stack==[]:
            break
        k = stack.pop()
        used[k] = 0
        depth-=1
        k+=1

递归结束条件:不能是ans.append(path) 因为变量 path 所指向的列表 在深度优先遍历的过程中只有一份
,深度优先遍历完成以后,回到了根结点,成为空列表 []。

最后,由于回溯算法的时间复杂度很高,因此在遍历的时候,如果能够提前知道这一条分支不能搜索到满意的结果,就可以提前结束,这一步操作称为 剪枝

剪枝

  • 回溯算法会应用「剪枝」技巧达到以加快搜索速度。有些时候,需要做一些预处理工作(例如排序)才能达到剪枝的目的。预处理工作虽然也消耗时间,但能够剪枝节约的时间更多;

提示:剪枝是一种技巧,通常需要根据不同问题场景采用不同的剪枝策略,需要在做题的过程中不断总结.

总结:
做题的时候,建议 先画树形图 ,画图能帮助我们想清楚递归结构,想清楚如何剪枝。拿题目中的示例,想一想人是怎么做的,一般这样下来,这棵递归树都不难画出。

在画图的过程中思考清楚:

  • 分支如何产生;
  • 题目需要的解在哪里?是在叶子结点、还是在非叶子结点、还是在从根结点到叶子结点的路径?
  • 哪些搜索会产生不需要的解的?例如:产生重复是什么原因,如果在浅层就知道这个分支不能产生需要的结果,应该提前剪枝,剪枝的条件是什么,代码怎么写?
练习
题型一:排列、组合、子集相关问题
2.全排列Ⅱ(47)

在 全排列 的基础上增加了 序列中的元素可重复 这一条件,但要求:返回的结果又不能有重复元素。
解题思路:
在遍历的过程中,一边遍历一遍检测,在一定会产生重复结果集的地方剪枝。
考虑重复元素一定要优先排序,将重复的都放在一起,便于找到重复元素和剪枝
question_47
剪枝:
question_47.2

def permuteUnique(self, nums: List[int]) -> List[List[int]]:
	nums.sort()
	n = len(nums)
	used = [0]*n
	ans = []
	def backtracking(path,depth):
		if depth==n:
			ans.append(path[:])
			return
		for i in range(n):
			if not used[i]:
				# 剪枝条件i > 0是为了保证nums[i-1]有意义
				# used[i-1] 是因为nums[i-1]在回退的过程中刚刚被撤销选择
				if(i>0 and nums[i]==nums[i-1] and not used[i-1]):
					continue
				path.append(nums[i])
				used[i] = 1
				backtracking(path,depth+1)
				path.pop()
				used[i] = 0
	return ans
3. 组合总和(39)

题目描述:
解题思路:
根据示例 1:输入: candidates = [2, 3, 6, 7],target = 7。

  • 候选数组里有 2,如果找到了组合总和为 7 - 2 = 5 的所有组合,再在之前加上 2 ,就是 7 的所有组合;
  • 同理考虑 3,如果找到了组合总和为 7 - 3 = 4 的所有组合,再在之前加上 3 ,就是 7 的所有组合,依次这样找下去。
    树形图:
    question_39
    说明:
  • 以 target = 7 为 根结点 ,创建一个分支的时 做减法
  • 每一个箭头表示:从父亲结点的数值减去边上的数值,得到孩子结点的数值。边的值就是题目中给出的 candidate 数组的每个元素的值;
  • 减到 0 或者负数的时候停止,即:结点 0 和负数结点成为叶子结点;
  • 所有从根结点到结点 0 的路径(只能从上往下,没有回路)就是题目要找的一个结果。

这棵树有 4 个叶子结点的值 0,对应的路径列表是 [[2, 2, 3], [2, 3, 2], [3, 2, 2], [7]],而示例中给出的输出只有 [[7], [2, 2, 3]]。即:题目中要求每一个符合要求的解是 不计算顺序 的。下面我们分析为什么会产生重复

产生重复的原因是: 在每一个结点,做减法,展开分支的时候,由于题目中说 每一个元素可以重复使用,我们考虑了 所有的 候选数,因此出现了重复的列表。

遇到这一类相同元素不计算顺序的问题,我们在搜索的时候就需要 按某种顺序搜索 具体的做法是:每一次搜索的时候设置 下一轮搜索的起点 begin,请看下图
question_39
即:从每一层的第 2 个结点开始,都不能再搜索产生同一层结点已经使用过的 candidates 里的元素。

剪枝提速

  • 根据上面画树形图的经验,如果 target 减去一个数得到负数,那么减去一个更大的树依然是负数,同样搜索不到结果。基于这个想法,我们可以对输入数组进行排序,添加相关逻辑达到进一步剪枝的目的;
  • 排序是为了提高搜索速度,对于解决这个问题来说非必要。但是搜索问题一般复杂度较高,能剪枝就尽量剪枝。实际工作中如果遇到两种方案拿捏不准的情况,都试一下。
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
	n = len(candidates)
	ans = []
	candidates.sort()
	def backtracking(path,target,begin):
		if target==0:
			ans.append(path[:])
			return 
		for i in range(begin,n): # for循环代表同一层
			residue = target-candidates[i]
			if residue<0:
				break
			path.append(nums[i])
			backtracking(path,residue,i)# 不是 begin =i+1 因为可以多次使用一个元素
			path.pop()
	backtracking([],target,0)
	return ans
什么时候使用 used 数组,什么时候使用 begin 变量
  • 排列问题,讲究顺序(即 [2, 2, 3] 与 [2, 3, 2] 视为不同列表时),需要记录哪些数字已经使用过,此时用 used 数组;
  • 组合问题,不讲究顺序(即 [2, 2, 3] 与 [2, 3, 2] 视为相同列表时),需要按照某种顺序搜索,此时使用 begin 变量。
4.组合总和Ⅱ(40)

这道题与上一问的区别在于:

  • 第 39 题:candidates 中的数字可以无限制重复被选取;
  • 第 40 题:candidates 中的每个数字在每个组合中只能使用一次。

相同点是:相同数字列表的不同排列视为一个结果。

question_4
剪枝发生在:同一层数值相同的结点第 2、3… 个结点,因为数值相同的第 1 个结点已经搜索出了包含了这个数值的全部结果,同一层的其它结点,候选数的个数更少,搜索出的结果一定不会比第 1 个结点更多,并且是第 1个结点的子集。

 def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
        n = len(candidates)
        candidates.sort()
        ans = []
		def backtracking(path,target,begin):
			if target==0:
				ans.append(path[:])
				return
			for idx in range(begin,n):
				residue = target-candidates[idx]
				if residue<0:
					break
				if idx>begin and candidates[idx-1]==candidates[idx]:
					continue
				path.append(candidates[idx])
				backtracking(path,residue,idx+1)
				path.pop()
		return ans

对于 if idx>begin and candidates[idx-1]==candidates[i] 的解释
在一个for循环中,所有被遍历到的数都是属于一个层级的。我们要让一个层级中,
必须出现且只出现一个 k,那么就放过第一个出现重复的 k,但不放过后面出现的 k。
第一个出现的 k 的特点就是 cur == begin. 第二个出现的 k 特点是cur > begin.

5.组合(77)

题目描述:
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

解题思路:

question_5

def combination(n,k):
	ans = []
	def backtracking(path,depth,begin):
		if depth == k:
			ans.append(path[:])
			return
		for idx in range(begin,n+1):
			path.append(idx)
			backtracking(path,depth+1,i+1)
			path.pop()
	backtracking([],0,1)
	return ans

优化:分析搜索起点的上界进行剪枝
我们上面的代码,搜索起点遍历到 n,即:递归函数中有下面的代码片段:

for idx in range(begin,n+1):
	path.append(idx)
	backtracking(path,depth+1,i+1)
	path.pop()

事实上,如果 n = 7, k = 4,从 5 开始搜索就已经没有意义了,这是因为:即使把 5 选上,后面的数只有 6 和 7,一共就 3 个候选数,凑不出 4 个数的组合。因此,搜索起点有上界,这个上界是多少,可以举几个例子分析。

分析搜索起点的上界,其实是在深度优先遍历的过程中剪枝,剪枝可以避免不必要的遍历,剪枝剪得好,可以大幅度节约算法的执行时间。
在这里插入图片描述

 def combine(self, n: int, k: int) -> List[List[int]]:
        ans = []

        def backtracking(path,depth,begin):
            if depth==k:
                ans.append(path[:])
                return
            for i in range(begin,(n-(k - len(path))+ 1)+1): # python中range()取不到结尾所以再+1
                path.append(i)
                backtracking(path,depth+1,i+1)
                path.pop()
        backtracking([],0,1)
        return ans
6. 子集(78)

题目描述:
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集
解题思路:
解法一:
每个元素选与不选
在这里插入图片描述

我们并不需要遍历,而是需要每一层都考虑一下当前元素的index对应的值,我们是要还是不要,于是又是小trick:我们不需要用遍历,但是又要对不同index进行判断,那么我们就传入一个index变量进去,每次改变index就可以了,用于指向每个元素。

回溯三要素

  1. 有效结果
    当指向元素的index==len(nums)的时候,就说明这一次的搜索结束了
    if index == len(nums):
        self.res.append(sol)
        return
    
  2. 回溯范围及答案更新
    不需要循环遍历,而只需要用一个index指向每一次的元素,下一层更新index = index+1
    对于答案更新,我们需要考虑选或不选当前答案,保存当前index指向的元素
    self.backtrack(sol+[nums[index]], index+1, nums)  
    self.backtrack(sol, index+1, nums)
    
  3. 剪枝条件
    不需要剪枝
def backtrack(idx):
    if idx == len(nums):
        ans.append(path[:])
        return
    # 选pos
    path.append(nums[pos])
    backtrack(idx + 1)
    path.pop()
    # 不选pos
    backtrack(idx + 1)

解法二:
顺序考虑,仅考虑选择的元素

在这里插入图片描述
根据图,我们发现还是需要遍历的,并且还是部分遍历而不是全部遍历,因为我们只需要遍历当前元素之后的元素们,又是小trtrick时间:部分遍历的时候,需要传入一个index的起点,用于保证是部分在循环:
在这里插入图片描述
回溯三要素

  1. 有效结果
    没有条件,所有结果都是有效结果

    self.res.append(path)
    
  2. 回溯范围及答案更新
    需要循环遍历,并且是部分遍历,只考虑当前元素之后的元素们,所以需要传入一个index表示起点
    !!!index仅用于表示起点,回溯的递归还是遍历的每个元素
    对于答案更新,依然是累加当前元素

    for i in range(index, len(nums)):
        self.backtrack(path+[nums[i]], i+1, nums) 
        ## 一定要注意这里的递归传入是i+1,index只是当前起点,不代表下一层范围
    
  3. 剪枝条件
    不需要剪枝

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        self.res = []
        self.backtrack([], 0, nums)
        
        return self.res
        
    def backtrack(self, path, index, nums):
        self.res.append(path)
        
        for i in range(index, len(nums)):
            self.backtrack(path+[nums[i]], i+1, nums)
7.子集 II(90):剪枝技巧同 47 题、39 题、40 题;

题目描述:
给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集

解题思路:
组合问题可以抽象为树形结构,那么 “使用过” 在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。

那么问题来了,我们是要同一树层上使用过,还是统一树枝上使用过呢?

回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。

所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。

排列问题里去重也是这个套路,所以理解“树层去重”和“树枝去重”非常重要。

用示例中的[1, 2, 2] 来举例,如图所示: (注意去重需要先对集合排序

在这里插入图片描述

class Solution:
    def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
        res = []  #存放符合条件结果的集合
        path = []  #用来存放符合条件结果
        def backtrack(nums,startIndex):
            res.append(path[:])
            for i in range(startIndex,len(nums)):
                if i > startIndex and nums[i] == nums[i - 1]:  #我们要对同一树层使用过的元素进行跳过
                    continue
                path.append(nums[i])
                backtrack(nums,i+1)  #递归
                path.pop()  #回溯
        nums = sorted(nums)  #去重需要排序
        backtrack(nums,0)
        return res
8.第 k 个排列(60):利用了剪枝的思想,减去了大量枝叶,直接来到需要的叶子结点;

题目描述:
给出集合 [1,2,3,…,n],其所有元素共有 n! 种排列。

按大小顺序列出所有排列情况,并一一标记,当 n = 3 时, 所有排列如下:

  1. “123”
  2. “132”
  3. “213”
  4. “231”
  5. “312”
  6. “321”

给定 n 和 k,返回第 k 个排列。
解题思路:
在这里插入图片描述
思路: 通过 计算剩余数字个数的阶乘数,一位一位选出第 k 个排列的数位。

基于以下几点考虑:

  • 所求排列 一定在叶子结点处得到,进入每一个分支,可以根据已经选定的数的个数,进而计算还未选定的数的个数,然后计算阶乘,就知道这一个分支的 叶子结点 的个数:
    • 如果 k 大于这一个分支将要产生的叶子结点数,直接跳过这个分支,这个操作叫「剪枝」;
    • 如果 k 小于等于这一个分支将要产生的叶子结点数,那说明所求的全排列一定在这一个分支将要产生的叶子结点里,需要递归求解。
      在这里插入图片描述
class Solution:
    def getPermutation(self, n: int, k: int) -> str:
        def dfs(n, k, index, path):
            if index == n:
                return
            # 计算还未确定的数字的全排列的个数,第 1 次进入的时候是 n - 1 (因为此时已经确定第一位的数字)
            cnt = factorial[n - 1 - index]
            for i in range(1, n + 1):
                if used[i]:
                    continue
                if cnt < k:
                    k -= cnt
                    continue
                path.append(i)
                used[i] = True
                dfs(n, k, index + 1, path)
                # 注意:这里要加 return,后面的数没有必要遍历去尝试了
                return

        if n == 0:
            return ""

        used = [False]*(n+1)
        path = []
        factorial = [1]*(n+1)
        for i in range(2, n + 1):
            factorial[i] = factorial[i - 1] * i

        dfs(n, k, 0, path)
        return ''.join([str(num) for num in path])
9.复原 IP 地址(93)

题目描述:
给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 ‘.’ 来形成。你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。

有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 ‘.’ 分隔。

解题思路:

  • 以 “25525511135” 为例,第一步时我们有几种选择?
    1. 选 “2” 作为第一个片段
    2. 选 “25” 作为第一个片段
    3. 选 “255” 作为第一个片段
  • 能切三种不同的长度,切第二个片段时,又面临三种选择。
  • 这会向下分支,形成一棵树,我们用 DFS 去遍历所有选择,必要时提前回溯。
    • 因为某一步的选择可能是错的,得不到正确的结果,不要往下做了。撤销最后一个选择,回到选择前的状态,去试另一个选择。
  • 回溯的第一个要点:选择,它展开了一颗空间树。

回溯的要点二——约束

  • 约束条件限制了当前的选项,这道题的约束条件是:
    1. 一个片段的长度是 1~3
    2. 片段的值范围是 0~255
    3. 不能是 “0x”、“0xx” 形式(测试用例告诉我们的)
  • 用这些约束进行充分地剪枝,去掉一些选择,避免搜索「不会产生正确答案」的分支

回溯的要点三——目标

  • 目标决定了什么时候捕获答案,什么时候砍掉死支,回溯。
  • 目标是生成 4 个有效片段,并且要耗尽 IP 的字符。
  • 当条件满足时,说明生成了一个有效组合,加入解集,结束当前递归,继续探索别的分支。
  • 如果满4个有效片段,但没耗尽字符,不是想要的解,不继续往下递归,提前回溯。

如图[‘2’,‘5’,‘5’,‘2’]未耗尽字符,不是有效组合,不继续选下去。撤销选择"2",回到之前的状态(当前分支砍掉了),切入到另一个分支,选择"25"。
在这里插入图片描述
下图展示找到一个有效的组合的样子。start 指针越界,代表耗尽了所有字符,且满 4 个片段。
在这里插入图片描述

代码实现

def restoreIpAddresses(self, s: str) -> List[str]:
        ans = []
        n = len(s)
        def backtracking(path,idx):
            if len(path)==4:
                if idx == n:
                    ans.append('.'.join(path[:]))
                return 
            for i in range(1,4):
                if idx+i>n:
                    return 
                sub = s[idx:idx+i]
                if sub != str(int(sub)):return
                if int(sub)>255:return
                path.append(sub)
                backtracking(path,idx+i)
                path.pop()
                
        backtracking([],0)
        return ans

题型二:Flood Fill

提示:Flood 是「洪水」的意思,Flood Fill 直译是「泛洪填充」的意思,体现了洪水能够从一点开始,迅速填满当前位置附近的地势低的区域。类似的应用还有:PS 软件中的「点一下把这一片区域的颜色都替换掉」,扫雷游戏「点一下打开一大片没有雷的区域」。
岛屿数量(200)
被围绕的区域(201)
单词搜索(202)

10.单词搜索(202)

题目描述:
给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。

单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格

解题思路:

使用深度优先搜索(DFS)和回溯的思想实现。关于判断元素是否使用过,visited 集合对使用过的元素做标记。

  1. 外层:遍历
    首先遍历 board 的所有元素,先找到和 word 第一个字母相同的元素,然后进入递归流程。假设这个元素的坐标为 (i, j),进入递归流程前,先记得把该元素打上使用过的标记:

    visited.add((i,j))
    
  2. 内层:递归
    打完标记,进入了递归流程。递归流程主要做了这么几件事:

    1. 从 (i, j) 出发,朝它的上下左右试探,看看它周边的这四个元素是否能匹配 word 的下一个字母
      • 如果匹配到了:带着该元素继续进入下一个递归
      • 如果都匹配不到:返回 False
  3. 当 word 的所有字母都完成匹配后,整个流程返回 True

几个注意点
递归时元素的坐标是否超过边界
回溯标记 visited.remove((i,j)) 以及 return 的时机

def exist(self, board, word):
	n,m = len(board),len(board[0])
	t = len(word)
	visited = set()
	for i in range(n):
		for j in range(m):
			if board[i][j] == word[0]:
				visited.add((i,j))
				if dfs(i,j,0):
					return True
				visited.remove((i,j))
	return False			


def dfs(x,y,depth):
	if depth == t-1:
		return True
	for nx,ny in [(x-1,y),(x+1,y),(x,y-1),(x,y+1)]:
		if 0<=nx<n and 0<=ny<m and board[nx][ny]==word[depth+1] and (nx,ny) not in visited:
		visited.add((nx,ny))
		if dfs(nx,ny,depth+1):
			return True
		visited.remove((nx,ny))
	return False	
题型三:字符串中的回溯问题
10. 电话号码的字母组合(17)

题目描述:
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母在这里插入图片描述
解题思路:
回溯过程中维护一个字符串列表,表示已有的字母排列(如果未遍历完电话号码的所有数字,则已有的字母排列是不完整的)。
该字符串列表初始为空。每次取电话号码的一位数字,从哈希表中获得该数字对应的所有可能的字母,并将其中的一个字母加到已有字母的后面,然后继续处理电话号码的后一位数字,直到处理完电话号码中的所有数字,即得到一个完整的字母排列。然后进行回退操作,遍历其余的字母排列。

回溯算法用于寻找所有的可行解,如果发现一个解不可行,则会舍弃不可行的解。在这道题中,由于每个数字对应的每个字母都可能进入字母组合,因此不存在不可行的解,直接穷举所有的解即可。

 def letterCombinations(self, digits: str) -> List[str]:
        if not digits:
            return list()
		ans = []
        dic = {'2':'abc','3':'def','4':'ghi','5':'jkl','6':'mno','7':'pqrs','8':'tuv','9':'wxyz'}
        n = len(digits)
		def backtracking(path,idx):
			if idx == n:
				ans.append(''.join(path))
				return
			digit = digits[idx]
			for c in dic[digit]:
				path.append(c)
				backtracking(path,idx+1)
				path.pop()
		backtracking([],0)
		return ans

字母大小写全排列(784)

11. 括号生成(22) :这道题广度优先遍历也很好写,可以通过这个问题理解一下为什么回溯算法都是深度优先遍历,并且都用递归来写。

题目描述:
数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
解题思路:
解法一:
在这里插入图片描述
画图以后,可以分析出的结论:

  • 当前左右括号都大于 0 时,才产生分支;
  • 产生左分支的时候,只看当前是否还有左括号可以使用;
  • 产生右分支的时候,还受到左分支的限制,右边剩余可以使用的括号数量一定得在严格大于左边剩余的数量的时候,才可以产生分支;
  • 在左边和右边剩余的括号数都等于 0 的时候结算。
 def generateParenthesis(self, n: int) -> List[str]:
 	ans = []
 	def dfs(path,left,right):
 		if left == 0 and right == 0:
 			ans.append(''.join(path))
 			return
 		if right<left:
	 		return
	 	if left >0:
	 		dfs(path+['('],left-1,right)
	 	if right>0:
	 		dfs(path+[')'],left,right-1)
	 
	 dfs([],n,n)
	 return ans

解法二:广度优先遍历

class Solution:
    def generateParenthesis(self, n: int) -> List[str]:
        stack = deque([('(',n-1,1)])
        ans = []
        while stack:
            temp,need,own = stack.popleft()
            if need == 0 and own == 0:
                ans.append(temp)
            if need > 0:
                stack.append((temp + '(',need-1,own+1))
            if own > 0:
                stack.append((temp + ')',need,own-1))
        return ans
题型四:游戏问题
11. N 皇后(51):

题目描述:
按照国际象棋的规则,皇后可以攻击与之处在 同一行 或 同一列 或 同一斜线 上的棋子。
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
解题思路:
p 为记录数组 i为行p[i] 为列 即(i,p[i])
剪枝:
检查当前位置是否与已经摆放的皇后冲突,若冲突则可直接退出

def solveNQueens(self, n: int) -> List[List[str]]:
        if n == 1:
            return [['Q']]
        ans = []
        p = [0]*n
        hashTable = [0]*n
        def dfs(idx):
            if idx == n:
                board = [['.']*n for i in range(n)]
                for i in range(n):
                    board[i][p[i]] = 'Q'
                    board[i] = ''.join(board[i])
                ans.append(board)
                return
            for i in range(n):
                flag = True
                if not hashTable[i]:
                    for pre in range(idx): # 剪枝 
                        if abs(idx-pre)==abs(i-p[pre]):  #同一斜线(两个坐标行与行直接的差值 == 列与列直接的差值)
                            flag = False
                            break
                    if flag:
                        p[idx] = i
                        hashTable[i] = 1
                        dfs(idx+1)
                        hashTable[i] = 0
        dfs(0)
        return ans

12. 解数独(53)

题目描述:
编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则:

  • 数字 1-9 在每一行只能出现一次。
  • 数字 1-9 在每一列只能出现一次。
  • 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 ‘.’ 表示。

解题思路:
按照「行优先」的顺序依次枚举每一个空白格中填的数字,通过递归 + 回溯的方法枚举所有可能的填法。当递归到最后一个空白格后,如果仍然没有冲突,说明我们找到了答案;在递归的过程中,如果当前的空白格不能填下任何一个数字,那么就进行回溯。

由于每个数字在同一行、同一列、同一个九宫格中只会出现一次,因此我们可以使用 l i n e [ i ] {line}[i] line[i] c o l u m n [ j ] {column}[j] column[j] b l o c k [ x ] [ y ] {block}[x][y] block[x][y] 分别表示第 i 行,第 j 列,第 (x, y) 个九宫格中填写数字的情况。在下面给出的三种方法中,我们将会介绍两种不同的表示填写数字情况的方法。

九宫格的范围为 0≤x≤2 以及 0≤y≤2。
具体地,第 i 行第 j 列的格子位于第 ( ⌊ i / 3 ⌋ , ⌊ j / 3 ⌋ ) (⌊i/3⌋,⌊j/3⌋) (⌊i/3,j/3⌋) 个九宫格中,其中 f l o o r ⌊ u ⌋ floor⌊u⌋ flooru 表示对 u 向下取整。

最容易想到的方法是用一个数组记录每个数字是否出现。由于我们可以填写的数字范围为[1,9],而数组的下标从 0 开始,因此在存储时,我们使用一个长度为 9 的布尔类型的数组,其中 i 个元素的值为 True,当且仅当数字 i+1出现过。例如我们用 line[2][3]=True 表示数字 4 在第 2 行已经出现过,那么当我们在遍历到第 2 行的空白格时,就不能填入数字 4。

在这里插入图片描述

class Solution:
    def solveSudoku(self, board: List[List[str]]) -> None:
        def dfs(pos: int):
            nonlocal valid
            if pos == len(spaces):
                valid = True
                return
            
            i, j = spaces[pos]
            for digit in range(9):
                if line[i][digit] == False and column[j][digit] == False and block[i // 3][j // 3][digit] == False:
                    line[i][digit] = True
                    column[j][digit] = True
                    block[i // 3][j // 3][digit] = True
                    board[i][j] = str(digit + 1)
                    dfs(pos + 1)
                    line[i][digit] = False
                    column[j][digit] = False
                    block[i // 3][j // 3][digit] = False
                if valid:
                    return
            
        line = [[False] * 9 for _ in range(9)]
        column = [[False] * 9 for _ in range(9)]
        block = [[[False] * 9 for _a in range(3)] for _b in range(3)]
        valid = False
        spaces = [] # 记录空白位置

        for i in range(9):
            for j in range(9):
                if board[i][j] == ".":
                    spaces.append((i, j))
                else:
                    digit = int(board[i][j]) - 1
                    line[i][digit] = True
                    column[j][digit] = True
                    block[i // 3][j // 3][digit] = True

        dfs(0)

祖玛游戏(54)
扫雷游戏(55)

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值