12.2 难题选解
12.2.1 数据结构
例题12-11 航班(Flights, ACM/ICPC NEERC 2012, UVa1520)
某国在一条直线上进行军事演习。有n(n≤50000)个导弹,用p、x、y3个整数表示(0≤p<x≤50000,0<y≤50),表示起点是(p,0),沿着对称的抛物线飞行,如图12-30所示,最高点是(x,y)。导弹按照输入顺序依次发射,相邻两个导弹的时间间隔是1分钟,而导弹飞行本身瞬间完成。
图12-30 导弹飞行轨迹
另外还有m架飞机(1≤m≤20000),每架飞机用4个整数t1、t2、x1、x2表示,即飞行时间为t1~t2(1≤t1≤t2≤n,其中第一个导弹的发射时刻为1,最后一个导弹的发射时刻为n),x坐标为x1~x2(0≤x1≤x2≤50000)。你的任务是为每架飞机计算出最小飞行高度h,使得时间区间[t1,t2]内所有导弹轨迹在x坐标x1~x2的范围内高度都不超过h。如果这个范围没有导弹,则最小高度定义为0。
【分析】
建立一棵线段树,叶结点中保存一个导弹的轨迹(抛物线),每个非叶结点u保存的是一个连续的导弹区间[m1,m2]中所有轨迹的“轮廓线”,如图12-31所示。
| ![]() |
图12-31 所有轨迹的轮廓线
不难看出,这是一棵关于“时间”的线段树。对于每架飞机(t1,t2,x1,x2),可以按照传统的区间分解的方式,转化为对O(logn)条轮廓线的max(x1,x2)查询(即在[x1,x2]上的最大值)。
为了让轮廓线支持max(x1,x2),需要用一个合适的数据结构表示轮廓线。抛物线之间的交点把轮廓线分成了若干段,其中每个部分是一个导弹轨迹的一部分,因此可以用五元组(a,b,c,x1,x2)表示抛物线y=ax2+bx+c在[x1,x2]中的部分,而轮廓线就是上述“抛物线片段”的序列(中间可能会有空白区域)。
只要把这些序列按照从左到右的顺序保存,然后创建一棵线段树(叶结点是抛物线片段),就可以在O(logn)时间内求出max(x1,x2)。而一共需要查询O(logn)条轮廓线,因此查询复杂度为O(log2n)。
最后考虑建树部分的时空复杂度。对于一个包含k个抛物线的轮廓线,使用类似于归并排序的方法,可以在O(klogk)的时间构造出一个空间为O(k)的线段树,因此总的时间复杂度为O(nlog2n),空间复杂度为O(nlogn)。
例题12-12 背单词(GRE Words Revenge, ACM/ICPC Chengdu 2013, UVa1676)
为了准备GRE考试,你打算花n(n≤105)天时间背单词。每天可以做两件事之一:
为了简单起见,单词都是01串。学的单词长度总和不超过105,文章总长度不超过5*106。
【分析】
最容易想到的算法就是维护“学过的所有单词”的AC自动机。由于AC自动机并不支持“快速插入新字符串”的操作,所以每次学到一个新单词w之后,必须重建AC自动机。这样,虽然“?t”操作非常高效(文章t中的每个字符只需O(1)时间),但重建AC自动机的开销是巨大的。如果一共学了k个单词,每个单词的长度均为L,则时间复杂度高达L+2L+3L+…+kL=O(k2L),系统是无法承受的。幸运的是,本题至少有3种高效解法,而且都有不错的启发性。
解法1:维护两个AC自动机big和small,每次学到一个单词后合并到small里,等small的字符总数超过一定数值后,合并到big里(并且清空small)。查询时把big和small分别查一遍,加起来即可,因此查询是每个字符O(1)的。
假设每个单词都是单字符的,一共有m个单词。当small中的字符总数超过k时合并,则每k次操作可以看的是一轮操作。时间复杂度为:
一共有m/k轮,所以总时间复杂度为m/k*O(k2)+k*O((m/k)2)=O(mk+m2/k)=m(k+m/k)。当k和m/k相近时最好,时间复杂度为O(m1.5)。
解法2:用多个AC自动机,字符个数分别为1,2,4,8,16,32,64,…,编号为0,1,2…,即编号为i的自动机的“理论”大小(即字符总数)为2i。当自动机i的大小超过2i时,把它所包含的字符串全部插入到自动机i+1中,并且清空自动机i。
假设所有单词的总长度为m,则自动机的最大编号为t=log2m。每个单词最多在自动机0,1,…,k里各待一次,所以插入单词的总时间复杂度为O(mlogm)。查询时需要在每个自动机里找,所以每个字符的查询时间为O(logm)。由于本题的查询比插入多一个数量级,所以解法2的实际运行效率比解法1略差。不过这个思路很经典,值得学习。
解法3:使用DAWG。设学习的单词为w1,w2,…,增量式的构造w1$w2$w3…的DAWG。对于“?t”操作,依次在DAWG中沿着边t1,t2,…进行转移。假设已经走了边ti,当前状态为S,所要统计的是t[1…i]有多少个后缀是学过的单词。根据前面的讨论,一个状态的最长单词的所有后缀就是当前状态及其在T(w)树中所有祖先状态的字符串集。但是t[1…i]不一定是S的最长单词,所以需要统计两项内容:
对于第一点,在DAWG的每个状态中保存一棵平衡树即可。第二点要困难一些:由于在DAWG的构造算法中需要动态修改T(w)中各个结点的父指针,所以需要用一个Link-Cut树来维护T(w),从而支持“一个状态的所有祖先状态的权值之和”。其实还有一个相对容易的方法可以代替动态树:用平衡树来维护T(w)的DFS序列。这里的DFS序列很像欧拉序列,不过记录的不是结点名称,而是带符号的权值,入栈时为正,出栈时为负。这样,DFS序列的前缀和就是从根结点到该结点的路径上所有结点的权值之和,并且“修改父亲指针”对应着把DFS序列的一个子序列剪切并粘贴到另外一个位置。在《训练指南》中已经介绍如何用伸展树高效地实现这一操作。
例题12-13 瓦里奥世界(Rujia Liu Loves Wario Land!, Rujia Liu's Present 3, UVa11998)
很久很久以前,瓦里奥世界只有一些废弃的矿山,但没有任何连接这些矿山的道路。已知各个矿山的初始矿藏值Vi,你的任务是按顺序执行m条指令,根据要求输出所求结果。操作指令及说明如表12-3所示。
表12-3 操作及含义
| 操作 | 含义 |
| 1xy | 修建一条直接连接x和y的道路。如果x和y已经连通(直接或者间接都算),则忽略此命令 |
| 2xv | 把矿山x的矿藏值改为v(可能是因为发现了新宝物,或者一些宝物被盗) |
| 3xyv | 统计x和y的简单路径上(包括x和y本身)有多少座矿山的矿藏值不超过v,然后把这些矿藏值乘起来,输出乘积除以k的余数。如果满足条件的矿山不存在,则输出一个0(而不是00或者01) |
限制:1≤n≤50000,1≤m≤100000,2≤k≤33333。对于每条指令,1≤x,y≤n,1≤v≤k。输入文件大小不超过10MB。
为了防止对所有指令进行预处理,本题的真实输入在前述输入格式基础上进行了“加密”,即输入的各条指令中除了“类型”之外的其他值(x、y、v)都增加了d,其中d是在处理此指令之前上一个输出的整数(如果在此指令之前并未输出过任何指令,d=0)。
【分析】
这是一道综合性很强的题目,而且要求在线算法。维护树上信息的方法主要有欧拉序列、动态树和树链剖分3种,但由于操作3的特殊性,动态树和欧拉序列都很难起作用:如果采用动态树,需要在O(1)时间内根据左右子树的信息计算父结点的信息。遗憾的是,操作3涉及的信息太复杂,通常需要树套树或者块链表实现,无法简单维护;如果采用欧拉序列,维护的信息需要满足区间减法。遗憾的是,操作3涉及的信息不满足。
看来只能从树链剖分入手。首先不考虑操作1,只处理修改(操作2)和查询(操作3)。用块链表维护每条重路径,如图12-32所示。每个块里最多保存B个结点,按照矿藏值从小到大排序,其中ID[i]表示价值第i小(i≥1)的结点编号,prod[i]表示价值前i小的结点的价值乘积。为了高效地执行链的分裂与合并(见后),不同块之间形成双向链表。
图12-32 用块链表维护每条重路径
修改操作(2xv)。首先要找到v所在的块b,然后重建块b,即把所有结点按照价值排序,重新计算前缀积和。“重建块”这个过程在其他地方也会用到,将其称为process(b)。
查询操作(3xyv)。设答案为res1和res2,初始时res1=0,res2=1。首先按照LCA的思路,每次把x和y中靠下方的结点往上“提”,即统计x到x所在链的首结点之间的路径,更新答案res1和res2,然后把x改成x上一条链的尾结点,直到x和y移到同一位置,即二者的LCA,如图12-33所示。
这样,问题就转化为了一系列的update(a,b,v,res1,res2)调用,表示已知a和b在同一个链中,统计a-b路径上所有价值不超过v的结点,个数加到res1中,乘积乘到res2中。注意本题的权值在结点上,所有轻边是完全不用考虑的。
如图12-34所示,update(a,b,v,res1,res2)可以这样实现:在a和b所在的块中需要暴力查找,即枚举块内的所有结点,把所有高度在a和b之间且价值不超过v的结点找出来。a和b之间的块因为是完整块,所以可以二分查找,找到价值不超过v的结点个数i,则prod[i]就是这些结点的价值乘积。
| ![]() |
| 图12-33 查询操作 | 图12-34 实现update(a,b,v,res1,res2) |
为了简单起见,每个结点u只记录链编号C(u),而不记录块编号,因此修改操作中需要先花O(L/B)时间找到u所在的块,然后用O(BlogB)时间重建块。查询操作最多需要调用O(logn)次update函数,而update函数的时间复杂度为O(L/B*log(B)+B)。
操作1的出现意味着树是会合并的,因此上面的讨论还不够。好在道路只增不减,所以可以用启发式合并,即每次把小树合并到大树中,则每个结点最多参与O(logn)次合并(8)。这样,问题的关键就在于如何高效地合并两棵树的树链剖分。
执行操作1xy时,首先找到x和y所在树的树根,如果相同,则忽略本操作;否则假设x所在的树结点比较多,y所在的树的结点比较少(否则可以交换x和y)。接下来,需要把y“嫁接”到结点x处。但是由于y所在树的树根可能是其他结点,首先要把y所在的树以y为树根重建(包括重建树链剖分),然后设x为y的父结点。
接下来是重头戏了:由于x多了一棵子树y,所以x往下的重边有可能会变化。例如,x是叶子,或者x原来的重边子结点W(x)的子树没有y的子树大,即size(W(x))<size(y)。那么x往下的重边需要改成连到y,即把x所在的链分裂,如图12-35所示。L'部分所有结点的“链编号”都发生了改变,但是根据合并的条件,修改的结点数不超过size(y)。分裂之后还要把y所在的链(注意y是链首)接到x的下方。这需要修改y所在链的所有结点的“链编号”,但是修改的结点数仍然不超过size(y)。
图12-35 将x所在的链分裂
最后是修改x及其所有祖先p的size(p)。x的祖先可能很多,不能一一修改,而只能一个块一个块地修改,即每个块设一个懒标记,表示该块所有结点的整体size增量,当访问size时再删除标记。这里有一个关键问题:x的所有祖先的size都变大了,所以它们到父结点的边可能会从轻边改成重边,因此还需要一些复杂的操作。幸运的是,此处并不需要严格地使用树链剖分的定义,而是可以让这些轻边保持原样。因为每个结点到根的路径上仍然最多有O(logn)条链,所以时间复杂度并不会变坏。这样,通过分裂链、合并链和修改size这3个步骤即完成了两棵树的合并。
还有两个细节没有提到:分裂链时需要分裂x所在的块,而在合并链时需要试着合并x和y所在的两个块(它们是相邻块)。根据块链表的一般思路,只有当这两个块在合并之后仍然不超过B时才合并。
这样,在合并过程中“修改链编号”的时间复杂度为O(size(y)),分裂合并块的时间复杂度为O(BlogB),而修改size的时间复杂度为O(n/B)。由于时间复杂度的表达式里同时出现了B和n/B,B既不能太大,又不能太小,取一个接近sqrt(B)的值可以让各个操作的时间复杂度趋于平均。由于各个操作的常数不同,而且链的实际长度还和测试数据相关,B的最佳取值最好是通过做实验的方法确定(实测50~300最佳)。
12.2.2 网络流
例题12-14 芯片难题(Chips Challenge, ACM/ICPC World Finals 2011, UVa1104)
作为芯片设计的一部分,你需要在一个N*N(N≤40)网格里放置部件。其中有些格子已经放了部件(用C表示),还有些格子不能放部件(用“/”表示),剩下的格子需要放置尽量多的新部件(用W表示)。
| ![]() |
| (a) | (b) |
图12-36 防止部件的最优解
要求对于所有1≤x≤N,第x行的部件个数(C和W之和)等于第x列的部件个数。为了保证散热,任意行或列的部件个数不能超过整个芯片总部件数的A/B。如图12-36所示,若A/B=3/10,则图12-36(a)的最优解如图12-36(b)所示,一共放置了7个新部件。
【分析】
根据经验,构造一个二分图,左边是行,右边是列,一个部件就是一条边Xi->Yj。如何表示第i行的总流量等于第i列呢?从Yi再连一条边到Xi即可。因为每个Y结点的出弧只有一条(到Xi),而每个Xi只有一条入弧(从Yi),所以Xi的流量肯定等于Yi的流量。进一步分析可发现:其实这样做等价于把Xi和Yi“粘”起来。也就是说,根本不需要构造二分图,一共n个结点即可。一个部件(i,j)就是有向弧i->j。如果在(i,j)上加上一个费用1,则总费用就是新部件的个数。这样就转化为了求最大费用循环流问题,用第11章中介绍的方法求解即可。
接下来还需要加上题目中的两个限制。首先是必须有流量的边,也就是C对应的边。有两种做法,一是设容量下界也是1,二是设cost为负无穷。接下来考虑每行每列A/B的限制。方法是枚举每行/列部件数的最大值m,给每个点增加结点容量m(然后用标准方法拆成两个点),然后求最大费用循环流,看看费用是否至少为m*B/A。注意,m的值只有0~n这n+1种可能,所以时间复杂度只需乘以O(n),仍然可以承受(9)。
例题12-15 《第七夜》、《时空轮回》与水的故事(Never7, Ever17 and Wa[t]er, Rujia Liu's Present 6, UVa12567)
有一个n个点、m条有向边的网络,每条边都有容量上下界b和c,求一个循环流,使得所有边中的最大流量和最小流量之差尽量小。n≤50,m≤200。
【分析】
本题虽然是网络流问题,但是“最大流量和最小流量之差”似乎无法对应到经典的网络流模型中。怎么办呢?
很多图论优化问题,包括最短路、最大流和最小费用流等,都可以用线性规划建模,本题是不是也可以呢?下面尝试一下。设第i条边的流量为xi,则容量限制可以列出两个不等式,对于每个结点可以列出流量平衡“等式”,目标是最小化max{xi}-min{xi}。问题还是出现在同一个位置:目标函数不是变量的线性组合,不符合“线性规划”的定义。
既然线性规划模型比较灵活,现在我们对目标函数进行代数变形。再引入两个变量A=min{xi},B=max{xi},然后对每个xi添加不等式A≤xi≤B,则目标变成了最小化B-A,符合线性规划模型。可是这能不能保证算出来最优解真的满足A=min{xi},B=max{xi}呢?如果不满足,例如,A<min{xi}(根据不等式约束,A≤min{xi}),那么把A改成min{xi}之后,约束仍然满足,并且目标函数变得更小,与最优解矛盾。因此,变形后的线性规划模型可以得到原题的最优解。
例题12-16 怪兽滴水嘴(Gargoyle, ACM/ICPC Xi'an 2006, UVa12110)
城堡顶层有n个怪兽状滴水嘴,还有一个包含m个连接点和k个水管的水流系统(1≤n≤25,1≤m≤50,1≤k≤1000)。从滴水嘴流出的水直接进入蓄水池,通过水管后重新由滴水嘴流出。假设水量无损失,每个连接点处的总入水速度应该等于总出水速度。水管中水流的速度有上下界,单位水速有固定费用。
你的任务是设计各水管的水速,用尽量少的总费用让各滴水嘴的出水速度相同。
每个水管用5个整数a,b,l,u,c表示(0≤a,b≤n+m,0≤l≤u≤100,1≤c≤100),即每个水管入口和出口编号(蓄水池编号为0,滴水嘴编号为1~n,连接点编号为n+1~n+m),水速下限、上限,以及单位水速的费用。水管不会连接两个相同点,即水管入口不会是滴水嘴,出口不会是蓄水池。每两个点之间最多一条水管(如果有水管从a到b,则不会再有其他水管也从a到b,也不会有水管从b到a)。输入结束标志为一个0。
【分析】
根据题意,蓄水池的编号为0。把它拆成两个点0和0',则本题的模型就是求一个最小费用流,使得进入0'点的所有流量均相同。根据题目背景,把那些流入蓄水池的弧称为“瀑布弧”。下面来看一个例子。
如图12-37所示,除了弧0→4的容量上下界均为1之外,其他弧的容量下界为0,上界为无穷大。所有水管的单位费用为1(注意,瀑布弧的费用为0)。不难发现这个例子的唯一可行解如图12-38所示(边上的数代表流量)。
| ![]() |
| 图12-37 瀑布弧 | 图12-38 唯一可行解 |
从图12-38可知,出现了非整数的流量。这样一来,就无法在修改模型之后只求一次费用流就得到最终结果,只能寄希望于参数搜索——先确定瀑布弧的相同流量f,然后再求出对应的最小费用c(f)。这样的想法是可行的,因为f确定下来以后问题就会转化为普通的带上下界最小费用流问题。这样,就需要把注意力集中在函数c(f)上。
首先考虑f的可行域。不难证明f的可行域为连续区间[left,right],因此可以用二分法确定这个可行域的边界:给瀑布弧设置下界0和上界f,如果网络没有可行流,则说明f<left;如果网络有可行流但有的瀑布弧不满载,则说明f>right(想一想,为什么)。
接下来怎么办?直接输出最小流对应的费用?很可惜,最小的f并不对应最小的费用。下面的例子很好地说明了这一点。
图12-39 最小的f不对应最小的费用
有两条弧的上下界均为1,因此流量必须为1。如果要f最小,应该沿着0→2→3→1→0'的顺序流动,但这样一来,经过了费用100的弧。另一方面,如果沿着0→2→1→0'和0→3→1→0'流动,虽然流量2不是最小的,但费用仅为4,如图12-39所示。
《训练指南》中介绍过“流量不固定的最小费用流”问题,并且指出费用是流量的下凸函数。这个结论在本题中也成立,即在可行域内c(f)是f的下凸函数,因此用三分法求解即可(10)。
本题是笔者为2006年ACM/ICPC西安赛区所命的题目,上述算法便是笔者当时给出的“标准算法”。虽然概念并不复杂,但是毕竟包含二分、三分以及容量有下界的最小费用流问题等诸多因素,用程序实现并不容易。看到这里,聪明的你是否能想到一个“取巧”的方法呢?没错,可以用线性规划方法!只需要加一些“瀑布弧流量全相等”的等式,本题就转化成了线性规划问题。不过,这个新算法和刚才介绍的传统方法相比,效率如何呢?读者不妨一试。
12.2.3 数学
例题12-17 简单加密法(Simple Encryption, ACM/ICPC Kuala Lumpur 2010, UVa12253)
输入K1(0<K1<50000),解方程
,即K1的K2次方的十进制末12位等于K2。注意,K2的十进制必须恰好包含12个数字,不能有前导0。输入保证有解。
【分析】
很多数学题除了需要知识和技巧之外,还需要经验和直觉(而计算机是验证“直觉”的绝好工具!),本题便是一例。本题的模1012很大,不妨先缩小一点,例如,把模改成103,那么K2的取值范围是100~999,直接枚举即可。取K1=123,不难枚举到唯一解是547。如果把模改成104,可以枚举到唯一解是2547。会不会是巧合?再换一个K1=234,可以枚举到模为103时的唯一解是616,104时的唯一解为1616。还有更神奇的:123547的末4位为2547,而234616的末4位是1616!
看上去可以得到一个猜想:如果
以dn结尾,则
也以dn结尾。这里dn是指把数字d放在n前面的数。试着验证一下:1232547的末5位是92547,12392547的末6位是692547。123692547的末7位是1692547。看上去很不错。如果这个结论是对的,那么只需要用暴力法求出一个很小的n使得
以n结尾,然后用这个结论不断地往n的前面加数字,直到它拥有12个数字为止——然后祈祷最后加上的那个数字不是0。这就是最终算法。
用数学归纳法可以证明上述结论(11),不过比赛当中通常无暇考虑。只要最终算法够简单,写程序的时间很可能还没有证明的时间长。即使写出来的程序是错的,也没有耽误太多的时间。
例题12-18 伟大的游戏——石头剪刀布(The Great Game, ACM/ICPC Kuala Lumpur 2008, UVa12164)
石头剪刀布的游戏规则是这样的:两个人一起出拳,必须出石头、剪刀、布之一。石头胜剪刀,剪刀胜布,布胜石头。你和某人玩石头剪刀布游戏,分为若干轮,每轮出G(1≤G≤1000)次拳。胜者得1分(如果两个人出的一样,都不得分)。每轮结束后,得分多的胜出(如果两人得分相同,则该轮没有人胜出)。当你的对手比你多赢L轮时,你就算输掉了整个比赛;当你比对手多赢W轮时,你就算赢得了整个比赛。你的任务是找一个最优策略,使赢得整个比赛的概率最大。1≤W,L≤100。
假定你的对手的策略是固定的,而且每轮都一样:第i次出拳时分别有ai%,bi%,ci%的概率出石头、布和剪刀。输入保证ai+bi+ci=100。
【分析】
你的任务是比对手多赢W轮,而各轮之间是不相关的,所以你需要每一轮都玩得尽量好。可是什么叫“玩得尽量好”呢?如果每一轮只有赢和输两种可能,那么“玩得尽量好”就是指获胜的概率尽量大。但是在本题中,每一轮除了输赢之外还有可能是平局。如果有两种策略,一种是20%概率赢,80%概率平(因此不可能输),但另外一种是80%概率赢,10%概率平(因此还有10%的概率输),哪种策略更好呢?仔细思考后会发现:虽然第一种策略的胜率比较低,但它是必胜的(即答案是100%)——对手没有任何机会获胜;第二种策略虽然赢的概率比较大,但却有概率输掉,如果L=1,答案肯定不是100%。
《训练指南》中曾经介绍过马尔科夫链。如果用一个编号为x的结点表示“比对手多赢x场”这个状态,则本题就是一个包含L+W+1个结点(即-L,-(L-1),…,0,1,…,W)的马尔科夫链,要求一个策略使得结点0首达结点W的概率最大。
假设最优策略使得每局获胜的概率为pwin,输掉的概率为plose,每个内结点(即不是-L也不是W的结点)往左的转移概率为plose,往右转移的概率为pwin,转移到自己的概率为(1-pwin-plose)。因为本题并不关心到达结点-L或W的具体时间,只关心先到达W的概率,所以刚才的马尔科夫链等价于去掉自环(即每个状态到自身的转移),然后把往左往右的概率归一化(即让二者加起来等于1)。此处要最大化的正是这条新马尔科夫链中的获胜概率,即p0=pwin/(pwin+plose)。
至此,问题分成了两个完全独立的部分:如何最大化p0,以及已知p0之后如何求出状态W的首达概率。后者的一般做法如下:设状态i时的获胜概率为d(i),根据边界d(-L)=0,d(W)=1以及马尔科夫方程联立求解。具体解法在《训练指南》中已有详细叙述。对于本题中特殊的马尔科夫链,还可以直接求出解的封闭形式。另外,还可以用迭代法而非高斯消元法求解方程组,这里不再详述。
前者也有两种解法:二分法和不动点迭代法。不动点迭代法及其收敛性的证明超出了本书的讨论范围,因此这里只介绍二分法。二分答案p,看看是否有一种策略使得pwin/(pwin+plose)≥p,即(1-p)*pwin-p*plose≥0。接下来就只需用动态规划计算(1-p)*pwin-p*plose的最大值了。令“胜”的权值为1-p,“负”的权值为-p,则问题转化为最大化权值的数学期望。设状态d(i,j)表示前i次猜拳,得分为j(注意j可能为负数)时的最大期望,分剪刀、石头、布3种情况讨论即可。
例题12-19 自行车(Cycling, ACM/ICPC NWERC 2012, UVa1677)
你有一个很棒的自行车:没有最大速度,加速度不超过0.5m/s2,但可以瞬间把速度减为0到当前速度之间的任意速度。T=0时刻,你在X=0的位置。目标位置是X=Xdest(1≤Xdest≤10000)。一共有L(0≤L≤10)个红绿灯,每个红绿灯用3个整数描述:位置Xi(0<Xi<Xdest),红灯长度Ri(10≤Ri≤500),绿灯长度(10≤Gi≤500)。T=0时,所有灯刚刚变红。不同红绿灯的位置保证不同。求到达目标位置的最短时间。
【分析】
用t-x图(横坐标为时间,纵坐标为位置)可以直观地表示出一个合法解,如图12-40所示。
从图中可以得到两个直观的结论。
结论1:通过一个点(t,x)时,速度越大越好,因为可以任意减速。
结论2:不要在中间(没有红绿灯的地方)变速,且不等待时加速度保持最大。
证明:首先考虑没有红绿灯的情况。如何保证通过点(t,x)时速度最大?画一个速度-时间图就一目了然了。相同时间(横轴)走相同路程(面积),而开始低速后加速最终得到的速度更高(纵轴),如图12-41所示。也就是说:要么就等着,要加速就要是最大加速度,并且等待/刹车一定是起点或者刚过红绿灯之后。
| ![]() |
| 图12-40 t-x 图 | 图12-41 速度-时间图 |
这样就证明了,只需考虑红绿灯刚刚变化时的状态(t,x)。注意,x只有L+1种取法(起点或者某个红绿灯处),而t只能取该红绿灯刚刚变色的时刻(x=0时t必须等于0)。稍后将会分析状态(t,x)的个数,不过现在先设计算法。
设d(t,x)表示自行车处于状态(t,x)下的最大速度,则可以写一个“刷表法动态规划”:枚举(t,x)的“下一个状态”(t',x')(其中t'>t,x'>x),更新d(t',x')。需要分两种情况讨论。
情况1:减速但不等待。这需要求解减速后的速度v,使得保持最大加速度行驶后恰好到达状态(t',x')。注意:因为行驶距离x'-x和时间t'-t都已经固定,且加速度恒定为0.5,可以直接解出v。如果v>d(t,x),说明这个解不合法(因为自行车不能瞬间加速!),而如果v<0,其实已经变成了情况2。
情况2:把速度减为0,等待一段时间后重新开始加速。因为初速度为0,加速度恒定为0.5,根据行驶距离可以直接算出加速时间,也就能算出等待时间了。
需要特别注意的是,不管是情况1还是情况2,算出具体路线以后却要判断这条路线会不会“闯红灯”。只有不闯红灯时才能用到达(t',x')时的速度更新d(t',x')。另外,每个状态d(t,x)都有可能直接最大加速冲到终点,从而更新最终答案,但也要判断有没有闯红灯。
状态有多少个呢?最坏的情况就是10个红绿灯把10000米分成11段,每段910米,且每次都要从头加速,因此行驶时间为11*sqrt(4*910)=664秒。另外,每个红灯处最多等500秒,因此总时间不超过5664秒,每个红绿灯最多经过5664/(10+10)<300个周期。粗略计算一下,上述算法的计算量是可以承受的,而且刚才的估算非常“悲观”,实际上很难达到(12)。
命题组最初设计的题目还要更难一点:自行车的速度还有一个上限值。有兴趣的读者可以思考一下,如何求解这个“加强版”的题目。另外,上述算法还有很大的优化余地(例如,计算d(t,x)时不一定要枚举所有满足t'<t,x'<x的状态(t',x')),有兴趣的读者可以深入思考。
例题12-20 折纸公理6(Huzita Axiom 6, ACM/ICPC NEERC 2011, UVa1678)
输入两条线l1,l2和两个点p1,p2,找一条直线l,使得p1的对称点落在l1上,且p2的对称点落在l2上。换句话说,如果以l为折纸痕,p1会折到l1上,p2会折到l2上,如图12-42所示。
图12-42 “折纸公理”问题示意图
输入保证l1,l2不同,但p1,p2可以相同。p1不在l1上,p2不在l2上。坐标都不超过10。如多解,输出任意解;如无解输出4个0。
【分析】
给定p,l,哪些直线能把p折到l上呢?假设l上有两个不同点A和B,则l上任意点可以写成p'(t)=A+t(B-A)。如果把p折到p'(t),则折纸痕为p-p'(t)的垂直平分线,化简为a(t)x+b(t)y+c(t)=0,其中a(t),b(t)为t的线性函数,c(t)为二次函数。这是一个直线族,即任取一个t,都能得到一条直线,把p折到l上。另一方面,对于任意一条能把p折到l上的直线,都存在这样一个参数t。此处把这个直线族记为(a(t),b(t),c(t))。
在本题中,有两对点和两条直线,因此可以得到两个直线族(a1(t),b1(t),c1(t))和(a2(t),b2(t),c2(t))。目标是求出一条直线同时属于两个直线族,这等价于求出两个参数t1和t2,使得直线a1(t1)x+b1(t1)y+c1(t1)=0和a2(t2)x+b2(t2)y+c2(t2)=0是同一条直线。
一条直线有多种表示法(例如,x+y+1=0和2x+2y+2=0是同一条直线),不能简单地认为a1(t1)=a2(t2),b1(t1)=b2(t2),c1(t1)=c2(t2),而只能认为三者“成比例”(但是要注意0不能做分母)。一种常见方法是将“二直线相等”变成以下两个条件:
根据这两个条件,可以列出两个关于t1和t2的方程,消去t2后,能得到一个关于t1的三次方程,用二分法求解即可(要注意退化情况)。
例题12-21 简单几何(Easy Geometry, ACM/ICPC NEERC 2013, UVa1679)
输入一个凸n(3≤n≤100000)边形,在内部找一个面积最大,边平行于坐标轴的矩形,如图12-43所示。
| ![]() |
图12-43 “简单几何”问题示意图
【分析】
虽然本题是几何题(而且题目名称里也有“几何”字样),但用纯几何的方法解题很难奏效。因为图形是凸的,可以从函数的角度考虑问题。对于任意横坐标x0,竖直线x=x0最多和凸多边形相交于两个点,设y1(x0)和y2(x0)分别为低点和高点的坐标。对于任意给定的x0,可以用二分查找的方法求出y1(x0)和y2(x0)。下面假设矩形的左端点为x,宽度为w,则最大矩形包含在如图12-44所示的阴影部分梯形中。
图12-44 二分查找求出y1(x0)和y2(x0)
根据图12-44,最大矩形的面积S1(x,w)=w*(min{y2(x),y2(x+w)}-max{y1(x),y1(x+w)})。当w固定时,上述表达式是x的凸函数,所以宽度为w的最大矩形面积S2(w)可以通过三分法求出。类似地,S2(w)也是关于w的凸函数,所以最大矩形的面积也可以通过三分法求出。
12.2.4 几何
例题12-22 打怪物(Shooting the Monster, ACM/ICPC Kuala Lumpur 2008, UVa12162)
你正在玩一个打怪物的游戏,其中怪物是一个巨大的不能动弹的n(n≤50)边形,位于右半屏幕。你发的子弹也是一个多边形,从左半屏幕开始匀速水平向右飞到无穷远处,速度为1。注意,怪物在被子弹打穿的过程中不会产生形变,也不会移动。
为了增加游戏的真实性,一发子弹对怪物的伤害等于子弹与怪物的公共部分面积对时间的积分。例如,在图12-45中,t分别为0和3,相交部分的面积分别为0和1。
对于上面的场景,可以画出相交面积随时间变化的曲线,如图12-46所示。
| ![]() |
| 图12-45 t 为0和3时相交部分面积 | 图12-46 相交面积随时间变化的曲线 |
根据定积分的定义,曲线下方的面积就是子弹对怪物的伤害。输入坐标均为绝对值小于500的整数。屏幕中点的x坐标为0,怪物多边形顶点的x坐标均大于0,子弹多边形顶点的x坐标均小于0。
【分析】
本题在定义上是一个积分题,但不一定要按照定义计算积分。如果按照定义,则需要分析两个多边形相交的面积随着时间的变化规律,而在题目中给出的那个曲线看上去毫无规律。怎么办呢?
因为子弹是水平向右飞行的,可以把两个多边形划分成水平条而非竖直条,则不同水平条之间的结果完全独立,依次求解后累加即可。具体来说,从两个多边形的所有顶点出发画一条水平线,则每个水平条内都是一些梯形(或退化成三角形),如图12-47所示。
图12-47 水平线划分出的梯形或三角形
对于一个水平条来说,同一个多边形划分出的梯形/三角形可以合并到一起(想一想,为什么),如图12-48所示。所以问题转化为子弹和怪物都是单个梯形的情况,可以直接求解(需要手工计算一个简单积分)。
| ![]() |
图12-48 子弹和怪物形状转化为梯形
例题12-23 快乐的轮子(Merrily, We Roll Along!, World Finals 2002, UVa1017)
你有一个圆形的轮子,放在一条由水平线段和竖直线段组成的折线道路上,轮子的中心在道路起点的正上方。在保持和折线接触的前提下,你沿着道路把轮子滚到尽头(即让轮子的中心在道路终点的正上方)。你的任务是计算圆心移动的总距离。
在下面的例子中,假定轮子半径为2,道路第一段和最后一段的高度相同,长度都是2。中间的水平线段长度为2.828427,比另两条水平线段低2个单位。滚动轮子时,轮子首先从位置1(起点)水平移动到位置2,然后旋转45°到位置3,再旋转45°到位置4,最后水平移动到位置5(终点),圆心移动距离为7.1416,如图12-49所示。
下面的例子更为复杂:两边是两条长度为3的水平线段,中间是一条长度为7,高度比两边低7个单位的水平线段。轮子的半径为1,移动总距离为26.142,如图12-50所示。
| ![]() |
| 图12-49 轮子滚动状态 | 图12-50 更复杂的轮子滚动状态 |
输入轮子的半径r和道路的段数n(1≤n≤50),以及每段道路的长度和道路右端处的高度变化值(正数代表变高,负数代表变低,最后一段道路右端的高度变化值保证为0),输出圆心移动距离,保留3位小数。输入保证第一段和最后一段道路的长度严格大于r,且在滚动过程中轮子不会同时碰到两条竖直道路。
【分析】
本题有两个常用算法。第一种方法类似于“清洁机器人”问题,先将道路外扩距离R,打散线段和圆弧,然后判断每条小线段和圆弧的中点与输入道路的距离是否小于R,如果是,则不要统计这条线段/圆弧,如图12-51所示。
图12-51 判断是否统计线段圆弧
这个算法比较易于理解和编写,查错也很方便,但运行速度较慢。还有一个概念上较为简单、速度快,但容易出错的算法:直接模拟。任何时刻有4个可能的状态:水平向右移动(0)、竖直向下移动(1)、竖直向上移动(2)、绕顶点顺时针旋转(3),可能的转移如图12-52所示。
图12-52 4种可能的状态
例题12-24 客房服务(Room Services, ACM/ICPC World Finals 2012, UVa1286)
给定一个凸n(3≤n≤100)边形和多边形内的一个点,要求从这个点出发,到达每条边恰好一次,然后回到起点,使得总路程尽量短。注意:到达一个点相当于到达了它所在的两条边。
【分析】
本题看上去相当困难,因为可行的路径有无穷多条。怎么办呢?物理老师曾经说过:光线总是沿着最短路线走。那么是不是可以借鉴一个光路呢?如图12-53所示,假设要从A到B,但是中间必须经过直线l。假设现在的路径是A->C->B。做A关于l的对称点A',则ACB的路径长度等于A'CB的路径长度。因为两点之间线段最短,A'CB最短时就是这三点共线时,即C和C'重合。
这样,即可得到结论:到达一条边时,只要到达的是边的内部而不是端点,路线都满足“光的反射定律”,即反射角等于入射角。另外,还能猜到一个直观(但不是很好证明)的结论:存在一个最优解,使得所有边按照逆时针顺序到达。有了这两个结论,就可以设计出主算法了。
首先枚举第一次到达的边,把环打断成线。为了方便,把第一次到达的边的终点编号为1,其他点按照逆时针顺序依次编号为2~n,起点编号为0,终点编号为n+1(起点和终点重合)。接下来进行动态规划:设d(i)为表示当前点编号是i,还需要多长路径才能走到终点。枚举下次走到的顶点编号j,则:
d(i)=min{w(i,j)+d(j)|j=i+1…n+1}
其中,w(i,j)表示从顶点i出发,到达顶点j,中途按顺序经过i~j之间所有边的最短路径长度,如图12-54所示。
| ![]() |
| 图12-53 ACB的路径长度最短 | 图12-54 经过i~j 的所有边的最短路径长度 |
计算w(i,j)时需要不断地计算i关于各条边的对称点,最后和j相连,然后恢复出整条折线。但是需要判断是否每次“到达一条边”时接触点都真的在线段的内部。如果接触点在线段外面,则说明这条路线是非法的,w(i,j)应设为正无穷。细心的读者可能会问:如果有接触点在线段外面,可以退而求其次,不走镜面反射路线,但也不该是正无穷啊?但其实这样做的结果是直接走到多边形的一个顶点,已经被上述动态规划算法考虑到了。
当i或者j为0或者n+1时,需要一些特殊处理。另外,还要注意j=i+1的情况。细节留给自行读者思考。
例题12-25 最短飞行路径(Shortest Flight Path, ACM/ICPC World Finals 2012, UVa1288)
如图12-55所示,地球表面有n个机场,要求从机场s飞到机场t时,飞行总距离最小(无解输出impossible),且飞行过程中始终满足:离最近机场的距离不超过R。由于油箱限制,最大连续飞行距离为c,所以可能需要中途在其他机场加油。本题距离都是指球面距离(假定飞机沿着地球表面飞行)。地球是半径为6370km的球,有多组询问(s,t,c)。n≤25,Q≤100。
图12-55 “最短飞行路径”问题示意图
【分析】
虽然这个题一看就是最短路径问题,但是构图才是本题的难点。假设已经成功构图,剩下的问题就是:有n'个点的图G,其中有n个点是特殊点(机场)。给定起点s和终点t,找一条最短路,使得路径上任意两个相邻特殊点的距离不超过c。首先以特殊点出发做单源最短路,求出每两个特殊点之间的最短路,然后构造一个新图G',结点是特殊点,边u-v的长为G'上u-v的最短路。最短路大于c时不加这条边。
图G的结点是所有机场和每个机场的“保护圈”的交点。一共有n个保护圈,交点数不超过600个(2C(n,2)≤600)。对于任意两个点,当且仅当二者可以“直达”时连一条边。“可以直达”意味着它们之间的大圆弧是安全的,即这个大圆弧完全位于所有保护圈的“并”的内部。注意这个大圆弧的不同部分可能会在不同机场的保护圈内,所以不能简单地取弧的中点后依次判断每个保护圈。
判断一条大圆线(13)a是否安全的正确方法是:对于每个保护圈s,求出a被s保护的范围,然后把所有范围求并,看看是否是完全覆盖a。保护圈交点的个数是O(n2),因此“需要判断是否安全”的大圆弧个数是O(n4)。对于O(n)个保护圈,求交点和区间并需要O(nlogn)时间,因此总时间复杂度为O(n5logn)。
12.2.5 非完美算法
例题12-26 可爱的魔法曲线(Lovely M[a]gical Curves, Rujia Liu's Present 6, UVa12565)
NURBS曲线是一种可爱而又“有魔法”的曲线。它的样子多变,非常灵活,如图12-56所示。
图12-56 NURBS曲线
NURBS曲线的数学表达式是:
其中,u是参数,n是控制点个数,k是曲线的度数,Pi和wi是第i个控制点的位置和权重。在上式中(计算过程中遇到的0/0按0算):
NURBS曲线的参数有严格的限制:
要求求出两条NURBS曲线的所有交点。n≤20,度数为1,2,3或者5,控制点坐标范围是[0,10],权值范围(0,10],Knot向量的第一个数保证为0,最后一个数保证为1。
输入保证NURBS曲线不病态,且没有特别接近的交点,输出保留3位小数。
【分析】
NURBS曲线和曲面是工业中常用的建模工具,也是工作中实际会用到的。NURBS曲线的定义看起来比较吓人,但仔细观察后可以发现,它实际上就是一个分段多项式曲线,可以用数学归纳法证明。Ni,0(u)是分段0次曲线(当u在ti和ti+1之间时为1,其他时候为0),而Ni,k(u)由两部分相加得到。注意,Ni,k-1(u)和Ni+1,k-1(u)的第二个下标都是i-1,而且系数都是u的一次函数,因此Ni,k(u)比Ni,k-1(u)的次数要大1。
看清楚定义之后,至少可以做一件事:对于一个给定的参数u,计算曲线中参数u所对应的点,即C(u)。于是,第一个算法诞生了:对一条NURBS曲线,有一个很大的正整数p,取步长s=1/p,然后对于参数i=0,1,2,…,n-1各求出一个点Pi=C(is)(想一想,为什么不计算Pn=C(1))。只要p足够大,折线P0-P2-…-Pp-1可以很好地逼近一条NURBS曲线。这样,用两条折线分别逼近两条NURBS曲线,然后求出两条折线的交点即可。如何求两条折线的交点?因为交点很少,采取《训练指南》中介绍的扫描法,可以在O(plogp)时间内完成这个任务。
这个方法看上去非常不优美,但是它可以解决问题。学习算法的目的不正是解决问题吗?在更好的算法被找到之前,应该尽可能地解决问题,不要轻易放弃。
上述方法只是一个基本梗概,有许多细节可以优化。例如,可以用二分法来“自适应”地构造折线,而不是像刚才那样均分参数空间。还可以不用扫描法,而是把x轴划分成一些相互重叠的小窄条,在每个窄条里寻找交点(14)。只要仔细选取上述方法的参数,就能更快、更准地找出所有交点,并且不会遗漏。
例题12-27 奇怪的歌剧院(A Strange Opera House, UVa11188)
昨天晚上,我做了一个奇怪的梦,梦到我站在一个多边形的歌剧院舞台上演唱。我的声音最多能被歌剧院的墙壁反射k次,如图12-57中的4幅图描绘了声音的反射方式,分别为歌剧院轮廓、声音直射的可达区域、声音反射一次的可达区域、声音反射两次的可达区域。
图12-57 声音的反射方式
观众都坐在墙边。你能帮我计算一下,有多少观众能听到我的歌声吗?
每组数据第一行为4个整数n,k,x,y(3≤i≤50,0≤k≤5),其中,n为歌剧院多边形的顶点数,k为最大反射次数,(x,y)为我唱歌的位置(保证严格在多边形的内部,不在墙上)。以下n行每行为歌剧院的一个顶点坐标。顶点按照顺时针或逆时针排列。所有坐标均为绝对值不超过1000的整数。对于每组数据,输出能听到我的声音的观众所对应的墙的总长度,保留两位小数。
【分析】
本题只需要按照题目意思反射声音,然后求出声音到达的墙的总长度即可。但这个概念上简单的过程却并不容易转化成程序。因为歌剧院是不规则多边形,声波在传播过程中可能经过多次反射,而且不同的声波的“反射序列”(即每次发生反射时由墙编号组成的序列)可能完全不同。幸运的是,这些声波依然是可以“离散化”的,即按照角度划分成若干区间,使得每个区间中声波的反射序列相同,如图12-58所示。
图12-58 声波的反射序列
这样的“离散化”方案虽然概念正确,但是很难像其他题目那样通过一次预处理完成,因为要事先考虑所有可能的反射序列(多达505种)。一种折中的方案是用深度优先搜索的方式,递归地把声波角度逐步细分。
如图12-59(a)所示,从P点出发,角度范围为A到E的声波被分成了4部分:A到B,B到C,C到D,D到E。接下来递归求解即可。为了递归求解,需要把子问题设计成和原问题相同的形式,即子问题也应有一个“音源”。
如图12-59(b)所示,从P发出的声音,初始范围是向量v1和v2之间,其中向量 PA和 PB中间的部分反射出来的区域等价于P关于AB的对称点P'直射A和B点,得到的区域中在有向线段 AB左侧的部分(这句话非常绕,请多读几遍)。这样,已经可以设计出递归过程了。参数有5个:已经反射的次数f、等价音源位置P,上次反射墙的有向线段 AB和初始范围向量v1和v2。在递归过程中,首先把角度区间分成若干个小区间,使得每个区间直射的是同一面墙,然后计算出发射后的递归参数并进行递归调用。程序细节留给读者编写。
| ![]() |
| (a) | (b) |
图12-59 将声波角度逐步细分
本题还有一个姐妹篇——奇怪的歌剧院Ⅱ(15),其中把“长度”改成了“面积”,即要求计算能听到歌手声音的区域面积。有兴趣的读者可以试一试。
例题12-28 最小包围长方体(Smallest Enclosing Box, Rujia Liu's Present 4, UVa12308)
给定三维空间中的n(n≤10)个点,求一个能包含所有点的体积最小的长方体。这个长方体的各个面不一定要平行于坐标平面。只需输出最小长方体的体积。
【分析】
在《训练指南》中用旋转卡壳的方法计算了n个点的最小包围矩形,时间复杂度为O(nlogn)。该方法基于这样一个定理:一定存在一个最小包围矩形(不管是面积最小还是周长最小),贴着凸包的一条边。
对于最小包围长方体,是否有这样的结论呢:一定存在一个最小包围长方体,贴着凸包的一个面?如果这个结论成立,问题就简单多了。首先计算三维凸包,然后枚举凸包上的一个面,再整体旋转所有点,使得这个面和z=0平面平行。这样,就可以忽略所有点的z坐标,求出面积最小的包围矩形R,则所求长方体的底就是R,高就是旋转之后所有点的z坐标最大值与最小值之差。因为n的范围很小,既使用最慢的三维凸包和最小包围矩形算法,也不会超时。
图12-60 正四面体
很可惜,上述结论是错的,即最小包围长方体不一定会贴住凸包上的一个面。如图12-60所示,正四面体(它的凸包是自身)就是一个反例:最小包围长方体的每个面都贴住了一条边,但是没有贴住任何一个面。
事实上,已知最强的结论是:最小包围长方体中至少有两个相邻面均贴住凸包的某条边。Joseph O'Rourke在论文《Finding Minimal Enclosing Boxes》中基于这个结论设计了一个三维旋转卡壳算法,成功地在多项式时间内解决了最小包围长方体问题,但算法很抽象、复杂,难以用到算法竞赛中。
前面曾经多次强调过,算法竞赛的目的是要解决问题。如果“正解”过于复杂,难以驾驭,可以寻找非完美解决方案。刚才的算法其实只有第一步错了,那么只要用其他办法找到最小包围长方体的一个面,还是可以用旋转、降维的方法进行求解。一个相对容易实现的方法是使用随机调整:先随机生成大量的平面,求出对应的解,然后选一些比较优秀的解进行“微调”——稍微旋转一下,如果旋转后的解更优,就更新答案。这样的随机调整方法有很多不同的实现方法,常用的一种是模拟退火方法,有兴趣的读者可以查阅相关资料。
12.2.6 杂题选讲
例题12-29 旅行(Journey, ACM/ICPC NEERC 2011, UVa1680)
有n(n≤100)个绘图函数,包含GO(前走一步)、LEFT(左转)、RIGHT(右转)、Fk(递归调用第k个函数然后继续执行本函数)4种指令。
例如程序:
f1: GO F2 GO F2 GO F2
f2: F3 F3 F3 F3
f3: GO LEFT
图12-61 程序绘制的图行
会画出如图12-61所示的图形。
有时,函数会无限执行下去,如GO F1。
每个函数最多包含100条指令。从(0,0)点开始执行f1,求画图过程中距离(0,0)点最大的曼哈顿距离(即|x|+|y|)。如果无限大,则输出Infinity。
【分析】
既然题目是递归,那么第一反应就是直接写个递归函数simulate(x,y,i,d),表示目前在(x,y),面朝方向d,执行函数fi。在执行函数时不断更新|x|+|y|的最大值。
可惜这样做是不行的,因为题面已经给出了一个无限递归的例子。所以要想沿着这个思路继续解题,必须避免无限递归。如何避免呢?最直接的方法就是检测无限递归,就像第6章介绍的图的DFS一样。检测到以后怎么办呢?直接输出Infinity?这样可不行。“无限走下去”也可能是“无限绕圈圈”,并不代表会离原点无限远。所以还应该记录一下出现无限递归时的位移,当且仅当位移不是(0,0)时,输出Infinity。
现在的程序不会无限递归了,可惜还是会超时,因为走的步数可能会非常多。例如f1是100个f2,f2是100个f3,f3是100个f4,…,f100是100个GO,则一共会执行100100个GO(这意味着本题需要输出高精度整数)。怎么办呢?既然已排除了无限递归,就可以用像动态规划一样的记忆化了:对于(i,d),记录面朝方向为d,执行完fi之后的方向、总位移(dx,dy)和路径上的max{|x|+|s|},然后尝试递推。
记忆化时之所以不记录(x,y),是因为它们可能会很大,而且不同的(x,y),当i相同时,执行fi的路线“形状”都是一样的,因此位移也一样。可新的问题又出现了:max{|x|+|y|}无法递推。具体来说,就是设位移为(x0,y0)时,无法根据max{|x|+|y|}计算出max{|x+x0|,|y+y0|}。
解决方法也非常巧妙。分别记录x+y,-x+y,-x-y,x-y这4个表达式的最大值。因为没有绝对值符号,这4个值是可以递推的;当计算最终答案时,这4个值的最大值就是max{|x|+|y|}(想一想,为什么)。
例题12-30 下雨(Rain, ACM/ICPC World Finals 2010, UVa1097)
有一个由许多不同形状的三角形沿边相互拼接而成的立体地形图,其中三角形的每条边要么是地形图的边界,要么与另外一个三角形的某条边完全重合。此时在地形图的上空开始下雨,雨水会被困在地形图中而形成湖。要求编写一个程序来确定所有的湖,以及每个湖水位的海拔高度。假设雨非常大,所有湖的水位都到达了最高点。
对于一个湖,一艘大小可以任意小但不为0的船可以在湖面上的任意两点间航行。如果两个湖在相接位置的水位深度均为0,则它们被认为是两个不同的湖。
输入第一行包含两个数p和q(p≥3,q≥3),分别表示地形图中点和边的个数。之后的p行描述每个点,每行首先是点的名字,接着是3个整数x,y,h,表示这个点的三维坐标,其中x、y(-10000≤x,y≤10000)为点在地平面上的坐标,h(0≤h≤8848)为点的海拔高度。接下来的q行描述每条边,每行包含两个点的名字,表示一条边的两个端点。
地形图在xy平面上的投影满足下列条件:
可以认为上述区域以外的点的海拔高度低于区域内任意一点的海拔高度,水在流到边界后会紧接着流出这个区域。
对于每组输入,在第一行输出数据的编号,接下来以递增的顺序在每行输出一个湖的海拔高度;如果没有湖,则输出一个0。
【分析】
首先建一个图,结点是所有区域(即三角形和“外界”无限大区域)。当且仅当两个区域u和v有公共边时,在图上连一条边,权值为u和v的两个公共顶点的较低高度,表示只要水位高于这个高度,水就可以从u流到v,或者从v流到u。
下面这一步需要点创造性思维:考虑水从某一个区域流到“外界”的路径。这条路径上的最大权重对应着一个“最小高度”,当水位达到这个高度时,水就可以顺着这条路径流到外面。但是水可以有多条通往外界的路径,只要水位大于任何一条路径的最小高度,水就可以顺着这条路径流出去。这正是一个最短路问题吗,只不过路径的“长度”是最大边权而非边权之和而已。第11章中已经讨论过这样的“变形最短路”问题。
用Dijkstra算法求出以外界为起点的单源最短路(因为边都是无向的,以外界为终点相当于以外界为起点)之后,对每个区域i都求出了一个d[i],即“能流到外界的最小水位”,只要d[i]大于区域i的3个顶点的最小高度,则说明区域i是有积水的,并且水位就是d[i]。求出了水位,用DFS或者BFS把连通的积水区域合并起来成为“湖”即可。
例题12-31 字典(Dictionary, ACM/ICPC NEERC 2013, UVa1681)
输入n(1≤n≤50)个不同的单词(每个单词的长度为1~10),设计一个结点数最少的树状字典,使得每个单词w都可以找到一条从上到下(即远离根结点)的路径,使得路径上出现的字母按顺序连接起来后可以得到w。如图12-62所示,7到5是north,16到12是eastern,29到2是european,3到25是regional,1到31是contest。
图12-62 “字典”问题示意图
【分析】
首先把题目的要求放宽一点:必须从根开始走,而不是从任意结点开始走。这样,只需要构造这些单词的Trie即可,如图12-63(a)所示。
这个Trie也可以理解成一个状态图,每个结点代表“当前得到的字符串前缀”,则本题中“从任意结点出发”的条件只需要加一些虚线边即可,如图12-63(b)所示。例如,加上了abc→c的虚线边之后,实际上可以从根走到abc,然后走虚线边“扔掉前两个字符”得到c,这和从根直接走到c是完全等价的。更妙的是,从abc到c这条“边”实际上并不在最终的树状字典中,所以用它来代替从根到c的这一条边,能让答案更优。
| ![]() |
| (a) | (b) |
图12-63 构选单词的Trie
一般地,对于任意两个前缀p和q,若q是p的后缀,则连一条从p到q的虚线边。在这个图中,我们的目标是找到一些边,使得这些边形成“树状字典”,并且包含的实线边最少。设实线边权为1,虚线边权为0,所求答案就是这个图的最小树形图。
例题12-32 算符破译(Equations in Disguise, Rujia Liu's Present 1, UVa11199(16))
已知字母a,b,c,d,…,m和数字(0~9)、加号(+)、乘号(1*)和等号(=)之间有一个一一对应关系(一一映射)。你的任务是根据n(1≤n≤20)个等式,尽可能地推导出这些对应关系。每个等式恰好包含一个等号,等号两边都是中缀表达式,数字都是十进制的,不含前导零(但整数0是允许的)。运算符均为二元的,乘法的优先级比加法高(没有括号)。
对于每组数据,输出所有可以确定的符号对(一个字母和它代表的数字/运算符)。换句话说,这些符号对应在所有解中均成立。无解,输出No;如果有解,但没有可以确定的符号对,则输出Oops。
例如,有两个等式{abcdec、cdefe},输出为“a6 b* d=f+”(所有可能的解为{6*2=12,2=1+1},{6*4=24,4=2+2},{6*8=48,8=4+4})。只有一个等式{abcde},则什么也确定不了(输出Oops),而只有一个等式{milim},则是无解(输出No)。
【分析】
本题的条件太苛刻,连运算符都没有给出,看上去非搜索莫属了。不难发现,应当先搜索等号、加号和乘号的位置,因为这三者出现的位置最苛刻(等号在每个等式中必须恰好出现一次,并且这三者中的任意两个都不能连续出现,也不能在等式的首尾位置)。例如,若有一个等式abcab,则c肯定是等号,因为只有c恰好出现一次。枚举完等号以后还有一个小优化:如果某些等式在等号左右两边的字符串完全相等,则不管怎么搜,这个等式都会成立,因此只需要标记出来,今后在搜索时就可以避开无谓的判断了。
接下来搜索各个数字。a+b=c这样的等式只需搜索a和b,则c就能直接计算出,所以需要重新安排各个数字的搜索顺序,使得更多的数字能够尽快直接计算出。例如,ab+cd=ef的一个较好的搜索顺序是:b,d,f,a,c,e。其中搜索完b,d之后可以直接计算出f(注意此时还要检查其他等式是否存在矛盾),而搜索完a,c后可以直接计算出e。
abc=d+e+f是不可能成立的,因为3个一位数加起来不可能是3位数。一般地,可以求出每个数的最小值和最大值,进而计算出等式两边的取值范围。例如,abc的取值范围是100~999(虽然不如123~987准确,但比较容易求),d+e+f的取值范围是0~27,因为27<100,所以无解。这个方法有一个软肋:0乘以任何数都等于0,所以在a*b=a*cdefg这样的等式里,这个方法完全不奏效。幸运的是,有一个办法可以减少这种情况的发生:先搜索0。等0确定下来以后,上下界估计就会准确一些。
看上去很吸引人吧?这个剪枝的效果很不错(即可以剪掉大量枝叶),但是效率却不佳。也就是说,有可能花费大量的运行时间在“判断是否满足剪枝条件上,这就舍本逐末了。一般来说,可以尝试以下方法来调整这种“低效剪枝”:牺牲效果(即少剪一点)而提高效率,或者只在搜索的前几层才检查剪枝条件,因此那时的结点还不多,效率不会太受影响,而剪枝成功后的好处更大。
还有一个剪枝更有意思:因为并不是要找出所有解,所以如果已经Oops了(即有解,但所有字母都是多解),直接终止整个搜索过程即可。一般地,设ans(c)表示“当前最终答案”中c的值(可能是“?”),val(c)表示“当前解”中c映射到的字符(必须是0~9或者加号、乘号或者等号),则还没有搜索的所有字符的ans都是“?”,已经搜索的字符c满足:要么ans(c)=‘?’,要么ans(c)=val(c),即继续搜索下去,不管val能不能变成一个合法解,都不会改变“最终答案”。所以应该终止当前解的搜索。注意,初始时ans为空,此时无论如何都要先搜出一个解。
刚才的描述比较抽象,下面举一个例子。假设目前已经得到了两个解:a=4,b=6,c=3,d=1;a=8,b=6,c=1,d=3,因此ans是a=?,b=6,c=?,d=?。再假设现在已经搜了a=2,b=6,但c和d还没搜。在这种情况下不管有没有解,有何种解,都改变不了ans。
有了这些优化,最终的程序速度会非常快。不过本题还有一个不起眼的“陷阱”:在输入中没有出现的字符并不一定是不确定的——因为是一一映射,如果已经确定了12个字母,剩下的那一个也就确定了。
例题12-33 独占访问(Exclusive Access, NEERC 2008, UVa1682)
多线程编程中的一个重要问题就是确保共享资源的独占访问。需要独占访问的资源称为临界区(CS),确保独占访问的算法称为互斥协议。
在本题中,假设每个程序恰好有两个线程,每个线程都是一个无限循环,重复进行以下工作:执行其他指令(与临界区无关的代码,称为NCS),调用enterCS,执行CS(即临界区代码),调用exitCS,然后继续循环。NCS和CS内的代码和协议完全无关。
在本题中,用共享的单比特变量(即每个变量只能储存0或者1)来实现互斥协议(即上述的enterCS和exitCS)。所有变量初始化为0,且读写任意一个变量只需要一条语句。两个线程可以有一个局部指令计数器IP指向下一条需要执行的指令。初始时,两个线程的IP都指向第一条指令。程序执行的每一步,计算机随机选择一个线程,执行它的IP所指向的指令,然后修改该线程的IP。为了分析互斥协议,定义“合法执行过程”如下:两个线程都执行了无限多条指令;或者其中一个线程执行了无限多指令,另一个线程执行了有限多条指令以后终止,且IP在NCS中。
表12-4中展示了3个互斥协议的伪代码。两个线程的id分别为0和1,变量want[0]、want[1]和turn为共享单比特变量。以“+”开头的代码实现了enterCS,而以“-”开头的代码实现了exitCS。NCS()和CS()表示执行NCS代码和CS代码,这些代码的具体内容和本题无关(假设它们不会修改共享变量)。
表12-4 3个互斥协议的伪代码

本题的任务是判断一个给定算法是否满足以下3个条件。
互斥性很容易满足:一个什么都不干的死循环就符合条件。上述3个算法均满足互斥性,但前两个算法不满足“无死锁”,而第3个算法(由Gary Peterson发明)满足所有3个条件。
输入包含多组数据。每组数据第一行为两个整数m1,m2(2≤mi≤9),即线程1和线程2的代码行数。接下来的m1行是线程1的代码,再接下来的m2行是线程2的代码。每个线程的代码都是一条指令占一行。每条指令的格式如下:首先是指令编号(顺序编号为1~mi,仅是为了可读性才放在输入中),然后是指令助记符,后面跟着若干个参数。有一种特殊的参数称为NIP,即下一条指令的编号(保证为1~mi之间的整数)。一共有3个单比特共享变量:A,B,C。指令助记符有以下4种。
在每个线程的代码中,NCS和CS恰好各出现一次。代码不一定是一个典型的无限循环,但保证交替执行CS和NCS。输入结束标志为文件结束符(EOF)。
对于每组数据,输出3个字母Y或者N,分别表示是否满足互斥性、无死锁和无饥饿条件。
【分析】
这是一道难题,即使在NEERC这样高水平的区域赛中,也只有一支队伍在比赛时通过此题。在考虑核心算法之前,要先把程序存起来(假设程序编号为0和1)。一个合理的数据结构是保存每条指令的字母c,var,op1,op2和nip,然后定义本题的“状态”为三元组(ip0,ip1,vars),即两个程序的“当前指令编号”以及3个变量的值(最多只有23=8种取值)。
接下来可以写一个Next(state,p)函数,即从状态state开始让程序p执行一条指令以后达到的新状态,然后从初始状态开始BFS/DFS,得到所有可能达到的状态,设为states数组。接下来的所有讨论都针对这个状态集。为了方便分析时间复杂度,设一共有n个可达状态。根据上面的讨论,n≤9*9*8=648。
本题的3个定义各不相同,下面分别验证。首先推敲一下“合法执行过程”的定义:“两个线程都执行了无限多条指令,或者其中一个线程执行了无限多指令,另一个线程执行了有限多条指令以后终止,且IP在NCS中”。也就是说,至少有一个线程会无限循环下去。对应到此处“状态”中,这表明状态会无限转移下去。但是在无限循环过程中如果有一个程序的IP始终没有变化,这个IP必须在NCS中。
exclusion的判定。这个相对比较容易,在计算可达状态集的同时顺便判断即可。
deadlock的判定。回忆“无死锁”的定义:在任意合法执行过程中,CS都执行了无限多次。从反面看,试着找一个执行方式,使得从某个时刻开始CS再也不执行了,这就表明出现了死锁。也就是说,存在一个满足以下3个条件之一的环。
条件1:进入环之后,程序0执行过,但从没有到达过CS,而程序1始终停止在NCS。
条件2:进入环之后,程序1执行过,但从没有到达过CS,而程序0始终停止在NCS。
条件3:进入环之后,程序0和程序1都不断执行,且都没有到达过CS。
starvation的判定。和死锁类似,饥饿的出现意味着某程序执行了无数条语句,但只有有限多次CS。也就是说,存在一个环,使得在该环中某程序曾经执行过,但没到达过CS。
主算法。既然死锁和饥饿都可以归结为找一个满足特定条件的环,可以枚举环的起点s0,然后用DFS找环。由于判定条件比较复杂,需要在DFS过程中加几个参数,用来记录各个条件是否满足。具体来说,可以编写递归过程dfs(s,m0,m1,c0,c1),表示当前状态为s,mi表示程序i有没有被执行过,ci表示程序i是否执行过CS。当s=s0且m0和m1至少有一个为true(说明找到圈)时判断。
情况一:两个程序都执行过(m0=m1=true)。如果两个程序中至少一个没进过CS(即!c0||!c1),说明发生饥饿;如果两个程序都没进过CS(即!c0&&!c1),说明发生死锁。
情况二:存在0≤p≤1使得程序p始终在NCS(即mp=false且s状态中程序p在NCS)且程序1-p没进过CS(c1-p=false),则同时发生死锁和饥饿。
对于每个确定的起始状态s0,dfs需要O(n)时间,因此总时间复杂度为O(n2)。
例题12-34 压缩(Compressor, UVa11521)
你的任务是压缩一个字符串。在压缩串中,[S]k表示S重复k次,[S]k{S1}t1{S2}t2…{Sr}tr(1≤ti<k, ti<ti+1)表示S重复k次,然后在其中第ti个S后面插入Si。这里的S称为压缩单元。压缩是递归进行的,因此上面的S, S1, S2,…也可以是压缩串。你的任务是使得压缩串的长度最小。
例如,I_am_WhatWhat_is_WhatWhat的最优压缩结果是I_am_[What]4{_is_}2。注意,上述k, t1, t2,……的长度均算作1,即使它们的十进制表示中包含超过1个数字。一个递归压缩的例子是aaaabaaaaaaaabaaaaaaaabaaaa,最优结果是[[a]8{b}4]3,长度为11。
输入包含不超过20组数据。每组数据包含不超过200个可打印字符,但不含空白字符、括号(小括号()、方括号[]或者花括号{}都算括号)或者数字。字母是大小写敏感的。
对于每组数据,输出长度和压缩串。如果有多解,任意输出一个压缩串即可。
【分析】
这是一道很难的动态规划题目,思路不难想到,但是细节处很容易想复杂或者写错。建议读者先自行思考一下,写一个程序试试,然后再阅读下面的题解。
设输入串为A。令f(x,y)表示字符串A[x…y](17)的最短压缩长度,则有两种状态转移方式:一是连接,只需枚举划分点m,转化为f(x,m)+f(m+1,y)(如图12-64所示);二是压缩,需要枚举压缩单元的长度L,转移到f(x,x+L-1)+3+g(x,y,L),这里的“+3”是方括号和数字k,g(x,y,L)是指:用A[x…x+L-1]作为单元来压缩A[x…y]时,后面的{S1}t1…{Sr}tr部分的最短长度。
图12-64 连接
注意,这个L必须满足A[x…y]的前L个字符等于后L个字符,因为ti<k,即不允许在最后面添加字符串。用O(n2)时间预处理出任意两个位置i和j开始的LCP(最长公共前缀)长度lcp[i][j]之后,则L满足条件,当且仅当lcp[x][y-L+1]>=L。
如何求解g(x,y,L)?同样需要进行动态规划。
首先枚举压缩单元下一次出现的位置i(需要满足lcp[x][i]≥L),如果中间有缝隙(i>x+L),则说明有插入串[x+L,i-1](如图12-65所示),需要递归压缩插入串(长度为3+f(x+L,i-1))。然后问题转化为了g(i,y,L),即压缩[i,y],压缩单元为S[i…i+T-1]。
| ![]() |
图12-65 有插入串
这样,综合f和g的状态转移方程,就可以求出最优解的长度了。如何输出方案?用递归比较方便,写起来和动态规划部分类似,只是当发现当前解和最优解一样时立即递归打印。需要注意的是,在输出f的方案时,要先得到g部分的方案,同时统计单位串的重复次数,然后再输出。
算法的理论时间复杂度为O(n4),但因为L的选取有限制,实际上效率很高。
例题12-35 公式编辑器(Formula Editor, UVa12417)
你的任务是编写一个类似于MathType的公式编辑器。从技术上讲,公式就是一个表达式,它是由元素组成的序列。有3种元素:基本元素(算术运算符、括号、数字和字母)、矩阵和分式。
公式编辑器为每个表达式创建了一个看不见的编辑框。由于矩阵中的每个单元格都是表达式,所以每个单元格也都有一个编辑框。类似地,每个分式的分子和分母分别有一个编辑框。
在如图12-66所示的表达式中,有5个编辑框。F1包围了整个表达式,F2和F3各包围一个矩阵单元格,F4包围了分子,而F5包围了分母。
图12-66 表达式中的编辑框
不难发现,编辑框相互嵌套。如果编辑框A直接包含编辑框B,则称A是B的父编辑框(例如,在图12-66中,F1是F2和F3的父编辑框,F3是F4和F5的父编辑框)。如果A和B拥有相同的父编辑框,则称A和B是兄弟(例如,在图12-66中F4和F5是兄弟,F2和F3也是兄弟)。
下面介绍光标移动的实现。在任意时刻,光标总是直接包含在某个编辑框中。它可能位于该编辑框中所有元素的左边(即“框首”),也可能位于所有元素的右边(即“框尾”),还可能位于某两个相邻元素之间。如果光标在元素X和元素Y之间,并且X在Y的左边,则称光标的左相邻元素为X,右相邻元素为Y。
光标支持6种移动方式:Up、Down、Left、Right、Home和End。假定直接包含光标的编辑框为A,则各种移动方式的细节如下。
Home(End):把光标移到A的框首(框尾)。注意,光标仍然被A所直接包含。
Up(Down):如果A的上(下)方有一个兄弟B,则把光标移动到B的框首,否则检查A的父编辑框。如果A的父编辑框有这样一个兄弟,则继续移动光标会移到该兄弟编缉框上。如果A的所有祖先编辑框均不含这样的兄弟,则忽略此命令。
Left(Right):有以下4种情况。
输出格式化。本题的输出为ASCII格式,因此需要把每个编辑框格式化成一个ASCII字符矩形(尽管多数字符都是空格)。表达式的字符矩形由组成它的各个元素的字符矩形(称为内矩形)经过水平拼接而成。各个内矩形根据基线进行对齐,相邻两个矩形之间没有空白,而内矩形和整个矩形的边界之间也没有空白。
每个元素都可以格式化为一个字符矩形,规则如下:
前面提到的“水平对齐”是这样的:首先把水平宽度最大的矩形固定下来,然后水平移动其他矩形,使得它们的水平中心线尽量整齐。如果对不齐(即该矩形的宽度和最大宽度的奇偶性不同),可以往左移动0.5个单位的宽度,如图12-67所示。
图12-67 向左移动0.5个单位宽度
注意有一个特例:当整个表达式为空时,ASCII矩形是一个空行——它的宽度为0,但高度为1。这一点在拼接和对齐时尤为重要。
输入处理。输入已转化为了一个命令字符串序列。对于每个字符串:
输入包含多组数据,每组数据以命令Done结束。单个数据包含不超过1000条命令,输入总大小不超过200KB。
【分析】
这道题目的主要难点是理清思路,建立合理的数据结构,使得编程难度、调试难度都达到一个不错的平衡点。
相关概念。题目中定义的主要概念有两个:元素和编辑框(即表达式),其中元素有3种:基本元素(单个字符)、分式和矩阵。这两个概念是交织在一起的,因为每个元素都有一个或多个编辑框,而编辑框就是一个或多个元素的有序序列。这里有个特别容易搞错的地方:元素的外面是没有编辑框的。例如,题目中的例子,4、“+”和矩阵外面都没有编辑框。6/7的外面有编辑框F3,但那是因为矩阵的每个单元格自带一个编辑框,如图12-68所示。
每个编辑框有一个“父元素”,而每个元素都有一个“父编辑框”,整个结构是一棵有两种结点的树。题目中的例子对应如图12-69所示。
| ![]() |
| 图12-68 元素外无编辑框 | 图12-69 父元素与父编辑框 |
因为很多操作涉及在编辑框中寻找“上一个元素”、“下一个元素”和“首尾元素”的操作,而且还有插入元素的操作,所以编辑框可以用链表来实现。光标要么位于编辑框的尾部,要么位于某个元素e的前面,则光标位置实际上可以表示为e的指针(18)。
另外,父元素相同的编辑框可以组织成十字链表(即有上下左右4个指针),从而支持快速的光标移动。当然,也可以写4个函数,动态计算每个编辑框上下左右的编辑框。这样,可得到如下的数据结构:
格式化输出。编辑框和元素都可以进行格式化输出,也有两种常见的思路。一是递归计算出所有子结点的格式化结果,得到二维字符矩阵,然后把这些字符矩阵拼起来。这样做的好处是直观,坏处是需要大量的字符复制。第二种方式是提供两个函数,一是计算尺寸,二是以某个点为左上角把字符矩阵“画”到一个固定的字符矩阵中。这样,格式化某个结点时,先计算所有子结点的尺寸,进行排版,得到每个子结点左上角的坐标,然后让每个子结点“绘制”自己(即写到一个叫output的全局二维数组中)。这种方法最大的好处是避免了大量的字符复制,也是常见GUI软件实现布局的方法。
落实到程序上,最传统的方法是使用面向对象程序设计方法(OOP),设计两个类Element和EditBox,以及Element的3个子类:Character、Fraction和Matrix。还有一种不很“优美”但很实用的方法:把所有类合在一起为Object,通过一个名为type的字段加以区别。例如,type=0表示编辑框,type=1、2、3分别表示基本元素、分式和矩阵。这样做的好处是代码紧凑(一些重复代码可以写在一起)(19),坏处是代码看上去没那么好维护,而且还会遭到软件工程师们的批评(20)。本书是算法书籍,无意讨论这些工程性问题,但有一点是肯定的:要具体问题具体分析,不存在适用于所有场合的“银弹”(21)。
例题12-36 疯狂的谜题(Killer Puzzle, UVa12666)
你有没有做过下面这个疯狂的谜题(22)?
请回答下面10个问题,各题都恰有一个答案是正确的。
(1)第一个答案是B的问题是哪一个?
A.2
B.3
C.4
D.5
E.6
(2)恰好有两个连续问题的答案是一样的,它们是:
A.2,3
B.3,4
C.4,5
D.5,6
E.6,7
(3)本问题答案和哪一个问题的答案相同?
A.1
B.2
C.4
D.7
E.6
(4)答案是A的问题的个数是:
A.0
B.1
C.2
D.3
E.4
(5)本问题答案和哪一个问题的答案相同?
A.10
B.9
C.8
D.7
E.6
(6)答案是A的问题的个数和答案是什么的问题的个数相同?
A.B
B.C
C.D
D.E
E.以上都不是
(7)按照字母顺序,本问题的答案和下一个问题的答案相差几个字母?
A.4
B.3
C.2
D.1
E.0(注:A和B相差一个字母)
(8)答案是元音字母的问题的个数是:
A.2
B.3
C.4
D.5
E.6(注:A和E是元音字母)
(9)答案是辅音字母的问题的个数是:
A.一个质数
B.一个阶乘数
C.一个平方数
D.一个立方数
E.5的倍数
(10)本问题的答案是:
A.A
B.B
C.C
D.D
E.E
注意:
(1)你的答案不能自相矛盾。例如,第一题的答案不能是B。
(2)你需要确保每道题的选项中只有你的答案是正确的,其他都是错误的。例如,若问题(5)的答案是A,那么问题(6)、(7)、(8)、(9)的答案都不能是A。
(3)你需要确保每道题目都是有效的。例如,若问题(2)和问题(3)的答案相同,且问题(8)和问题(9)的答案也相同,则问题(2)是非法的,因为并不是恰好有两个连续问题的答案一样。
这道题目当然可以手算,但是作为程序员,编程求解会更有意思。
编程求解。最容易想到的方法就是穷举法,即考虑所有510=9765625种可能,依此检查答案是否合法(即每道题有且只有你的答案是正确的)。伪代码如下:
forall(answer_list):
bad = False
for testing_question in [1,2,3,4,5,6,7,8,9,10]:
for testing_option in ["a","b","c","d","e"]:
# your answer should be correct
if testing_option == answer_list[testing_question] and
check(testing_question, testing_option) == False:
bad = True
# other options must be incorrect
if testing_option != answer_list[testing_question] and
check(testing_question, testing_option) == True:
bad = True
if not bad:
print answer_list
在上述伪代码中,answer_list是一个字母列表(下标从1开始),其中第i个字母表示第i个问题的答案。本题的唯一解是cdebeedcba(如果每道题目的答案前加上题目编号,它是1c2d3e4b5e6e7d8c9b10a)。
是不是很神奇?还有更神奇的。你可以写一个更加通用一些的程序,以求解其他类似的谜题,而不仅仅是解上面这一个谜题。不过在此之前,需要把问题描述加以形式化。
问题的形式化描述。本题采用一种LISP方言来描述谜题。LISP的语法很简单。(f a b)表示用参数a和b调用函数f,相当于C/C++/Java的f(a, b)。类似地,(f a (g b c) d)相当于C/C++/Java中的f(a, g(b, c), d)。下面是一道问题的例子:
3. (equal (answer 3) (answer (option-value)))
a. 1
b. 2
c. 4
d. 7
e. 6
上面的问题涉及两个重要的内置函数,如表12-5所示。
表12-5 两个重要的内置函数
| 函数 | 说明 |
| (answer idx) | 返回伪代码中的answer_list[idx] |
| (option-value) | 返回伪代码中testing_option的计算结果(即把它看作一个表达式) |
在上面的例子中,如果testing_option的计算结果是c,则(option-value)返回4(整型),因为4是选项c所对应的计算结果。注意,testing_option的文本可以是一个复杂的表达式,参见样例输入。
上面用到的函数check(testing_question, testing_option)可以这样实现:
check(testing_question, testing_option):
1. set-up the function (option-value) so that it returns the evaluation result of testing_option of testing_question
2. evaluate the lisp expression of testing_question (e.g. the expression (equal (answer 3) (answer (option-value))) in the example above)
3. if an unhandled exception is raised during the evaluation, returns False
4. if the result of step 2 is boolean, return it; otherwise return False
有一个特殊的表达式叫做none-of-above,其计算结果取决于其他选项的计算结果。每个问题最多只有一个none-of-above的选项,并且一定是最后一个选项。
下面是本题所用LISP方言的一些细节。
下面是预定义函数列表。所有以“!”开头的函数有可能抛出异常,而以“@”开头的函数会处理异常。和C++/Java/Python一样,当异常从一个函数抛出后,表达式计算的过程将会终止,除非有该函数的调用者处理异常。
基本函数如表12-6所示。
表12-6 基本函数
| 函数 | 说明 |
| (equal a b) | 返回伪代码中的answer_list[idx] |
| (option-value) | 上面已经讨论过 |
| !(answer idx) | 上面已经讨论过。如果idx不是整数或不在范围1~n内(其中n 是问题总数),则抛出异常 |
| !(answer-value idx) | 返回answer_list[idx]对应的表达式的值。Idx取值非法时会抛出异常 |
谓词是一类特殊的函数,唯一参数是个任意类型的值,返回一个布尔值,不会抛出异常,如表12-7所示。
表12-7 谓词
| 函数 | 说明 |
| primp-p | 当且仅当参数是一个正素数时返回true |
| factorial-p | 当且仅当参数是一个阶乘数时返回true |
| square-p | 当且仅当参数是一个平方数时返回true |
| cubic-p | 当且仅当参数是一个立方数时返回true |
| vowel-p | 当且仅当参数是单个字符的串,并且是元音时返回true |
| consonant-p | 当且仅当参数是单个字符的串,并且是辅音时返回true |
查询和统计函数如表12-8所示。
表12-8 查询和统计函数
| 函数 | 说明 |
| !@(first-question pred) | 返回满足谓词pred的第一个问题编号1~n 。如果不存在,则抛出异常 |
| !@(last-question pred) | 返回满足谓词pred的最后一个问题编号1~n 。如果不存在,则抛出异常 |
| !@(only-question pred) | 返回满足谓词pred的唯一问题编号1~n 。如果不存在或者不唯一,则抛出异常 |
| @(count-question pred) | 返回满足谓词pred的问题个数 |
| !(diff-answer idx1 idx2) | 返回问题idx1和idx2的答案之差(例如,a和b相差1)。返回值总是0~m 的整数。如果idx1或idx2非法,则抛出异常 |
注意:表12-8中的前4个函数(即有“@”标记的函数)可以处理异常,即如果在计算pred的过程中抛出了异常,这4个函数不会把异常传递给它的调用者,而是当作pred返回了false。例如,如果answer_list是abc,则表达式(count-question (make-answer-diff-next-equal 0))返回0,而不会抛出异常,尽管计算((make-answer-diff-next-equal 0)3)时会抛出异常。注意,所有其他函数都不会处理异常,例如,若一共只有3个问题,则(factorial-p (answer-value 5))会抛出异常,而不是返回false。
谓词生成器如表12-9所示。
表12-9 谓词生成器
| 函数 | 说明 |
| !(make-answer-diff-next-equal num) | 返回一个谓词(p idx)。该谓词先计算(diff-answer idx idx+1),当计算结果等于num时返回true。当num不是整数时抛出异常 |
| (make-answer-equal a) | 返回一个谓词(p idx)。该谓词先计算(answer idx)。当计算结果等于a时返回true |
| (make-answer-is pred) | 返回一个谓词(p idx)。该谓词先计算(answer idx)。当计算结果满足谓词pred时返回true |
| (make-answer-value-equal a) | 和上面类似。计算的是(answer-value idx) |
| (make-answer-value-is pred) | 和上面类似。计算的是(answer-value idx) |
| !(make-is-multiple num) | 返回谓词(p i)。该谓词返回true当且仅当i是整数且是num的倍数。当num不是整数时抛出异常 |
| !(make-equal val) | 返回谓词(p v)。该谓词返回true当且仅当(equal v val)为真。当val既不是整数也不是字符串时抛出异常 |
| (make-not pred) | 返回谓词(p v)。当且仅当(pred v)为false时该谓词返回true |
| (make-and pred1 pred2) | 返回谓词(p v)。当且仅当(pred1 v)和(pred2v)均为true时返回true。注意,pred1和pred2都要测试,不能进行短路操作 |
| (make-or pred1 pred2) | 返回谓词(p v)。当且仅当(pred1 v)和(pred2v)至少有一个为true时返回true。注意,pred1和pred2都要测试,不能进行短路操作 |
例如,(make-is-multiple 3)返回谓词“是3的倍数”,因此((make-is-multiple 3)6)返回true,而((make-is-multiple 3)10)返回false。类似地,(make-not (make-or square-p prime-p))返回谓词“既不是平方数也不是素数”。
输入包含不超过50组数据。每组数据的第一行是问题的个数n和选项的个数m(2≤n≤10,2≤m≤5),每个问题用m+1行表示,即问题的表达式和各个选项的表达式。问题按输入顺序编号为1~n,选项编号为a~e。选项保证是合法的表达式,并且不会调用(option-value)(否则会引起无限递归!)。每个问题后有一个空行。输入的大部分数据都是简单的。
对于每组数据,输出数据编号和所有答案,按照字典序从小到大排列,各占一行。
样例输入(节选):
3 3
(equal (option-value) (count-question (make-answer-equal "a")))
3
0
1
(equal (option-value) "a")
"c"
"b"
"a"
((option-value) (count-question (make-answer-equal "c")))
(make-and (make-is-multiple 2) (make-or factorial-p prime-p))
(make-not prime-p)
"none-of-above"
样例输出(节选):
Case 1:
bcb
cca
【分析】
这是笔者为第9届湖南省大学生程序设计竞赛所命的一道压轴题目。本题的背景与Lisp相关,但为了题目的清晰简洁以及“公平”起见,有些细节与Scheme和Common Lisp不同。实际上,Common Lisp是笔者最喜欢的语言之一(23),所以“让更多参加算法竞赛的人知道Lisp”成为了本题的另一个目标。
本题的题干很长,不过核心内容并不多,主要是预定义函数太多。其实整个题目的意思很简单,就是用穷举法求解一个复杂的逻辑谜题。因为这个谜题的题干和选项都采用LISP方言来描述,而且这个方言(即预定义函数)还要足够强大到可以描述题目最初提到的那个经典谜题,所以题目的复杂程度可想而知。
主算法就是穷举所有可能的answer_list,依次判断是否正确;判断answer_list是否正确的方法就是依次判断每个问题的每个选项是否满足条件——answer_list中选中的选项必须正确,其他选项必须错误(还要加上对none-of-above的特判)。所以其实问题的核心在于:给定answer_list,计算一个表达式。
表达式是按照字符串的格式输入的,但是为了效率,应当事先把它解析并保存在合理的数据结构中,这样才能快速求值。这个过程相当于程序设计语言的“编译”。不过这个编译的结果并不是机器指令,而是我们自己设计的内部格式,例如,一个称为Expression的类。具体来说,它有两种情况,一是常数(例如字符串、布尔值),另一个是函数调用。
每个Expression都可以计算,得到一个计算结果,因此Expression应该有一个eval(context)函数,返回一个Value类型的变量,这里的context是指“上下文”,即所有的question表达式,option表达式,还有answer_list等。计算表达式所需要的所有内容都在context里。
根据题意,Value类型除了C++中的int、bool或者字符串char*之外(24),还可以是函数(实际上用于“闭包”,后面还会讨论),因此需要自定义一个Function类。由于Value类主要用于承载数据,此处不再用继承的方式编写int、bool等子类,而是用不同的TYPE加以区分。例如(25):
struct Value {
ValueType type; //值的类型,有INTEGER、BOOLEAN等
bool boolVal;
int intVal;
const char * strVal;
Function * funVal; //自定义的Function类
//还有一些GetBoolean()、GetFunction()以及MakeBoolean()、MakeFunction()等函
数,其作用望文知义,具体实现略
}
class Function {
public:
virtual ~Function() {}
virtual Value Call(const Context & c, const Value* params, int paramsCount)=0;
};
对于上述代码中的技巧,特别是纯虚函数,请读者自行阅读相关资料。有了这些,就可以定义Expression类了。
class Expression {
public:
virtual ~Expression() {}
virtual Value Evaluate(const Context & context) = 0;
};
class LiteralExpression : public Expression {
Value _arg;
public:
//构造函数略
virtual Value Evaluate(const Context &) { return _arg; }
};
class CallExpression : public Expression {
Expression * _functionExpression;
Expression ** _params; //也可以用vector,但速度稍慢,因为最多只有两个params
int _paramsCount;
public:
//构造/析构函数略
virtual Value Evaluate(const Context & context) {
Value fn = _functionExpression->Evaluate(context);
if (fn.GetType() == ERROR) return Value::MakeError(); //抛出异常
assert(fn.GetType() == FUNCTION); //必须是函数
Value evaluatedParams[2]; //最多是二元函数
for (int i = 0; i < _paramsCount; ++i)
evaluatedParams[i] = _params[i]->Evaluate(context);
return fn.GetFunction()->Call(context, evaluatedParams, _paramsCount);
}
};
这里有一个地方需要特别注意:CallExpression里的_functionExpression的类型是Expression,因此它既有可能是LiteralExpression又有可能是CallExpression。例如(equal 1 1),这里的_functionalExpression就是LiteralExpression,即equal;但是对于((make-equal 1) 1),_functionExpression就是(make-equal 1),是一个CallExpression。
另外,上面的代码包含了异常处理。在Value中增加了一种类型:ERROR。如果在计算fn时抛出了异常,则整个表达式都应抛出异常。
接下来有3个任务:写Parser、编写预定义函数和编写主程序。主程序在题目中已经给出,这里不再赘述。Parser不难编写,但是在处理常量表达式时要注意。根据题目,一共只有3种常量表达式:遇到数字串,得到的Value是整型,例如10;遇到带引号的字符序列,得到的Value是字符串,例如“none-of-above”;遇到不带引号的字符序列,得到的Value是函数,例如equal。换句话说,所有预定义函数都必须是Function类或者它的子类,否则无法保存到Value中。
因此接下来的工作重点是编写预定义函数。这个工作理论上并不困难,但代码量大(占到总程序的一半以上),并且容易出错。所以在编码之前,有必要把一些细节想清楚。
之前说过,所有预定义函数应当是Function类或者它的子类,但具体来说还是有两种不同的写法。一种是写一个巨大的 PredefinedFunction类,保存一个functionName,然后在Call函数中根据functionName判断。还有一种写法是每个函数写一个单独的子类。两种写法各有利弊,读者可以根据需要进行选用。
不管使用哪种方法,都面临一个问题:如何保存动态生成的函数(即闭包)。其实动态生成的函数并不是任意生成的。例如,所有由make-equal生成的函数都较相似,只是有一个参数a不一样。所以可以把所有“由make-equal生成的函数”统一处理。
如果采用方法一(即一个巨大的PredefinedFunction类),可以用functionName=“generated-by-make-equal”来表示由make- equal生成的函数,另外在类中增加成员变量a和functionName,一同代表(make-equal a)的返回值。
如果采用方法二(每个函数是一个类),推荐把由make-equal生成的类写成MakeEqual函数的内部类,因为其他类都不会用到这个类。这样一来,甚至没必要给它命名。例如:
class MakeEqual : public Function1 {
class _F : public Function1 { //内部类
Value _val;
public:
inline _F(const Value & val) : _val(val) {}
virtual Value Call(const Context & context, const Value & a) {
return Equal().Call(context, a, _val);
}
};
public:
virtual Value Call(const Context &, const Value & val) {
return Value::MakeFunction(new _F(val));
}
};
上面的代码还展示了方法二的一个重要技巧:由于最多是二元函数,可以编写Function的3个子类:Function0、Function1、Function2(即有0个、1个、2个参数的类),然后让具体的函数继承这3个类(26)。这样做可以把一些与具体函数无关的操作(例如,检查参数个数,以及是否有参数是ERROR类型)移到这3个类中,还可以加一些方便调试的语句,让具体函数的实现更简洁。由于本题的特殊性,还可以编写IntegerPredicate和 StringPredicate两个子类,进一步地避免重复代码(主要是参数类型检查)。
至此,整个题目就分析完毕了。按照上述方法编写的代码效率很高,可以在很短的时间内通过测试数据。但优化是无止境的。如果把本题的主算法改成回溯(而非完全枚举),可以实现一个杀手级的剪枝,程序运行效率可以提高几十倍甚至上百倍。剪枝的思路如下:在answer_list没有枚举完时,虽然有些表达式无法算出结果,但有些表达式仍是能算出结果的(例如,前两题的答案确定后,(diff-answer 1 2)就能算出来了)。不确定的结果可以在Value类中新增一个NA类型,然后在函数求值时判断:当函数本身和所有参数都不是NA类型时,答案也是确定性的。这个剪枝思路很直观,不过需要注意细节,有兴趣的读者可以自行尝试。
例题12-37 太空站之谜(Mysterious Space Station, Rujia Liu's Present 7(27), UVa12731)
3000年的一天,人们在茫茫的宇宙中发现了一些奇怪的太空站。科学家们用高科技探测出了它们的精确位置,并绘制了地图,准备派一批机器人到那里进行深入的研究。
地图是一个N*M的矩形网格,如图12-70所示每个格子要么是可以穿梭自如的真空(用白色表示),要么是无法逾越的未知物质(用阴影表示)。机器人每次可以沿着东(E)、南(S)、西(W)、北(N)中的一个方向前进到相邻格子)如果那里没有未知物质阻挡)。由于太空站内没有任何光线和其他可被机器人感知的物质,机器人只有在尝试往某一个方向行进并失败以后才能知道该方向的相邻格子无法到达,而不能事先知道某一方向上是否有障碍。
有趣的是,太空站里所有未知物质连成一片(沿东、南、西、北4个方向连通),把所有真空格围在中间,形成一个真空大厅,机器人从任何一个真空格出发都可以走到其他所有真空格中。另外,太空站内没有“狭窄的通道”,即对于每个真空格子来说,它的南北方向至少有一个相邻格子是真空,东西方向也至少有一个相邻格子是真空。为了方便,把所有的真空格按照从北到南,从西到东标号为1,2,3……。如图12-71所示就是其中一个叫FT的太空站的地图标记。
| ![]() |
| 图12-70 地图 | 图12-71 FT太空站的地图标记 |
机器人一号被运送到了FT的12号真空格(由于技术限制,机器人们只能被运送到某个和未知物质有公共边的格子)后开始工作。机器人从起始位置出发往东走一格,再往北走一格,以为到达了8号格。但当它试着往北移动时,发现竟然没有被阻挡,而是成功地走到8号格上方那个地图上标记为未知物质的格子。这一重大发现很快传遍了所有在太空站内工作的机器人。它们一致认为地图有误,因而用集体罢工的方式向人类提出抗议。
针对这一情况,科学家们解释说:地图并没有绘制错,该现象的发生是因为太空站中存在着某种神秘的传送装置——虽然机器人一号在行走中已经被瞬间转移到其他格子中去了,但他自己却一点也感觉不到。
科学家们指出,太空站中有K个传送装置,每一个装置逻辑上连接着两个不同的真空格子,称为传送门。每个传送门只能属于一个传送装置,并且任意传送门周围的8个格子中不会有其他传送门或者未知物质。如果两个传送门属于同一个传送装置,那么当机器人沿某一个方向进入其中一个传送门,它就会被瞬间转移到另一个传送门并沿该方向再前进一格。在机器人看来,这一过程和普通的行走并没有区别,因此它们无法感知瞬间转移的进行。以FT为例,由于有一个传送装置连接着10号格和13号格,机器人一号的实际路线是12->11->5->1,根本没有到达格子8上面那个不能去的格子。
机器人明白了其中的奥秘以后,迫不及待地想要找出这些传送装置,但又担心自己在太空站中的工作时间会过长。经过一番慎重的考虑,科学家们决定请你编写一个智能控制程序,帮助机器人用不超过32767步数找到所有传送装置。
本题是一道交互式题目。对于每组数据,你的程序应当首先读入整数N, M, K(6≤N,M≤15,1≤K≤5)的值,然后是一个N行M列的地图,其中“.”表示真空,“*”表示未知物质,“S”表示起点。起始位置保证与至少一个未知物质格有公共边,真空格保证不出现在地图的边或角上。输入数据保证无错,行末无多余空格。
接下来,你的程序应当向标准输出打印一些移动机器人的指令,每个指令占一行,格式为MoveRobot D,其中D为4个字符N, E, S, W之一。然后你的程序可以从标准输入中读到指令的执行结果,0表示失败,1表示成功。
算出结果之后,你的程序应当向标准输出打印恰好K条输出指令,每个指令占一行,格式为Answer pos1 pos2,表示有一个传送装置连接真空格pos1和pos2。每个传送装置应恰好输出一次,顺序任意。当所有K条输出完毕之后,你的程序应准备求解下一组数据测试(即再次读取N, M, K)。当N=M=K=0时输入结束。
注意,向标准输出打印每一行之后必须执行flush标准输出(例如,C/C++可以执行函数fflush(stdout))。
如图12-72所示是一个交互范例。
图12-72 交互范例
【分析】
本题是笔者第一次给正式比赛命的题目,参加现场比赛的20位IOI国家集训队员的最好成绩是解决10个测试点中的2个。
在此之前,IOI99中出现过一道看上去类似的题目“地下城市”(28):给定一张地图,但是不知道你的当前位置。要求使用look和move指令来算出你的当前位置,其中look可以判断当前位置的某个方向是空地O还是墙W,move则是往某个方向移动一格。目标是look的次数尽量少。这道题目可以用筛法解决。初始时所有空地都有可能是“当前位置”,根据look指令的返回值,可以排除一些可能性,当可能性只有一种时,它就是正确答案。当然,还有一些细节问题要考虑(例如,需要计算一下到哪个位置去look比较容易排除更多的可能性),但算法的主框架就是这样。因为最多只有100*100=10000个可能的位置,所以并不是很困难。
本题却是完全不同的。最多有112=121个不与未知物质相邻的真空格,任选5对格子的方法有很多种(有兴趣的读者可以自己算一下),而且很难简单地通过几条指令来排除一种方案,看来需要放弃“筛法”。
怎么办呢?看来只好用逻辑思考的方法设计方案了。一开始机器人是知道自己位置的,可是走了几次以后就不知道自己在哪里了。根据题目给出的信息,移动是可逆的,即如果成功执行了移动序列EENWN,则执行序列SESWW的结果一定是每步都成功,并且回到了执行EENWN之前的位置。有了这个结论,就不怕“走丢”了,大不了原路返回,继续下一次探索。
尽管如此,“走丢”这件事情还是应该尽量避免,因为在不知道当前位置的情况下,能获得的信息十分有限。所以机器人应当遵循以下基本原则:尽量在肯定没有传送门的格子中行走。不过,未知格子总是避不开的,因为我们必须找到传送门。如图12-73所示,白色格子是肯定没有传送门的,因为它们和未知物质相邻。但是灰色格子就不一定了:它们可能是传送门,也可能不是。如何判断呢?
设需要判断A是不是传送门。首先走到B,然后执行移动序列SW,则当且仅当A不是传送门时,移动序列SW可以成功,并且当前位置是C。是否可能执行S时从A传送到另外一个位置D,然后执行W时再传送回C呢?不可能,因为一个传送门只能属于一个传送装置,而从D往W走一步后不可能走到与A配对的传送门(从D往N走才能走到与A配对的传送门)。
这样一来,问题的关键就变成了判断当前位置是不是C。首先,如果当前格子不“靠边”,说明它肯定不是C,直接排除;否则可以用“单手扶墙法”来“绕圈”(29)。例如,从A开始左手扶墙,可以得到这样一个移动序列:NESESWWN,然后回到A。如果从A上面的格子出发,移动序列应当是ESESWWNN,如图12-74所示。不难发现,如果把移动序列看成一个环状串,每个格子的移动序列对应的都是这个环状串的一种线性表示。换句话说,根据一个“靠墙点”的“扶墙移动序列”,就能确定这个点的具体位置。
| ![]() |
| 图12-73 找到传送门 | 图12-74 判断当前集团 |
这样,用“假设-验证”的方法确定了A是不是传送门——先假设A不是传送门,然后执行一些事先设计好的指令,看看结果是否和预想的一样。在上面的例子中,绕墙一周只需要十几次MoveRobot指令(注意绕墙的过程中可能会“碰壁”,所以实际执行的指令往往比移动序列长),非常方便。
按照“从外向里”的顺序,可以 依次确定每个未知格是不是传送门。具体来说,对于每一个待判断的格子,首先假设它不是传送门,然后进入格子,从另一个方向离开格子,走到墙边,再用绕墙法判断假设是否正确。因为传送门互不相邻,所以第一步“进入格子”和第三步“走到墙边”都可以完美地避开未知格子和传送门,只在肯定不是传送门的真空格中移动。需要特别指出的是,如果假设不成立,说明该格子是传送门,这时必须原路返回,否则会继续“走丢”。
现在只需确定2K个传送门之间的配对关系即可。不难发现,这一步也可以用“假设-验证”法,细节留给读者思考。
需要说明的是,上述算法只是一个梗概,还有很多细节可以优化,例如,“绕墙”过程不一定要执行完毕。一旦发现假设是错误的,可以原路返回,而不必求出完整的“扶墙移动序列”。其他还有很多地方可以减少不必要的指令,实际效果也非常好(30),读者不妨一试。