2026-06-10 11:17:10 +08:00
|
|
|
|
---
|
|
|
|
|
|
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)** | 当前正在生成子节点的节点 | 栈顶正在处理的节点 |
|
|
|
|
|
|
|
|
|
|
|
|
### 五、回溯法一般框架
|
|
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
```cpp
|
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
using namespace std;
|
|
|
|
|
|
|
|
|
|
|
|
// 回溯法框架
|
|
|
|
|
|
// t: 当前搜索深度(第几个决策)
|
|
|
|
|
|
// n: 问题规模(总共需要做的决策数)
|
|
|
|
|
|
// x: 当前解向量
|
|
|
|
|
|
void backtrack(int t, int n, vector<int>& x) {
|
2026-06-10 11:17:10 +08:00
|
|
|
|
if (t > n)
|
2026-06-10 11:19:43 +08:00
|
|
|
|
// 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]会被下次循环覆盖)
|
2026-06-10 11:17:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
各部分含义:
|
|
|
|
|
|
|
|
|
|
|
|
- **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ⁿ)
|
|
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
```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; // 回溯
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-10 11:17:10 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```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 个皇后,使得任意两个皇后不在同一行、同一列或同一对角线上。
|
|
|
|
|
|
|
|
|
|
|
|
**回溯策略**:
|
|
|
|
|
|
- 每行放一个皇后(一行一个,所以只需确定列号)
|
|
|
|
|
|
- 逐行放置,每列尝试每个位置
|
|
|
|
|
|
- 检查列冲突和对角线冲突
|
|
|
|
|
|
- 冲突则剪枝
|
|
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
```cpp
|
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
#include <iostream>
|
|
|
|
|
|
using namespace std;
|
2026-06-10 11:17:10 +08:00
|
|
|
|
|
2026-06-10 11:19:43 +08:00
|
|
|
|
// 判断在 (row, col) 放置皇后是否安全
|
|
|
|
|
|
bool isSafe(vector<string>& board, int row, int col, int n) {
|
2026-06-10 11:17:10 +08:00
|
|
|
|
// 检查同一列
|
2026-06-10 11:19:43 +08:00
|
|
|
|
for (int i = 0; i < row; i++)
|
|
|
|
|
|
if (board[i][col] == 'Q') return false;
|
2026-06-10 11:17:10 +08:00
|
|
|
|
// 检查左上对角线
|
2026-06-10 11:19:43 +08:00
|
|
|
|
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--)
|
|
|
|
|
|
if (board[i][j] == 'Q') return false;
|
2026-06-10 11:17:10 +08:00
|
|
|
|
// 检查右上对角线
|
2026-06-10 11:19:43 +08:00
|
|
|
|
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] = '.'; // 回溯
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-10 11:17:10 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**解释**: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|试题册索引]]
|