Files
final-exam/算法设计与分析/复习文档/回溯法.md
T

283 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags:
- 算法设计与分析
- 复习
- 回溯法
create time: 2026-06-10 11:11
---
# 回溯法
## 概述
回溯法(Backtracking)是一种在解空间树中进行深度优先搜索的算法策略。它系统地搜索问题的解空间,当发现当前路径不可能得到解时,回退到上一步尝试其他路径。回溯法是求解NP-hard问题(如图着色、N皇后)的常用方法。
## 正文
### 一、回溯法基本思想
回溯法的核心思想是:在解空间树中按 **DFS(深度优先搜索)** 的方式搜索,当发现某个分支不满足约束条件时,**剪枝**——跳过该分支,回退到父节点尝试其他分支。
> [!warning] 回溯法使用DFS实现,不是队列
> 回溯法是沿着一条路径尽可能深地搜索,走不通就回退。它使用**栈**(或递归调用栈)实现DFS,而不是使用队列(BFS)。
### 二、解空间结构
回溯法的解空间是一棵树(不是图),常见的解空间结构有两种:
1. **子集树(Subset Tree)**:用于子集选择问题。每个节点表示是否选择某个元素,共 2ⁿ 个叶节点。
2. **排列树(Permutation Tree)**:用于排列问题。每个节点表示排列中的某个位置放哪个元素,共 n! 个叶节点。
```mermaid
flowchart TD
Root["Root (empty)"]
L1_1["Include item 1"]
L1_2["Exclude item 1"]
L2_1["Include item 2"]
L2_2["Exclude item 2"]
L2_3["Include item 2"]
L2_4["Exclude item 2"]
Leaf1["{1,2}"]
Leaf2["{1}"]
Leaf3["{2}"]
Leaf4["{}"]
Root --> L1_1
Root --> L1_2
L1_1 --> L2_1
L1_1 --> L2_2
L1_2 --> L2_3
L1_2 --> L2_4
L2_1 --> Leaf1
L2_2 --> Leaf2
L2_3 --> Leaf3
L2_4 --> Leaf4
```
> [!tip] 子集树与排列树
> 子集树有 2ⁿ 个叶节点,每个元素有"选/不选"两种选择。排列树有 n! 个叶节点,表示所有可能的排列方式。
### 三、剪枝函数
剪枝是回溯法效率的关键。两类剪枝函数:
1. **约束函数(Constraint Function)**:可行性剪枝。判断当前部分解是否满足约束条件,不满足则剪掉。
2. **限界函数(Bound Function)**:最优性剪枝。判断当前路径是否可能到达最优解,不可能则剪掉。
> [!question] 回溯法的剪枝函数包括哪两类?
> 约束函数(可行性剪枝)和限界函数(最优性剪枝)。约束函数判断当前解是否合法;限界函数判断当前路径是否有希望得到最优解。两类函数共同作用,大幅减少搜索空间。
### 四、节点分类
在回溯搜索过程中,节点分为三类:
| 节点类型 | 定义 | 类比 |
|---------|------|------|
| **活结点(Live Node)** | 正在被扩展、还有未探索子节点的节点 | 栈中正在处理的节点 |
| **死结点(Dead Node)** | 已被扩展完毕,所有子节点都已探索 | 已经从栈中弹出的节点 |
| **扩展结点(E-Node)** | 当前正在生成子节点的节点 | 栈顶正在处理的节点 |
### 五、回溯法一般框架
```cpp
#include <vector>
using namespace std;
// 回溯法框架
// t: 当前搜索深度(第几个决策)
// n: 问题规模(总共需要做的决策数)
// x: 当前解向量
void backtrack(int t, int n, vector<int>& x) {
if (t > n)
// output(x); // 到达叶节点,输出解
return;
// f(n,t) 和 g(n,t) 为第t个位置可选值的范围
// h(i) 将第i个候选值赋给x[t]
// constraint(t) 为约束函数,bound(t) 为限界函数
for (int i = 1; i <= n; i++) {
x[t] = i; // 第t个位置尝试第i种选择
if (/* constraint(t) && */ /* bound(t) */ true)
backtrack(t + 1, n, x); // 满足约束,继续深入
// 否则剪枝,回退(x[t]会被下次循环覆盖)
}
}
```
各部分含义:
- **t**:当前搜索深度(第几个决策)
- **f(n,t)** 和 **g(n,t)**:第 t 个位置可选值的范围
- **h(i)**:将第 i 个候选值赋给 x[t]
- **constraint(t)**:约束函数,判断可行性
- **bound(t)**:限界函数,判断是否有希望得到最优解
- **output(x)**:输出一个完整的解
### 六、经典应用
#### 6.1 图着色/四色问题
**问题**:给定一个无向图 G 和 m 种颜色,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。当 m=4 时即为著名的四色问题。
**回溯策略**:
- 对每个顶点,尝试 m 种颜色
- 检查当前着色是否与相邻顶点冲突
- 冲突则剪枝,换下一种颜色
- 所有顶点着色完成则输出解
**时间复杂度**:最坏 O(mⁿ),四色问题为 O(4ⁿ)
```cpp
#include <vector>
#include <iostream>
using namespace std;
// 判断将 vertex 着色为 colors[vertex] 是否安全
bool isSafe(vector<vector<int>>& graph, vector<int>& colors, int vertex) {
int n = graph.size();
for (int v = 0; v < n; v++) {
if (graph[vertex][v] && colors[v] == colors[vertex])
return false; // 相邻顶点颜色相同,不安全
}
return true;
}
// 图着色回溯算法
// graph: 邻接矩阵, m: 颜色数, colors: 着色方案, vertex: 当前处理的顶点
void graphColoring(vector<vector<int>>& graph, int m, vector<int>& colors, int vertex) {
int n = graph.size();
if (vertex == n) {
// 所有顶点着色完成,输出解
for (int i = 0; i < n; i++)
cout << colors[i] << " ";
cout << endl;
return;
}
for (int c = 1; c <= m; c++) {
colors[vertex] = c; // 尝试颜色c
if (isSafe(graph, colors, vertex))
graphColoring(graph, m, colors, vertex + 1);
colors[vertex] = 0; // 回溯
}
}
```
```mermaid
flowchart TD
V1c1["V1: color 1"]
V1c2["V1: color 2"]
V2c1["V2: color 1"]
V2c2["V2: color 2"]
V2c3["V2: color 3"]
V2fail1["V2: color 1 = CONFLICT"]
V3c1["V3: color 1"]
V3c2["V3: color 2"]
V3fail1["V3: color 1 = CONFLICT"]
V3fail2["V3: color 2 = CONFLICT"]
Solution["Solution: V1=1, V2=2, V3=3"]
V1c1 --> V2fail1
V1c1 --> V2c2
V1c2 --> V2c1
V1c2 --> V2c3
V2fail1 --> |"Pruned"| V1c1
V2c2 --> V3fail2
V2c2 --> V3c1
V2c1 --> V3fail1
V2c1 --> V3c2
V3c1 --> Solution
```
**解释**:以三节点图为例(V1-V2, V2-V3, V1-V3),先给 V1 尝试颜色1,然后给 V2 尝试颜色1(与V1冲突,剪枝),再试颜色2(可行)。接着给 V3 尝试颜色1(与V1冲突),颜色2(与V2冲突),颜色3(可行)。得到解 V1=1, V2=2, V3=3。
#### 6.2 N皇后问题
**问题**:在 N×N 的棋盘上放置 N 个皇后,使得任意两个皇后不在同一行、同一列或同一对角线上。
**回溯策略**:
- 每行放一个皇后(一行一个,所以只需确定列号)
- 逐行放置,每列尝试每个位置
- 检查列冲突和对角线冲突
- 冲突则剪枝
```cpp
#include <vector>
#include <iostream>
using namespace std;
// 判断在 (row, col) 放置皇后是否安全
bool isSafe(vector<string>& board, int row, int col, int n) {
// 检查同一列
for (int i = 0; i < row; i++)
if (board[i][col] == 'Q') return false;
// 检查左上对角线
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--)
if (board[i][j] == 'Q') return false;
// 检查右上对角线
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++)
if (board[i][j] == 'Q') return false;
return true;
}
// N皇后回溯算法
// board: 棋盘, row: 当前处理的行, n: 棋盘大小
void solveNQueens(vector<string>& board, int row, int n) {
if (row == n) {
// 输出一个解
for (int i = 0; i < n; i++)
cout << board[i] << endl;
cout << endl;
return;
}
for (int col = 0; col < n; col++) {
if (isSafe(board, row, col, n)) {
board[row][col] = 'Q'; // 放置皇后
solveNQueens(board, row + 1, n);
board[row][col] = '.'; // 回溯
}
}
}
```
**解释**:N皇后是经典的回溯应用。由于每行只放一个皇后,解空间是排列树。剪枝函数检查列冲突和两条对角线冲突,大幅减少搜索空间。
#### 6.3 迷宫搜索
**问题**:给定一个迷宫,找到从入口到出口的路径。
**回溯策略**:
- 使用DFS探索迷宫
- 走过的路径标记为已访问
- 走到死路则回退,撤销标记
- 到达出口则输出路径
#### 6.4 骑士巡游
**问题**:在 m×n 的棋盘上,骑士从某个位置出发,按"日"字形移动(8个方向),能否不重复地走遍所有格子?
**回溯策略**:
- 从当前位置出发,尝试8个方向
- 选择合法且未访问的位置
- 走不通则回退
- 走完所有格子则找到解
### 七、回溯法的复杂度分析
回溯法的最坏时间复杂度通常是**指数级或阶乘级**:
| 问题 | 解空间类型 | 最坏复杂度 |
|------|----------|----------|
| 子集问题 | 子集树 | O(2ⁿ) |
| 排列问题 | 排列树 | O(n!) |
| 图着色(m种) | m叉树 | O(mⁿ) |
| N皇后 | 排列树 | O(n!) |
> [!tip] 回溯法是NP-hard问题的常用求解方法
> 图着色、N皇后、哈密顿回路等问题都是NP-hard问题,不存在多项式时间的精确算法。回溯法虽然最坏是指数级,但通过剪枝可以大幅减少实际搜索量,是实践中最常用的精确求解方法。
> [!question] 回溯法和暴力枚举有什么区别?
> 暴力枚举会检查所有可能的解,而回溯法通过剪枝函数提前排除不可能的分支,大幅减少搜索空间。例如N皇后问题,暴力枚举需要检查 nⁿ 种布局,而回溯法通过冲突检测剪枝,实际搜索量远小于此。
## 关联笔记
- [[算法设计与分析/试题册/index|试题册索引]]