回溯法的本质
回溯法是一种系统枚举解空间的方法。它把求解过程看成一棵搜索树:
- 每一层对应一个决策阶段;
- 每条边对应一种选择;
- 从根到叶的一条路径对应一个候选解;
- 如果当前路径已经不可能得到合法解或更优解,就提前停止继续向下搜索。
回溯的核心动作是:
- 选择:在当前状态下尝试一种可能。
- 递归:进入下一层继续搜索。
- 撤销选择:递归返回后恢复现场,让同层的其他选择能够在干净状态下继续尝试。
因此,回溯不是单纯的递归。它强调“递归前改变状态,递归后恢复状态”。
适用条件
当问题满足下面特征时,通常可以考虑回溯:
| 条件 |
含义 |
| 分步决策 |
答案可以由若干步选择组成 |
| 层次结构明显 |
第 $k$ 步的选择依赖前 $k-1$ 步的状态 |
| 需要枚举 |
需要找全部方案、某个可行方案,或在全部可行方案中找最优方案 |
| 可剪枝 |
可以在中途判断某些分支一定无效或不优 |
常见题型包括 N 皇后、全排列、组合、子集、迷宫路径、数独、0-1 背包搜索版、图中的路径枚举等。
暴力递归如果不剪枝,搜索树可能指数级增长。使用回溯前要先估计状态规模,否则容易超时。
通用模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| void backtrack(int step) { if (step == target_step) { record_answer(); return; }
for (int choice = first_choice(step); choice <= last_choice(step); choice++) { if (!is_valid(step, choice)) { continue; }
make_choice(step, choice); backtrack(step + 1); undo_choice(step, choice); } }
|
模板中的关键不是函数名,而是状态变化的对称性:
1 2 3
| make_choice(...); backtrack(...); undo_choice(...);
|
如果漏掉 undo_choice,递归返回后,上一层状态会残留下一层的修改,同层其他分支就会被污染。
组合问题中的 start
组合问题不关心元素顺序。例如 {1, 3} 和 {3, 1} 是同一个组合。为了避免重复枚举,通常约定元素只能按下标递增顺序选取。
1 2 3 4 5 6 7 8 9 10 11
| void combine(int start, int depth) { if (depth == k) { record_answer(); return; }
for (int i = start; i < n; i++) { path[depth] = a[i]; combine(i + 1, depth + 1); } }
|
这里的 start 表示“本层只能从哪个下标开始选”。递归时传入 i + 1,就能保证后面不会再选到当前元素以及它前面的元素,从而自然排除重复排列。
N 皇后问题
N 皇后要求在 $n \times n$ 的棋盘上放置 $n$ 个皇后,使任意两个皇后都不在同一行、同一列、同一条对角线上。
状态设计
按“行”逐层放置皇后:
row 表示当前要放第几行;
- 每一行只放一个皇后,因此本层只需要枚举列
col;
col_used[col] 记录某列是否已有皇后;
diag1[row + col] 记录主对角线是否被占用;
diag2[row - col + n - 1] 记录副对角线是否被占用。
两个对角线编号的依据:
| 对角线 |
同一对角线上的不变量 |
| 主对角线 |
row - col 相同 |
| 副对角线 |
row + col 相同 |
因为 row - col 可能为负,所以常用 row - col + n - 1 平移成非负下标。
C 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| #include <stdio.h> #include <stdbool.h>
#define MAX_N 20
static int n; static int solution_count; static int queen_col[MAX_N]; static bool col_used[MAX_N]; static bool diag_sum_used[2 * MAX_N]; static bool diag_diff_used[2 * MAX_N];
static void print_solution(void) { for (int row = 0; row < n; row++) { for (int col = 0; col < n; col++) { putchar(queen_col[row] == col ? 'Q' : '.'); } putchar('\n'); } putchar('\n'); }
static bool can_place(int row, int col) { int diag_sum = row + col; int diag_diff = row - col + n - 1;
return !col_used[col] && !diag_sum_used[diag_sum] && !diag_diff_used[diag_diff]; }
static void place_queen(int row, int col) { int diag_sum = row + col; int diag_diff = row - col + n - 1;
queen_col[row] = col; col_used[col] = true; diag_sum_used[diag_sum] = true; diag_diff_used[diag_diff] = true; }
static void remove_queen(int row, int col) { int diag_sum = row + col; int diag_diff = row - col + n - 1;
queen_col[row] = -1; col_used[col] = false; diag_sum_used[diag_sum] = false; diag_diff_used[diag_diff] = false; }
static void solve_n_queens(int row) { if (row == n) { solution_count++; print_solution(); return; }
for (int col = 0; col < n; col++) { if (!can_place(row, col)) { continue; }
place_queen(row, col); solve_n_queens(row + 1); remove_queen(row, col); } }
int main(void) { if (scanf("%d", &n) != 1 || n <= 0 || n > MAX_N) { return 0; }
for (int i = 0; i < n; i++) { queen_col[i] = -1; }
solve_n_queens(0); printf("%d\n", solution_count); return 0; }
|
剪枝
剪枝是在搜索树中提前砍掉“不可能产生合法答案”或“不可能产生更优答案”的分支。剪枝不会改变问题的解空间,只是避免访问没有必要访问的状态。
| 类型 |
含义 |
例子 |
| 可行性剪枝 |
当前状态已经不可能合法 |
N 皇后中同列或同斜线已有皇后 |
| 最优性剪枝 |
当前状态继续搜索也不可能超过已有答案 |
当前收益 + 剩余理论上界不超过当前最优值 |
| 边界剪枝 |
越界、资源不足、步数超限 |
迷宫坐标越界、容量为负 |
| 去重剪枝 |
跳过等价分支 |
排序后跳过同层重复元素 |
| 顺序剪枝 |
先搜索更可能成功或更优的分支 |
先放约束最多的位置 |
剪枝的前提是正确性:被剪掉的分支必须确定不会影响答案。
易错点
| 易错点 |
后果 |
| 只递归,不撤销选择 |
状态污染,同层其他分支判断错误 |
| 递归终止条件写晚 |
已经形成完整解后仍继续向下枚举 |
组合问题没有使用 start |
同一个组合被按不同顺序重复统计 |
| 剪枝条件过强 |
合法答案被误删 |
| 只估计单条路径深度 |
忽略整棵搜索树规模,导致复杂度误判 |