从零开始的数据结构教程(七) 回溯算法
🔄 标题一:回溯核心思想——走迷宫时的“穷举+回头”策略
回溯算法 (Backtracking) 是一种通过探索所有可能的候选解来找出所有的解或某些解的算法。它就像你在一个复杂的迷宫中寻找出路:当你遇到一个岔路口时,你会选择一条路继续走下去;如果走到了死胡同,你就会回溯到上一个岔路口,尝试另一条路。这个过程包含了穷举所有可能性,并在发现无效路径时及时“回头”。
三大核心特征
- 决策树遍历:回溯过程可以被想象成遍历一棵决策树。树的每个节点代表一个决策点,每次选择都相当于从当前节点走向一个子节点。例如,在全排列问题中,每一步你都从剩余的数字中选择一个来填充当前位置。
- 状态回退 (Backtrack):当当前路径无法满足条件(走到死胡同)时,你需要撤销最近的决策,回到上一个决策点,尝试其他分支。这通常通过在递归调用后恢复之前的状态来实现。例如,在 N 皇后问题中,放置皇后后,如果后续无法找到解,就需要移除这个皇后,尝试其他位置。
- 剪枝优化 (Pruning):这是回溯算法的关键优化手段。在决策树的某个节点,如果你能判断出当前分支的后续路径不可能得到有效解,就可以提前终止对该分支的探索,避免不必要的计算。例如,在组合总和问题中,如果当前累加的和已经超过了目标值,就没必要继续往下加了。
通用代码模板
回溯算法通常采用递归的方式实现,可以抽象出以下通用模板:
def backtrack(路径, 选择列表):# 1. 满足结束条件:找到一个解,将其添加到结果集if 满足结束条件:结果.append(路径.copy()) # 注意:这里必须是深拷贝,否则后续路径修改会影响已保存的结果return# 2. 遍历所有可能的选择for 选择 in 选择列表:# 3. 剪枝:如果当前选择不合法(不满足约束条件),则跳过if 选择不合法:continue# 4. 做选择:将当前选择添加到路径中路径.add(选择) # 或者 path.append(选择) 等# 5. 递归:进入下一个决策层backtrack(路径, 新选择列表) # 新选择列表可能根据当前选择更新# 6. 状态回退:撤销当前选择,为下一次循环做准备路径.remove(选择) # 或者 path.pop() 等
♟️ 标题二:排列/组合问题——彩票号码生成器
排列和组合是回溯算法最基础也最常见的应用场景。它们之间的核心区别在于是否考虑元素的顺序以及是否允许元素重复使用。
全排列(LeetCode 46)
- 问题:给定一个不含重复数字的数组
nums
,返回其所有可能的全排列。 - 特点:每个元素只能使用一次,顺序不同算作不同排列。
def permute(nums):res = [] # 存储所有结果的列表n = len(nums)# backtrack 函数:# path: 当前已经形成的排列# used: 记录哪些数字已经被使用过,用集合(set)方便快速查找和删除def backtrack(path, used):# 满足结束条件:当路径的长度等于原数组长度时,说明一个排列已完成if len(path) == n:res.append(path.copy()) # 注意深拷贝return# 遍历所有可能的选择for num in list(used): # 遍历可用数字的副本,因为循环内会修改 used# 做选择:将当前数字添加到路径path.append(num)# 更新选择列表:从可用数字中移除当前数字used.remove(num)# 递归:进入下一个决策层backtrack(path, used)# 状态回退:撤销选择,恢复可用数字path.pop()used.add(num)backtrack([], set(nums)) # 初始调用:空路径,所有数字都可用return res# 示例
# print(permute([1, 2, 3]))
# 输出: [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
- 关键区别:排列问题在每次递归时,都从所有未使用的数字中选择一个。而组合问题通常通过限制遍历的起始索引来避免重复组合和处理顺序。
组合总和(LeetCode 39)
- 问题:给定一个无重复元素的数组
candidates
和一个目标和target
。找出candidates
中所有可以使数字和为target
的组合。candidates
中的数字可以无限制重复被选取。 - 特点:元素可以重复使用,组合不考虑顺序。
def combinationSum(candidates, target):res = []candidates.sort() # 排序是关键剪枝,方便后续判断和跳过# backtrack 函数:# start: 当前轮次开始遍历 candidates 的索引,用于避免重复组合# path: 当前已经形成的组合# remain: 还需要凑齐的剩余目标值def backtrack(start, path, remain):# 满足结束条件:找到一个解if remain == 0:res.append(path.copy())return# 剪枝:如果剩余值小于0,说明当前路径无法达到目标值,直接返回if remain < 0:return# 遍历所有可能的选择(从 start 索引开始,避免重复组合)for i in range(start, len(candidates)):# 剪枝:如果当前候选数已经大于剩余目标值,则后续的数也肯定大于,直接中断循环if candidates[i] > remain:break # 因为 candidates 已排序# 做选择:将当前数添加到路径path.append(candidates[i])# 递归:进入下一个决策层。注意这里递归调用时传入的是 `i` 而不是 `i+1`,# 允许当前数字重复选取backtrack(i, path, remain - candidates[i])# 状态回退:撤销选择path.pop()backtrack(0, [], target) # 初始调用:从索引0开始,空路径,目标值为 targetreturn res# 示例
# print(combinationSum([2, 3, 6, 7], 7))
# 输出: [[2, 2, 3], [7]]
👑 标题三:N 皇后问题——棋盘上的冲突检测
N 皇后问题是回溯算法的经典应用,它完美展示了如何通过递归和剪枝来解决约束满足问题。
问题变形
- 经典 N 皇后:在一个 N × N N \times N N×N 的棋盘上放置
N
个皇后,使得它们之间互不攻击(即任意两个皇后不能在同一行、同一列或同一对角线上)。 - 数独求解器(LeetCode 37):同样是基于回溯,通过尝试填充数字并检查约束来解决数独。通常会结合位运算等技巧来优化冲突检测。
冲突检测优化
在 N 皇后问题中,高效地判断一个位置是否能放置皇后是关键。除了使用布尔数组或集合记录已占用的行/列/对角线外,还可以利用数学关系来优化:
- 列冲突:
col
集合记录已占用的列索引。 - 主对角线冲突 (从左上到右下):同一主对角线上的
(row, col)
满足row - col
为常数。 - 副对角线冲突 (从右上到左下):同一副对角线上的
(row, col)
满足row + col
为常数。
def solveNQueens(n):res = [] # 存储所有解决方案# 记录已占用的列、主对角线、副对角线cols = set() # 记录已占用的列索引diag1 = set() # 记录已占用的主对角线索引 (row - col)diag2 = set() # 记录已占用的副对角线索引 (row + col)# path 用二维列表表示棋盘,'.' 为空,'Q' 为皇后# 初始化一个 n*n 的棋盘,全部填充 '.'board = [['.'] * n for _ in range(n)]# backtrack 函数:尝试在当前行放置皇后# row: 当前正在考虑放置皇后的行def backtrack(row):# 满足结束条件:所有行都已成功放置皇后if row == n:# 将当前棋盘(board)转换为字符串列表形式,并添加到结果res.append(["".join(r) for r in board])return# 遍历当前行的所有列,尝试放置皇后for col in range(n):# 剪枝:检查当前位置 (row, col) 是否会发生冲突if col in cols or (row - col) in diag1 or (row + col) in diag2:continue # 如果冲突,则跳过当前列,尝试下一列# 做选择:放置皇后board[row][col] = 'Q'cols.add(col)diag1.add(row - col)diag2.add(row + col)# 递归:进入下一行,继续放置皇后backtrack(row + 1)# 状态回退:撤销选择,将当前位置的皇后移除,并从集合中移除对应信息board[row][col] = '.'cols.remove(col)diag1.remove(row - col)diag2.remove(row + col)backtrack(0) # 从第 0 行开始尝试放置皇后return res# 示例
# print(solveNQueens(4))
# 输出类似棋盘布局的字符串列表
✂️ 标题四:回溯剪枝实战——火柴拼正方形(LeetCode 473)
火柴拼正方形问题是一个很好的回溯与剪枝结合的例子。它要求你将给定长度的火柴分配到四条边,使得它们能构成一个正方形。
问题转化
- 将数组
matchsticks
分成四组,每组火柴的长度之和都等于正方形的边长(即总和 / 4
)。 - 这本质上是一个多组划分问题,可以用回溯法解决。
剪枝策略
高效的剪枝是解决此问题的关键:
- 初始检查:如果所有火柴的总长度不能被 4 整除,或者火柴数量小于 4,则直接返回
False
。 - 排序:将火柴棍按从大到小的顺序排序。这样,长的火柴棍会优先被尝试放置,如果它们无法适应,可以更快地进行剪枝。
- 当前边超长:在尝试放置火柴时,如果当前火柴加上某条边的当前长度超过了目标边长,则直接跳过该火柴。
- 跳过重复状态:如果当前火柴尝试放在
sides[j]
后失败了,那么当sides[j]
与sides[j-1]
相等时,再次尝试将当前火柴放在sides[j-1]
会导致重复的搜索路径,可以跳过。这要求sides
数组在每次递归前都是有序的,或者通过其他方式避免重复尝试。
def makesquare(matchsticks):total = sum(matchsticks)if total % 4 != 0 or len(matchsticks) < 4:return Falseside = total // 4 # 计算正方形的边长matchsticks.sort(reverse=True) # 关键剪枝:从大到小排序火柴棍# sides 数组:表示四条边的当前长度# 初始化为 [0, 0, 0, 0]sides = [0] * 4# backtrack 函数:尝试将第 i 根火柴放到四条边中的一条# i: 当前正在考虑的火柴棍索引# sides: 四条边的当前长度def backtrack(i):# 满足结束条件:所有火柴都已成功放置if i == len(matchsticks):# 检查四条边的长度是否都等于目标边长return all(s == side for s in sides)# 遍历四条边,尝试将当前火柴放入其中for j in range(4):# 剪枝1:如果将当前火柴放入 sides[j] 会使该边超长,则跳过if sides[j] + matchsticks[i] > side:continue# 剪枝2:如果当前边 sides[j] 和前一条边 sides[j-1] 的长度相同,# 且前一条边在尝试放置当前火柴后失败了,那么再次尝试放在这条相同的边上也会失败。# 这有助于避免重复的搜索路径。这个剪枝的前提是 `sides` 数组是排序的,# 但在这里,`sides` 只是记录每条边的累加长度,不是排序的。# 更精确的剪枝是:如果当前 `sides[j]` 的长度和 `sides[j-1]` 相同,# 且它们是空的(即还没开始累加),或者当前火柴和前一个火柴相同,# 可以考虑跳过。但最简单的形式就是只看 `sides[j]` 的值。# 这里的 `j > 0 and sides[j] == sides[j-1]` 剪枝,# 实际上是利用了 `sides` 数组的相对顺序来避免重复计算,# 只有当 `sides` 是有序处理时才有效。# 在本例中,因为火柴是倒序排的,这个剪枝可能需要更精细的判断。# 最简单有效的剪枝是直接检查 `sides[j] + matchsticks[i] > side`。# 为避免误解,我们暂时移除 `j > 0 and sides[j] == sides[j-1]` 剪枝,# 或者强调其适用场景和条件。在这里,更通用且安全的剪枝是 `j > 0 and sides[j] == sides[j-1]` # 只有当 `sides` 数组中的元素(代表边长)是唯一值时才考虑。# 对于本问题,通常不进行此剪枝,或使用更严格的条件。# 做选择:将当前火柴添加到 sides[j]sides[j] += matchsticks[i]# 递归:尝试放置下一根火柴if backtrack(i + 1):return True # 如果找到了一个解决方案,则直接返回 True# 状态回退:撤销选择,将当前火柴从 sides[j] 中移除sides[j] -= matchsticks[i]return False # 如果所有边都尝试过,仍无法放置当前火柴,则返回 Falsereturn backtrack(0) # 从第一根火柴(索引0)开始
📊 总结表:回溯问题类型
回溯算法的应用广泛,通常可以根据问题类型来划分:
问题类型 | 典型例题 | 剪枝技巧 |
---|---|---|
排列问题 | 全排列 II(LeetCode 47) | 排序后跳过重复数字,防止生成重复排列。 |
子集问题 | 子集(LeetCode 78)、组合(LeetCode 77) | 限制遍历的起始索引,确保组合唯一且避免重复。 |
分割问题 | 分割回文串(LeetCode 131) | 预处理所有子串的回文判断,避免重复计算。 |
棋盘/矩阵问题 | 解数独(LeetCode 37)、N 皇后 | 利用行/列/对角线/宫格标记已用数字,或位运算优化冲突检测。 |
组合优化 | 组合总和 II(LeetCode 40)、火柴拼正方形 | 排序输入,提前剪枝不符合条件的路径;跳过重复的决策分支。 |