一、递归与回溯的引入
1.1 递归算法概述
递归是一种常见的算法设计方法,简单来说就是函数内部直接或间接调用自身 。例如,求 n! 时,可以通过调用自身求出 (n−1)!,再乘以 n 得到结果。当然,如果只有自我调用而没有停止条件,递归就会无限进行下去,因此必须有一个终止条件 。比如阶乘的例子中,当 n=0 时,我们直接返回 1(因为 0!=1)。也就是说,递归程序通常包含两个核心要素:递归边界 (终止条件)和递归式(递推关系),它们支撑起了递归的核心逻辑。
1.2 什么是回溯
回溯,听起来与递归似乎没有直接联系,但实际上回溯算法是一种在递归基础上加入了"状态重置"的暴力搜索方法 。它并没有一个特定的库函数,而是一种算法思想:我们先试探某一种可能性,在试探过程中递归地深入搜索,当递归返回后,立即撤销刚才的选择(即回溯),表示当前分支已经探索完毕,转而尝试下一个分支,直到穷举所有可能。这种"试探-递归-撤销"的过程,使得回溯能够系统地遍历所有候选解。
值得一提的是,回溯本质上仍属于暴力枚举,时间复杂度通常较高(往往是指数级),但对于许多组合优化问题(如排列组合、子集、棋盘问题等),它是最直接且易于实现的求解方法,因此学习回溯算法依然十分必要。
二、全排列问题的回溯求解
2.1 例题重现
给定一个整数 n(1 ≤ n ≤ 8),输出 1 到 n 的所有全排列,要求按字典序输出 -1。
输入格式
输入只有一行,包含一个整数 n。
输出格式
输出所有排列,每行一个排列,每个排列中的数字之间用空格隔开 -1。
样例输入
3
样例输出
1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1
样例解释
当n = 3时,所有可能的排列如上所示。可以理解为:先固定第一位为1,递归处理剩余数字 [2,3] 得到 [1,2,3] 和 [1,3,2];再固定第一位为2,递归处理 [1,3] 得到 [2,1,3] 和 [2,3,1];最后固定第一位为3,递归处理 [1,2] 得到 [3,1,2] 和 [3,2,1] 。
2.2 为什么要使用回溯算法
全排列问题要求我们逐个位置选择数字 ,每个位置的选择都会影响后续可能性,这形成了一个多阶段决策树 。
回溯算法正是用递归来深度优先遍历这棵树 :每层递归负责一个位置的选择,选完后继续深入,当一条路走到尽头(得到一个排列)时,自动退回上一层并撤销刚才的选择,从而尝试其他分支。如果没有这种"试探-撤销"机制,就无法系统地穷举所有排列。因此,回溯是解决全排列最自然、最通用的方法。
2.3 基本思路
以n=3为例,假设我们最开始选的是1,之后我们就会面临继续选择的问题,因为不能选择的相同的元素,此时就需要一个bool型的uesd数组,标记我们已经使用过的数字。
按照回溯的思路,我们编写回溯函数的时候应该先写递归边界,此题的边界即为寻找完了所有元素,假设有一个长度为n的数组nums表示1-n的数,以及一个数组path,表示回溯过程中的路径,那么递归边界为path的大小为n,即两数组的大小相等,当满足这个条件时,我们设一个二维数组result来存放排列的组合,把path存到result里,然后返回。当然,如果我们在主函数里定义四个数组,需要将其全部当成参数传入。
其次便是"试探-递归-撤销 "的流程,我们先进行循环,表示顺序试探1-n,然后,我们需要判断当前该数字是否使用过(根据used数组)若未使用,则将该数字放入path后,并将used数组的对应的值改为true(试探),之后进行递归调用(递归),最后则将该数字从path中移除,并将uesd数组的对应值改为false(撤销),同时,正是通过这个撤销操作,我们才能让循环继续尝试下一个数字,从而穷举所有排列。此外,主函数在这里不作赘述。
2.4 示例代码
cpp
#include <bits/stdc++.h>
using namespace std;
/**
* 回溯算法生成全排列
* @param nums 包含所有待排列数字的数组(原数据,不会修改)
* @param used 标记每个数字是否已被使用(会修改)
* @param path 当前正在构建的排列路径(会修改)
* @param result 存储所有找到的排列(最终结果)
*/
void backtrace(vector<int>& nums, vector<bool>& used, vector<int>& path, vector<vector<int>>& result) {
// 终止条件:当前路径长度等于数字总数,说明找到了一个完整排列
if (path.size() == nums.size()) {
result.push_back(path); // 将当前排列存入结果集
return;
}
// 遍历所有数字,尝试将每个未使用的数字放入当前位置
for (int i = 0; i < nums.size(); i++) {
if (!used[i]) { // 如果当前数字尚未被使用
used[i] = true; // 做选择:标记为已使用
path.push_back(nums[i]); // 将该数字加入路径
// 递归进入下一层,继续构造剩余位置的数字
backtrace(nums, used, path, result);
// 回溯:撤销刚才的选择,以便尝试其他数字
path.pop_back(); // 从路径中移除该数字
used[i] = false; // 恢复未使用状态
}
}
}
int main() {
int n;
cin >> n; // 输入需要排列的数字个数
vector<int> nums(n); // 存放1~n的数字
vector<int> path; // 当前路径
vector<vector<int>> result; // 存放所有排列结果
vector<bool> used(n + 1); // 标记数组,大小为n+1,但实际只用索引0~n-1
// 初始化nums为1,2,...,n
for (int i = 0; i < n; i++) {
nums[i] = i + 1;
}
// 调用回溯函数生成全排列
backtrace(nums, used, path, result);
// 输出所有排列结果,每个排列内数字之间用空格隔开
for (const auto& perm : result) {
for (int i = 0; i < perm.size(); i++) {
cout << perm[i];
// 如果不是最后一个数字,则输出空格
if (i != perm.size() - 1) cout << " ";
}
cout << endl; // 每个排列占一行
}
return 0;
}
2.5 总结
全排列是回溯算法的经典入门题。它通过递归 实现深度优先搜索,在每一层递归中尝试所有可选数字,并在递归返回后撤销选择 (即回溯),从而穷举所有可能的排列。整个过程遵循"试探-递归-撤销"的模式。值得注意的是,该做法的时间复杂度:O(n!),因为 n 个数的全排列共有 n! 种,每种排列都需要 O(n) 时间来构建,所以,当n较大时,生成速度会明显变慢。
三、n皇后问题的回溯求解
3.1 例题重现
题目描述
在 N×N 的棋盘上放置 N 个皇后,使得它们彼此之间不能相互攻击。即:任意两个皇后都不在同一行、同一列或同一条对角线上。输出所有可能的放置方案。
输入格式
一个整数 n(通常 1 ≤ n ≤ 9,具体范围可根据题目要求调整),表示棋盘的大小和皇后的数量。
输出格式
输出所有可行的棋盘布局,每个布局由 n 行字符组成,其中 'Q' 表示皇后,'.' 表示空格。不同布局之间用空行隔开。
样例输入
4
样例输出
.Q..
...Q
Q...
..Q.
..Q.
Q...
...Q
.Q..
说明
-
上述样例展示了 4 皇后问题的两个解。
-
每个解中,任意两个皇后都不在同一行、同一列或对角线上。
-
输出顺序可以任意,但通常按字典序或任意顺序均可。
3.2 为什么要用回溯算法
与全排列问题相似,n皇后要求我们逐个位置选择是否放置皇后 ,每个位置的选择都会影响后续可能性,这也形成了一个多阶段决策树。回溯算法的每一次递归都决定了在某一行皇后应该放置在哪一列,同时撤销操作保证了我们能够探索不同分支,从而保证能够走遍全部分支。
3.3 基本思路
以 n=4 为例,假设我们从第 0 行开始放置皇后。每一行我们只能放一个皇后,并且要保证这个皇后与之前所有行已放置的皇后都不冲突(不同列、不同对角线)。此时我们就需要借助一个isVaild 函数来判断在当前行的某一列放皇后是否合法。
按照回溯的思路,编写回溯函数时先写递归边界。此题的边界就是我们已经放置完了所有行,即当前行号row 等于n。此时棋盘上已经有了一个完整的合法布局,我们就把当前棋盘chessboard存入结果集result 中,然后返回。
其次便是"试探-递归-撤销 "的流程。我们从当前行row 开始,循环遍历每一列col(从 0 到 n-1),表示尝试在这一行的每一列放置皇后。对于每一列,先用isVaild判断该位置是否合法(即检查同一列、两条对角线上方是否已有皇后)。如果合法,则在chessboard放上 'Q'(试探)。调用backtrack函数,行号加 1,继续处理下一行(递归)。当递归返回后,将chessboard重新置为 '.',表示撤销刚才放置的皇后,以便循环尝试当前行的下一列(撤销)。正是通过这个撤销操作,我们才能让循环继续尝试当前行的其他列,从而穷举所有可能的放置方案。当所有列都尝试完毕后,函数自然结束,返回上一层。
主函数负责读入n,初始化棋盘(全部为 '.'),调用backtrack从第 0 行开始搜索,最后输出所有结果。这里也不再赘述。
3.4 示例代码
cpp
#include <bits/stdc++.h> // 包含常用头文件(竞赛常用,建议实际开发中改用具体头文件)
using namespace std;
/**
* 判断在棋盘 chessboard 的第 row 行、第 col 列放置皇后是否合法
* @param chessboard 当前棋盘(二维字符数组)
* @param n 棋盘大小
* @param row 当前行
* @param col 当前列
* @return true 表示可以放置,false 表示冲突
*/
bool isValid(vector<vector<char>>& chessboard, int n, int row, int col) {
// 1. 检查同一列的上方是否有皇后
for (int i = 0; i < row; i++) {
if (chessboard[i][col] == 'Q')
return false;
}
// 2. 检查右上对角线(行减、列加)的上方是否有皇后
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
if (chessboard[i][j] == 'Q')
return false;
}
// 3. 检查左上对角线(行减、列减)的上方是否有皇后
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (chessboard[i][j] == 'Q')
return false;
}
// 所有检查通过,可以放置
return true;
}
/**
* 回溯算法核心函数
* @param chessboard 当前棋盘(引用,会随递归修改)
* @param n 棋盘大小
* @param result 存储所有可行解的容器(三维字符数组)
* @param row 当前要放置皇后的行号
*/
void backtrack(vector<vector<char>>& chessboard, int n,
vector<vector<vector<char>>>& result, int row) {
// 递归边界:已经成功放置完所有行(row 从 0 到 n-1)
if (row == n) {
result.push_back(chessboard); // 将当前棋盘(一个完整解)存入结果集
return;
}
// 尝试当前行的每一列
for (int col = 0; col < n; col++) {
// 剪枝:只有当前位置合法才继续深入
if (isValid(chessboard, n, row, col)) {
chessboard[row][col] = 'Q'; // 试探:放置皇后
backtrack(chessboard, n, result, row + 1); // 递归:处理下一行
chessboard[row][col] = '.'; // 回溯:撤销皇后,尝试下一列
}
}
}
int main() {
int n;
cin >> n; // 输入棋盘大小
// 初始化棋盘,全部填为 '.'
vector<vector<char>> chessboard(n, vector<char>(n, '.'));
// 存储所有解的容器,每个解是一个 n×n 的字符矩阵
vector<vector<vector<char>>> result;
// 从第 0 行开始回溯搜索
backtrack(chessboard, n, result, 0);
// 输出所有解
for (auto& solution : result) { // 遍历每一个解
for (int i = 0; i < n; i++) { // 遍历每一行
for (int j = 0; j < n; j++) { // 遍历每一列
cout << solution[i][j]; // 输出字符('Q' 或 '.')
}
cout << endl; // 换行
}
cout << endl; // 两个解之间空一行
}
return 0;
}
3.5 总结
N皇后是回溯算法的经典应用,通过逐行放置皇后 ,在每一行尝试所有可能的列,利用剪枝函数提前排除冲突的分支,最终找出所有合法布局。整个过程遵循"试探-递归-撤销"的模式。
通过N皇后问题,我们进一步巩固了回溯算法的框架,并学习了如何在搜索过程中利用约束条件进行剪枝,这是回溯算法高效的关键。
四、回溯总结
回溯算法是一种通过递归 来系统地搜索所有可能解的暴力枚举方法。它的核心思想可以概括为三个步骤:
-
做选择:在当前步骤尝试一种可能性(例如在全排列中选一个数字,在 N 皇后中放一个皇后)。
-
递归:基于当前选择,继续深入下一步,探索后续的可能性。
-
撤销选择 :当递归返回后,立刻取消刚才的选择(即回溯),以便循环尝试当前步骤的其他选项。
正是通过这种"试探-递归-撤销"的循环,回溯能够穷举所有路径,找到所有可行解。同时,在搜索过程中可以利用约束条件提前剪枝(如 N 皇后中的isVaild判断),跳过明显无效的分支,从而保证结果正确,也提升了速度。
回溯算法通常适用于组合、排列、子集、棋盘搜索等问题,虽然时间复杂度往往较高(如 O(n!) 或 O(2ⁿ)),但对于许多问题,它是最直观且易于实现的解法。掌握回溯的通用模板,就能灵活应对各种类似的搜索问题。
