【暖*墟】#dfs回溯法# 八皇后问题与延伸

无论是排列生成还是子集枚举,前面都给出了两种思路:递归构造和直接枚举。

直接枚举法的优点是思路和程序都很简单,缺点在于无法减小枚举量——必须生成所有可能的解,然后检查。

另一方面,在递归构造中,生成和检查过程可以有机结合起来,从而减少不必要的枚举。

这就是本节的主题——回溯法

回溯法的应用范围很广,只要能把待求解的问题分成不太多的步骤,每个步骤又只有不太多的选择,都可以考虑。

为什么说“不太多”呢?想象一棵包含L层,每层的分支因子均为b的解答树,

无论是b太大还是L太大,结点数都会是一个天文数字。

1 八皇后问题

在棋盘上放置8个皇后,使得它们互不攻击,此时每个皇后的攻击范围为同行同列和同对角线,要求找出所有解。

【分析】

最简单的思路是把问题转化为“从64个格子中选一个子集”,使得“子集中恰好有8个格子,

且任意两个选出的格子都不在同一行、同一列或同一个对角线上”。这正是子集枚举问题

然而,64个格子的子集有2^64个,太大了,这并不是一个很好的模型。

第二个思路是把问题转化为“从64个格子中选8个格子”,这是组合生成问题

根据组合数学,有4.426*10^9种方法,比第一种方案优秀,但仍然不够好。

经过思考,不难发现以下事实:恰好每行每列各放置一个皇后。

如果用C[x]表示第x行皇后的列编号,则问题变成了全排列生成问题

而0~7的排列一共只有8!=40320个,枚举量不会超过它。

四皇后问题的完整解答树只有17个结点,比4!=24小。为什么会这样呢?这是因为有些结点无法继续扩展

例如,在(0,2,*,*)中,第2行无论将皇后放到哪里,都会和第0行和第1行中已放好的皇后发生冲突。

在这种情况下,递归函数将不再递归调用它自身,而是返回上一层调用,这种现象称为回溯(backtracking)

提示:当把问题分成若干步骤并递归求解时,如果当前步骤没有合法选择,则函数将返回上一级递归调用,这种现象称为回溯。

下面的程序简洁地求解了八皇后问题。在主程序中读入n,并为tot清零,然后调用search(0),即可得到解的个数tot。

void search(int cur) {
  if(cur == n) tot++;  //递归边界。只要走到了这里,所有皇后必然不冲突
  else for(int i = 0; i < n; i++) {
    int ok = 1;
    C[cur] = i;   //尝试把第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);   //如果合法,则继续递归
  }
}

注意:既然是逐行放置的,则皇后肯定不会横向攻击,因此只需检查是否纵向和斜向攻击即可。

条件“cur-C[cur] == j-C[j] || cur+C[cur] == j+C[j]”用来判断皇后(cur,C[cur])和(j,C[j])是否在同一条对角线上。

结点数似乎很难进一步减少了,但程序效率可以继续提高:

利用二维数组vis[2][ ]直接判断当前尝试的皇后所在的列和两个对角线是否已有其他皇后。

注意到主对角线标识y-x可能为负,存取时要加上n

void search(int cur) {
  if(cur == n) tot++;
  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数组的使用。vis数组的确切含义是什么?

它表示已经放置的皇后占据了哪些列、主对角线和副对角线。

将来放置的皇后不应该修改这些值——至少“看上去没有修改”。

一般地,如果在回溯法中修改了辅助的全局变量,则一定要及时把它们恢复原状(除非故意保留所做修改)。

提示:如果在回溯法中使用了辅助的全局变量,则一定要及时把它们恢复原状。特别地,若函数有多个出口,则需在每个出口处恢复被修改的值。


2 其他问题举例

(1) 素数环

#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ll;

/*【素数环】(UVa 524)
输入正整数n,把整数1, 2, 3,…, n组成一个环,使得相邻两个整数之和均为素数。
输出时从整数1开始逆时针排列。同一个环应恰好输出一次。n≤16。 */

/*【分析】
由模型不难得到:每个环对应于1~n的一个全排列,但排列总数高达16!=2*1013,生成-测试法会超时吗?下面进行实验:
for(int i = 2; i <= n*2; 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]]) { 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));                 //1的位置不变
运行后发现,当n=12时就已经很慢,而当n=16时无法运行出结果。

下面试试回溯法:
void dfs(int cur){
  if(cur == n && isp[A[0]+A[n-1]]){    //递归边界。别忘了测试第一个数和最后一个数
    for(int i = 0; i < n; i++) printf("%d ", A[i]);    //打印方案
    printf("\n");
  }
  else for(int i = 2; i <= n; i++)    //尝试放置每个数i
    if(!vis[i] && isp[i+A[cur-1]]){    //如果i没有用过,并且与前一个数之和为素数
     A[cur] = i;
     vis[i] = 1;                 //设置使用标志
     dfs(cur+1);
     vis[i] = 0;                 //清除标志
    }
}
回溯法比生成-测试法快了很多,即使n=18速度也不错。将上面的函数名设为dfs并不是巧合。
从解答树的角度讲,回溯法正是按照深度优先的顺序在遍历解答树。在后面的内容中,还将学习更多遍历解答树的方法。 */

int n,a[20],vis[20];

int is_prime(int n){ //判断素数
    if(n<2) return false;
    for(int i=2;i*i<=n+1; i++)
        if(n % i == 0) return false;
    return true;
}

void dfs(int s){
    if(s==n&&is_prime(a[1]+a[n])){ //递归边界
        for(int i=1; i<n; i++) cout<<a[i]<<" ";
        cout<<a[n]<<endl;
    }
    else{
        for(int i=2; i<=n; i++)
            if(!vis[i]&&is_prime(i+a[s])) //如果i没有用过,并且与前一个数之和为素数
            {
                a[s+1]=i; vis[i]=1; //标记
                dfs(s+1); vis[i]=0; //清除标记
            }
    }
}

int main(){
    int t=0;
    while(cin>>n){
        memset(vis,0,sizeof(vis));
        a[1]=1;
        if(t!=0) cout<<endl;            //一定注意输出格式
        t++; cout<<"Case "<<t<<":"<<endl;
        dfs(1);
    }
    return 0;
}

(2)困难的串

#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ll;

/*【困难的串】(UVa 129)
如果一个字符串包含两个相邻的重复子串,则称它是“容易的串”,其他串称为“困难的串”。
输入正整数n和L,输出由前L个字符组成的、字典序第k小的困难的串。
样例输入: 7 3    30 3
样例输出: ABACABA     ABACABCACBABCABACABCACBACABA   */

//【分析】从左到右依次考虑每个位置上的字符。判断当前串的后缀是否与之前重复。
// 后缀要从一个比较到n+1/2个(奇数多考虑一个),例如:ABCABA,先比较A与前面的B,再比较BA与CA,以此类推。
// 注意:我们递推考虑的是往字符串上一个个加字母,但有时会出现加上去的字母都不满足的情况,如:ABACABA,不管加A,B,C都不行,
// 这时候输出的应该是返回前一个的值ABACABC,所以多设一个值num储存字典序数,cur储存总个数。

int ss[100];
int n,l,num;//用num存储字典序
 
bool judged(int cur){
    for(int j=1;j*2<=cur+1;j++){ //后缀长度从1判断到cur+1/2,+1是考虑到奇数情况
        bool flag = true;
        for(int k = 0; k < j; k++)
            if(ss[cur - k] != ss[cur - k - j]){ //前后不相同才合法
                flag = false; break;
            }
        if(flag) return false; //某一后缀长度时相同,说明不合法
           
    }
    return true;
}
 
void output(int cur){
    for(int i = 0; i <= cur; i++){
        if(i % 4 == 0 && i>0){
            if(i % 64 == 0 && i>0) putchar('\n');
            else putchar(' ');
        }
        putchar(ss[i]);
    }
    printf("\n%d\n", cur + 1);
}
 
bool dfs(int cur){ //cur为字母个数
    int i;
    for(int i = 0; i < l; i++){
        ss[cur] = 'A'+i;
        if(judged(cur)){
            num++; //每次只要判断正确就+1,但个数可能不变
            if(num == n){ output(cur); return true; }
            if (dfs(cur+1)) return true;    
        }   
    }
    return false;
}
 
int main(){
    while(scanf("%d%d",&n,&l) != EOF && n && l){
        num = 0; dfs(0); 
    }
}

(3)带宽

#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ll;

/*【带宽】(UVa 140)
给出一个n(n≤8)个结点的图G和一个结点的排列,
定义结点i的带宽b(i)为i和相邻结点在排列中的最远距离,
而所有b(i)的最大值就是整个图的带宽。
给定图G,求出让带宽最小的结点排列。   */

// 递归枚举全排列,两种方法来剪枝。

int G[27][27]; //一共26字母,可以直接二维数组存图
const char a[27]="ABCDEFGHIJKLMNOPQRSTUVWXYZ"; //用字母输入结点

int n; //记录字母种类数 n<=8
int v[26],ans[10],res[10],temp[10]; //v[i]记录与之连接的边数
//ans[i]存字母编号的排列顺序,res[i]存26个字母中是结点的字母编号
//temp[i]是动态数组,每次在新循环中更新
int hard=10; //最小带宽

int turn(char c) {
    return strchr(a,c)-a; //查找字符串a中首次出现字符c的位置。 
}

void dfs(int cur,int k) {
    if(cur==n&&k<hard) { //递归边界,且可以更新
        hard=k;
        for(int i=0;i<n;++i) ans[i]=temp[i];
        return;
    }
    for(int i=0;i<n;++i) {   
        int ok=1;
        for(int j=0;j<cur;j++)
            if(temp[j]==res[i]) {
                ok=0; break; //这个字母是否已经被放好了
            }
        if(ok) {
            for(int j=0;j<cur;++j)
                if(G[temp[j]][res[i]])
                    if((cur-j)>k) k=cur-j; //带宽变大
            if(k<hard) { //剪枝
                temp[cur]=res[i];
                dfs(cur+1,k);
            }
        }
    }
}

int main() {
    char s[150]; 
    while(scanf("%s",s)==1&&s[0]!='#') {
        n=0,hard=10;
        memset(v,0,sizeof(v));
        memset(G,0,sizeof(G));
        for(int i=0,j=0;;++j) {
            if(s[j]==';'||s[j]=='\0') {
                int x=turn(s[i]),y=i+2; // “A:FB;” 形式输入
                v[x]++; //↑↑找出i对应的字母编号记为x
                while(y!=j){ //还有结点没有计算完毕
                    int t=turn(s[y]); //找下一个字母对应的编号
                    G[x][t]=1; G[t][x]=1; //建边
                    y++; v[t]++;
                }
                i=j+1; //进入下一个输入组
            }
            if(s[j]=='\0') break; //输入完毕
        }

        for(int i=0;i<26;++i)
            if(v[i]) res[n++]=i; //记录结点

        dfs(0,1);
        for(int i=0;i<n;++i) {
            if(i==n-1) printf("%c -> %d",'A'+ans[i],hard); //最后一个
            else printf("%c ",'A'+ans[i]);
        }
        printf("\n");
    }
    return 0;
}

(4)天平难题

#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ll;

/*【天平难题】(UVa1354)

给出房间的宽度r和s个挂坠的重量wi。设计一个尽量宽(但宽度不能超过房间宽度r)的天平。
天平由一些长度为1的木棍组成。木棍的每一端要么挂一个挂坠,要么挂另外一个木棍。
设n和m分别是两端挂的总重量,要让天平平衡,必须满足n*a=m*b。
挂坠的宽度忽略不计,且不同的子天平可以相互重叠。

输入第一行为数据组数。每组数据前两行为房间宽度r和挂坠数目s(0<r<10,1≤s≤6)。
以下s行每行为一个挂坠的重量Wi(1≤wi≤1000)。输入保证不存在天平的宽度恰好在r-10^-5和r+10^-5之间
(这样可以保证不会出现精度问题)。对于每组数据,输出最优天平的宽度。
如果无解,输出-1。你的输出和标准答案的绝对误差不应超过10-8。 */

//利用二进制枚举所有的子集 

struct Tree {
    double L, R; // distance from the root to the leftmost/rightmost point
    Tree():L(0),R(0) {}
};

const int maxn = 6;

int n, vis[1<<maxn];
double r, w[maxn], sum[1<<maxn];
vector<Tree> tree[1<<maxn];

void dfs(int subset) {
    if(vis[subset]) return;
    vis[subset] = true;
    bool have_children = false;
    for(int left = (subset-1)⊂ left; left = (left-1)&subset) {
        have_children = true;
        int right = subset^left;
        double d1 = sum[right] / sum[subset];
        double d2 = sum[left] / sum[subset];

        dfs(left); dfs(right);

        for(int i = 0; i < tree[left].size(); i++)
            for(int j = 0; j < tree[right].size(); j++) {
                Tree t;
                t.L = max(tree[left][i].L + d1, tree[right][j].L - d2);
                t.R = max(tree[right][j].R + d2, tree[left][i].R - d1);
                if(t.L + t.R < r)  tree[subset].push_back(t);
            }
    }
    if(!have_children) tree[subset].push_back(Tree());
}

int main() {
    int T; scanf("%d", &T);
    while(T--) {
        scanf("%lf%d", &r, &n);
        for(int i = 0; i < n; i++) scanf("%lf", &w[i]);
        for(int i = 0; i < (1<<n); i++) {
            sum[i] = 0;
            tree[i].clear();
            for(int j = 0; j < n; j++)
                if(i & (1<<j)) sum[i] += w[j];
        }
        int root = (1<<n)-1;
        memset(vis, 0, sizeof(vis));
        dfs(root);

        double ans = -1;
        for(int i = 0; i < tree[root].size(); i++)
            ans = max(ans, tree[root][i].L + tree[root][i].R);
        printf("%.10lf\n", ans);
    }
    return 0;
} //先留着,以后慢慢想


猜你喜欢

转载自blog.csdn.net/flora715/article/details/80969801
今日推荐