Skip to content

12.4 回溯回顾:系统探索与及时放弃

核心问题:搜索空间如何系统探索?

直觉:像走迷宫——走不通就退回来换条路,记录成功的路径。


一、问题情境:如何放置N个皇后使它们互不攻击?

想象你面前是一个8×8的棋盘,需要放置8个皇后,且它们不能互相攻击(同行、同列、同对角线)。

朴素方案:你枚举所有可能的皇后位置组合(64个位置选8个),检查是否满足条件。

python
# 朴素方案:枚举所有组合(时间爆炸)
def naive_n_queens(n):
    all_combinations = itertools.combinations(range(n*n), n)  # C(64, 8) = 4.4亿种
    valid = []
    for combo in all_combinations:
        if is_valid(combo, n):  # 检查是否不互相攻击
            valid.append(combo)
    return valid

这个方案组合数是C(n², n)——n=8时有4.4亿种组合!

思考:有没有更聪明的方法?

一个直觉浮现:逐行放置皇后,发现冲突就退回上一行换位置,避免无用的尝试

这就是回溯思想——N皇后问题的经典解法。


二、直觉引入:迷宫探索的艺术

2.1 什么是回溯?

**回溯(Backtracking)**的核心直觉是:

系统地试探所有可能的解,一旦发现当前路径无解,就退回换条路。

想象你在走迷宫:

  1. 试探:选择一个方向前进
  2. 检查:判断是否到达终点或遇到死路
  3. 成功记录:到达终点则记录路径
  4. 失败回退:遇到死路则退回上一个路口,换方向
  5. 重试:继续试探

2.2 回溯框架

python
def backtrack_template(state, choices):
    """
    回溯通用框架
    
    思路:
    1. 试探:选择一个候选
    2. 检查:判断是否可行
    3. 成功:记录解
    4. 失败:回退,换候选
    """
    # 基础情况:找到一个解
    if is_solution(state):
        record(state)
        return
    
    # 遍历所有候选
    for choice in choices:
        # 检查候选是否可行(剪枝)
        if is_valid(state, choice):
            # 试探:应用候选
            apply(state, choice)
            
            # 递归探索
            backtrack(state, next_choices)
            
            # 回退:撤销候选(回溯)
            undo(state, choice)

2.3 剪枝的重要性

剪枝是回溯效率的关键——提前判断"这条路走不通",不浪费时间探索。

剪枝类型思想例子
可行性剪枝当前状态无法到达解N皇后:同列已有皇后
最优性剪枝当前解不可能优于已知最优分支限界法
对称性剪枝等价状态只搜索一次图着色:颜色交换对称

三、算法定义:回溯形式化

3.1 回溯的搜索空间

回溯本质上是在搜索空间中系统探索:

搜索空间结构:
- 树结构:每层代表一个决策点,分支代表候选选择
- 搜索策略:深度优先(DFS)——一条路走到头,失败回退
- 效率关键:剪枝减少无效探索

N皇后的搜索树

第1行:8个候选位置(8个分支)
第2行:7个候选位置(排除同列)
第3行:6个候选位置(排除同列、同对角线)
...
第8行:最后的位置

剪枝后:搜索空间远小于理论C(64, 8)=4.4亿
实际计算:n=8时约15720次试探(剪枝效果显著)

3.2 回溯复杂度分析

时间复杂度

理论复杂度:O(搜索空间 × 单次检查成本)
实际复杂度:取决于剪枝效果

剪枝效果:
- 无剪枝:搜索全空间
- 有效剪枝:搜索空间大幅减少

空间复杂度

O(递归深度 × 单层状态大小)

递归深度:通常O(n)
单层状态:取决于问题

四、实现示例:N皇后教学化代码

4.1 N皇后完整实现

python
def n_queens_backtrack(n):
    """
    N皇后问题的回溯实现
    
    思路:
    1. 逐行放置皇后(从第0行到第n-1行)
    2. 每行试探所有列位置
    3. 检查冲突:同列、同对角线
    4. 冲突则跳过(剪枝),无冲突则放置并递归下一行
    5. 找到解则记录,失败则回退上一行换列
    """
    solutions = []
    board = [-1] * n  # board[row] = col(每行皇后所在的列)
    
    def is_safe(row, col):
        """检查(row, col)位置是否安全"""
        for r in range(row):
            c = board[r]
            # 同列检查
            if c == col:
                return False
            # 同对角线检查:|row-r| == |col-c|
            if abs(row - r) == abs(col - c):
                return False
        return True
    
    def backtrack(row):
        """回溯核心:逐行放置"""
        # 基础情况:所有皇后已放置
        if row == n:
            # 记录解(复制当前board)
            solutions.append(board.copy())
            return
        
        # 试探所有列位置
        for col in range(n):
            # 剪枝:检查安全性
            if is_safe(row, col):
                # 试探:放置皇后
                board[row] = col
                
                # 递归:探索下一行
                backtrack(row + 1)
                
                # 回退:撤销放置(自动回退,因为board[row]会被重新赋值)
    
    backtrack(0)
    return solutions

# 测试
solutions = n_queens_backtrack(8)
print(f"N=8的解数:{len(solutions)}")  # 92
print("第一个解:", solutions[0])  # [0, 4, 7, 5, 2, 6, 1, 3]

4.2 代码背后的回溯思想

代码段对应回溯步骤
for col in range(n)试探:遍历候选
if is_safe(row, col)剪枝:检查可行性
board[row] = col试探:应用候选
backtrack(row + 1)递归:探索下一层
(自动回退)回退:撤销候选

五、复杂度分析:时间与空间

5.1 时间复杂度

N皇后:理论O(n!),实际远小于此(剪枝效果)

理论复杂度:
第1行:n个候选
第2行:n-1个候选(排除同列)
第3行:n-2个候选
...
总计:n × (n-1) × ... × 1 = n!

剪枝效果:
同对角线检查进一步减少候选
实际试探次数:n=8时约15720次(远小于8!=40320)

对比朴素方案

算法时间复杂度n=8试探次数
枚举所有组合(朴素)C(n², n)4.4亿
回溯(无剪枝)n!40320
回溯(有剪枝)远小于n!~15720

回溯让效率提升28万倍

5.2 空间复杂度

N皇后:O(n)

空间使用:
- board数组:O(n)
- 递归栈:O(n)
- solutions:O(解数 × n)
总计:O(n)

六、与前章节关联:回溯在哪里出现过?

6.1 回溯前章节应用

章节回溯应用说明
Ch5N皇后、图着色、子集枚举回溯专题
Ch4DFS图遍历回溯在图上的应用
Ch8NP完全问题的精确解回溯在难解问题上的应用

6.2 图着色:另一回溯问题

问题:给定图,用k种颜色染色,相邻节点颜色不同。

python
def graph_coloring_backtrack(graph, k):
    """
    图着色的回溯实现
    
    思路:
    1. 逐节点染色
    2. 每节点试探k种颜色
    3. 检查相邻节点颜色是否冲突
    4. 冲突则跳过,无冲突则染色并递归下一节点
    """
    n = len(graph)
    colors = [-1] * n  # colors[node] = color(-1表示未染色)
    solutions = []
    
    def is_valid(node, color):
        """检查node染color是否可行"""
        for neighbor in graph[node]:
            if colors[neighbor] == color:
                return False
        return True
    
    def backtrack(node):
        """回溯核心:逐节点染色"""
        if node == n:
            solutions.append(colors.copy())
            return
        
        for color in range(k):
            if is_valid(node, color):
                colors[node] = color
                backtrack(node + 1)
                colors[node] = -1  # 回退
    
    backtrack(0)
    return solutions

七、设计反思:回溯的失败案例

7.1 失败案例1:旅行商问题(TSP)

问题:给定城市和距离矩阵,找访问所有城市的最短路径(回到起点)。

回溯尝试

python
def tsp_backtrack(distances):
    """
    TSP的回溯解法
    
    问题:城市数n较大时,搜索空间爆炸
    """
    n = len(distances)
    visited = [False] * n
    best_path = []
    best_distance = float('inf')
    
    def backtrack(current_city, path, distance):
        """回溯核心:逐步构建路径"""
        # 基础情况:所有城市已访问
        if len(path) == n:
            # 回到起点
            total_distance = distance + distances[current_city][path[0]]
            if total_distance < best_distance:
                best_distance = total_distance
                best_path = path.copy()
            return
        
        # 试探所有未访问城市
        for next_city in range(n):
            if not visited[next_city]:
                # 最优性剪枝:如果当前距离已超过最优,跳过
                if distance + distances[current_city][next_city] > best_distance:
                    continue
                
                visited[next_city] = True
                path.append(next_city)
                backtrack(next_city, path, distance + distances[current_city][next_city])
                path.pop()
                visited[next_city] = False
    
    visited[0] = True
    backtrack(0, [0], 0)
    return best_path, best_distance

失败分析

搜索空间:n!(访问所有排列)
n=30时:30! ≈ 2.65 × 10^32(天文数字)
即使剪枝,也无法在合理时间内求解

正确策略:
- n较小(≤15):回溯可行
- n中等(15-30):分支限界法(最优性剪枝)
- n较大(>30):近似算法或启发式(贪心、遗传算法等)

7.2 失败案例2:约束少的NP完全问题

问题:某些NP完全问题约束少,剪枝效果差。

例子:SAT问题(布尔可满足性)

SAT问题:
给定布尔变量和约束,找一组赋值满足所有约束。

约束少时:
剪枝条件弱,几乎无法提前判断失败
搜索空间:2^n(每个变量真假两种选择)
n=100时:2^100 ≈ 10^30

正确策略:
- 启发式搜索(如WalkSAT)
- 随机化算法
- 或接受部分解

7.3 失败总结:回溯的三禁忌

禁忌现象正确策略
搜索空间爆炸n!或2^n过大近似算法或启发式
剪枝效果差约束少,无法提前判断失败添加更多剪枝条件或换方法
需最优解而非所有解只需一个最优解,而非全部解DP或分支限界法

八、知识卡片

C12-25 回溯框架

直觉解释:像走迷宫,走不通退回来换条路。

核心内容:试探 → 检查 → 成功记录/失败回退 → 重试。

关键特征:系统搜索整个解空间,保证找到所有解或最优解。

常见误解

  • ❌ 回溯比DP慢——它们解决的问题类型不同
  • ✅ 正确理解:回溯求所有解,DP求一个最优解

实例:N皇后——逐行放皇后,冲突则回退尝试下一列。

C12-26 剪枝策略

直觉解释:提前判断"这条路走不通",不浪费时间探索。

核心内容

剪枝类型思想
可行性剪枝不满足约束则停止
最优性剪枝已不可能比当前最优更优则停止
对称性剪枝等价状态只搜索一次

关键特征:剪枝不影响结果正确性,只减少搜索量。

常见误解

  • ❌ 剪枝越多越好——剪枝本身有判断成本,需平衡
  • ✅ 正确理解:剪枝成本和搜索节省要平衡

实例:N皇后——某列已有皇后则跳过该列(可行性剪枝)。

C12-27 回溯 vs DP

直觉解释:回溯"穷举所有可能",DP"记住算过的"。

核心内容

维度回溯DP
搜索策略搜索全空间(可能重复计算)缓存重叠子问题
适用场景求所有解/路径问题求一个最优解

关键特征:回溯适合求所有解;DP适合求一个最优解。

常见误解

  • ❌ DP优于回溯——回溯能求所有解,DP只能求一个最优解
  • ✅ 正确理解:回溯和DP解决的问题类型不同

实例:路径问题——求所有路径用回溯;求最短路径用DP或贪心。

C12-28 回溯复杂度分析

直觉解释:搜索空间大小 × 每次检查成本 = 总工作量。

核心内容:时间复杂度 = 解空间大小 × 单次检查成本。

关键特征:最坏情况指数级,实际运行依赖剪枝效果。

常见误解

  • ❌ 回溯总是指数级——剪枝可能大幅降低实际复杂度
  • ✅ 正确理解:剪枝效果决定实际运行效率

实例:N皇后——理论O(n!),实际剪枝后远小于此。

C12-29 回溯失败案例

直觉解释:搜索空间太大,剪枝不够,时间爆炸。

核心内容:失败原因:

  1. 搜索空间指数爆炸
  2. 剪枝策略无效
  3. 约束条件太少

关键特征:无有效剪枝时,回溯退化成暴力枚举。

常见误解

  • ❌ 回溯失败说明问题无解——可能是搜索空间太大,需要换个方法
  • ✅ 正确理解:回溯失败≠问题无解,只是方法不适用

实例:TSP——n=30时回溯无法承受,需要近似算法。

C12-30 回溯直觉建立

直觉解释:看到"找所有解"或"搜索+约束",想回溯。

核心内容:判断回溯适用的直觉问题:

  1. 能逐步试探吗?
  2. 有约束条件吗?
  3. 能判断失败并回退吗?

关键特征:抓住"试探+回退"的本质,系统探索而非随机尝试。

常见误解

  • ❌ 回溯就是递归——关键是失败后的回退和状态恢复
  • ✅ 正确理解:回溯 = 试探 + 剪枝 + 回退 + 状态恢复

实例:八皇后的直觉——放一个皇后,检查冲突,冲突则换位置,所有位置都冲突则回退上一行。

C12-31 启发式搜索

直觉解释:回溯时"优先走更可能有解的路"。

核心内容:评估函数引导搜索方向(如A*算法的 f(n) = g(n) + h(n))。

关键特征:启发函数 h(n) 决定搜索效率,越接近真实代价越高效。

常见误解

  • ❌ 启发式搜索总是更快——启发函数设计不当可能更慢
  • ✅ 正确理解:启发函数质量决定搜索效率

实例:A*寻路——g(n) 已走距离,h(n) 到终点估计距离,优先搜索f(n)小的节点。


C12-32 回溯应用场景

直觉解释:看到"找所有解"或"搜索+约束",想回溯。

核心内容:判断回溯适用的三类问题:组合问题(排列、子集)、约束满足问题(N皇后、数独)、路径搜索问题(迷宫、字梯)。

关键特征:回溯适合"解空间大但约束多"的问题,约束越多剪枝越有效。

常见误解

  • ❌ 回溯只能求所有解——回溯也可以求一个解或最优解(找到即停止)
  • ✅ 正确理解:回溯是通用搜索框架,解的数量需求决定终止条件

实例:数独求解——逐格填数字,约束检查(同行、同列、同宫不重复),剪枝效果极好。


九、练习设计

基础练习(理解回溯)

练习 12.4-1:用回溯解决子集生成问题(生成所有子集)。

提示

python
def subsets_backtrack(nums):
    """
    子集生成:生成nums的所有子集
    
    思路:
    1. 逐元素决定:选或不选
    2. 试探两种选择
    3. 到达末尾则记录当前子集
    """
    subsets = []
    
    def backtrack(index, current):
        if index == len(nums):
            subsets.append(current.copy())
            return
        
        # 选择:不选当前元素
        backtrack(index + 1, current)
        
        # 选择:选当前元素
        current.append(nums[index])
        backtrack(index + 1, current)
        current.pop()  # 回退
    
    backtrack(0, [])
    return subsets

练习 12.4-2:分析为何N皇后用回溯而非DP。

答案

N皇后特点:
- 需找所有解,而非最优解
- 子问题不重叠(每行的候选依赖于之前已放置的皇后)
- 状态难以缓存(需要记录所有已放置皇后的位置)

DP不适用:
- DP求最优解,N皇后求所有解
- DP需要重叠子问题,N皇后子问题不重叠
- DP状态难以定义(需记录所有已放置皇后的位置,状态爆炸)

回溯适用:
- 求所有解
- 系统探索搜索空间
- 剪枝效果好(同列、同对角线检查)

进阶练习(回溯应用)

练习 12.4-3:设计回溯算法解决排列生成问题(生成所有排列)。

思路

python
def permutations_backtrack(nums):
    """
    排列生成:生成nums的所有排列
    
    思路:
    1. 逐位置填充元素
    2. 试探所有未使用的元素
    3. 剪枝:已使用的元素跳过
    """
    permutations = []
    used = [False] * len(nums)
    
    def backtrack(path):
        if len(path) == len(nums):
            permutations.append(path.copy())
            return
        
        for i in range(len(nums)):
            if not used[i]:  # 剪枝:已使用跳过
                used[i] = True
                path.append(nums[i])
                backtrack(path)
                path.pop()
                used[i] = False
    
    backtrack([])
    return permutations

设计练习(回溯 vs DP)

练习 12.4-4:对比回溯和DP在"最长简单路径问题"上的解法。

分析

最长简单路径:无重复节点的最长路径

回溯解法:
- 逐步构建路径
- 剪枝:已访问节点跳过
- 复杂度:O(V!)(搜索所有路径)

DP解法:
- 状态:dp[u][v][visited_set] = u到v且经过visited_set的最长路径
- 状态爆炸:O(V^2 × 2^V)
- 不适用

结论:回溯优于DP(DP状态爆炸)

开放练习

练习 12.4-5:LLM的Tree of Thoughts如何体现回溯思想?

思考方向

Tree of Thoughts:
1. 将LLM生成视为搜索树
2. 每个thought是树的一个节点
3. 评估thought质量(可行性剪枝)
4. 保留高质量thought,剪枝低质量thought
5. 搜索最佳thought序列

与回溯对比:
- 回溯:单路径探索,失败回退
- Tree of Thoughts:多路径并行探索,保留top-k

Tree of Thoughts是回溯的扩展:
- 不只探索一条路,而是探索多条候选路
- 用LLM评估thought质量(启发式剪枝)

导航上一节:12.3 动态规划回顾 | 下一节:12.5 六问诊断法

新时代的算法课程