引言
你是否曾遇到过这样的问题:面对一个复杂的决策过程,每一步都有多种选择,你需要找出所有满足条件的解决方案?比如,如何列出数字1, 2, 3的所有可能排列?或者,如何从一个集合中找出所有和为特定值的子集?这类问题看似千变万化,但它们背后都隐藏着一种强大而优雅的解决思路——回溯算法。
回溯算法是解决“枚举所有可能性”问题的利器,它本质上是一种采用深度优先搜索策略的暴力尝试法。但与纯粹的暴力枚举不同,回溯算法融入了“试错”与“剪枝”的思想:它像一位聪明的探险家,在决策树的丛林中探索每一条路径;一旦发现某条路不可能通向目的地,便果断折返,尝试下一个岔路口。这种“选择-验证-撤销”的核心步骤,使得它在许多场景下比盲目枚举高效得多。
在本篇文章中,我们将从最经典的排列、组合、子集问题入手,由浅入深地揭开回溯算法的神秘面纱。你将看到,一个清晰易懂的回溯模板如何成为解决这些问题的“万能钥匙”。更重要的是,我们将一起探索这个基础模板如何灵活变通,应用于更复杂的场景。
一、回溯框架
核心框架
def backtrack(选项表):
if 满足结束条件:
保存结果
return
for 选项 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] = True
track.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]:
continue
track.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] = True
track.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]中重复元的
2
2
2的相对位置发生了变化,不应该选择,
这样,就仅存在
[
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表示目前已经选择了几个,进入下一次需要加1
track.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
"""
# 终止条件:已选元素个数等于k
if 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-9
m = len(nums) # 候选数字长度(固定为9)
res = [] # 存储所有符合条件的组合
track = [] # 记录当前递归路径(当前尝试的组合)
def backtrack(start: int, sum_val: int) -> None:
"""
回溯递归核心函数
通过递归遍历所有可能的组合方式,使用剪枝条件优化搜索过程。
:param start: int 当前可选的起始索引
:param sum_val: int 当前路径的数字和
:return: None
"""
# 终止条件:组合长度等于k且和等于n
if 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:
break
track.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] = True
track.append(nums[i])
# 递归进入下一层决策树
backtrack(cnt + 1)
# 撤销选择(回溯)
track.pop()
used[i] = False
backtrack(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]:
continue
track.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:
break
track.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:
break
track.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. 单词拆分中,需要判断
s
s
s是否能拆分为
w
o
r
d
D
i
c
t
wordDict
wordDict,我们只用关心能否、有无、对错,最值,而不关心到底有几种“能”的情况,这类型的题一般用动态规划,即根据递推公式一步一步布林布林地从头计算到尾,返回
d
p
dp
dp表的最后一个结果即可。
而本题和139. 单词拆分不一样,该题更关心的是将 s s s拆分成 w o r d D i c t wordDict wordDict中的单词,总共有多少种拆分方式,而这用到则应该是回溯,因为你回溯中的路径 t r a c k track track正好可以用来存放不同的可能。
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)) # 将当前字母组合转为字符串
return
current_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的计数为1
result = 0 # 存储满足条件的路径总数
def dfs(node: Optional[TreeNode], current_sum: int) -> None:
"""
深度优先搜索递归函数
遍历二叉树的同时维护前缀和计数,统计满足条件的路径数量。
:param node: Optional[TreeNode] 当前访问的树节点
:param current_sum: int 从根节点到当前节点的路径和
:return: None
"""
nonlocal result
if 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] -= 1
dfs(root, 0) # 从根节点开始遍历,初始前缀和为0
return 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
1041

被折叠的 条评论
为什么被折叠?



