回溯法有"通用解题法"之称。用它可以系统地搜索问题的所有解。回溯法是一个既带有系统性又带有跳跃性的搜索算法。它在问题的解空间树中,按深度优先策略,从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,先判断该结点是否包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的搜索,逐层向其祖先结点回溯;否则,进入该子树,继续按深度优先策略搜索。回溯法求问题的所有解时,要回溯到根,且根结点的所有子树都被搜索遍才结束。回溯法求问题的一个解时,只要搜索到问题的一个解就可结束。这种以深度优先方式系统搜索问题解的算法称为回溯法,它适用于求解组合数较大的问题。其核心思想是:
- 逐步构建候选解:从初始状态出发,逐步添加决策点,形成部分解
- 约束检查:在每一步验证当前部分解是否满足约束条件
- 回溯机制:当部分解无法继续扩展时,撤销最近的选择,尝试其他路径
回溯算法通常采用"深度优先搜索"来遍历解空间。在"二叉树"章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。还记得在二叉树中我们学到的遍历思想吗
以前序遍历为例:
cpp
/* 前序遍历:例题一 */
void preOrder(TreeNode *root) {
if (root == nullptr) {
return;
}
if (root->val == 7) {
// 记录解
res.push_back(root);
}
preOrder(root->left);
preOrder(root->right);
}
回溯算法,是因为该算法在搜索解空间时会采用"尝试"与"回退"的策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。
对于例题一,访问每个节点都代表一次"尝试",而越过叶节点或返回父节点的 return 则表示"回退"。
我们可以用path表示路径:例如我们需要寻找7这个节点的路径
cpp
void preOrder(TreeNode *root) {
if (root == nullptr) {
return;
}
// 尝试
path.push_back(root);
if (root->val == 7) {
// 记录解
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
// 回退
path.pop_back();
}
复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于"剪枝"。
了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为 的节点,则提前返回,不再继续搜索。代码如下所示:
cpp
void preOrder(TreeNode *root) {
if (root == nullptr || root -> val == 3) {
return;
}
// 尝试
path.push_back(root);
if (root->val == 7) {
// 记录解
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
// 回退
path.pop_back();
}
剪枝"是一个非常形象的名词。如图 13-3 所示,在搜索过程中,我们"剪掉"了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。
算法框架
cpp
/* 回溯算法框架 */
void backtrack(State *state, vector<Choice *> &choices, vector<State *> &res) {
// 判断是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 不再继续搜索
return;
}
// 遍历所有选择
for (Choice choice : choices) {
// 剪枝:判断选择是否合法
if (isValid(state, choice)) {
// 尝试:做出选择,更新状态
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, choice);
}
}
}
优点与局限性
回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优点在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受。
- 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
- 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。
回溯典型例题
回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。
搜索问题:这类问题的目标是找到满足特定条件的解决方案。
- 全排列问题:给定一个集合,求出其所有可能的排列组合。
- 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
- 汉诺塔问题:给定三根柱子和一系列大小不同的圆盘,要求将所有圆盘从一根柱子移动到另一根柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。
约束满足问题:这类问题的目标是找到满足所有约束条件的解。
- 皇后:在 的棋盘上放置 个皇后,使得它们互不攻击。
- 数独:在 的网格中填入数字 ~ ,使得每行、每列和每个 子网格中的数字不重复。
- 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。
组合优化问题:这类问题的目标是在一个组合空间中找到满足某些条件的最优解。
- 0-1 背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选择物品使得总价值最大。
- 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
- 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。
n皇后
n皇后问题是一个经典的组合优化问题,目标是在一个n×n的棋盘上放置n个皇后,使得它们互不攻击。皇后可以攻击同一行、同一列或同一对角线上的任何棋子。
在n×n的棋盘上放置n个皇后,需满足以下条件:
- 任意两个皇后不能位于同一行。
- 任意两个皇后不能位于同一列。
- 任意两个皇后不能位于同一对角线(包括主对角线和副对角线)。
以4后问题为例这是这其中的一个解
"#Q##",
"###Q",
"Q###",
"##Q#"]
那么我们可以用一个容量为n的队列来存放每一行皇后的位置
cpp
class Queue
{
private:
int n = 0;
std::vector<int> x;
int sum = 0;
void PrintQuene()
{
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= n; j++)
{
if (x[i] == j)
{
printf("%2c", 'Q');
}
else
{
printf("%2c", '#');
}
}
printf("\n");
}
printf("\n_______________________\n");
}
对于这个问题我们发现n <= 3 时是无解的,而且我们要对皇后的攻击方向进行判断
如何处理对角线约束呢?设棋盘中某个格子的行列索引为 row -col ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,即主对角线上所有格子的行减列 为恒定值。
也就是说,如果两个格子满足 ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图 13-18 所示的数组 diags1 记录每条主对角线上是否有皇后。
同理,次对角线上的所有格子的 是恒定值 。我们同样也可以借助数组 diags2 来处理次对角线约束。
cpp
bool Place(t)
{
for (int j = 1; j < t;j++)
{
if(x[j] == x[t] || abs(j - t) == abs(x[t] - x[j]))
{
return false;
}
}
return true;
}
void backTrack(int i)
{
if (i > n)
{
sum++;
PrintQuene();
}
else
{
for (int j = 1; j <= n; j++)
{
x[i] = j;
if(Place(i))
backTrack(i + 1);
}
}
}