算法基础之回溯法
本文将详细介绍回溯法的基本原理和适用条件,并通过经典例题辅助读者理解回溯法的思想、掌握回溯法的使用。本文给出的例题包括:N皇后问题、子集和问题。
算法原理
在问题的解空间树中,回溯法按照深度优先的搜索策略,从根结点出发搜索解空间树,回溯法实际上一个类似穷举的搜索尝试过程。由于采用回溯法求解时存在退回到祖先结点的过程,所以需要保存搜索过的结点,可以自定义栈来保存祖先结点,也可以采用递归。
在搜索解空间时,通常采用两种策略避免无效搜索,以提高搜索效率。一是使用约束函数在拓展结点处剪除不满足约束条件的路径,二是使用限界函数剪去得不到问题解或最优解的路径,这两类函数统称为剪枝函数。
回溯法的一般非递归设计如下:
int x[n]; // 解向量
void backtrack(int n){
int i=1; // 根结点层次
while(i>=1){
if(ExistSubNode(t)){ // 当前结点存在子结点
x[i]取一个可能的值;
if(constraint(i)&&bound(i)){ // 满足约束条件和限界函数
if(x为可行解) 输出x; // 输出解
else i++; // 下一层次
}
}
else i--; // 回溯
}
}
回溯法的一般递归设计如下:
int x[n]; // 解向量
void backtrack(int i){
if(i>n) 输出结果; // 到达叶子结点
else {
for(j=下界;j<=上界;j++){ // 枚举x[i]的所有可能
x[i]=j;
...
if(constraint(i)&&bound(i)) // 满足约束条件和限界函数
backtrack(i+1); // 下一层次
}
}
}
N皇后问题
题目描述
\(N\) 皇后问题要求在一个 \(N×N\) 的棋盘上放置 \(N\) 个皇后,使得它们彼此不受“攻击”。观察表明 \(N\) 皇后问题的解存在对称性,求其中不对称的那些解的数量。
输入输出
输入:皇后个数N。
输出:不对称的那些解。
解题思路
仔细观察 \(N\) 皇后的解,发现一种方案可以通过“对称”得到另一种方案。以“左右对称”为例,当 \(N=5\),限定第一行皇后在左边一半区域时,方案数为 \(6\),如下图所示。

通过“左右对称”可以获得另一方案,同时发现,后面有两种方案重复,去除重复方案后,剩下的刚好是 \(N=5\) 时的全部方案,如下图所示。

当 \(N\) 为偶数时关于中间那条线对称,当 \(N\) 为奇数时关于中间那一列对称。利用对称性可以使得工作量减少一半,为此,在放置皇后时,增加两条限制
- 第一行的皇后只放在左边一半区域,也即位置小于等于 \((n+1)/2\);
- 当 \(N\) 为奇数且第一行皇后刚好放在 \((n+1)/2\) 位置(即中间)时,为避免重复,第二行皇后必须放在左边一半区域。
代码实现
int *Q, N, ANS; // 棋盘 棋盘大小 解的数量
int main() {
printf("请输入棋盘大小N: ");
while (cin >> N) {
ANS = 0, Q = new int[N + 1], Q[1] = 0;
// 求解
queen();
// N=1时无第二行,无法施加两条限制,特殊处理
printf("解的总数为: %d\n", N!=1?ANS * 2:ANS);
printf("\n请输入棋盘大小N: ");
}
}
/**
* N皇后非递归回溯求解-基础实现
* 所有下标均从1开始
*/
void queen() {
// 第一个皇后
int k = 1;
// N是否为奇数、中间位置、当前行最多能放到第几列
int odd = N & 1, M = (N + 1) >> 1, L;
// 开始放置皇后
while (k > 0) {
// 第k个皇后尝试下一个位置
Q[k]++;
// 第一行放置的皇后不能超过中间
if (k == 1)L = M;
// N为奇数且第一行放在中间时,第二行不能超过中间
else if (k == 2 && odd && Q[1] == M)L = M - 1;
// 其它情况可以放到中间的右边
else L = N;
// 寻找第k行的下一个可以放置的位置
while (Q[k] <= L && !place(k))Q[k]++;
// 已超过当前行的上限L,回溯,返回上一行
if (Q[k] > L)--k;
// 如果放置所有皇后,则打印结果,否则放置下一行
else k == N ? (showRes(),ANS++) : Q[++k] = 0;
}
}
/**
* 判断第k个皇后当前位置是否合适 Q[k]是第k个皇后放置的位置
* @param k 第k个皇后
* @return 是否可以放置
*/
bool place(int k) {
for (int i = 1; i < k; ++i)
// 同列、同斜线已存在皇后
if (Q[i] == Q[k] || abs(Q[i] - Q[k]) == abs(i - k))return 0;
return 1;
}
/**
* 打印可行解
*/
void show() {
printf("(");
for (int i = 1; i <= N; ++i)printf("%d,", Q[i]);
printf("\b)\n");
}
时间复杂度:\(O(n^n)\)。
空间复杂度:\(O(n)\)。
子集和问题
题目描述
已知包含 \(n\) 个不同正整数 \(w_i\) 的集合,\((0≤i≤n-1)\),求该集合的所有满足条件的子集,使得每个子集中的正整数之和等于另一个给定的正整数 \(W\)。
输入输出
输入:一行输入 \(n\) 和 \(W\) 的值,第二行输入 \(n\) 个不同的正整数 \(w_i\)。
输出:如果有答案,则输出所有满足条件的子集(用固定长度 \(n\) 元组 \(x\) 表示,\(x_i\) 为 \(0\) 或 \(1\))。如果没有答案,则输出 \(no\ solution!\)。
解题思路
当 \(N=4\) 时,解空间树如下图所示。其中,结点中的数字为结点的编号,规定往结点左边“走”,对应 \(x_i\) 记为 \(1\),即表示选取第 \(i\) 个正整数,往结点右边“走”,对应 \(x_i\) 记为 \(0\),即表示不选取第 \(i\) 个正整数。
集合中的正整数按输入顺序从 \(0\) 开始编号,设数组 \(x\),\(x[i]=1\) 表示选择第 \(i\) 个正整数,\(x[i]=0\) 表示不选择第 \(i\) 个正整数。\(sw\) 记录尝试选取第 \(i\) 个正整数时,下标为 \(0\) 到 \(i-1\) 的正整数中已选取的正整数的和,\(uw\) 记录尝试选取第 \(i\) 个正整数时,下标为 \(i+1\) 到 \(n-1\) 的正整数的和。当面对第 \(i\) 个正整数时,需要依次尝试选取第 \(i\) 个和不选取第 \(i\) 个。
尝试选取第 \(i\) 个时,先判断第 \(i\) 个是否可选。若 \(sw+w[i]≤W\),即,在假定选取第 \(i\) 个的情况下,已选正整数的和没有超过给定正整数 \(W\),则第 \(i\) 个可选,否则,不可选,剪掉左枝。
例如,当前处于上图中的结点 \(4\),考虑选取第 \(3\) 个正整数,到达结点 \(8\),如果已选取的正整数的和超过给定正整数 \(W\),则剪去以结点 \(8\) 为根结点的二叉树,因为,结点 \(8\) 往下,无论做何选择,已选正整数的和都不可能为 \(W\)(在结点 \(8\) 时就已经超过 \(W\))。
尝试不选取第 \(i\) 个时,先判断此后是否存在解。若 \(sw+uw≥W\),即,在假定不选取第 \(i\) 个的情况下,此后已选正整数的和仍有可能达到 \(W\),则此后存在解,否则,此后不存在解,剪掉右枝。
例如,当前处于上图中的第 \(4\) 个节点,考虑不选取第 \(3\) 个正整数,到达结点 \(9\),如果从结点 \(9\) 开始,一直往左“走”也无法达到给定正整数 \(W\),则剪去以结点 \(9\) 为根节点的子树,因为,此后最大和都已不可能达到 \(W\)。
代码实现
int N, W; // 正整数个数 指定和
int *w, *x; // 正整数 解
int main() {
int rw = 0;
// 输入正整数个数N
cin >> N >> W;
w = new int[N], x = new int[N];
// 输入N个正整数
for (int i = 0; i < N;rw += w[i++])cin >> w[i];
// 求解
solve(0, 0, rw);
}
/**
* 尝试选取第i个正整数(i从0开始)
* 面对第i个正整数,需要依次尝试选取第i个和不选取第i个
* 1.选第i个: 选第i个前判断第i个是否可选,sw+W[i]<=W即为可选
* 2.不选第i个: 不选第i个前,判断此后是否存在解,sw+uw>=W即为存在解
* @param i 第i个正整数
* @param sw [0,i-1]已选正整数的和
* @param uw [i+1,n-1]的和
*/
void solve(int i, int sw, int uw) {
// 已达叶子结点
if (i >= N) {
if (sw == W)ANS++,show(); //找到一个解
return;
}
// 选取第i个,未超过W(超过则剪掉左枝)
if (sw + w[i] <= W) {
x[i] = 1; // 选取第i个
solve(i + 1, sw + w[i], uw - w[i]); // 尝试选取第i+1个
}
// 不选第i个,此后存在解(不存在则剪掉右枝)
if (sw + uw >= W) {
x[i] = 0; // 不选取第i个
solve(i + 1, sw, uw - w[i]); // 尝试选取第i+1个
}
}
/**
* 输出解
*/
void show() {
printf("(%d", x[0]);
for (int i = 1; i < N;) printf(",%d", x[i++]);
printf(")\n");
}
时间复杂度:\(O(2^n)\)。
空间复杂度:\(O(n)\)。
经验总结
回溯法与深度优先遍历非常相似,剪枝是回溯法的一个明显特征,但并不是任何回溯法都包含剪枝,因而很难区分回溯法与深度优先遍历,广义来讲,带回退的算法都是回溯算法。
如果仅仅采用深度优先,那么需要遍历整个解空间,与穷举并无太大区别,此时的回溯法可看做按深度优先+穷举,穷举的时间复杂度无疑是较高的。为了提高搜索的效率,在搜索解空间时,需要在拓展结点处剪除不满足约束条件的路径和得不到问题解或最优解的路径。不满足约束条件是指,当前路径已经不满足题目对解的要求,说明此后含有该路径的路径也必然不符合要求(算法开始前可能需要对元素进行排序才能满足这一点),因此,没必要在此基础上继续尝试。得不到问题解或最优解是指,虽然当前路径目前来说是合法的,但此后即使“使尽全力“也无法达到要求,此时也没必要在此基础上继续尝试。不满足约束条件和得不到问题解或最优解,前者侧重考虑当前,后者侧重考虑将来。
回溯法常采用递归来实现,在递归调用返回时会自动回退和恢复,但也可采用非递归实现,如本实验的N皇后问题的实现,此时需要编码实现回退和恢复。采用递归实现,代码简洁,采用非递归实现,需要处理的问题较多,逻辑稍微更加复杂,代码量相对而言可能更多。
END
文章文档:公众号 字节幺零二四 回复关键字可获取本文文档。

本文将详细介绍回溯法的基本原理和适用条件,并通过经典例题辅助读者理解回溯法的思想、掌握回溯法的使用。本文给出的例题包括:N皇后问题、子集和问题。
浙公网安备 33010602011771号