1. 枚举法的局限
在解决问题的时候,我们经常需要用到枚举法来枚举出所有可能的结果,然后判断枚举出的结果是否满足条件,如果满足条件则接受,不满足则拒绝。下面是一个非常经典的例子:
例:鸡翁一值钱五,鸡母一值钱三,鸡雏三值钱一。百钱买百鸡,问鸡翁、鸡母、鸡雏各几何?
简单的来说就是解下列方程组:
{
x
+
y
+
z
=
100
5
x
+
3
y
+
z
/
3
=
100
\begin{cases} x+y+z=100 \\5x+3y+z/3=100 \end{cases}
{x+y+z=1005x+3y+z/3=100
最经典的解法是利用简单的枚举:
#include <iostream>
using namespace std;
int main(){
for (int x = 0; x <= 20; x++)
for (int y = 0; y <= 33; y++) {
int z = 100 - x - y;
if((z % 3 == 0) && (5 * x + 3 * y + z/3 == 100)){
cout << x << " " << y << " " << z << endl;
}
}
return 0;
}
从上例可以看出,枚举法的特点是枚举一定范围内的所有可能结果,然后判断结果是否满足要求。
下面考虑迷宫问题,例一个4*4的简单迷宫为例:
这是一个
4
∗
4
4*4
4∗4的迷宫,起点为左上角,终点为右下角。
如果采用枚举法解决这个问题,首先需要枚举出所有可能的路线(有兴趣的可以计算一下有多少种可能的路线,反正我没兴趣哈哈),下面简单的举几个例子:
显然,蓝色线和红色线的例子在第一次遇到墙之后就应该知道不可能成功。因此可以看出,由于枚举法是将所有的结果枚举出来之后再判断是否满足要求,这对于问题规模较小时是可行的。当问题规模较大时,例如枚举一个
100
∗
100
100*100
100∗100的迷宫的所有可能路线时,所需的时间非常长,甚至于无法得出结果。
2. 回溯法
回溯法与枚举法则不同,它通过将待求解问题分为多个步骤,每个步骤又包含多个可能的情况,通过对每种情况进行试探,如果试探失败则回溯到之前的一步试探其他可能的情况。因此,回溯法又称试探法。
八皇后
下面考虑回溯法最经典的八皇后的问题:
在8*8棋盘上放置8个皇后,使得它们之间无法相互攻击,皇后的攻击范围为同行、同列和同对角线,示意图如下:
其中,左图灰色位置为该皇后可攻击的位置,右图为一个可能的解。
如果枚举64个位置中选择8个位置,然后再判断能否相互攻击,则需要枚举的数量为 C 64 8 C_{64}^8 C648,显然行不通。
回溯法将八皇后问题分解为8个步骤,依次在第1、2…8行放置一个皇后,则可能的结果为
C
8
1
⋅
C
7
1
⋅
C
6
1
⋅
C
5
1
⋅
C
4
1
⋅
C
3
1
⋅
C
2
1
⋅
C
1
1
=
8
!
=
40320
种
C_8^1·C_7^1·C_6^1·C_5^1·C_4^1·C_3^1·C_2^1·C_1^1=8!=40320种
C81⋅C71⋅C61⋅C51⋅C41⋅C31⋅C21⋅C11=8!=40320种
实际上,利用回溯法递归求解问题时,在每个步骤都会判断合法性,如果不合法,则回溯到上一级递归调用。因此,利用回溯法不会遍历上述所有组合,当搜索到解答树的某结点时,如果不满足要求,则不会继续搜索。由于八皇后的解答树太大了,为了绘图和展示方便,下面给出四皇后的解答树来说明这一点:
从上图可以看出,四皇后的解答树中只包含了小部分可能的解,虽然多了许多中间结点,但总的结点数为17<4!。类似的,利用回溯法解决八皇后问题遍历的结点数要远小于8!个(经测试需要搜索共2057个结点,不到8!的十分之一)。
下面是一个简单的示例:
#include <iostream>
using namespace std;
int cnt=0;
int C[10]={0};
void search(int row){
if(row== 8) cnt++;
else{
for(int col = 0; col<8; col++){
int ok = 1;
C[row] = col;
for(int j = 0; j<row; j++)
if(C[row] == C[j] || cur - C[row] == j - C[j] || row+ C[row] == j + C[j]){
ok = 0;
break;
}
if(ok) search(row+1);
}
}
}
int main(){
search(0);
cout << cnt << endl;
return 1;
}
上例中,递推终止的条件是row==8
,这里不需要用return
返回,因为if(row==8)
成立分支程序已经不进行递归了,因此相当于停止递归。此外,这里用到了三个条件来判断当前位置是否合法:
① 前row行的当前列是否有皇后:C[row] == C[j]
① 前row行的当前主对角线是否有皇后:cur - C[row] == j - C[j]
① 前row行的当前斜对角线是否有皇后:row+ C[row] == j + C[j]
这里实际上用到了同一主对角线的行列之差相同,同一斜对角线的行列之和相同的原理,如下图所示:
在上面的程序中,我们每次递归都循环判断了前row行的皇后的攻击范围,实际上我们并不需要这么做,因为前row-1行之前已经判断过了,我们可以删去一些重复的操作。这里可以利用一个二维数组visit[3][]来保存危险的列、危险的主对角线以及危险的斜对角线。当每次放置皇后之后更新visit数组即可。
#include <iostream>
using namespace std;
int cnt=0;
int C[10]={0};
int visit[3][16]={0};
void search(int row){
if(row == 8) cnt++;
else{
for(int col = 0; col<8; col++){
if(!visit[0][col] && !visit[1][row+col] && !visit[2][row-col+8]){
C[row] = col;
visit[0][col] = visit[1][row+col] = visit[2][row-col+8] = 1;
search(row+1);
visit[0][col] = visit[1][row+col] = visit[2][row-col+8] = 0; // 一定要恢复
}
}
}
}
int main(){
search(0);
cout << cnt << endl;
return 1;
}
增加了一个visit数组之后我们就不需要循环判断前row行的皇后位置了。此外,由于visit是一个全局的辅助数组,我们在同一级递归之间一定要恢复更改的全局辅助变量。因为这里同一级递归种的不同分支相当于不同的试探,我们要保证它们的初始条件相同。
素数环
下面再看一个回溯法的经典例题:素数环。
例:输入正整数n,把整数1,2,3…,n组成一个环,使得相邻两个整数之和均为素数。输出时从整数1开始逆时针排列。同一个环应恰好输出一次。n≤16。
如果直接采用枚举法也是可以解决这个问题的,由排列的公式
A
16
16
=
16
!
=
20922789888000
A_{16}^{16}=16!=20922789888000
A1616=16!=20922789888000,可以看出枚举量是非常大的,因此利用回溯法可以更加的高效。
下面分析递归的终止条件和递归函数的参数,以及是否需要辅助数组等
① 终止条件:很容易想到应该为cur == n
,但实际上还应计算第一个数和最后一个数之和是否为素数。
② 递归的参数:第一个应该是当前已排列的个数,其他的一些参数可通过全局变量来访问。
③ 辅助数组:可以增加一个辅助数组来标记已使用的数
下面给出示例代码:
#include <iostream>
using namespace std;
// 2、3、5、7、11、13、17、19、23、29、31
int isPrime[32]={0,0,1,1,0,1,0,1,
0,0,0,1,0,1,0,0,
0,1,0,1,0,0,0,1,
0,0,0,0,0,1,0,1,};
int n = 6;
int A[16];
int isUsed[20];
void pRing(int cur){
if(cur == n && isPrime[A[0]+A[cur-1]]){
for(int i=0 ;i<n ;i++) cout << A[i]<< " ";
cout << endl;
}
else{
for(int i=2; i<=n; i++){
if(!isUsed[i] && isPrime[i+A[cur-1]]){
A[cur] = i;
isUsed[i] = 1;
pRing(cur+1);
isUsed[i] = 0;
}
}
}
}
int main(){
A[0] = 1;
pRing(1);
return 1;
}
上例中,使用了一个isPrime数组来判断是否是素数,这里也可以改为一个判断素数的函数,但是由于解答树的每个节点都会用到这个函数,而经测试,当n=16时,解答树的结点已经超过100万,因此直接列出一个范围内的素数表可以更加的高效。此外,isUsed数组用于标记已经被使用的数,应注意在该数组在同级递归中应该保持不变。
困难的串
例:如果一个字符串包含两个相邻的重复子串,则称它为“容易的串”,其他串称为“困难的串”,例如 B ‾ B ‾ 、 A B C D A C A B ‾ C A B ‾ 、 A B C D ‾ A B C D ‾ \underline{B}\underline{B}、ABCDA\underline{CAB}\underline{CAB}、\underline{ABCD}\underline{ABCD} BB、ABCDACABCAB、ABCDABCD是“容易的串”,而 D 、 D C 、 A B D A B 、 C B A B C B A D、DC、ABDAB、CBABCBA D、DC、ABDAB、CBABCBA都是困难的串。
输入正整数n和L,输出由L个字符组成的、字典序第n小的困难的串。例如,当L=3时,前7个困难的串分别为A、AB、ABA、ABAC、ABACA、ABACAB、ABACABA。输入保证答案不超过80个字符。
首先分析题目的要求,这里要求输出的是“困难的串”,也就是不含相邻重复子串
的串。我们可以通过从左到右按字典序递归构造,由于已构造完的部分串肯定是"困难的串",因此我们不需要考虑中间部分是否包含相邻重复子串,相邻重复子串只可能出现在串的后面区域。
此外,由于程序找到第n个“困难的串”即可终止递归,因此我们可以显式的终止程序,这里利用return 0
即可。
下面是示例代码:
#include <iostream>
using namespace std;
int n=30, L=3;
int cnt;
int A[80];
int dfs(int cur){
if(cnt++ == n){
for(int i=0; i<cur; i++) cout << (char)('A'+A[i]);
cout << endl;
return 0;
}
else{
for(int i=0; i<L; i++){
A[cur] = i;
int ok = 1;
///
for(int j=1; 2*j<cur+2; j++){
int equal = 1;
for(int k=0; k<j; k++)
if(A[cur-k] != A[cur-k-j]){
equal = 0;
break;
}
if(equal){ok = 0;break;}
}
///
if(ok)
if(!dfs(cur+1)) return 0;
}
}
return 1;
}
int main(){
dfs(0);
return 1;
}
输出结果为:
ABACABCACBABCABACABCACBACABA
程序中,两行斜线中的部分代码用于判断构造的串的尾部是否有相邻子串。