算法学习——技巧小结7(回溯:排列、组合、子集)
引言
你是否曾遇到过这样的问题:面对一个复杂的决策过程,每一步都有多种选择,你需要找出所有满足条件的解决方案?比如,如何列出数字1, 2, 3的所有可能排列?或者,如何从一个集合中找出所有和为特定值的子集?这类问题看似千变万化,但它们背后都隐藏着一种强大而优雅的解决思路——回溯算法。
回溯算法是解决“枚举所有可能性”问题的利器,它本质上是一种采用深度优先搜索策略的暴力尝试法。但与纯粹的暴力枚举不同,回溯算法融入了“**试错”**与“剪枝”的思想:它像一位聪明的探险家,在决策树的丛林中探索每一条路径;一旦发现某条路不可能通向目的地,便果断折返,尝试下一个岔路口。这种“选择-验证-撤销”的核心步骤,使得它在许多场景下比盲目枚举高效得多。
在本篇文章中,我们将从最经典的排列、组合、子集问题入手,由浅入深地揭开回溯算法的神秘面纱。你将看到,一个清晰易懂的回溯模板如何成为解决这些问题的“万能钥匙”。更重要的是,我们将一起探索这个基础模板如何灵活变通,应用于更复杂的场景。
一、回溯框架
核心框架
def backtrack(选项表):if 满足结束条件:保存结果returnfor 选项 in 选项表:做选择下一层回溯撤销选择
具体还可根据选择表是否含重复元素,以及能否重复选择同一个元素,将核心框架,引申出三种子框架。
第一类: 无重复元素、不可重复选择
# 组合/子集问题回溯算法框架
def backtrack(start: int):for i in range(start, len(nums)):track.append(nums[i]) # 做选择backtrack(i + 1) # 选项表从i+1开始,因为已选择的不能再选track.pop() # 撤销选择# 排列问题回溯算法框架
def backtrack(cnt):for i in range(len(nums)):# 剪枝if used[i]:continue# 做选择used[i] = Truetrack.append(nums[i])backtrack(cnt+1) # cnt表示目前已经选择了几个,进入下一次需要加1# 撤销选择track.pop()used[i] = False
第二类:有重复元素,不可重复选择
# 组合/子集问题回溯算法框架
nums.sort()
def backtrack(start: int):for i in range(start, len(nums)):# 跳过值相同的相邻树枝if i > start and nums[i] == nums[i - 1]:continuetrack.append(nums[i]) # 做选择backtrack(i + 1) # 注意参数track.pop() # 撤销选择# 排列问题回溯算法框架
nums.sort()
def backtrack(cnt):for i in range(len(nums)):# 剪枝if used[i]:continue# 重复选项x是首次出现,可以选也可以不选# 若重复选项x'并非首次出现,那我们就需看其前面的那个x是否被选择:# 1.如果x已被选择,则x'可以选也可以不选# 2.如果x未被选择,则x'不可选,需要跳过if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:continue# 做选择used[i] = Truetrack.append(nums[i])backtrack(cnt+1) # cnt表示目前已经选择了几个,进入下一次需要加1# 撤销选择track.pop()used[i] = False
与第一类不同,第二类是有重复元素的,比如[1,2,2′,2′′,3][1, 2, 2',2'',3][1,2,2′,2′′,3]。
若我们要解决它的组合问题,例如三个数之和为6的组合有哪些,就会发现类似[1,2,3][1, 2,3][1,2,3]和[1,2′,3][1, 2',3][1,2′,3]是重复的,因此我们需要在进行选取之前先排序,然后在每一层回溯中,遇到重复元素时,只在该元素首次出现时选择,后面出现时跳过即可。
若我们需要找出它的全排列时可发现,如[1,2,2′,2′′,3][1, 2, 2' ,2'',3][1,2,2′,2′′,3]就和[1,2′,2,2′′,3][ 1,2',2,2'',3][1,2′,2,2′′,3]、[1,2′,2′′,2,3][ 1,2',2'',2,3][1,2′,2′′,2,3]、[1,2′′,2′,2,3][ 1,2'',2',2,3][1,2′′,2′,2,3]、[1,2′′,2,2′,3][ 1,2'',2,2',3][1,2′′,2,2′,3]、[1,2,2′′,2′,3][ 1,2,2'',2',3][1,2,2′′,2′,3]会产生重复。为了避免这种重复情况,我们需要先将选项表排序,使得其中的重复元素都相邻放置,然后再做选择时,确保重复元素的相对位置不变。
如上例子中,[1,2′,2,2′′,3][ 1,2',2,2'',3][1,2′,2,2′′,3]、[1,2′,2′′,2,3][ 1,2',2'',2,3][1,2′,2′′,2,3]、[1,2′′,2′,2,3][ 1,2'',2',2,3][1,2′′,2′,2,3]、[1,2′′,2,2′,3][ 1,2'',2,2',3][1,2′′,2,2′,3]、[1,2,2′′,2′,3][ 1,2,2'',2',3][1,2,2′′,2′,3]中重复元的222的相对位置发生了变化,不应该选择,
这样,就仅存在[1,2,2′,2′′,3][1, 2, 2' ,2'',3][1,2,2′,2′′,3],就不会出现重复。
第三类:无重复元素,可重复选择
# 组合/子集问题回溯算法框架
def backtrack(start):# 回溯算法标准框架for i in range(start, len(nums)):track.append(nums[i]) # 做选择backtrack(i) # 可以重复选择,所以下一层依旧从i开始track.pop() # 撤销选择# 排列问题回溯算法框架
def backtrack(cnt):for i in range(len(nums)): track.append(nums[i]) # 做选择backtrack(cnt+1) # cnt表示目前已经选择了几个,进入下一次需要加1track.pop() # 撤销选择
二、基本题型
类型1:无重复、不可复选
78. 子集
class Solution:def subsets(self, nums: List[int]) -> List[List[int]]:res = [] # 存储所有子集结果track = [] # 记录当前递归路径(当前正在构建的子集)n = len(nums) # 输入集合的长度def backtrack(start: int) -> None:"""回溯递归核心函数通过递归遍历所有可能的子集组合,每次递归调用都代表一个新的决策点。:param start: int 当前可选的起始索引:return: None"""# 每个节点都是一个子集,直接加入结果# 注意:这里不需要终止条件,因为循环会自动结束res.append(track.copy())# 遍历从start开始的所有元素for i in range(start, n):track.append(nums[i]) # 做出选择,将当前元素加入子集backtrack(i + 1) # 递归进入下一层,i+1确保不重复使用同一元素track.pop() # 撤销选择,回溯backtrack(0) # 从索引0开始回溯return res
46. 全排列
class Solution:def permute(self, nums: List[int]) -> List[List[int]]:"""生成输入数组的所有可能排列(使用回溯算法)该方法采用回溯算法递归地构建所有可能的排列组合。通过维护一个已使用标记数组来避免重复选择元素,当当前路径长度等于输入数组长度时,将该路径加入结果列表。:param nums: List[int] 输入整数数组,元素可以重复:return: List[List[int]] 返回所有可能的排列组合,每个组合都是一个整数列表"""n = len(nums) # 输入数组的长度res = [] # 存储所有排列结果track = [] # 记录当前递归路径(当前正在构建的排列)used = [False] * n # 标记数组,记录元素是否已被使用def backtrack(cnt) -> None:"""回溯递归函数,用于生成所有排列通过深度优先搜索遍历所有可能路径,使用标记数组避免重复选择元素。当路径长度等于输入数组长度时,将当前路径加入结果列表。:return: None"""# 终止条件:当前路径长度等于数组长度if cnt == n:# 注意需要使用copy(),否则后续修改会影响已存储的结果res.append(track.copy())return# 遍历所有可能的选择for i in range(n):# 跳过已使用的元素if used[i]:continue# 做出选择used[i] = True # 标记当前元素已使用track.append(nums[i]) # 将当前元素加入路径# 递归进入下一层决策backtrack(cnt+1)# 撤销选择(回溯)track.pop() # 移除最后添加的元素used[i] = False # 取消标记# 从空路径开始回溯backtrack(0)return res
77. 组合
class Solution:def combine(self, n: int, k: int) -> List[List[int]]:def backtrack(cnt: int, start: int) -> None:"""回溯递归核心函数通过递归遍历所有可能的组合方式,使用start参数避免重复组合。:param cnt: int 当前已选择的元素个数:param start: int 当前可选的起始数字:return: None"""# 终止条件:已选元素个数等于kif cnt == k:# 必须使用copy(),否则后续修改会影响已存储的结果res.append(track.copy())return# 遍历从start到n的所有数字for i in range(start, n + 1):track.append(i) # 做出选择,将当前数字加入组合# 递归进入下一层:# cnt+1 表示已选数字增加1# i+1 确保下一个数字比当前大,保持升序避免重复backtrack(cnt + 1, i + 1)track.pop() # 撤销选择,回溯res = [] # 存储所有组合结果track = [] # 记录当前递归路径(当前正在构建的组合)backtrack(0, 1) # 从已选0个元素,起始数字1开始回溯return res
216. 组合总和 III
class Solution:def combinationSum3(self, k: int, n: int) -> List[List[int]]:# 提前终止条件:1-9中k个数的最大和为45(9+8+...+ (9-k+1))if n > 45:return []nums = list(range(1, 10)) # 候选数字1-9m = len(nums) # 候选数字长度(固定为9)res = [] # 存储所有符合条件的组合track = [] # 记录当前递归路径(当前尝试的组合)def backtrack(start: int, sum_val: int) -> None:"""回溯递归核心函数通过递归遍历所有可能的组合方式,使用剪枝条件优化搜索过程。:param start: int 当前可选的起始索引:param sum_val: int 当前路径的数字和:return: None"""# 终止条件:组合长度等于k且和等于nif len(track) == k and sum_val == n:res.append(track.copy()) # 保存当前组合return# 遍历从start开始的所有候选数字for i in range(start, m):# 剪枝条件1:当前和加上数字后超过目标值,直接终止循环if sum_val + nums[i] > n:breaktrack.append(nums[i]) # 做出选择,将当前数字加入组合# 递归进入下一层:# i+1 确保每个数字只用一次# sum_val+nums[i] 更新当前和backtrack(i + 1, sum_val + nums[i])track.pop() # 撤销选择,回溯backtrack(0, 0) # 从索引0,初始和0开始回溯return res
类型2:有重复、不可复选
47. 全排列 II
class Solution:def permuteUnique(self, nums: List[int]) -> List[List[int]]:nums.sort() # 先排序使相同元素相邻,便于后续剪枝n = len(nums)used = [False] * n # 标记数组元素是否被使用过res = [] # 存储所有排列结果track = [] # 记录当前路径(当前排列)def backtrack(cnt: int) -> None:"""回溯递归函数,用于生成所有唯一排列通过深度优先搜索遍历所有可能路径,使用剪枝条件避免重复排列。当路径长度等于输入数组长度时,将当前路径加入结果列表。:param cnt: int 当前路径长度(已选元素个数):return: None"""# 终止条件:当前路径长度等于数组长度if cnt == n:res.append(track.copy()) # 添加当前路径的副本到结果return# 遍历所有可选元素for i in range(n):# 跳过已使用的元素if used[i]:continue# 剪枝条件:当前元素与前一个相同且前一个未被使用# 说明同一树层已经使用过相同元素,跳过以避免重复if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:continue# 选择当前元素used[i] = Truetrack.append(nums[i])# 递归进入下一层决策树backtrack(cnt + 1)# 撤销选择(回溯)track.pop()used[i] = Falsebacktrack(0) # 从路径长度0开始回溯return res
90. 子集 II
class Solution:def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:nums.sort() # 先排序使相同元素相邻,便于后续剪枝n = len(nums)res = [] # 存储所有子集结果track = [] # 记录当前递归路径(当前正在构建的子集)def backtrack(start: int) -> None:"""回溯递归核心函数通过递归生成所有子集,使用start参数控制遍历起始位置,通过剪枝条件避免重复子集。:param start: int 当前可选的起始索引:return: None"""# 每个节点都是一个子集,直接加入结果res.append(track.copy())# 遍历从start开始的所有元素for i in range(start, n):# 剪枝条件:跳过同一树层使用过的相同元素if i > start and nums[i] == nums[i - 1]:continuetrack.append(nums[i]) # 做出选择,将当前元素加入子集backtrack(i + 1) # 递归进入下一层,注意i+1避免重复使用同一元素track.pop() # 撤销选择,回溯backtrack(0) # 从索引0开始回溯return res
40. 组合总和 II
类型:有重复、不可复选
class Solution:def combinationSum2(self, nums: List[int], target: int) -> List[List[int]]:nums.sort() # 关键步骤:排序使相同数字相邻,便于后续剪枝n = len(nums)res = [] # 存储所有符合条件的组合track = [] # 记录当前递归路径(当前尝试的组合)def backtrack(start: int, sum_val: int) -> None:"""回溯递归核心函数通过递归遍历所有可能的组合方式,使用剪枝条件优化搜索过程。:param start: int 当前可选的起始索引:param sum_val: int 当前路径的数字和:return: None"""# 终止条件:当前和等于目标值if sum_val == target:res.append(track.copy()) # 保存当前组合return# 遍历从start开始的所有候选数字for i in range(start, n):# 剪枝条件1:跳过同一树层使用过的相同数字if i > start and nums[i] == nums[i - 1]:continue# 剪枝条件2:当前和加上数字后超过目标值,直接终止循环# (因为数组已排序,后续数字只会更大)if sum_val + nums[i] > target:breaktrack.append(nums[i]) # 做出选择,将当前数字加入组合# 递归进入下一层:# i+1 确保每个数字只用一次# sum_val+nums[i] 更新当前和backtrack(i + 1, sum_val + nums[i])track.pop() # 撤销选择,回溯backtrack(0, 0) # 从索引0,初始和0开始回溯return res
类型3:无重复,可复选
39. 组合总和
class Solution:def combinationSum(self, nums: List[int], target: int) -> List[List[int]]:nums.sort() # 关键步骤:排序便于后续剪枝操作n = len(nums)res = [] # 存储所有符合条件的组合track = [] # 记录当前递归路径(当前尝试的组合)def backtrack(start: int, sum_val: int) -> None:"""回溯递归核心函数通过递归遍历所有可能的组合方式,使用剪枝条件优化搜索过程。:param start: int 当前可选的起始索引:param sum_val: int 当前路径的数字和:return: None"""# 终止条件:当前和等于目标值if sum_val == target:res.append(track.copy()) # 保存当前组合return# 遍历从start开始的所有候选数字for i in range(start, n):# 剪枝条件:当前和加上数字后超过目标值,直接终止循环# (因为数组已排序,后续数字只会更大)if sum_val + nums[i] > target:breaktrack.append(nums[i]) # 做出选择,将当前数字加入组合# 递归进入下一层:# 传递i而不是i+1,允许重复使用当前数字# sum_val+nums[i] 更新当前和backtrack(i, sum_val + nums[i])track.pop() # 撤销选择,回溯backtrack(0, 0) # 从索引0,初始和0开始回溯return res
三、变体题型
140. 单词拆分 II
在139. 单词拆分中,需要判断sss是否能拆分为wordDictwordDictwordDict,我们只用关心能否、有无、对错,最值,而不关心到底有几种“能”的情况,这类型的题一般用动态规划,即根据递推公式一步一步布林布林地从头计算到尾,返回dpdpdp表的最后一个结果即可。
而本题和139. 单词拆分不一样,该题更关心的是将sss拆分成wordDictwordDictwordDict中的单词,总共有多少种拆分方式,而这用到则应该是回溯,因为你回溯中的路径tracktracktrack正好可以用来存放不同的可能。
class Solution:def wordBreak(self, s: str, wordDict: List[str]) -> List[str]:"""将字符串分割为字典中的单词组合(回溯算法实现)该方法通过回溯算法找出所有可能的单词分割方式,使得分割后的每个单词都存在于给定的字典中。使用记忆化剪枝优化算法效率,避免重复计算。:param s: str 待分割的字符串:param wordDict: List[str] 单词字典列表:return: List[str] 返回所有有效的单词分割方案,每个方案是用空格分隔的字符串"""word_set = set(wordDict) # 转换为集合提高查找效率max_len = max(len(word) for word in wordDict) if wordDict else 0 # 字典中最长单词长度n = len(s) # 字符串长度res = [] # 存储所有有效的分割方案track = [] # 记录当前递归路径(当前分割的单词序列)def backtrack(idx: int) -> None:"""回溯递归核心函数通过递归尝试所有可能的分割方式,使用最大单词长度剪枝优化搜索过程。:param idx: int 当前处理的字符串起始索引:return: None"""# 终止条件:已处理完整个字符串if idx == n:res.append(" ".join(track)) # 将当前单词序列用空格连接成字符串return# 尝试所有可能的单词长度(从1到最大单词长度)for word_len in range(1, max_len + 1):# 剪枝条件1:确保不越界if idx + word_len > n:break # 由于单词长度递增,后续长度只会更大,直接终止循环# 剪枝条件2:当前子串必须在字典中word = s[idx:idx + word_len]if word in word_set:track.append(word) # 做出选择,将当前单词加入序列backtrack(idx + word_len) # 递归处理剩余字符串track.pop() # 撤销选择,回溯backtrack(0) # 从字符串起始位置开始回溯return res
17. 电话号码的字母组合
class Solution:def letterCombinations(self, digits: str) -> List[str]:# 边界条件处理:空输入直接返回空列表if not digits:return []# 数字到字母的映射字典(九宫格键盘布局)digit_to_letters: Dict[str, str] = {"2": "abc","3": "def","4": "ghi","5": "jkl","6": "mno","7": "pqrs","8": "tuv","9": "wxyz"}n = len(digits) # 输入数字字符串的长度res = [] # 存储所有字母组合结果track = [] # 记录当前递归路径(当前正在构建的字母组合)def backtrack(idx: int) -> None:"""回溯递归核心函数通过递归生成所有可能的字母组合,每个数字对应多个字母选择。:param idx: int 当前处理的数字索引:return: None"""# 终止条件:已处理完所有数字if idx == n:res.append("".join(track)) # 将当前字母组合转为字符串returncurrent_digit = digits[idx] # 当前处理的数字# 遍历当前数字对应的所有字母for letter in digit_to_letters[current_digit]:track.append(letter) # 做出选择,添加当前字母backtrack(idx + 1) # 递归处理下一个数字track.pop() # 撤销选择,回溯backtrack(0) # 从第一个数字开始回溯return res
437. 路径总和 III
这是一道综合性很强的题,需要我们在二叉树的结构上使用前缀和、枚举右维护左以及回溯等技巧。
- 首先是要通过二叉树的前序遍历来获取每个节点的值;
- 然后利用枚举右维护左的思想,来维护一个前缀和字典,记录从根节点到当前节点这一路径上出现的路径和;
- 最后是运用回溯的思想,在我们遍历完当前节点的左右子树后,退出当前节点之前,撤销刚进入时向前缀和字典添加的记录。
class Solution:def pathSum(self, root: Optional[TreeNode], targetSum: int) -> int:"""统计二叉树中和等于目标值的路径数量(前缀和算法实现)该方法使用深度优先搜索结合前缀和技巧,高效统计二叉树中路径和等于目标值的所有路径数量。通过维护前缀和字典,可以在O(n)时间内解决问题。:param root: Optional[TreeNode] 二叉树的根节点:param targetSum: int 需要匹配的目标和值:return: int 返回满足条件的路径数量"""prefix_sum_count = defaultdict(int) # 前缀和计数器prefix_sum_count[0] = 1 # 初始化前缀和为0的计数为1result = 0 # 存储满足条件的路径总数def dfs(node: Optional[TreeNode], current_sum: int) -> None:"""深度优先搜索递归函数遍历二叉树的同时维护前缀和计数,统计满足条件的路径数量。:param node: Optional[TreeNode] 当前访问的树节点:param current_sum: int 从根节点到当前节点的路径和:return: None"""nonlocal resultif node is None:return# 计算当前路径和current_sum += node.val# 查找满足 current_sum - targetSum 的前缀和数量result += prefix_sum_count[current_sum - targetSum]# 做选择:更新当前前缀和的计数prefix_sum_count[current_sum] += 1# 递归遍历左右子树dfs(node.left, current_sum)dfs(node.right, current_sum)# 撤销选择:回溯时恢复前缀和计数(重要)prefix_sum_count[current_sum] -= 1dfs(root, 0) # 从根节点开始遍历,初始前缀和为0return result
131. 分割回文串
class Solution:def partition(self, s: str) -> List[List[str]]:n = len(s) # 字符串长度res = [] # 存储所有回文分割方案track = [] # 记录当前递归路径(当前分割的回文子串列表)def backtrack(start: int) -> None:"""回溯递归核心函数从指定位置开始寻找所有可能的回文子串分割方案。:param start: int 当前处理的起始索引:return: None"""# 终止条件:已处理完整个字符串if start == n:res.append(track.copy()) # 保存当前分割方案return# 尝试所有可能的结束位置for end in range(start, n):# 检查当前子串是否为回文substring = s[start:end + 1]if substring == substring[::-1]: # 回文判断track.append(substring) # 做出选择,添加回文子串backtrack(end + 1) # 递归处理剩余字符串track.pop() # 撤销选择,回溯backtrack(0) # 从字符串起始位置开始回溯return res