Skip to content

5.1 回溯框架——解空间的系统枚举

问题情境:八皇后的困境

一个看似简单的问题

八皇后问题:在 8×8 的棋盘上放置 8 个皇后,使得任意两个皇后不在同一行、同一列或同一对角线上。

这个问题看起来很直观——"放 8 个棋子而已"。但当你真正尝试时,会发现一个令人困惑的现象:

前 7 个皇后都放好了,第 8 个却无路可走。

这不是运气不好——这是算法设计中最常见的陷阱:局部可行不等于全局可行

第一次尝试:随意放置

小明是个初学者,他随手开始放置:

第 1 行:放在第 1 列 ✓
第 2 行:放在第 3 列 ✓(不冲突)
第 3 行:放在第 5 列 ✓
...
第 7 行:放在第 2 列 ✓
第 8 行:??? 所有位置都被攻击!

小明困惑了:"前 7 步都正确,为什么最后一步不行?问题出在哪里?"

关键问题:不是第 8 步的问题,而是前 7 步中某些选择导致第 8 步无解。但究竟是哪一步?

第二次尝试:暴力枚举

小红想了个"笨办法"——枚举所有可能的放置方式:

  • 第 1 行有 8 种选择
  • 第 2 行有 8 种选择
  • ...
  • 总共:$8^8 = 16,777,216$ 种组合

她写了个程序,遍历所有 16,777,216 种组合,检查每种是否合法。

python
def brute_force():
    solutions = []
    for a1 in range(8):
        for a2 in range(8):
            for a3 in range(8):
                ...
                    for a8 in range(8):
                        placement = [a1, a2, ..., a8]
                        if is_valid(placement):
                            solutions.append(placement)
    return solutions

问题

  • 太慢了:16,777,216 次检查
  • 太多无效组合:大部分组合在第 2 行就已经冲突

直觉:能不能早点发现冲突,避免继续探索无效分支?


直观思路:遇到冲突就回头

从暴力到聪明:提前检测

小红改进了代码:每放一个皇后,就检查是否冲突。如果冲突,跳过后续尝试

python
def smarter_brute_force():
    solutions = []
    for a1 in range(8):
        for a2 in range(8):
            if conflicts([a1, a2]):  # 提前检测!
                continue  # 跳过,不再尝试 a3-a8
            for a3 in range(8):
                if conflicts([a1, a2, a3]):
                    continue  # 跳过
                ...
    return solutions

效果:从 16,777,216 减少到大约 15720 次有效检查。

关键洞察早点发现错误,早点放弃

更进一步的思考:DFS 的直觉

小红意识到,这种"逐行放置、遇到冲突就跳过"的方法,本质上就是深度优先搜索(DFS)

第 1 行选第 1 列 → 往下探索
  第 2 行选第 1 列 → 冲突!跳过,尝试第 2 列
  第 2 行选第 2 列 → 冲突!跳过,尝试第 3 列
  第 2 行选第 3 列 → 合格,往下探索
    第 3 行选第 1 列 → 冲突!跳过
    第 3 行选第 2 列 → 冲突!跳过
    ...

直觉图景:像走迷宫——一条路走到黑,遇到墙就退回来尝试其他分支。

从 DFS 到回溯:发现"回头"的机制

小红继续思考:如果走到第 7 行发现第 8 行无解怎么办?

答案:退回第 7 行,尝试其他列。

如果第 7 行所有列都不行呢?

答案:退回第 6 行,尝试其他列。

这就是回溯的本质:走不通就回头,系统性地尝试所有可能


规范定义:回溯框架

什么是回溯?

定义:回溯是一种通过系统性枚举解空间来寻找所有合法解的算法框架。其核心机制是:逐步构建解,遇到矛盾时回退到上一个决策点,尝试其他候选。

为什么叫"回溯"?

  • "溯"是逆流而上
  • "回溯"就是逆着决策过程返回——从当前状态退回到之前的状态

解空间的树形结构

直觉理解:把所有可能的解组织成一棵"决策树"。

                根节点(空棋盘)
               /   |   |   |   |   |   |   \
             1列  2列  3列  4列  5列  6列  7列  8列  (第1行选择)
             /|\
            / | \
          ...(第2行选择)

每个节点:一个部分解(已放置的皇后) 每条边:一个决策(在当前行选择某列) 叶子节点:完整解(8 个皇后都放好)

关键观察:这棵树非常庞大($8^8$ 个节点),但大部分分支在第 2-3 层就因冲突被剪掉。

回溯 vs DFS vs BFS

策略空间复杂度优点缺点
DFS$O(\text{深度})$空间效率高可能错过近处最优解
BFS$O(\text{宽度})$保证最短路径空间爆炸($8^8$)
回溯$O(\text{深度})$系统枚举所有解指数时间

为什么回溯选择 DFS?

因为空间效率——DFS 只需存储当前路径(深度 8),BFS 需存储整层节点($8^7$)。


算法实现:五子程序框架

Skiena 的优雅框架

Steven Skiena 在《The Algorithm Design Manual》中提出了一个优雅的回溯框架,将算法分解为五个清晰的子程序。

直觉理解:把回溯看成"模板"或"骨架",具体问题只需填充五个"插槽"。

python
def backtrack(a, k, input):
    """
    回溯框架主函数
    a: 当前解向量(部分解)
    k: 当前决策层(已填充到第 k 个分量)
    input: 问题输入(如棋盘大小 n)
    """
    # 1. 检查是否完整解
    if is_a_solution(a, k, input):
        process_solution(a, k, input)
        return
    
    # 2. 生成下一步的候选
    k = k + 1
    candidates = construct_candidates(a, k, input)
    
    # 3. 尝试每个候选
    for c in candidates:
        # 执行决策
        a[k] = c
        make_move(a, k, input)
        
        # 递归探索
        backtrack(a, k, input)
        
        # 回退决策(关键!)
        unmake_move(a, k, input)

五个子程序详解

为什么要分五个子程序?

因为不同问题有不同的"解定义""候选生成""决策执行"——框架提供骨架,具体问题填充内容。

1. is_a_solution:何时算完成?

作用:判断当前部分解是否已经是完整解。

八皇后问题

python
def is_a_solution(a, k, n):
    """已放置 n 个皇后"""
    return k == n

子集生成问题

python
def is_a_solution(a, k, n):
    """已对所有元素做出选择"""
    return k == n

Sudoku 问题

python
def is_a_solution(a, k, board):
    """所有空格都填满"""
    return len(find_empty(board)) == 0

2. construct_candidates:下一步有哪些选择?

作用:生成当前决策层的所有候选值。

八皇后问题

python
def construct_candidates(a, k, n):
    """当前行所有不被攻击的列"""
    candidates = []
    for col in range(1, n + 1):
        if is_safe(a, k, col):  # 检查冲突
            candidates.append(col)
    return candidates

子集生成问题

python
def construct_candidates(a, k, n):
    """选或不选"""
    return [True, False]

3. process_solution:找到解后做什么?

作用:处理找到的解(打印、计数、存储等)。

python
def process_solution(a, k, n):
    """打印解"""
    print(f"解: {a[1:n+1]}")

# 或者只计数
solution_count = 0
def process_solution(a, k, n):
    global solution_count
    solution_count += 1

4. make_move:执行决策

作用:执行决策,更新辅助数据结构(可选)。

python
def make_move(a, k, n):
    """可选:维护攻击矩阵加速后续检查"""
    # 如维护 attack[row][col] = True
    pass

5. unmake_move:回退决策(关键!)

作用:回退决策,恢复数据结构状态。

python
def unmake_move(a, k, n):
    """清除之前维护的攻击矩阵"""
    # 清除 attack[row][col]
    pass

为什么需要 unmake_move?

因为回溯会"回头"——需要恢复之前的状态,才能尝试其他候选。这是与普通 DFS 的关键区别。


完整实现:八皇后求解器

冲突检测

python
def is_safe(board, row, col):
    """
    检查在 (row, col) 放置皇后是否安全
    board[i] = j 表示第 i 行皇后在第 j 列
    """
    # 检查之前的行(同列)
    for i in range(row):
        if board[i] == col:  # 同列冲突
            return False
        # 对角线冲突:|row差| = |col差|
        if abs(board[i] - col) == abs(i - row):
            return False
    return True

为什么这样检查?

  • 同列:前面行的皇后可能在同一列
  • 对角线:两个位置在对角线上当它们的行列差相等

完整求解器

python
def solve_n_queens(n):
    """求解 n 皇后问题,返回所有解"""
    solutions = []
    board = [-1] * n  # board[i] = 第 i 行皇后所在列
    
    def backtrack(row):
        # 终止条件:所有皇后都已放置
        if row == n:
            solutions.append(board.copy())
            return
        
        # 尝试当前行的每一列
        for col in range(n):
            if is_safe(board, row, col):
                # make_move:放置皇后
                board[row] = col
                
                # 递归探索下一行
                backtrack(row + 1)
                
                # unmake_move:回溯(清除放置)
                board[row] = -1
    
    backtrack(0)
    return solutions

# 执行
solutions = solve_n_queens(8)
print(f"共找到 {len(solutions)} 个解")

搜索过程追踪

让我们追踪前几步:

尝试第 1 行:
  col=0: 安全 ✓ → 往下探索
  尝试第 2 行:
    col=0: 同列冲突 ✗ → 跳过
    col=1: 对角线冲突 ✗ → 跳过
    col=2: 安全 ✓ → 往下探索
    尝试第 3 行:
      col=0: 同列冲突 ✗
      col=1: 对角线冲突 ✗
      col=2: 同列冲突 ✗
      col=3: 对角线冲突 ✗
      col=4: 安全 ✓ → 往下探索
      ...

观察:每次冲突都立即跳过,不再继续深入。


正确性分析:为什么回溯保证找到所有解?

系统性保证

定理:回溯算法保证遍历解空间树的所有可能分支,不会遗漏任何合法解。

证明思路

  1. 对于每个决策层 $k$,算法枚举所有候选 $c \in C_k$
  2. 对每个合法候选,递归处理后续层
  3. 因此,解空间树的每个可达节点都被访问

反证法:假设存在合法解 $a^*$ 未被发现。

设 $a^* = (a_1^, a_2^, ..., a_n^*)$。对于每层 $k$:

  • $a_k^$ 必在候选集 $C_k$ 中(否则 $a^$ 不是合法解)
  • 算法枚举 $C_k$ 的所有元素
  • 因此必然枚举到 $a_k^*$

矛盾。□

剪枝安全性

关键问题:候选集过滤会不会删掉合法解?

答案:不会。construct_candidates 只过滤不可能成为解前缀的候选。

示例:八皇后中,与已放皇后冲突的列,不可能出现在任何合法解中(因为规则要求不能冲突)。


复杂度分析

时间复杂度

回溯的时间取决于解空间大小

$$T(n) = O(\text{搜索树节点数} \times \text{每节点处理时间})$$

八皇后问题

  • 解空间树节点数:$O(n!)$(利用约束剪枝后)
  • 每节点处理:$O(n)$(冲突检测)
  • 总时间:$O(n! \times n)$

与暴力枚举对比

  • 暴力枚举:$O(n^n)$(每步独立选择)
  • 回溯:$O(n!)$(利用约束提前剪枝)

为什么回溯更快?

因为冲突检测剪掉了大部分无效分支——第 2 行就冲突的组合,不再探索第 3-8 行。

空间复杂度

数据结构空间说明
递归栈$O(n)$当前搜索路径深度
解向量$O(n)$存储当前部分解
辅助结构$O(n^2)$如攻击矩阵(可选)
总计$O(n^2)$远小于 BFS 的 $O(n^n)$

变式与反例

反例 1:回溯不能解决的问题

问题:找最短路径。

回溯能找到所有路径,但无法保证"最短"——因为它深度优先,可能先找到长路径。

改进:用 BFS 或 A*。

反例 2:回溯效率陷阱

问题:无约束的排列生成。

如果没有任何约束(如 Sudoku 的规则),回溯退化为暴力枚举,时间 $O(n^n)$。

教训:回溯的效率来自约束剪枝。问题约束越强,回溯越高效。

变式 1:排列生成

python
def permutations(n):
    """生成 {1, ..., n} 的所有排列"""
    a = [None] * (n + 1)
    
    def backtrack(k):
        if k == n:
            print(a[1:n+1])
            return
        
        k += 1
        used = set(a[1:k])
        candidates = [i for i in range(1, n+1) if i not in used]
        
        for c in candidates:
            a[k] = c
            backtrack(k)
            a[k] = None
    
    backtrack(0)

变式 2:子集生成

python
def subsets(n):
    """生成 {1, ..., n} 的所有子集"""
    a = [None] * (n + 1)
    
    def backtrack(k):
        if k == n:
            subset = [i for i in range(1, n+1) if a[i]]
            print(subset)
            return
        
        k += 1
        for choice in [True, False]:
            a[k] = choice
            backtrack(k)
    
    backtrack(0)

常见错误诊断

错误 1:忘记 unmake_move

python
# 错误示例
def backtrack_wrong(row):
    for col in range(n):
        if is_safe(board, row, col):
            board[row] = col
            backtrack(row + 1)
            # 缺少 board[row] = -1

后果:回溯后,board[row] 仍保留之前的值,导致后续候选被错误判断为冲突。

错误 2:候选集生成不完整

python
# 错误示例:只生成"看起来好"的候选
def construct_candidates_wrong(a, k, n):
    # 只返回第一个不冲突的候选
    for col in range(n):
        if is_safe(a, k, col):
            return [col]  # 只返回一个!
    return []

后果:遗漏其他合法候选,错过解。

正确做法:返回所有合法候选,让框架逐一尝试。

错误 3:解判断错误

python
# 错误示例:判断"所有格子都填满"
def is_a_solution_wrong(a, k, board):
    return k == len(board)  # 错了!应该判断是否有空格

练习

基本理解

  1. 概念解释:为什么回溯叫"回溯"?用自己的话解释"溯"的含义。

  2. 对比分析:回溯和 DFS 的区别是什么?为什么回溯需要 unmake_move

  3. 直觉验证:画一个 4 皇后问题的搜索树前 3 层,标注哪些分支因冲突被剪掉。

方法应用

  1. 子集生成:用回溯框架生成 {1, 2, 3} 的所有子集,输出搜索顺序。

  2. 排列生成:修改子集生成的代码,实现排列生成({1, 2, 3} 的所有排列)。

  3. 八皇后实现:完整实现八皇后求解器,统计 4 皇后问题有多少解。

错误诊断

  1. 诊断错误:给定一个"忘记 unmake_move"的回溯代码,预测会发生什么错误。

  2. 候选集错误:如果候选集生成只返回第一个合法候选,会错过哪些解?

  3. 复杂度误判:有人说"回溯时间复杂度是 O(n)",诊断这个错误说法。

方案比较

  1. DFS vs BFS:为什么回溯用 DFS 而不是 BFS?如果用 BFS 会怎样?

  2. 暴力 vs 回溯:对比暴力枚举($n^n$)和回溯($n!$),为什么差距这么大?

  3. 框架优劣:Skiena 的五子程序框架有什么优点?什么情况下可以简化?

开放设计

  1. 改进思路:回溯太慢时,如何改进?(提示:见下一节剪枝)

  2. 适用场景:什么问题适合用回溯?什么问题不适合?

  3. LLM 连接:思维链(CoT)为什么被称为"无回溯的 DFS"?分析局限。

综合应用

  1. Sudoku 回溯:设计 Sudoku 的回溯求解器(暂不考虑剪枝)。

  2. 组合问题:设计一个问题,让回溯效率很低(无约束)。

  3. 真实问题:用回溯解决实际问题:安排课程表(避免时间冲突)。


思考题

为什么回溯保证正确但不保证效率?

回溯的系统枚举保证不遗漏解(正确性),但解空间可能巨大(指数级)。效率取决于约束强度——约束越强,剪枝越多,效率越高。

为什么要用五子程序框架?

框架分离"骨架"和"内容"——骨架是通用的搜索逻辑,内容是问题特定的判断和操作。这样使得:

  • 代码清晰易读
  • 模板可复用(不同问题只需填充不同子程序)
  • 易于验证正确性(每部分独立检查)

如果问题没有约束怎么办?

没有约束时,回溯退化为暴力枚举。这时应该考虑:

  • 换用动态规划(如果有最优子结构)
  • 换用贪心(如果有贪心选择性质)
  • 换用启发式搜索(如果只求近似解)

过渡:回溯保证正确性,但效率可能很差——八皇后虽快,但 Sudoku 可能需要百万步。下一节:如何让百万步变成几十步——剪枝的威力。


参考文献

  • Skiena S S. The Algorithm Design Manual[M]. 3rd ed. Springer, 2020. Chapter 5.
  • Knuth D E. The Art of Computer Programming, Volume 4A: Combinatorial Algorithms[M]. Addison-Wesley, 2011.

新时代的算法课程