暴力求解法之回溯法

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 44的迷宫,起点为左上角,终点为右下角。
如果采用枚举法解决这个问题,首先需要枚举出所有可能的路线(有兴趣的可以计算一下有多少种可能的路线,反正我没兴趣哈哈),下面简单的举几个例子:

显然,蓝色线和红色线的例子在第一次遇到墙之后就应该知道不可能成功。因此可以看出,由于枚举法是将所有的结果枚举出来之后再判断是否满足要求,这对于问题规模较小时是可行的。当问题规模较大时,例如枚举一个 100 ∗ 100 100*100 100100的迷宫的所有可能路线时,所需的时间非常长,甚至于无法得出结果。

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种 C81C71C61C51C41C31C21C11=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} BBABCDACABCABABCDABCD是“容易的串”,而 D 、 D C 、 A B D A B 、 C B A B C B A D、DC、ABDAB、CBABCBA DDCABDABCBABCBA都是困难的串。
输入正整数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

程序中,两行斜线中的部分代码用于判断构造的串的尾部是否有相邻子串。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值