只要能把待求解的问题分成不太多的步骤,每个步骤又只有不太多的选择,都可以考虑应用回溯法。想象一棵包含 L 层,每层的分支因子均为b的解答树,它的结点数高达1+b+b^2+...+b^(L-1)=(b^L-1)/(b-1)。无论是b太大还是L太大,结点数都会是天文数字。
一、八皇后问题
最简单的思路是,从8X8=64个格子中选一个子集,使得满足要求。正是子集枚举问题。然而,规模是2^64个。
第二个想法是,从64个格子中选8个格子。这是组合生成问题,有C(64,8)=4.426X10^9种方案。
进一步优化,因恰好每行每列只放置一个皇后,若用C[x]表示第x行皇后的列编号,则是全排列生成问题。有8!=40320个。
在编写递归枚举程序之前,需要深入分析问题,对模型优化。一般还应对解答树的结点数有一个粗略的估计。
事实上,四皇后问题的完整解答树个数只有17个结点,比4!=24小;因为有些结点无法继续扩展。(即为可行性约束)
当把问题分成若干步骤并递归求解时,若当前步骤没有合法选择,则递归函数不再递归调用自身,而是返回上一级递归调用,这种现象称为回溯。递归枚举算法常称为回溯法。
Code:
//八皇后问题求解,解的个数
#include<stdio.h>
#include<stdlib.h>
void search(int cur);
int C[100];
int cnt;
int n;
int main()
{
scanf("%d",&n);
cnt=0;
search(0);
printf("%d\n",cnt);
system("pause");
return 0;
}
void search(int cur)//放置第cur行
{
if(cur==n) cnt++;//递归边界
else
for(int i=0;i<n;++i)
{
C[cur]=i;//尝试把第cur行的皇后放在第 i 列
int ok=1;
for(int j=0;j<cur;++j)//检查是否和前面的皇后冲突
if(C[cur]==C[j]||C[cur]+cur==C[j]+j||C[cur]-cur==C[j]-j)
{ ok=0; break; }
if(ok) search(cur+1);
}
}
结点数似乎很难进一步减少,但效率可以继续提高:利用二维布尔值数组vis[3][n*2]直接判断当前尝试的皇后所在的列和两个对角线是否已有其他皇后。(主对角线标识y-x可能为负,所以加上n) (这个思想相当于用一个数组省掉了一个循环中的小循环,可以对比之前的枚举排列中第一个思路,那里else中的i循环里的j这个小循环,和这里类似,也可以用一个数组来替代掉。)
Code:
#include<stdio.h>
#include<stdlib.h>
#define MAXN 100
void search(int cur);
int C[MAXN];
int vis[3][MAXN];
int n;
int cnt;
int main()
{
scanf("%d",&n);
cnt=0;
search(0);
printf("%d\n",cnt);
system("pause");
return 0;
}
void search(int cur)
{
if(cur==n) cnt++;
else
for(int i=0;i<n;++i)
{
if(!vis[0][i]&&!vis[1][cur+i]&&!vis[2][cur-i+n])//利用二维数组直接判断
{
C[cur]=i;//如果不打印解,整个C数组可以省略
vis[0][i]=vis[1][cur+i]=vis[2][cur-i+n]=1;//修改全局辅助变量
search(cur+1);
vis[0][i]=vis[1][cur+i]=vis[2][cur-i+n]=0;//切记!要改回来
}
}
}
一般地,在回溯法中修改了辅助的全局变量,则一定要及时把它们恢复原状。(如这里的vis数组)例如,若函数有多个出口,则需在每个出口处恢复被修改的值。除非,你估计保留你的修改。(如这里的全局变量cnt。还是不太一样。。)
二、素数环
输入正整数n,把1到n组成一个环,使得相邻两个整数之和均为素数。输出时从整数1开始逆时针排列,同一个环输出一次。n<=16。
第一个思路是,生成-测试法。对每个排列进行测试,16!=2X10^13.
Code:
//素数环,生成-测试法
#include<cstdio>
#include<algorithm>
#define MAXN 1000
using namespace std;
int isp[MAXN];//布尔数组,isp[i]表示整数i是否为素数
int A[MAXN];
int is_prime(int x)
{//do NOT use this if x is very large
if(x==1) return 0;
for(int i=2;i*i<=x;++i)
if(x%i==0) return 0;
return 1;
}
int main()
{
int n;
scanf("%d",&n);
for(int i=2;i<=2*n;++i) isp[i]=is_prime(i);
for(int i=0;i<n;++i) A[i]=i+1;
do
{
int ok=1;
for(int i=0;i<n;++i)
if(isp[A[i]+A[(i+1)%n]]==0) { ok=0; break; }
if(ok)
{
for(int i=0;i<n;++i) printf("%d ",A[i]);
printf("\n");
}
}while(next_permutation(A+1,A+n));
system("pause");
return 0;
}
发现,当n=12时就很慢。 用回溯法的话,和之前八皇后类似,dfs(cur)函数表示放置第cur位置上的数。第0位置肯定放1,从第1位置开始放,每个位置都是从2到n的枚举,并且测试是否满足。
Code:
//素数环,回溯法
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define MAXN 1000
void dfs(int cur,int n);
int A[MAXN];
int vis[MAXN];
int isp[MAXN];
int is_prime(int x)
{
if(x==1) return 0;
for(int i=2;i*i<=x;++i)
if(x % i==0) return 0;
return 1;
}
int main()
{
int n;
scanf("%d",&n);
for(int i=2;i<=2*n;++i) isp[i]=is_prime(i);
memset(vis,0,sizeof(vis));
A[0]=1;
dfs(1,n);
system("pause");
return 0;
}
void dfs(int cur,int n)//放置第cur位置
{
if(cur==n && isp[A[n-1]+A[0]])//递归边界。测试第一个数和最后一个数
{
for(int i=0;i<n;++i) printf("%d ",A[i]);
printf("\n");
}
else
for(int i=2;i<=n;++i)//尝试放2到n
if(!vis[i]&&isp[i+A[cur-1]])//若i没放过,且与前一个数之和为素数
{
A[cur]=i;
vis[i]=1;//设置使用标志
dfs(cur+1,n);
vis[i]=0;//清楚标志
}
}
经测试,回溯法比生成-测试法快很多。 另外,从解答树的角度,回溯法正是按照深度优先的顺序在遍历解答树。
三、困难的串
若一个字符串包含两个相邻的重复子串,则是容易的。如BB、ABCDABCD等。而D、DC、CBABCBA则是困难的串。输入正整数n和L,输出由前L个字符组成的、字典序第n小的困难的串。
一种方法是检查所有长度为偶数的子串,判断前一半是否等于后一半。尽管正确,但做了很多无用功。
在八皇后问题中,我们只判断当前皇后是否和前面的皇后冲突,但并不判断以前的皇后是否相互冲突——那些皇后在之前已经判断过了。同样的道理,这里只需要判断当前串的后缀,而非所有子串。
这里还是递归的一种思想,就是只考虑当前位置。这样的话,即是假设前面的位置是已经符合要求的,现在考虑末位新加的当前位置,则只需要从后面考虑子串即可。从当前位置往前数长度为j的子串,和连续的再向前长度为j的子串,是否相同。
Code:
#include<stdio.h>
#include<stdlib.h>
int dfs(int cur);
int cnt;
int n,ll;
int C[85];
int main()
{
scanf("%d%d",&n,&ll);
cnt=0;
dfs(0);
system("pause");
return 0;
}
int dfs(int cur)
{
if(cnt++==n)
{
for(int i=0;i<cur;++i) printf("%c",C[i]+'A');
printf("\n");
return 0;
}
for(int i=0;i<ll;++i)//当前位置尝试各个字符
{
C[cur]=i;
int ok=1;//当前串是否是困难的
for(int j=1;j*2<=cur+1;++j)//从后往前判断2*j长度的各一半是否相同
{
int equal=1;//当前长度为j的两个串是否相同
for(int k=0;k<j;++k)//遍历长度为j的前后部分的对应位置
if(C[cur-k]!=C[cur-j-k]) { equal=0; break;}
if(equal) { ok=0; break;}
}
if(ok) { if(dfs(cur+1)==0) return 0;}//递归搜索。如果已找到解,则直接退出
}
return 1;
}
此题一开始可以想到在第0位置放置A,然后放置第1位置,只有当后面的位置没有选择、即不能满足条件时,才回到前面位置继续换大一点的字符枚举,如第0位置可能要换成B。其中每个位置都要枚举,枚举到什么程序又不确定,没有选择时还需要回溯,这种情况就要递归。
这里的回溯,是按照深度优先遍历解答树,保证了字典序的第n小。
下面是左边是L=2时完整的解答树,右边是L=3时部分的解答树。事实上,L=3时,可以构造出无限长的困难的串。
(图用Visio画的,而且直接截图,不太好,有人推荐个常用画图的吗?而且感觉CSDN博客贴图好麻烦。)
四、带宽
一个有n个结点的图G和这些结点的排列,定义结点i的带宽b(i)为i和相邻结点在排列中的最远距离,而所有b(i)的最大值就是整个图的带宽。给定图G,求出让带宽最小的结点排列。
直接的思路是,递归枚举全排列,分别计算带宽,选取最小的方案。
如何优化呢?八皇后问题中,有很多可行性约束,可以在得到完整解之前避免扩展那些不可行的结点,但本题并没有可行性约束——任何排列都是合法的。
优化:一,记录目标已找到的最小带宽k。若发现两个结点的距离大于等于k,则再怎么扩展也不可能比当前解更优,可以剪枝。二,在搜索到结点u时,u结点还有m个相邻点没有确定位置,那么对结点u来说,最理想的情况是这m个结点紧跟在u后面,这样的结点带宽为m,而其他任何非理想情况的带宽至少为m+1。这样,若m>=k,可以剪枝。
这样的剪枝优化思路在很多论文中也很常见。此题相当于是最小化最大值。记录下已找到的最小值,检测当前情况时,发现当前情况的最优值比已找到的最优值差,则可以直接剪枝。
练习:UVa 140
附,打印解法的八皇后Code:
//解数,及具体方法(每行皇后放置的列号)
#include<stdio.h>
#include<string.h>
#define MAXN 50
void search(int cur);
int n;
int cnt=0;
int c[MAXN];
int path[1000][MAXN];
//int k=0;
int main()
{
while(scanf("%d",&n)==1)
{
cnt=0;
//k=0;
search(0);
printf("%d\n",cnt);
for(int i=0;i<cnt;++i)
{
for(int j=0;j<n;++j) printf("%d ",path[i][j]);
printf("\n");
}
}
return 0;
}
void search(int cur)
{
if(cur==n) { memcpy(path[cnt++],c,sizeof(c)); }
else for(int i=0;i<n;++i)
{
int ok=1;
c[cur]=i;
for(int j=0;j<cur;++j)
if(c[cur]==c[j]||cur+c[cur]==j+c[j]||cur-c[cur]==j-c[j]) {ok=0; break;}
if(ok) search(cur+1);
}
}
//解数,及具体放置。回溯法。
#include<stdio.h>
#include<string.h>
#define MAXN 50
void search(int cur);
int c[MAXN];
int path[1000][MAXN];//path的行数不要开错了,和MAXN没关系
//int k=0;
int cnt=0;
int vis[3][2*MAXN];
int n;
int main()
{
while(scanf("%d",&n)==1)
{
cnt=0;
//k=0;
memset(vis,0,sizeof(vis));
search(0);
printf("%d\n",cnt);
for(int i=0;i<cnt;++i)
{
for(int j=0;j<n;++j) printf("%d ",path[i][j]);
printf("\n");
}
}
return 0;
}
void search(int cur)
{
if(cur==n) { memcpy(path[cnt++],c,sizeof(c));}
else for(int i=0;i<n;++i)
{
if(!vis[0][i]&&!vis[1][cur+i]&&!vis[2][cur-i+n])
{
c[cur]=i;
vis[0][i]=vis[1][cur+i]=vis[2][cur-i+n]=1;
search(cur+1);
vis[0][i]=vis[1][cur+i]=vis[2][cur-i+n]=0;
}
}
}
参考:刘汝佳《算法竞赛入门经典》