08 · 回溯算法

来源: labuladong 回溯三要素 + 代码随想录回溯模板 + 剪枝优化
核心价值: 回溯 = DFS + 撤销操作,本质是暴力穷举的优雅写法
题量: 8 题


一、本质理解

labuladong 对回溯的定位非常精辟:

回溯 = 决策树的遍历过程

回溯三要素

要素说明代码体现
路径已经做出的选择path 列表
选择列表当前可以做的选择for 循环遍历
结束条件到达决策树底层if 判断后 return

回溯 vs 其他算法

算法关系区别
DFS回溯 = DFS + 状态重置DFS 只管遍历,回溯遍历后要撤销
暴力穷举回溯 = 优雅的暴力回溯用递归实现,结构清晰
动态规划回溯 = DP 无记忆版本DP 有重叠子问题优化,回溯没有

二、标准模板

result = []
 
def backtrack(path, choices):
    # 结束条件
    if "满足条件":
        result.append(path[:])  # 深拷贝,非常重要!
        return
    
    for choice in choices:
        # 做选择
        path.append(choice)
        # 进入下一层决策树
        backtrack(path, new_choices)
        # 撤销选择
        path.pop()

三个关键点

关键点说明常见错误
深拷贝result.append(path[:]) 而非 path直接 append(path) 会导致 result 中的值随 path 变化
撤销操作递归返回后必须把状态还原忘记 pop 或忘记还原 visited 标记
剪枝在递归前跳过无效的选择漏掉剪枝条件导致大量无用计算

三、核心变体

变体 1:全排列(用 visited 排除已选)

def permute(nums):
    res = []
    used = [False] * len(nums)
    
    def backtrack(path):
        if len(path) == len(nums):
            res.append(path[:])
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            used[i] = True
            path.append(nums[i])
            backtrack(path)
            path.pop()
            used[i] = False
    
    backtrack([])
    return res

变体 2:子集(用 start 控制不回头)

def subsets(nums):
    res = []
    
    def backtrack(start, path):
        res.append(path[:])  # 每个节点都加入结果
        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()
    
    backtrack(0, [])
    return res

变体 3:组合总和(可重复选 + 剪枝)

def combination_sum(candidates, target):
    res = []
    candidates.sort()  # 排序便于剪枝
    
    def backtrack(start, path, remaining):
        if remaining == 0:
            res.append(path[:])
            return
        for i in range(start, len(candidates)):
            if candidates[i] > remaining:  # 剪枝
                break
            path.append(candidates[i])
            backtrack(i, path, remaining - candidates[i])  # 可重复选,start 传 i
            path.pop()
    
    backtrack(0, [], target)
    return res

变体 4:单词搜索(网格上的回溯)

def exist(board, word):
    m, n = len(board), len(board[0])
    
    def backtrack(i, j, k):
        if k == len(word):
            return True
        if i < 0 or i >= m or j < 0 or j >= n or board[i][j] != word[k]:
            return False
        
        # 标记已访问
        temp, board[i][j] = board[i][j], '#'
        res = (backtrack(i+1, j, k+1) or
               backtrack(i-1, j, k+1) or
               backtrack(i, j+1, k+1) or
               backtrack(i, j-1, k+1))
        board[i][j] = temp  # 撤销标记
        return res
    
    for i in range(m):
        for j in range(n):
            if backtrack(i, j, 0):
                return True
    return False

变体 5:N 皇后(经典棋盘问题)

def solve_n_queens(n):
    res = []
    # 列、主对角线、副对角线的占用标记
    cols = set()
    diag1 = set()  # 主对角线:row - col 为常数
    diag2 = set()  # 副对角线:row + col 为常数
    
    def backtrack(row, path):
        if row == n:
            res.append([''.join(row) for row in path])
            return
        for col in range(n):
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue
            # 做选择
            cols.add(col)
            diag1.add(row - col)
            diag2.add(row + col)
            path.append(['Q' if c == col else '.' for c in range(n)])
            backtrack(row + 1, path)
            # 撤销选择
            path.pop()
            cols.remove(col)
            diag1.remove(row - col)
            diag2.remove(row + col)
    
    backtrack(0, [])
    return res

四、排列 vs 组合 vs 子集

问题使用方式去重方式结果数量
排列每次从所有元素中选visited 标记已选n!
组合每次从 start 之后选start 参数控制C(n,k)
子集每次从 start 之后选start 参数控制2^n

含重复元素时的去重技巧(排序+跳过同层重复):

# 以全排列去重为例
nums.sort()
for i in range(len(nums)):
    if used[i] or (i > 0 and nums[i] == nums[i-1] and not used[i-1]):
        continue

五、Hot 100 回溯题目清单

题号题目难度核心技巧建议用时
46全排列Mediumvisited 标记25 min
78子集Mediumstart 参数25 min
17电话号码组合Medium逐位组合25 min
39组合总和Medium排序剪枝30 min
22括号生成Medium左右括号计数30 min
79单词搜索Medium网格回溯35 min
131分割回文串Medium切割+回文判断35 min
51N 皇后Hard对角线标记40 min

六、易错点与技巧

  1. 深拷贝问题res.append(path[:]) 而非 res.append(path),这是最常见的 bug
  2. 剪枝是灵魂:回溯不剪枝 = 暴力穷举,剪枝后效率大幅提升
  3. 排序预处理:组合总和、含重复元素的排列/组合,先排序
  4. 撤销要彻底:做了什么选择,一定要在递归后还原
  5. 选择顺序:排列关注顺序(visited),组合不关注(start)

七、复杂度总结

问题类型时间复杂度空间复杂度
全排列(无重复)O(n × n!)O(n)
子集O(n × 2^n)O(n)
组合总和O(指数级)O(target)
N 皇后O(n!)O(n)

八、参考与延伸