一、回溯算法的核心概念
回溯算法(Backtracking)是一种通过深度优先搜索(DFS) 探索所有可能解的暴力搜索策略,其核心思想是:在探索过程中,当发现当前路径无法形成有效解时,撤销上一步的选择并回退到上一状态,继续尝试其他路径。
这种思想类似"走迷宫":沿着一条路走到尽头,若不通则退回上一个岔路口,选择另一条路继续尝试。回溯算法通过"尝试-撤销-再尝试"的循环,遍历问题的解空间树(所有可能解构成的树结构),最终找到所有有效解或最优解。
二、回溯算法的基本原理
-
解空间树
问题的所有可能解可抽象为一棵"解空间树":
- 根节点:初始状态(无任何选择);
- 中间节点:部分解(已做出部分选择);
- 叶子节点:完整解(所有选择均已做出)。
回溯算法通过DFS遍历这棵树,从根节点出发,逐层深入,直至叶子节点(得到解)或判定当前路径无效(剪枝)。
-
核心操作
回溯算法的执行过程可概括为3步:
- 选择:在当前状态下,从可选选项中选择一个,加入当前解;
- 递归:基于选择的状态,进入下一层递归,探索更深的路径;
- 撤销(回溯):递归返回后,撤销上一步的选择,恢复到上一状态,以便尝试其他选项。
三、基本框架与伪代码
回溯算法通常用递归实现,通用框架如下:
cpp
// 全局变量:存储所有有效解
vector<vector<int>> result;
// 全局变量:存储当前路径(部分解)
vector<int> path;
void backtrack(选择列表, 路径状态) {
// 终止条件:到达叶子节点(路径构成完整解)
if (路径满足结束条件) {
result.push_back(path); // 记录解
return;
}
// 遍历所有可选选项
for (选择 : 选择列表) {
// 剪枝:若当前选择无效,直接跳过(优化关键)
if (选择不满足约束条件) continue;
// 1. 做出选择
path.push_back(选择);
// 2. 递归探索下一层
backtrack(更新后的选择列表, 更新后的状态);
// 3. 撤销选择(回溯)
path.pop_back();
}
}
关键要点:
- 选择列表:随递归深度动态变化(例如组合问题中,后续选择需排除已选元素);
- 剪枝条件:根据问题约束设计(例如N皇后中,当前位置与已放皇后冲突则剪枝);
- 状态恢复:
path.pop_back()是回溯的核心,确保递归返回后状态与进入前一致。
四、适用场景
回溯算法适用于需枚举"所有可能解"或"满足约束条件的解"的问题,典型场景包括:
- 组合问题:从集合中选取k个元素(不考虑顺序),如"组合总和";
- 排列问题:对集合元素进行全排列(考虑顺序),如"全排列";
- 子集问题:求集合的所有子集,如"子集";
- 切割问题:将字符串/数组切割为满足条件的子部分,如"分割回文串";
- 棋盘问题:满足特定约束的布局,如"N皇后""数独"。
五、经典问题与C++实现示例
示例1:组合问题(LeetCode 77. 组合)
问题:给定两个整数n和k,返回1~n中所有可能的k个数的组合(如n=4, k=2时,解为[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]])。
思路:
- 解空间树:每层选择一个数,且后选的数必须大于已选的数(避免重复组合,如[1,2]与[2,1]视为同一组合);
- 剪枝:若剩余可选数字不足k - path.size()个,直接终止(例如当前path已有1个元素,剩余数字需至少1个才能凑齐k=2)。
代码:
cpp
#include <vector>
using namespace std;
vector<vector<int>> result;
vector<int> path;
void backtrack(int n, int k, int start) {
// 终止条件:path长度为k
if (path.size() == k) {
result.push_back(path);
return;
}
// 遍历可选数字(从start开始,避免重复)
// 剪枝:i最多到n - (k - path.size()) + 1(剩余数字足够)
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
path.push_back(i); // 选择
backtrack(n, k, i + 1); // 下一层从i+1开始
path.pop_back(); // 撤销
}
}
vector<vector<int>> combine(int n, int k) {
backtrack(n, k, 1);
return result;
}
示例2:全排列问题(LeetCode 46. 全排列)
问题:给定不含重复数字的数组,返回所有可能的全排列(如[1,2,3]的全排列为[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]])。
思路:
- 解空间树:每层选择一个未使用的数字(需标记已用元素);
- 剪枝:若数字已被使用,直接跳过。
代码:
cpp
#include <vector>
using namespace std;
vector<vector<int>> result;
vector<int> path;
void backtrack(vector<int>& nums, vector<bool>& used) {
// 终止条件:path长度等于数组长度
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
// 遍历所有数字
for (int i = 0; i < nums.size(); i++) {
if (used[i]) continue; // 剪枝:跳过已使用的数字
used[i] = true; // 标记为使用
path.push_back(nums[i]);// 选择
backtrack(nums, used); // 递归
path.pop_back(); // 撤销选择
used[i] = false; // 恢复标记
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<bool> used(nums.size(), false);
backtrack(nums, used);
return result;
}
示例3:N皇后问题(LeetCode 51. N皇后)
问题:在n×n的棋盘上放置n个皇后,使它们不能互相攻击(同一行、列、对角线无多个皇后),返回所有可行布局。
思路:
- 解空间树:每行放一个皇后,列号作为选择(一行一个,避免行冲突);
- 剪枝:检查当前列和对角线是否已有皇后(列冲突:同一列;对角线冲突:行差=列差)。
代码:
cpp
#include <vector>
#include <string>
using namespace std;
vector<vector<string>> result;
// 检查当前位置(row, col)是否合法
bool isValid(int row, int col, vector<int>& queens) {
for (int i = 0; i < row; i++) {
// 列冲突 或 对角线冲突(行差=列差)
if (queens[i] == col || abs(row - i) == abs(col - queens[i])) {
return false;
}
}
return true;
}
void backtrack(int n, int row, vector<int>& queens) {
// 终止条件:所有行都放置了皇后
if (row == n) {
// 生成棋盘字符串
vector<string> board;
for (int col : queens) {
string s(n, '.');
s[col] = 'Q';
board.push_back(s);
}
result.push_back(board);
return;
}
// 尝试当前行的每一列
for (int col = 0; col < n; col++) {
if (!isValid(row, col, queens)) continue; // 剪枝:冲突则跳过
queens[row] = col; // 放置皇后(记录列号)
backtrack(n, row + 1, queens); // 下一行
queens[row] = -1; // 撤销(可省略,因后续会覆盖)
}
}
vector<vector<string>> solveNQueens(int n) {
vector<int> queens(n, -1); // 记录每行皇后的列号
backtrack(n, 0, queens);
return result;
}
六、剪枝策略(优化核心)
回溯算法的效率取决于剪枝的有效性,常见剪枝方式:
- 可行性剪枝:若当前选择违反问题约束(如N皇后的冲突),直接终止当前路径;
- 最优性剪枝:若问题求最优解(如旅行商问题),当前路径代价已超过已知最优解,无需继续探索;
- 重复性剪枝:通过排序+跳过重复元素(如全排列II中处理重复数字),避免生成重复解。
七、时间与空间复杂度
- 时间复杂度:取决于解空间树的规模,通常为O(N!)(排列问题)或O(2ⁿ)(子集问题),剪枝可降低实际运行时间,但理论复杂度不变;
- 空间复杂度:主要为递归栈深度(O(N))和存储解的空间(O(K),K为解的总数)。
回溯算法是解决"枚举所有可能解"问题的通用框架,其核心是"选择-递归-撤销"的循环和有效的剪枝策略。掌握回溯算法需理解解空间树的结构,针对不同问题设计合理的选择列表、终止条件和剪枝规则。