Python中的回溯法详解:理论基础与实际应用(组合、分割、子集、排列及N皇后问题)
目录
一、回溯法理论基础
回溯法是一种系统地搜索所有可能解的算法策略,常用于解决组合、排列、子集、图的遍历等问题。它通过逐步构建候选解并在发现当前候选解不满足条件时进行“回溯”,撤销上一步的选择,从而探索其他可能的解。
1.1 回溯法原理
回溯法基本步骤可以概括为:
-
构建解空间树:
- 回溯法通常通过递归实现,形成一棵解空间树。每个节点代表一个选择或状态,树的深度代表选择的层次。
-
选择和撤销:
- 从根节点开始,逐层选择,构建候选解。当达到某个条件(如满足目标或达到某个深度)时,进行相应的处理(如记录解)。
- 如果当前选择不满足条件,或者已经达到某个限制(如深度、数量等),则进行回溯,撤销上一步的选择,恢复到上一个状态。
-
剪枝:
- 在遍历过程中,可以根据特定条件提前停止某些路径的搜索,以减少不必要的计算。这称为剪枝,能够提高算法效率。
1.2 回溯法优缺点
优点:
缺点:
那么既然回溯法并不高效为什么还要用它呢?接下来就介绍一下不得不用回溯法的一些场景。
1.3 回溯法应用场景
回溯法适用于许多问题,以下是一些典型的应用场景:
- 组合问题
例如,从一组数中选择若干个数,使得它们的和等于某个特定值。在这个问题中,解空间树的每个分支代表是否选择某个数,通过回溯法可以遍历所有可能的组合,找到满足条件的组合。 - 切割问题
假设要将一根长度为n的木棒切割成若干段,每段长度有一定限制,并且要满足某些条件(如切割后的小段总价值最大等)。回溯法可以用来尝试不同的切割方案,通过不断地选择切割点,当发现当前切割方案不符合要求时就回溯,重新选择切割点。 - 子集问题
给定一个集合,求它的所有子集。解空间树的根节点表示空集,每个分支表示是否将集合中的一个元素加入到子集中。通过回溯法可以系统地生成所有可能的子集。 - 排列问题
比如求一个数组的所有排列情况。解空间树的每个分支代表选择数组中的一个元素放在某个位置,通过回溯法可以遍历所有可能的排列顺序。 - 棋盘问题
在国际象棋棋盘上放置棋子(如八皇后问题),要求棋子之间满足一定的规则(如皇后不能互相攻击)。回溯法可以用来尝试在棋盘的不同位置放置棋子,当发现当前放置方式违反规则时就回溯,重新选择棋子的放置位置。
1.4 回溯法模板
# 回溯函数模板返回值以及参数
void backtracking(参数) {
# 回溯函数终止条件
if (终止条件) {
存放结果;
return;
}
# 回溯搜索的遍历过程
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
二、回溯法应用
2.1 组合问题
2.1.1 组合个数
链接:组合个数
问题描述:
给定两个整数 n 和 k,返回 1 … n 中所有可能的 k 个数的组合。
示例: 输入: n = 4, k = 2 输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]
问题分析:
本题在尝试暴力法解决时,会发现根据k的数值不同,第二个for循环的次数也不同,因此暴力法书写起来非常困难。而回溯法是处理组合问题的经典用法,使用回溯法并不为了追求更高的时间效率和更低的空间占用,而是为了更方便地暴力解决问题。
模板解决
套用回溯法模板解决该问题:
1、回溯法参数及返回值
我们需要定义path来存放临时处理的符合条件的列表,定义result来存放符合条件的path并作为返回值输出,同时需要定义标记startIndex来记录标记。
def backtracking(self, n, k, startIndex, path, result)
2、回溯法的结束条件
当我们的path长度等于k时,即找出了一组k个数的组合,我们便可以终止此次回溯并保存path。
if len(path) == k:
result.append(path[:])
return
3、回溯体
需要使用for循环来处理横向遍历,for循环处理的条件等于集合中剩余数字的个数,即for i in range(startIndex, n + 1)
;需要使用递归来实现树的向下遍历,即backtracking(n, k, i + 1, path, result
完整为:
for i in range(startIndex, n + 1):
path.append(i) # 处理节点
self.backtracking(n, k, i + 1, path, result)
path.pop() # 回溯节点
整体代码:
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result = []
self.backtracking(n, k, 1, [], result)
return result
def backtracking(self, n, k, startIndex, path, result):
if len(path) == k:
result.append(path[:])
return
for i in range(startIndex, n + 1):
path.append(i) # 处理节点
self.backtracking(n, k, i + 1, path, result)
path.pop() # 回溯节点
二、剪枝优化
本次回溯设定的结束条件是len(path) == k
,在进行for循环横向遍历时,会发现如果n = 4,k = 3时,第一次循环中的i == 3,i == 4便没有什么意义了。所以可以在处理for循环时,改一下边界值来实现剪枝的操作,如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
优化后的for循环为
for i in range(startIndex, n - (k - len(path)) + 2)
整体代码为:
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result = [] # 存放结果集
self.backtracking(n, k, 1, [], result)
return result
def backtracking(self, n, k, startIndex, path, result):
if len(path) == k:
result.append(path[:])
return
for i in range(startIndex, n - (k - len(path)) + 2): # 优化的地方
path.append(i) # 处理节点
self.backtracking(n, k, i + 1, path, result)
path.pop() # 回溯,撤销处理的节点
2.1.2 组合总和III
链接:216. 组合总和 III
问题描述:
找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:
只使用数字1到9
每个数字 最多使用一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
问题分析:
这个题目和组合个数题目相似,相加之和为n,即我们要在[1,n]中找到k个数的不重复组合,使其满足相加之和为n,且只能使用数字1到9。我们可以在上个题的基础上修改回溯的终止条件并且修改for循环的遍历条件即可。
整体代码:
class Solution:
def combinationSum3(self, k: int, n: int) -> List[List[int]]:
result = []
self.sumbacktracking(n, k, 1, [], result)
return result
def sumbacktracking(self, n, k, startIndex, path, result):
# 相比上个题,多了一步比较sum的值是否等于n的处理
if len(path) == k and sum(path) == n:
result.append(path[:])
return
# sum(path)是一步剪枝的操作,当求和大于n时没必要往后算。
elif len(path) > k or sum(path) > n:
return
# 题目要求数字要在1-9之间,所以要修改for循环的条件
for i in range(startIndex,9 - (k - len(path)) + 2):
path.append(i)
self.sumbacktracking(n, k, i+1, path, result)
path.pop()
2.1.3 输入法组合
链接:17. 电话号码的字母组合
问题描述:
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
问题分析:
这个题目是组合问题的实际应用,这种应用题的核心是将生活问题抽象为代码问题。字符串的长度即为树的高度,每个字符代表的字母组合的字母数即为树的宽度,这样就抽象为了具体的回溯问题,注意字符列表和字符的转换即可。
整体代码:
class Solution:
def letterCombinations(self, digits: str) -> List[str]:
result = []
# 定义字符列表。
self.numberLists = ['abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz']
if not digits:
return result
self.numbertracking(digits, 0, "", result)
return result
def numbertracking(self, numbers, index, path, result):
# 终止条件。
if len(path) == len(numbers):
result.append(path)
return
number = int(numbers[index])
letters = numberLists[number-2]
for i in range(len(letters)):
path += letters[i]
self.numbertracking(numbers, index + 1, path, result)
path,pop()
2.1.4 组合总和
链接:组合总和
问题描述:
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复被选取。
问题分析:
相比于问题 2.1.2,这个问题修改了条件为:candidates 中的数字可以无限制重复被选取。注意递归时的i不需要+1即可
整体代码:
class Solution:
def combinationSum(self, candidates, target):
result = []
candidates.sort() # 需要排序
self.backtracking(candidates, target, 0, 0, [], result)
return result
def backtracking(self, candidates, target, total, startIndex, path, result):
if total == target:
result.append(path[:])
return
for i in range(startIndex, len(candidates)):
# 剪纸处理,如果当前总和已经大于目标值,说明这个组合已经不需要计算了
if total + candidates[i] > target:
break
total += candidates[i]
path.append(candidates[i])
# 传入的i值不需要+1了,因为数字可以重复选取
self.backtracking(candidates, target, total, i, path, result)
total -= candidates[i]
path.pop()
2.1.4 组合总和II
链接:组合总和II
问题描述:
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用 一次 。
问题分析:
相比于之前的组合问题,这个问题不同在于:
1、备选集合中的元素可以重复。
2、组合中的数字个数没有限制。
相比于上个问题,需要注意candidates[i]值选取的时候不能选取和candidates[i-1]相同的数字,不然最后的结果会出现组合的重复,如[1,1,2],目标值为3时,如果第一次取第一个1,再取2,得到[1,2],第二次又取第二个1,再取2,还得到[1,2],那么最后的组合就会出现重复的数字。
整体代码:
class Solution:
def backtracking(self, candidates, target, total, startIndex, path, result):
if total == target:
result.append(path[:])
return
for i in range(startIndex, len(candidates)):
# 判断当前数字是否取过
if i > startIndex and candidates[i] == candidates[i - 1]:
continue
if total + candidates[i] > target:
break
total += candidates[i]
path.append(candidates[i])
# 每个数字在每个组合中只能使用 一次,所以i需要+1
self.backtracking(candidates, target, total, i + 1, path, result)
total -= candidates[i]
path.pop()
def combinationSum2(self, candidates, target):
result = []
candidates.sort()
self.backtracking(candidates, target, 0, 0, [], result)
return result
2.2 分割问题
2.2.1 分割回文子串
链接:分割回文子串
问题描述:
给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
返回 s 所有可能的分割方案。
示例: 输入: “aab” 输出: [ [“aa”,“b”], [“a”,“a”,“b”] ]
问题分析:
这是一道分割问题的回溯法应用,回溯树的宽度为截取的位置,回溯树的高度为截取的次数。递归的终止条件即为截取到字符串的末尾无法继续截取时。同时需要判断本次截取的起始位置和终止位置中间的子串是否为回文串,满足条件则继续递归。
模板解决
class Solution:
def partition(self, s: str) -> List[List[str]]:
result = []
self.backtrack(s, 0, [], result)
return result
# 判断是否为回文串
def is_palindrome(self, sub: str) -> bool:
return sub == sub[::-1]
def backtrack(self, s: str, start: int, path: List[str], result: List[List[str]]):
# 递归终止条件,当截取位置到达字符串末尾时完成递归
if start == len(s):
result.append(path[:])
return
# 从起始位置的下一位开始截取,直至到达末尾
for end in range(start + 1, len(s) + 1):
if self.is_palindrome(s[start:end]):# 判断是否为回文串
self.backtrack(s, end, path + [s[start:end]], result)
2.2.2 复原IP地址
链接:复原IP地址
问题描述:
给定一个只包含数字的字符串,复原它并返回所有可能的 IP 地址格式。
有效的 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 = “25525511135”
输出:[“255.255.11.135”,“255.255.111.35”]
问题分析:
这是一道分割问题的回溯法的实际问题应用,需要先将实际问题抽象为代码问题,在进行代码分析。因为IP地址是四段的,所以需要将该数字字符串分割三次,每一次分割截取的字符需要满足0-255之间、前导不含0,必须为四个整数。我们还是使用上一题的判断回文的方式,将判断函数写为一个单独的函数,同时需要注意递归的截止条件为截取三次。
模板解决:
class Solution:
def restoreIpAddresses(self, s: str) -> List[str]:
result = []
# 判断字符是否满足ip地址要求
def is_ip(number: str) -> bool:
return 0 <= int(number) <= 255 and (number == "0" or not number[0] == "0")
def backtrack(start: int, path: List[str]):
# 当片段为4时,递归结束并判断是否截取到最后
if len(path) == 4:
if start == len(s):
result.append(".".join(path))
if len(path) > 4:
return
for i in range(start + 1, min(start + 4, len(s) + 1)):
number = s[start: i]
if is_ip(number):
path.append(number)
backtrack(i, path)
path.pop()
backtrack(0, [])
return result
2.3 子集问题
2.3.1 不重复元素的子集
链接:不重复元素的子集
问题描述:
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例: 输入: nums = [1,2,3] 输出: [ [3], [1], [2], [1,2,3], [1,3], [2,3], [1,2], [] ]
问题分析:
这是一道子集问题的回溯法应用。类似组合问题,子集也是在一个集合中构造一个覆盖所有可能的回溯树,与组合和分割不同的是,子集的结果是要找到所有的节点,而分割和组合是要找到满足条件的叶子节点。
模板解决:
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
def backtrack(start: int, path: List[int]):
# 每次调用都将当前路径添加到结果中
result.append(path[:])
# 从当前索引开始,尝试添加每个数字到路径中
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
result = []
backtrack(0, [])
return result
2.3.2 含重复元素的子集
链接:含重复元素的子集
问题描述:
给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例:
输入: [1,2,2]
输出: [ [2], [1], [1,2,2], [2,2], [1,2], [] ]
问题分析:
这是一道子集问题的回溯法应用。类似2.1.4组合问题II,需要在截取的时候进行去重
模板解决:
from typing import List
class Solution:
def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
def backtrack(start: int, path: List[int]):
result.append(path[:])
for i in range(start, len(nums)):
# 跳过重复元素
if i > start and nums[i] == nums[i - 1]:
continue
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
nums.sort() # 排序以便于跳过重复元素
result = []
backtrack(0, [])
return result
2.3.3 递增子序列
链接:491.递增子序列
问题描述:
给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是2。
示例:
输入: [4, 6, 7, 7]
输出: [[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]
问题分析:
该题还是一道求所有子集的问题,不过他有两个限制,一个是求递增序列,二是含重复元素,所以需要在不排序的基础上进行去重,我们需要引入used集合来判断当前元素有没有处理过。同时需要判断新加入的元素是否满足path递增。
模板解决:
class Solution:
def findSubsequences(self, nums: List[int]) -> List[List[int]]:
def backtrack(start: int, path: List[int]):
if len(path) > 1:
result.append(path[:])
# 引入used集合
used = set()
for i in range(start, len(nums)):
# 判断是否递增
if path and nums[i] < path[-1]:
continue
# 判断是否使用过
if nums[i] in used:
continue
used.add(nums[i])
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
result = []
backtrack(0, [])
return result
2.4 排列问题
2.4.1 全排列
链接:46. 全排列
问题描述:
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2,3]
输出: [ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]
问题分析:
与求子集问题不同的是,排列问题需要在每一递归路径上去重,而不是在树的每一横层去重,所以我们需要引入used集合,在每次递归前将该元素加入集合,递归开始前判断是否重复,重复则跳过。同时不需要再传入start参数,因为全排列需要全部都可以作为开头。
模板解决:
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
result = []
def backtracking(path:List[int], used:set):
if len(path) == len(nums):
result.append(path[:])
return
for i in range(len(nums)):
if nums[i] in used:
continue
used.add(nums[i])
path.append(nums[i])
backtracking(path,used)
path.pop()
used.remove(nums[i])
backtracking([],set())
return result
2.4.2 含重复元素的排列问题
链接:47. 全排列II
问题描述:
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
问题分析:
与上个问题相比,多了重复元素的限制。我们需要对for循环的每一横层去重,同时对每一条路径去重。
模板解决:
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
nums.sort()
result = []
def backtracking(path:List[int], used:List[bool]):
if len(path) == len(nums):
result.append(path[:])
return
for i in range(len(nums)):
# 对每一路径去重
if used[i]:
continue
# 对每一横层去重
if i>0 and nums[i] == nums[i-1] and not used[i-1]:
continue
used[i] = True
path.append(nums[i])
backtracking(path,used)
path.pop()
used[i] = False
backtracking([],[False]*len(nums))
return result
2.5 应用题
链接:332.重新安排行程
问题描述:
给定一个机票的字符串二维数组 [from, to],子数组中的两个成员分别表示飞机出发和降落的机场地点,对该行程进行重新规划排序。所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。
提示:
如果存在多种有效的行程,请你按字符自然排序返回最小的行程组合。例如,行程 [“JFK”, “LGA”] 与 [“JFK”, “LGB”] 相比就更小,排序更靠前
所有的机场都用三个大写字母表示(机场代码)。
假定所有机票至少存在一种合理的行程。
所有的机票必须都用一次 且 只能用一次。
示例 1:
输入:[[“MUC”, “LHR”], [“JFK”, “MUC”], [“SFO”, “SJC”], [“LHR”, “SFO”]]
输出:[“JFK”, “MUC”, “LHR”, “SFO”, “SJC”]
问题分析:
虽然放在回溯法板块,但是更像是图论的问题,将实际问题转换为代码问题已经很难,如何进行回溯的撰写更是难上加难。按照之前的回溯模板可以写出下面的代码,但是在处理某些用例时会出现超时。
首先要将所有的航班转换为字典的形式,并按照字母排序。接着按顺序对JFK机场的目的地进行遍历,遍历后要将对应机场从JFK的目的地中删除,同时对目的地的目的地机场再次遍历。如果触发了递归终止条件且结果为False,则回溯遍历。如果结果为True,则输出结果。
模板解决:
class Solution:
def findItinerary(self, tickets: List[List[str]]) -> List[str]:
def backtracking(airport):
# 递归终止条件
if len(path) == len(tickets) + 1:
return True
# 当前机场没有下个航班
if airport not in graph or not graph[airport]:
return False
for i in range(len(graph[airport])):
next_airport = graph[airport][i]
path.append(next_airport)
graph[airport].pop(i)
# 因为只要一条最优结果,所以满足条件时直接return True即可
if backtracking(next_airport):
return True
# 回溯
# 将next机场重新插入i位置
graph[airport].insert(i, next_airport)
path.pop()
return False
graph = defaultdict(list)
for start, end in sorted(tickets):
graph[start].append(end)
path = ["JFK"]
backtracking("JFK")
return path
链接:51. N皇后
问题描述:
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
问题分析:
N皇后问题也是经典的回溯法问题,初看题目可能会感到复杂,将实际问题抽象之后会发现套模板十分的简单.n*n的棋盘,每一行只能放一个棋子,总共n个棋子,其实有种之前的组合问题的感觉.从1-n中每次取一个数,最终的数列要满足皇后的限制.只是将取数这个操作变为了对棋盘进行修改.而皇后的限制需要单独用函数写出.
模板解决:
class Solution:
def solveNQueens(self, n: int) -> List[List[str]]:
result = []
# 初始化棋盘
path = ["." * n for _ in range(n)]
def backtracking(path: List[List[str]], row: int):
# 终止条件
if row == n:
result.append(path[:])
return
for col in range(n):
if isValid(col, row, path):
path[row] = path[row][:col] + "Q" + path[row][col+1:]
backtracking(path, row + 1)
path[row] = path[row][:col] + "." + path[row][col+1:]
def isValid(col: int, row: int, path: List[List[str]]):
# 如果第一排 不用比较直接放就行
if row > 0:
# 查询同一列有没有皇后
for i in range(row):
if path[i][col] == "Q":
return False
# 查询左上、右上有无皇后
i, j = row - 1, col - 1
while i >= 0 and j >= 0:
if path[i][j] == 'Q':
return False # 左上方向已经存在皇后,不合法
i -= 1
j -= 1
# 检查 135 度角是否有皇后
i, j = row - 1, col + 1
while i >= 0 and j < len(path):
if path[i][j] == 'Q':
return False # 右上方向已经存在皇后,不合法
i -= 1
j += 1
return True
backtracking(path, 0)
return result
三、总结
在python与回溯法的应用中,我们将回溯法的一些问题抽象为了四类,组合、切割、子集、排列问题。其实这四种问题不过都是对回溯法模板的变形,只是组合、切割、排列要收集叶子节点,子集要收集每一个节点;同时切割是要将目标切成片段并判断,而排列需要每次都从0出发进行判断。
在解决这类问题时,首先要判断适不适合回溯法。当我们发现解决问题需要递归使用for循环且for循环的使用次数与参数有关,则可以使用回溯法。即不断地递归深入当条件不满足时回溯到上一层,条件满足时记录结果。
在判断为回溯法问题后,需要构建一棵回溯二叉树,同时套用模板将问题解决。
最后还要进行剪枝的操作,将树中不满足条件的枝叶提前排除,避免过多的计算。
作者:至尊皇堡