1. 回溯算法核心思想回顾
在正式进入LeetCode Hot 100回溯专题的第二部分之前,我们先快速回顾一下回溯算法的基本框架。回溯本质上是一种暴力搜索算法,通过递归遍历所有可能的解空间,并在过程中通过剪枝策略减少不必要的计算。
回溯算法的通用模板如下:
python复制def backtrack(路径, 选择列表):
if 满足结束条件:
结果集.append(路径)
return
for 选择 in 选择列表:
做选择
backtrack(新的路径, 新的选择列表)
撤销选择
这个模板在解决排列、组合、子集等问题时表现出强大的适用性。在第一部分中,我们已经通过数独求解、全排列等问题验证了其有效性。今天我们将继续探索更复杂的回溯应用场景。
2. 组合总和问题深度解析
2.1 组合总和I(LeetCode 39)
题目要求:给定一个无重复元素的数组和一个目标数,找出所有可以使数字和为目标数的组合。同一个数字可以重复使用。
关键点分析:
- 元素可重复使用意味着在递归时不需要跳过当前元素
- 需要排序数组以便于剪枝
- 终止条件是当前和等于target
优化后的解法:
python复制def combinationSum(candidates, target):
res = []
candidates.sort()
def backtrack(start, path, target):
if target == 0:
res.append(path.copy())
return
for i in range(start, len(candidates)):
if candidates[i] > target:
break # 剪枝
path.append(candidates[i])
backtrack(i, path, target - candidates[i]) # 注意这里传入i而不是i+1
path.pop()
backtrack(0, [], target)
return res
时间复杂度分析:
最坏情况下为O(N^(T/M+1)),其中N是候选数个数,T是目标数,M是候选数中的最小值。这个复杂度来自于解空间树的高度和宽度。
2.2 组合总和II(LeetCode 40)
与前一题的区别在于:
- 数组中可能包含重复元素
- 每个数字在每个组合中只能使用一次
去重策略:
python复制def combinationSum2(candidates, target):
res = []
candidates.sort()
def backtrack(start, path, target):
if target == 0:
res.append(path.copy())
return
for i in range(start, len(candidates)):
# 关键去重逻辑
if i > start and candidates[i] == candidates[i-1]:
continue
if candidates[i] > target:
break
path.append(candidates[i])
backtrack(i+1, path, target - candidates[i]) # 这里i+1保证不重复使用
path.pop()
backtrack(0, [], target)
return res
去重原理说明:
当candidates[i] == candidates[i-1]时,如果i > start,说明在同一层级已经处理过相同的数字,跳过以避免重复解。这种去重方式比使用集合更高效。
3. 分割回文串问题(LeetCode 131)
3.1 问题分析与解法
题目要求将字符串分割成若干子串,使得每个子串都是回文串,返回所有可能的分割方案。
解法框架:
python复制def partition(s):
res = []
def isPalindrome(sub):
return sub == sub[::-1]
def backtrack(start, path):
if start == len(s):
res.append(path.copy())
return
for end in range(start+1, len(s)+1):
substr = s[start:end]
if isPalindrome(substr):
path.append(substr)
backtrack(end, path)
path.pop()
backtrack(0, [])
return res
3.2 性能优化策略
原始解法中对每个子串都进行了完整的回文检查,可以通过动态规划预处理优化:
python复制def partition(s):
n = len(s)
dp = [[False]*n for _ in range(n)]
for i in range(n):
dp[i][i] = True
for l in range(2, n+1):
for i in range(n-l+1):
j = i + l - 1
if s[i] == s[j] and (l == 2 or dp[i+1][j-1]):
dp[i][j] = True
res = []
def backtrack(start, path):
if start == n:
res.append(path.copy())
return
for end in range(start, n):
if dp[start][end]:
path.append(s[start:end+1])
backtrack(end+1, path)
path.pop()
backtrack(0, [])
return res
优化效果:
预处理时间复杂度O(N^2),将每次回文检查的O(N)降为O(1),整体时间复杂度从O(N*2^N)优化到O(2^N)。
4. 复原IP地址问题(LeetCode 93)
4.1 问题约束分析
题目要求将数字字符串恢复成有效的IP地址格式,需要满足:
- 分成恰好4段
- 每段数字在0-255之间
- 不能有前导零(除非本身就是0)
解法实现:
python复制def restoreIpAddresses(s):
res = []
def backtrack(start, path):
if len(path) == 4:
if start == len(s):
res.append(".".join(path))
return
for l in range(1, 4):
if start + l > len(s):
break
segment = s[start:start+l]
if (len(segment) > 1 and segment[0] == '0') or int(segment) > 255:
continue
path.append(segment)
backtrack(start+l, path)
path.pop()
backtrack(0, [])
return res
4.2 剪枝策略详解
- 长度限制:每段最多3位数字,所以循环范围是1-3
- 前导零检查:长度大于1且首字符为'0'则跳过
- 数值范围:转换为整数后必须≤255
- 剩余长度检查:确保剩余字符足够组成剩余段数
5. 子集问题进阶
5.1 子集II(LeetCode 90)
与普通子集问题的区别在于输入可能包含重复元素,需要去重。
去重实现:
python复制def subsetsWithDup(nums):
res = []
nums.sort()
def backtrack(start, path):
res.append(path.copy())
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()
backtrack(0, [])
return res
去重原理:
排序后,相同元素会相邻。当i > start且当前元素等于前一个元素时,说明在同一层级已经处理过该数值,跳过以避免重复子集。
6. 排列问题变种
6.1 全排列II(LeetCode 47)
与标准全排列的区别在于输入可能包含重复数字,需要去除重复排列。
解法实现:
python复制def permuteUnique(nums):
res = []
nums.sort()
used = [False] * len(nums)
def backtrack(path):
if len(path) == len(nums):
res.append(path.copy())
return
for i in range(len(nums)):
if used[i] or (i > 0 and nums[i] == nums[i-1] and not used[i-1]):
continue
used[i] = True
path.append(nums[i])
backtrack(path)
path.pop()
used[i] = False
backtrack([])
return res
去重关键:
not used[i-1]的判断确保了只有当前一个相同元素未被使用时才跳过,这保证了相同元素的相对顺序,避免生成重复排列。
7. 回溯算法优化技巧总结
7.1 常见剪枝策略
- 排序预处理:使相同元素相邻,便于去重
- 和值剪枝:在组合总和问题中,当当前和超过目标时提前终止
- 层级去重:通过比较与前一个元素的关系避免同一层级的重复
- 剩余长度检查:在IP地址等问题中提前判断剩余字符是否足够
7.2 状态记录方式对比
- 索引传递:适用于组合类问题(如combinationSum)
- 使用标记数组:适用于排列类问题(如permuteUnique)
- 路径记录:通用方法,记录当前选择路径
7.3 时间复杂度分析指南
- 组合问题:通常O(2^N)
- 排列问题:通常O(N!)
- 带剪枝的问题:实际复杂度可能远低于理论最坏情况
8. 实战问题解析
8.1 单词拆分II(LeetCode 140)
题目要求:给定一个非空字符串和一个包含非空单词列表的字典,在字符串中增加空格来构建一个句子,使得句子中所有的单词都在词典中。
解法实现:
python复制def wordBreak(s, wordDict):
wordSet = set(wordDict)
res = []
def backtrack(start, path):
if start == len(s):
res.append(" ".join(path))
return
for end in range(start+1, len(s)+1):
word = s[start:end]
if word in wordSet:
path.append(word)
backtrack(end, path)
path.pop()
backtrack(0, [])
return res
优化建议:
可以先用动态规划判断是否可分割,避免无意义的递归:
python复制def wordBreak(s, wordDict):
wordSet = set(wordDict)
n = len(s)
dp = [False]*(n+1)
dp[0] = True
for i in range(1, n+1):
for j in range(i):
if dp[j] and s[j:i] in wordSet:
dp[i] = True
break
if not dp[n]:
return []
res = []
def backtrack(start, path):
if start == n:
res.append(" ".join(path))
return
for end in range(start+1, n+1):
word = s[start:end]
if word in wordSet and (end == n or dp[end]):
path.append(word)
backtrack(end, path)
path.pop()
backtrack(0, [])
return res
8.2 括号生成(LeetCode 22)
虽然通常归类为DFS问题,但其回溯本质相同:
python复制def generateParenthesis(n):
res = []
def backtrack(left, right, path):
if len(path) == 2*n:
res.append("".join(path))
return
if left < n:
path.append('(')
backtrack(left+1, right, path)
path.pop()
if right < left:
path.append(')')
backtrack(left, right+1, path)
path.pop()
backtrack(0, 0, [])
return res
关键点:
- 左括号数量不超过n
- 右括号数量不超过左括号
- 通过参数传递当前左右括号计数
9. 回溯算法常见误区
9.1 路径复用的陷阱
错误示范:
python复制res.append(path) # 错误!后续修改会影响已存储的结果
正确做法:
python复制res.append(path.copy()) # 必须创建副本
9.2 去重时机的混淆
常见错误是在不同层级进行去重,正确的去重应该在同一层级进行:
python复制# 正确去重方式
if i > start and nums[i] == nums[i-1]:
continue
9.3 终止条件遗漏
特别是在处理字符串问题时,容易忘记检查是否已经处理完所有字符:
python复制# 必须检查是否到达字符串末尾
if start == len(s):
res.append(path.copy())
return
10. 回溯算法扩展应用
10.1 数独求解器(LeetCode 37)
虽然在第一部分已经介绍过,但这里提供一个更优化的版本:
python复制def solveSudoku(board):
def backtrack():
for i in range(9):
for j in range(9):
if board[i][j] == '.':
for num in '123456789':
if isValid(i, j, num):
board[i][j] = num
if backtrack():
return True
board[i][j] = '.'
return False
return True
def isValid(row, col, num):
for i in range(9):
if board[row][i] == num or board[i][col] == num:
return False
box_row, box_col = row//3*3, col//3*3
for i in range(3):
for j in range(3):
if board[box_row+i][box_col+j] == num:
return False
return True
backtrack()
优化点:
- 找到第一个空格后立即尝试填充,而不是收集所有空格
- 使用字符串而非整数比较,减少类型转换
- 提前返回避免不必要的搜索
10.2 N皇后问题(LeetCode 51)
经典的回溯应用:
python复制def solveNQueens(n):
res = []
def backtrack(row, cols, diag1, diag2, path):
if row == n:
res.append(["".join(r) for r in path])
return
for col in range(n):
d1, d2 = row-col, row+col
if col not in cols and d1 not in diag1 and d2 not in diag2:
cols.add(col)
diag1.add(d1)
diag2.add(d2)
path[row][col] = 'Q'
backtrack(row+1, cols, diag1, diag2, path)
path[row][col] = '.'
diag2.remove(d2)
diag1.remove(d1)
cols.remove(col)
empty_board = [['.']*n for _ in range(n)]
backtrack(0, set(), set(), set(), empty_board)
return res
位运算优化版:
python复制def solveNQueens(n):
res = []
def backtrack(row, cols, diag1, diag2, path):
if row == n:
res.append(["".join(r) for r in path])
return
available = ((1 << n) - 1) & ~(cols | diag1 | diag2)
while available:
pos = available & -available
col = bin(pos-1).count('1')
path[row][col] = 'Q'
backtrack(row+1, cols | pos, (diag1 | pos) << 1, (diag2 | pos) >> 1, path)
path[row][col] = '.'
available &= available - 1
empty_board = [['.']*n for _ in range(n)]
backtrack(0, 0, 0, 0, empty_board)
return res
11. 回溯算法性能调优
11.1 记忆化搜索应用
在某些问题中,可以通过缓存中间结果避免重复计算。例如在单词拆分II中,可以记忆化以字符串start位置为键的结果:
python复制def wordBreak(s, wordDict):
wordSet = set(wordDict)
memo = {}
def backtrack(start):
if start in memo:
return memo[start]
if start == len(s):
return [""]
res = []
for end in range(start+1, len(s)+1):
word = s[start:end]
if word in wordSet:
for sentence in backtrack(end):
res.append(word + (" " + sentence if sentence else ""))
memo[start] = res
return res
return backtrack(0)
11.2 迭代实现回溯
某些问题可以用栈模拟递归过程,减少函数调用开销:
python复制def subsets(nums):
res = []
stack = [(0, [])]
while stack:
start, path = stack.pop()
res.append(path)
for i in range(start, len(nums)):
stack.append((i+1, path+[nums[i]]))
return res
12. 回溯算法与其他算法的结合
12.1 回溯与动态规划
如前文所示,在单词拆分等问题中,先用DP判断可行性,再用回溯收集结果,可以显著提高效率。
12.2 回溯与贪心算法
在某些问题中,可以先用贪心策略缩小搜索范围,再用回溯精确求解。例如在组合总和问题中,先排序就是一种贪心思想。
12.3 回溯与位运算
如N皇后问题的位运算优化所示,用位图表示状态可以极大提高空间效率。
13. 回溯算法解题框架总结
经过这些问题的实践,我们可以总结出一个更通用的回溯解题框架:
- 定义状态表示:明确递归函数的参数和返回值
- 确定终止条件:明确什么情况下应该记录结果并返回
- 枚举选择列表:确定每一步可以选择哪些选项
- 实施剪枝策略:提前排除不可能产生解的分支
- 做出选择并递归:修改状态,进入下一层决策
- 撤销选择:恢复状态,以便尝试其他选择
14. 高频面试问题解析
14.1 电话号码的字母组合(LeetCode 17)
python复制def letterCombinations(digits):
if not digits:
return []
digit_map = {
'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
for char in digit_map[digits[index]]:
path.append(char)
backtrack(index+1, path)
path.pop()
backtrack(0, [])
return res
14.2 递增子序列(LeetCode 491)
python复制def findSubsequences(nums):
res = []
def backtrack(start, path):
if len(path) >= 2:
res.append(path.copy())
used = set()
for i in range(start, len(nums)):
if nums[i] in used:
continue
if not path or nums[i] >= path[-1]:
used.add(nums[i])
path.append(nums[i])
backtrack(i+1, path)
path.pop()
backtrack(0, [])
return res
15. 回溯算法进阶挑战
对于已经掌握基本回溯技巧的开发者,可以尝试以下进阶题目:
- 通配符匹配(LeetCode 44):虽然主要用动态规划,但回溯思路也值得尝试
- 正则表达式匹配(LeetCode 10):类似通配符匹配但更复杂
- 划分为k个相等的子集(LeetCode 698):需要结合剪枝和优化技巧
- 优美的排列(LeetCode 526):排列问题的变种
- 24点游戏(LeetCode 679):需要处理运算符和优先级
这些题目将帮助你更深入地理解回溯算法的应用边界和优化极限。