7.7 竞赛题目选讲

本章的篇幅不少,但实际上介绍的算法很有系统性,并不杂乱。这里先把这些算法和常见解决问题的思路总结一下,然后选讲一些例题,

直接枚举。例如,类似“1~n的整数中有多少个满足……”,“输入一个长度为n的序列,有多少个连续子序列满足……”的问题都可以用直接枚举法。枚举法可以解决问题,但是效率不一定足够高。第8章中将详细讨论算法效率的分析方法。

枚举子集和排列n个元素的子集有2n个,可以用递归的方法枚举(前面介绍的增量法和位向量法都属于递归枚举),也可以用二进制的方法枚举。递归法的优点在于效率高,方便剪枝,缺点在于代码比较长。一般来说,当n很小(如n≤15)时,会使用二进制的方式枚举。

n个不同元素的全排列有n!个。除了用递归的方法枚举之外,还可以用STL的next_permutation来枚举,它也适用于有重复元素的情形。

回溯法。简单地说,回溯法几乎就是递归枚举,只是多了一条:违反题目要求时及时终止当前递归过程,即回溯(backtracking)。回溯法最经典的题目就是八皇后问题,这个问题也常常被作为“判断有没有学过回溯法”的依据。7.4节的几个例题非常经典,覆盖了回溯法的几个常见话题:搜索对象的选取(天平难题)、最优性剪枝(带宽),以及减少无用功(困难的串)。

状态空间搜索。从本质上讲,状态空间搜索算法和图算法的相似度比较大,但是图往往是“隐式”给出,所以这些算法又称“隐式图搜索”或者“产生式系统”(4)。如果仔细品味前面《八数码问题》的解法,可以发现这个解法其实就是一个普通的BFS加上了“结点查找表”。前面介绍了3种方法实现结点查找表,各有用武之地。建议读者先熟练掌握后面两种(哈希表和STL集合),待学习完第10章后再尝试使用第一种方法(一一映射,或称“完美哈希”)。这些方法不仅能加快状态空间搜索的速度,还能给其他算法加速。第8章和第9章中将继续讨论这个问题。另外,双向广度优先搜索和A*等算法也有各自的用武之地,虽然限于篇幅未加介绍,但是笔者鼓励大家花一些时间搜索相关资料,并加以学习。例题中的“万圣节后的早晨”就是一处很好的“试验田”。

迭代加深搜索。本章最后介绍了迭代加深搜索。这是一个长期以来被“低估”了的算法,可以用来解决很多看起来更适合用BFS或者回溯法解决的问题,埃及分数问题就是一个绝好的例子,而例题“编辑书稿”也非常经典。

例题7-11 宝箱(Zombie's Treasure Chest, Shanghai 2011, UVa12325)

你有一个体积为N的箱子和两种数量无限的宝物。宝物1的体积为S1,价值为V1;宝物2的体积为S2,价值为V2。输入均为32位带符号整数。你的任务是计算最多能装多大价值的宝物。例如,n=100,S1=V1=34,S2=5,V2=3,答案为86,方案是装两个宝物1,再装6个宝物2。每种宝物都必须拿非负整数个。

【分析】

最容易想到的方法是:枚举宝物1的个数,然后尽量多拿宝物2。这样做的时间复杂度为O(N/S1),当NS1相差非常悬殊时效率很低。当然,如果N/S2很小时可以改成枚举宝物2的个数,所以这个方法不奏效的条件是:S1和S2都很小,而N很大。

幸运的是,S1和S2都很小时,有另外一种枚举法B:S2个宝物1和S1个宝物2的体积相等,而价值分别为S2*V1和S1*V2。如果前者比较大,则宝物2最多只会拿S1-1个(否则可以把S1个宝物2换成S2个宝物1);如果后者比较大,则宝物1最多只会拿S2-1个。不管是哪种情况,枚举量都只有S1或者S2。

这样,就得到了一个比较“另类”的分类枚举算法:

N/S1比较小时枚举宝物1的个数,时间复杂度为O(N/S1),否则,当N/S2比较小时枚举宝物2的个数,时间复杂度为O(N/S2),否则说明S1和S2都比较小,执行枚举法B,时间复杂度为O(max{S1, S2})。

例题7-12 旋转游戏(The Rotation Game, Shanghai 2004, UVa1343)

如图7-20所示形状的棋盘上分别有8个1、2、3,要往A~H方向旋转棋盘,使中间8个方格数字相同。图7-20(a)进行A操作后变为图7-20(b),再进行C操作后变为图7-20(c),这正是一个目标状态(因为中间8个方格数字相同)。要求旋转次数最少。如果有多解,操作序列的字典序应尽量小。

图7-20 旋转游戏示意图

【分析】

本题是一个典型的状态空间搜索问题,可惜如果直接套用八数码问题的框架会超时。为什么?学完第10章的组合计数部分后会知道:8个1、8个2、8个3的全排列个数为24!/(8!*8!*8!)=9465511770。换句话说,最坏情况下最多要处理这么多结点!

解决方法很巧妙:本题要求的是中间8个数字相同,即8个1或者8个2或者8个3。因此可以分3次求解。当目标是“中间8个数字都是1”时,2和3就没有区别了(都是“非1”),因此状态总数变成了8个1,16个“非1”的全排列个数,即24!/(8!*16!)=735471,在可以接受的范围内了(5)。另外,除了BFS外还可以用IDA*,代码更清晰易懂(详见代码仓库)。

例题7-13 快速幂计算(Power Calculus, ACM/ICPC Yokohama 2006, UVa1374)

输入正整数n(1≤n≤1000),问最少需要几次乘除法可以从x得到xn?例如,x31需要6次:。计算过程中x的指数应当总是正整数(如x-3=x/x4是不允许的)。

【分析】

这个题有一点“埃及分数”的味道,可以考虑迭代加深搜索。当前状态是已经得到的指数集合,操作是任选两个数进行加法和减法,并且不能产生重复的数,如图7-21所示。

图7-21 快速幂计算示意图

沿用之前的符号,d表示当前深度,maxd表示深度上限,则如果当前序列最大的数乘以2maxd-d之后仍小于n,则剪枝(想一想,为什么)。另外,为了尽快接近目标,不应该“任选”两个数,而应该先选较大的数,并且先试加法再试减法(6)。这样做可以在最后一次迭代(即找到解的那次迭代)中比较快地找到解,从而终止整个搜索过程,而不需要等整个解答树扩展完毕。

因为题目一共只有1000种可能的输入,写完程序之后可以试试是否对所有输入都能足够快地出解。只要比赛允许,甚至可以预先把n=1~1000范围的所有解算出来,输出成如下源代码:


#include<cstdio>
int answer[ ] = {0, 0, 1, ...}; //answer[1]=0, answer[2]=1, ...
int main( ) {
  int n;
  while(scanf("%d", &n) == 1 && n) printf("%d\n", answer[n]);
  return 0;
}

这样的技巧俗称“打表”。本题还有一些常见的优化,例如,限制减法的次数(实际上大部分时候都是最大的数乘以2),或者限制超过n的数的个数(事实上,可以证明最多有一个数需要超过n),读者不妨一试。另外还有一个猜想:每次总是使用“刚刚得到”的那个数。限于水平,笔者无法证明这个猜想,但是1000以内没有找到反例。

例题7-14 网格动物(Lattice Animals, ACM/ICPC NEERC 2004, UVa1602)

输入nwh(1≤n≤10,1≤whn),求能放在w*h网格里的不同的n连块的个数(注意,平移、旋转、翻转后相同的算作同一种)。例如,2*4里的5连块有5种(第一行),而3*3里的8连块有以下3种(第二行),如图7-22所示。

【分析】

本题看上去没有什么好办法,只能用回溯法求解。如何求解呢?首先需要确定搜索对象。因为要求各个格子连通,所以可以把“连通块”作为搜索对象,每次枚举一个位置,然后放一个新的块,最后判重,如图7-23所示。

 

  图7-22 网格动物例题示意图     图7-23 回溯法求解  

需要注意的是,如果采用最简单的写法,每个n连块都会被重复枚举很多次(想一想,为什么)。也可以用前面介绍过的方法判重,但实际上有办法确保每个n连块恰好被枚举一次,由Redelmeier发现,有兴趣的读者可以自行研究(7)

本题非常经典,强烈建议读者编写程序。

可以参考en.wikipedia.org/wiki/Polyomino。

例题7-15 破坏正方形(Square Destroyer, ACM/ICPC Taejon 2001, UVa1603)

有一个火柴棍组成的正方形网格,每条边有n根火柴,共2n(n+1)根。从上到下、从左到右给各个火柴编号,如图7-24(a)所示。现在拿走一些火柴,问在剩下的火柴中,至少还要拿走多少根火柴才能破坏所有正方形?例如,在图7-24(b)中,拿掉3根火柴就可以破坏掉仅有的5个正方形。

 

  (a)     (b)  

图7-24 破坏正方形示意图

【分析】

不难想到用迭代加深搜索作为主算法框架。搜索对象有两种:(1)每次考虑一个没有被破坏的正方形,在边界上找一根火柴拿掉;(2)每次找一个至少能破坏一个正方形的火柴,然后拿掉。两种方法各有不同的优化方法:

搜索对象是正方形。应先考虑小正方形,再考虑大正方形,因为破坏完小正方形之后,很多大正方形已经被破坏了,但是反过来却不一定。还可以加入最优性剪枝,即把每个正方形看成一个顶点,有公共火柴的正方形连一条边,则每个连通分量至少要拿走一根火柴。

搜索对象是火柴。应先搜索能破坏尽量多正方形的火柴。这需要计算出待考虑的每根火柴可以破坏掉多少个正方形,从大到小排序为d[1], d[2], d[3],……当d[1]=1时即可停止搜索,因为此时可以直接计算出还需要的火柴个数(想一想,为什么)。这个d数组也可以用于最优性剪枝,找到最小的i,使得d[1]+d[2]+…+d[i]≥k(其中k为还剩的正方形个数),则至少还要i根火柴。

值得一提的是:本题还可以用经典的DLX算法解决。该算法超出了本章的范围,但在《算法竞赛入门经典——训练指南》中有详细叙述。