回溯算法(Backtracking)常被称为"暴力解法的优化版"。它的核心思想是:一条路走到底,走不通就回头,换条路再走。
建议把回溯想象成在遍历一棵多叉树。
1. 全排列 (Permutations) - LeetCode 46
题目: 给定一个不含重复数字的数组 nums,返回其所有可能的全排列。
解题思路:
- 状态: 我们已经选了哪些数。
- 决策树: 第一层选一个数,第二层在剩下的数里选一个...
- 关键点: 使用一个
used数组记录哪些数已经被选过了,避免重复选择。
代码实现:
python
def permute(nums):
res = []
path = []
used = [False] * len(nums)
def backtrack():
if len(path) == len(nums):
res.append(path[:]) # 注意要拷贝一份 path
return
for i in range(len(nums)):
if used[i]: continue # 如果用过了,跳过
used[i] = True
path.append(nums[i])
backtrack() # 递归
path.pop() # 回溯:撤销选择
used[i] = False # 回溯:撤销标记
backtrack()
return res
2. 子集 (Subsets) - LeetCode 78
题目: 给你一个整数数组 nums,数组中的元素互不相同。返回该数组所有可能的子集。
解题思路:
- 直观理解: 每个数字都有"选"或"不选"两个状态。
- 树形结构: 为了防止重复(比如
[1,2]和[2,1]被视为同一个子集),我们规定只能往后选 。引入一个start参数。 - 特点: 树上的每一个节点都是我们要的答案。
代码实现:
python
def subsets(nums):
res = []
path = []
def backtrack(start):
# 每一个节点都是子集,直接加入结果
res.append(path[:])
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1) # 从下一个数开始选,避免重复
path.pop()
backtrack(0)
return res
3. 电话号码的字母组合 - LeetCode 17
题目: 给定一个数字字符串(2-9),返回它能表示的所有字母组合。
解题思路:
- 映射: 先建立数字到字母的映射(如
2: "abc")。 - 层级: 字符串有多长,递归就有几层。第一层选第一个数字对应的字母,第二层选第二个数字的字母。
代码实现:
python
def letterCombinations(digits):
if not digits: return []
phone = {'2':"abc", '3':"def", '4':"ghi", '5':"jkl",
'6':"mno", '7':"pqrs", '8':"tuv", '9':"wxyz"}
res = []
def backtrack(index, path):
if index == len(digits):
res.append("".join(path))
return
letters = phone[digits[index]]
for char in letters:
path.append(char)
backtrack(index + 1, path)
path.pop()
backtrack(0, [])
return res
4. 组合总和 (Combination Sum) - LeetCode 39
题目: 给定一个无重复元素的数组 candidates 和一个目标数 target,找出使数字和为 target 的所有组合。数字可以无限制重复选择。
解题思路:
- 去重策略: 同样使用
start索引,保证搜索顺序,不回回头选前面的数。 - 可重复选: 在递归时,传进去的索引依然是
i而不是i + 1,表示当前数字还能接着选。 - 剪枝: 如果当前和已经大于
target,直接返回。
代码实现:
python
def combinationSum(candidates, target):
res = []
path = []
def backtrack(start, current_sum):
if current_sum == target:
res.append(path[:])
return
if current_sum > target:
return
for i in range(start, len(candidates)):
path.append(candidates[i])
# 注意这里还是传 i,因为可以重复使用同一个数字
backtrack(i, current_sum + candidates[i])
path.pop()
backtrack(0, 0)
return res
5. 括号生成 (Generate Parentheses) - LeetCode 22
题目: 数字 n 代表生成括号的对数,设计一个函数,用于能够生成所有可能的并且有效的括号组合。
解题思路:
- 本质: 这其实是做选择:放左括号还是右括号?
- 有效性限制:
- 只要左括号数量
< n,就可以放左括号。 - 只要右括号数量
< 左括号数量,就可以放右括号(保证括号能闭合)。
- 只要左括号数量
代码实现:
python
def generateParenthesis(n):
res = []
def backtrack(S, left, right):
# 长度达到 2n,说明括号放完了
if len(S) == 2 * n:
res.append(S)
return
# 如果左括号还没放够,就放一个左括号
if left < n:
backtrack(S + '(', left + 1, right)
# 如果右括号比左括号少,说明可以放个右括号来匹配
if right < left:
backtrack(S + ')', left, right + 1)
backtrack("", 0, 0)
return res
继续进阶。回溯算法在处理二维网格和复杂约束(如 N 皇后)时,核心逻辑依然是**"尝试 -> 递归 -> 撤销"**。
6. 单词搜索 (Word Search) - LeetCode 79
题目: 给定一个二维网格 board 和一个字符串单词 word。判断单词是否存在于网格中(必须按顺序,通过相邻单元格连接,不能重复使用同一单元格)。
解题思路:
- 搜索方式: 遍历网格的每一个格子作为起点。从起点开始,向上下左右四个方向进行 DFS(深度优先搜索)。
- 回溯核心:
- 检查当前格子是否匹配
word的第k个字母。 - 为了防止"回头路",在递归前把当前格子改一个临时字符(如
'#'),递归结束之后再改回来。
- 检查当前格子是否匹配
- 剪枝: 越界、字符不匹配、或已访问过的格子直接返回
False。
代码实现:
python
def exist(board, word):
rows, cols = len(board), len(board[0])
def backtrack(r, c, k):
# 递归出口:单词所有字母都匹配完了
if k == len(word):
return True
# 边界检查及字符匹配检查
if r < 0 or r >= rows or c < 0 or c >= cols or board[r][c] != word[k]:
return False
# 1. 做选择:标记当前格子已访问
temp = board[r][c]
board[r][c] = '#'
# 2. 递归:向四个方向探索
res = (backtrack(r+1, c, k+1) or
backtrack(r-1, c, k+1) or
backtrack(r, c+1, k+1) or
backtrack(r, c-1, k+1))
# 3. 撤销选择:恢复现场
board[r][c] = temp
return res
for r in range(rows):
for c in range(cols):
if backtrack(r, c, 0): return True
return False
7. 分割回文串 (Palindrome Partitioning) - LeetCode 131
题目: 给你一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。返回所有可能的分割方案。
解题思路:
- 视角转换: 想象你在字符串的缝隙中"切几刀"。
- 决策树:
- 第一刀可以切在第 1 个字符后、第 2 个...直到末尾。
- 限制条件: 只有当切下来的"左侧部分"是回文串时,才继续对"右侧剩余部分"进行递归切割。
- 终止条件: 切到了字符串的末尾。
代码实现:
python
def partition(s):
res = []
path = []
def is_palindrome(sub):
return sub == sub[::-1]
def backtrack(start):
if start == len(s):
res.append(path[:])
return
for i in range(start, len(s)):
# 切下 s[start:i+1]
sub = s[start : i+1]
if is_palindrome(sub):
path.append(sub) # 1. 做选择
backtrack(i + 1) # 2. 递归
path.pop() # 3. 撤销选择
backtrack(0)
return res
8. N 皇后 (N-Queens) - LeetCode 51
题目: 在 n×nn \times nn×n 的棋盘上放置 nnn 个皇后,使其不能互相攻击(同一行、同一列、同一条斜线都不能有两个皇后)。返回所有可能的方案。
解题思路:
- 逐行放置: 每一行只放一个皇后。这样就天然解决了"行冲突"。
- 列与斜线冲突判断:
- 列: 用一个集合
cols记录哪些列放过了。 - 正斜线 (左上到右下): 同一条线上的行索引 rrr 和列索引 ccc 的差值
r - c是固定的。 - 副斜线 (右上到左下): 同一条线上的行索引 rrr 和列索引 ccc 的和
r + c是固定的。
- 列: 用一个集合
- 回溯过程: 尝试在当前行的每一列放皇后 -> 检查冲突 -> 递归下一行 -> 撤销。
代码实现:
python
def solveNQueens(n):
res = []
board = [['.'] * n for _ in range(n)]
# 记录哪些列、正斜线、副斜线已被占用
cols = set()
diag1 = set() # r - c
diag2 = set() # r + c
def backtrack(r):
if r == n:
# 找到一个解,把棋盘转成字符串格式
res.append(["".join(row) for row in board])
return
for c in range(n):
if c in cols or (r - c) in diag1 or (r + c) in diag2:
continue
# 1. 做选择
board[r][c] = 'Q'
cols.add(c)
diag1.add(r - c)
diag2.add(r + c)
# 2. 递归下一行
backtrack(r + 1)
# 3. 撤销选择(回溯)
board[r][c] = '.'
cols.remove(c)
diag1.remove(r - c)
diag2.remove(r + c)
backtrack(0)
return res
💡 综合总结:如何攻克"回溯"?
-
确定"路径"和"选择列表":
- 全排列:选择列表是还没用过的数字。
- 单词搜索:选择列表是上下左右。
- N 皇后:选择列表是当前行所有不冲突的列。
-
判断"终止条件":
- 通常是索引达到数组边界,或者路径长度达到目标。
-
理解"回溯"的精髓:
- 回溯不是为了"删掉数据",而是为了**"恢复现场"**。
- 因为所有的递归分支都共享同一个状态变量(如
path数组、棋盘board),如果不撤销上一条路径的操作,下一条路径就会被污染。
-
去重与剪枝(进阶):
- 如果题目包含重复元素(如 LeetCode 90. 子集 II),通常需要先排序 ,然后在循环中判断
if i > start and nums[i] == nums[i-1]: continue。
- 如果题目包含重复元素(如 LeetCode 90. 子集 II),通常需要先排序 ,然后在循环中判断
💡 刷题总结(给新手的建议):
-
回溯模版:
pythondef backtrack(参数): if 终止条件: 存放结果 return for 选择 in 选择列表: 做选择 backtrack(下一个路径) 撤销选择 (回溯) -
何时用
start?- 如果是组合/子集 问题(不考虑顺序,
[1,2]和[2,1]一样),需要start防止往回走。 - 如果是排列 问题(考虑顺序,两者不一样),不需要
start,但需要used数组记录。
- 如果是组合/子集 问题(不考虑顺序,
-
画图: 第一次做回溯题,一定要在纸上画出那棵"递归树"。理解了树的深度(递归)和广度(for循环),回溯就掌握了一半。