7.4 回溯法

无论是排列生成还是子集枚举,前面都给出了两种思路:递归构造和直接枚举。直接枚举法的优点是思路和程序都很简单,缺点在于无法简便地减小枚举量——必须生成(generate)所有可能的解,然后一一检查(test)。

另一方面,在递归构造中,生成和检查过程可以有机结合起来,从而减少不必要的枚举。这就是本节的主题——回溯法(backtracking)。

回溯法的应用范围很广,只要能把待求解的问题分成不太多的步骤,每个步骤又只有不太多的选择,都可以考虑应用回溯法。为什么说“不太多”呢?想象一棵包含L层,每层的分支因子均为b的解答树,其结点数高达。无论是b太大还是L太大,结点数都会是一个天文数字。

回溯法是初学者学习暴力法的第一个障碍,学习时间短则数天,长则数月甚至一年以上。为了减少不必要的困扰,在学习回溯法之前,请读者确保7.2节和7.3节的所有递归程序都可以熟练、准确地写出。

7.4.1 八皇后问题

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

 

  (a)皇后的攻击范围     (b)一个可行解  

图7-4 八皇后问题

【分析】

最简单的思路是把问题转化为“从64个格子中选一个子集”,使得“子集中恰好有8个格子,且任意两个选出的格子都不在同一行、同一列或同一个对角线上”。这正是子集枚举问题。然而,64个格子的子集有264个,太大了,这并不是一个很好的模型。

第二个思路是把问题转化为“从64个格子中选8个格子”,这是组合生成问题。根据组合数学,有种方案,比第一种方案优秀,但仍然不够好。

经过思考,不难发现以下事实:恰好每行每列各放置一个皇后。如果用C[x]表示第x行皇后的列编号,则问题变成了全排列生成问题。而0~7的排列一共只有8!=40320个,枚举量不会超过它。

提示7-9:在编写递归枚举程序之前,需要深入分析问题,对模型精雕细琢。一般还应对解答树的结点数有一个粗略的估计,作为评价模型的重要依据,如图7-5所示。

图7-5 四皇后问题的解答树

图7-5中给出了四皇后问题的完整解答树。它只有17个结点,比4!=24小。为什么会这样呢?这是因为有些结点无法继续扩展。例如,在(0,2,*,*)中,第2行无论将皇后放到哪里,都会和第0行和第1行中已放好的皇后发生冲突,其他还未放置的皇后更是如此。

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

提示7-10:当把问题分成若干步骤并递归求解时,如果当前步骤没有合法选择,则函数将返回上一级递归调用,这种现象称为回溯。正是因为这个原因,递归枚举算法常被称为回溯法,应用十分普遍。

下面的程序简洁地求解了八皇后问题。在主程序中读入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])是否在同一条对角线上。其原理可以用图7-6来说明。

 

  a)格子(x,y)的y-x  值标识了主对角线   (b)格子(x,y)的x+y  值标识了副对角线

图7-6 棋盘中的对角线标识

结点数似乎很难进一步减少了,但程序效率可以继续提高:利用二维数组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数组的确切含义是什么?它表示已经放置的皇后占据了哪些列、主对角线和副对角线。将来放置的皇后不应该修改这些值——至少“看上去没有修改”。一般地,如果在回溯法中修改了辅助的全局变量,则一定要及时把它们恢复原状(除非故意保留所做修改)。若不信,可以把“vis[0][i]= vis[1][cur+i] = vis[2][cur-i+n] = 0”注释掉,验证还能否正确求解八皇后问题。另外,在调用之前一定要把vis数组清空。

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

7.4.2 其他应用举例

例题7-4 素数环(Prime Ring Problem, UVa 524)

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

样例输入:

6

样例输出:

1 4 3 2 5 6

1 6 5 2 3 4

【分析】

由模型不难得到:每个环对应于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并不是巧合——从解答树的角度讲,回溯法正是按照深度优先的顺序在遍历解答树。在后面的内容中,还将学习更多遍历解答树的方法。

提示7-12:如果最坏情况下的枚举量很大,应该使用回溯法而不是生成-测试法。

例题7-5 困难的串(Krypton Factor, UVa 129)

如果一个字符串包含两个相邻的重复子串,则称它是“容易的串”,其他串称为“困难的串”。例如,BB、ABCDACABCAB、ABCDABCD都是容易的串,而D、DC、ABDAB、CBABCBA都是困难的串。

输入正整数nL,输出由前L个字符组成的、字典序第k小的困难的串。例如,当L=3时,前7个困难的串分别为A、AB、ABA、ABAC、ABACA、ABACAB、ABACABA。输入保证答案不超过80个字符。

样例输入:

7 3

30 3

样例输出:

ABACABA

ABACABCACBABCABACABCACBACABA

【分析】

基本框架不难确定:从左到右依次考虑每个位置上的字符。因此,问题的关键在于如何判断当前字符串是否已经存在连续的重复子串。例如,如何判断ABACABA是否包含连续重复子串呢?一种方法是检查所有长度为偶数的子串,分别判断每个字串的前一半是否等于后一半。尽管是正确的,但这个方法做了很多无用功。还记得八皇后问题中是怎么判断合法性的吗?判断当前皇后是否和前面的皇后冲突,但并不判断以前的皇后是否相互冲突——那些皇后在以前已经判断过了。同样的道理,我们只需要判断当前串的后缀,而非所有子串。

提示7-13:在回溯法中,应注意避免不必要的判断,就像在八皇后问题中那样,只需判断新皇后和之前的皇后是否冲突,而不必判断以前的皇后是否相互冲突。

程序如下:


int dfs(int cur){                    //返回0表示已经得到解,无须继续搜索
  if(cnt++ == n){
    for(int i = 0; i < cur; i++) printf("%c", 'A'+S[i]); //输出方案
    printf("\n");
    return 0;
  }
  for(int i = 0; i < L; i++){
    S[cur] = i;
    int ok = 1;
    for(int j = 1; j*2 <= cur+1; j++){    //尝试长度为j*2的后缀
     int equal = 1;
     for(int k = 0; k < j; k++)        //检查后一半是否等于前一半
       if(S[cur-k] != S[cur-k-j]) { equal = 0; break; }
     if(equal) { ok = 0; break; }        //后一半等于前一半,方案不合法
    }
    if(ok) if(!dfs(cur+1)) return 0;    //递归搜索。如果已经找到解,则直接退出
  }
  return 1;
}

有意思的是,L = 2时一共只有6个串;当L≥3时就很少回溯了。事实上,当L=3时,可以构造出无限长的串,不存在相邻重复子串。

例题7-6 带宽(Bandwidth, UVa 140)

给出一个nn≤8)个结点的图G和一个结点的排列,定义结点i的带宽b(i)为i和相邻结点在排列中的最远距离,而所有b(i)的最大值就是整个图的带宽。给定图G,求出让带宽最小的结点排列,如图7-7所示。

图7-7 图G

下面两个排列的带宽分别为6和5。具体来说,图7-8(a)中各个结点的带宽分别为6, 6, 1, 4, 1, 1, 6, 6,图7-8(b)中各个结点的带宽分别为5, 3, 1, 4, 3, 5, 1, 4。

 

  (a)     (b)  

图7-8 两个排列的带宽

【分析】

如果不考虑效率,本题可以递归枚举全排列,分别计算带宽,然后选取最小的一种方案。能否优化呢?和八皇后问题不同的是:八皇后问题有很多可行性约束(feasibility constraint),可以在得到完整解之前避免扩展那些不可行的结点,但本题并没有可行性约束——任何排列都是合法的。难道只能扩展所有结点吗?当然不是。

可以记录下目前已经找到的最小带宽k。如果发现已经有某两个结点的距离大于或等于k,再怎么扩展也不可能比当前解更优,应当强制把它“剪”掉,就像园丁在花园里为树修剪枝叶一样,也可以为解答树“剪枝(prune)”。

除此之外,还可以剪掉更多的枝叶。如果在搜索到结点u时,u结点还有m个相邻点没有确定位置,那么对于结点u来说,最理想的情况就是这m个结点紧跟在u后面,这样的结点带宽为m,而其他任何“非理想情况”的带宽至少为m+1。这样,如果mk,即“在最理想的情况下都不能得到比当前最优解更好的方案”,则应当剪枝。

提示7-14:在求最优解的问题中,应尽量考虑最优性剪枝。这往往需要记录下当前最优解,并且想办法“预测”一下从当前结点出发是否可以扩展到更好的方案。具体来说,先计算一下最理想情况可以得到怎样的解,如果连理想情况都无法得到比当前最优解更好的方案,则剪枝。

例题7-7 天平难题(Mobile Computing, ACM/ICPC Tokyo 2005, UVa1354)

给出房间的宽度rs个挂坠的重量wi。设计一个尽量宽(但宽度不能超过房间宽度r)的天平,挂着所有挂坠。

天平由一些长度为1的木棍组成。木棍的每一端要么挂一个挂坠,要么挂另外一个木棍。如图7-9所示,设nm分别是两端挂的总重量,要让天平平衡,必须满足n*a=m*b

图7-9 天平

例如,如果有3个重量分别为1, 1, 2的挂坠,有3种平衡的天平,如图7-10所示。

 

                    
          图7-10 3种平衡的天平          

挂坠的宽度忽略不计,且不同的子天平可以相互重叠。如图7-11所示,宽度为(1/3)+1+(1/4)。

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

【分析】

如果把挂坠和木棍都作为结点,则一个天平对应一棵二叉树,如题目中给出的,挂坠为1, 1, 2的3个天平如图7-12所示。

 

  图7-11 子天平相互重叠     图7-12 与天平对应的二叉树  

对于一棵确定二叉树,可以计算出每个挂坠的确切位置,进而计算出整个天平的宽度,所以本题的核心任务是:枚举二叉树。

如何枚举二叉树呢?最直观的方法是沿用回溯法框架,每次选择两个结点组成一棵子树,递归s-1层即可。以4个挂坠1, 1, 2, 3为例,下面是解答树的一部分(每个结点的子树并没有全部画出),如图7-13所示。

图7-13 解答树

上面的方法已经足够解决本题,但还有优化的余地,因为有些二叉树被枚举了多次(如图7-13中的两个粗框结点)。

推荐的枚举方法是:自顶向下构造,每次枚举左子树用到哪个子集,则右子树就是使用剩下的子集(细节请参考代码仓库)。在第9章中会专门讨论“枚举子集”的高效算法,建议读者在学习之后重新实现本题。