9.5 竞赛题目选讲
例题9-18 跳舞机(Tango Tango Insurrection, UVa 10618)
你想学着玩跳舞机。跳舞机的踏板上有4个箭头:上、下、下、右。当舞曲开始时,屏幕上会有一些箭头往上移动。当向上移动箭头与顶部的箭头模板重合时,你需要用脚踩一下踏板上的相同箭头。不需要踩箭头时,踩箭头并不会受到惩罚,但当需要踩箭头时,必须踩一下,哪怕已经有一只脚放在了该箭头上。很多舞曲的速度快,需要来回倒腾步子,所以最好写一个程序来帮助你选择一个轻松的踩踏方式,使得能量消耗最少。
为了简单起见,将一个八分音符作为一个基本时间单位,每个时间单位要么需要踩一个箭头(不会同时需要踩两个箭头),要么什么都不需要踩。在任意时刻,你的左右脚应放在不同的两个箭头上,且每个时间单位内只有一只脚能动(移动和/或踩箭头),不能跳跃。另外,你必须面朝前方以看到屏幕(即:你不能把左脚放到右箭头上,并且右脚放到左箭头上)。
当你执行一个动作(移动和/或踩)时,消耗的能量这样计算:
正常情况下,你的左脚不能放到右箭头上(或者反之),但有一种情况例外:如果你的左脚在上箭头或者下箭头,你可以临时扭着身子用右脚踩左箭头,但是在你的右脚移出左箭头之前,你的左脚都不能移到另一个箭头上。类似地,右脚在上箭头或者下箭头时,你也可以临时用左脚踩右箭头。一开始,你的左脚在左箭头上,右脚在右箭头上。
输入包含最多100组数据,每组数据包含一个长度不超过70的字符串,即各个时间单位需要踩的箭头。L和R分别表示左右箭头,“.”表示不需要踩箭头。输出应是一个长度和输入相同的字符串,表示每个时间单位执行动作的脚。L和R分别是左右脚,“.”表示不踩。比如,.RDLU的最优解是RLRLR,第一次是把右脚放在下箭头上。
【分析】
虽然本题的条件比较杂乱,但总的来说不难发现:可以按“箭头”划分阶段,再记录一下左右脚的位置以及上次左脚有没有踩,就可以顺利地动态规划了。
具体来说,用d(i,a,b,s)表示已经踩了i个箭头(i≥0),左右脚分别在箭头a和b上,且上一个周期移动的脚的集合为s(s=0表示没有脚移动,s=1表示左脚移动,s=2表示右脚移动),则最终答案为d(0,1,2,0)。4个箭头的编号为0-上,1-左,2-右,3-下。
如果下一步是“.”,有3种决策:左脚移动到另一个箭头;右脚移动到另一个箭头;不动。注意,虽然这次移动什么箭头都不会踩到,但还是要输出移动的脚。
如果下一步是4个箭头之一,有两种决策:左脚移动到该箭头;右脚移动到该箭头。注意不要枚举不符合题目要求的移动方式。
例题9-19 团队分组(Team them up!, ACM/ICPC NEERC 2001, UVa1627)
有n(n≤100)个人,把他们分成非空的两组,使得每个人都被分到一组,且同组中的人相互认识。要求两组的成员人数尽量接近。多解时输出任意方案,无解时输出No Solution。
例如,1认识2, 3, 5;2认识1, 3, 4, 5;3认识1, 2, 5,4认识1, 2, 3,5认识1, 2, 3, 4(注意4认识1但1不认识4),则可以分两组:{1,3,5}和{2,4}。
【分析】
设两个组的编号为0和1。因为同组中的人相互认识,所以如果有两个人a和b不是相互认识,那么a和b只能分到两个不同的组。这样,如果已知某个人是第0组,那么不认识它的所有人都应该是第1组。而不认识这些人的所有人都应该是0组,依此类推。这样,如果把“不相互认识”关系看成一个图,则每个连通分量都可以独立推导(推导过程中可能遇到矛盾,此时原问题无解)。例如,上面的样例对应图9-16(注意a认识b,但b不认识a,也应该连一条边)。
图9-16 团队分组样例示意图
对于连通分量{1,3,4,5},假设1在组0,可以推导出3,4,5都在组1;反过来,如果1在组1,可以推导出3,4,5都在组0。设组0比组1的人数多d个,可以总结出如表9-1所示。
表9-1 组0和组1人数分布
| 情况1 | 情况2 | |
| 连通分量1 | 组0:{2};组1:{} (d加1) | 组0:{};组1:{2}(d减1) |
| 连通分量2 | 组0:{4};组1:{1,3,5}(d减2) | 组0:{1,3,5};组1:{4}(d加2) |
可以看到,每个连通分量的两种情况分别对应于d加一个值或者减一个值,最终目标是d的绝对值尽量少。想到了什么?没错!是0-1背包问题,只是没有“体积”,而“重量”有正有负,最后也不是要“重量”最大,而是最接近0。
例题9-20 装满水的气球(Dropping water balloons, UVa 10934)
一年一度的新生周活动开始了,你们做好了大量的装满水的气球,准备拿来恶搞那些可怜的新生。活动开始之前,你们突然发现一个问题:这些气球实在是太硬了,很难把它们打破(如果打不破,它们就没有任何意义了)。甚至从好几层高的楼顶上把它们扔到地面,也打不破。你的任务是借助一个n层的高楼确定气球的硬度(所有气球硬度相同)。
实验过程是这样的:每次你拿着一个气球爬到第f层楼,将它摔到地面。如果气球破了,说明它的硬度不超过f;如果没破,说明硬度至少为f。注意,气球不会被实验所“磨损”。换句话说,如果在某层楼上往下摔,气球没破,那么在同一层楼不管再摔多少次它也不会破。
给你k个气球用来实验(可以打破它们)。你的任务是求出至少需要多少次实验,才能确定气球的硬度(或者得出结论:站在最高层也摔不破)。
输入每行包含两个整数k, n(1≤k≤100,1≤n<264),输出最少需要的实验次数。如果63次不够,输出“More than 63 trials needed”。
【分析】
用状态d(i,j)表示用i个球实验j次所能测试的楼的最高层数。根据动态规划的常见思路,我们考虑第一次决策,设测试楼层为k。
如果气球破了,说明前k-1层必须能用i-1个球实验j-1次测出来,也就是说,取k=d(i-1,j-1)+1是最优的。
如果气球没有破,则相当于把第k+1层楼看作1楼以后继续。因此在第k层楼之上还可以测d(i,j-1)层楼,即d(i,j) = k+d(i,j-1) = d(i-1,j-1) + 1 + d(i,j-1)。
例题9-21 修缮长城(Fixing the Great Wall, ACM/ICPC CERC 2004, UVa1336)
长城被看作一条直线段,有n(1≤n≤1000)个损坏点需要用机器人GWARR修缮。可以用三元组(xi,ci,di)描述第i个损坏点的参数,其中xi是位置,ci是立刻修缮(即时刻=0时开始修缮)的费用,di是单位时间增加的修缮费用。换句话说,如果在时刻ti开始修缮第i个损坏点,费用为ci+tidi。上述参数满足1≤xi≤500000,0≤ci≤50000,1≤di≤50000。
修缮的时间忽略不计,GWARR的速度恒定为v(1≤v≤100),因此从修缮点i走到修缮点j需要|xi-xj|/v单位的时间。初始坐标为x(1≤x≤500000)。输入保证损坏点的位置各不相同,且GWARR的初始位置不与任何一个损坏点重合。
你的任务是找到修缮所有点的最小费用(用截尾法保留整数部分)。输入保证最小费用不超过109。
【分析】
首先将所有修缮点按照坐标从小到大排序,不难发现在任意时候,已修复的点一定是一个连续的区间,因此可以考虑用d(i,j,k)表示修复完(i,j),且当前位置为k(k=0表示在左端点i,k=1表示在右端点j)时已经发生的总费用。
但是这样会带来一个问题:今后的费用无法计算,因为不知道当前时间。不过没关系,谁说必须当费用发生以后才能计算?可以事先把还没有发生但是肯定会发生的费用累加到答案中,然后“时钟归零”。事实上,在前面已经用过一次这种技巧了,那就是例题“颜色的长度”。
设d(i,j,k)表示修复完(i,j),且当前位置为k(含义同上)时,已经发生的总费用与所有“肯定会发生的未来费用”之和,使用刷表法,则一共只有两个决策。
决策1:往左走,修理点i-1,转移到d(i-1,j,0)。假设当前点为p(k=0时p=i,否则p=j),则到达点i-1的时间为t=|Xi-1-Xp|/v。在这段时间里,所有未修理点(即点1~i-1和j+1~n)的费用都增加了t,需要把这些点的总费用(sum_d(1,i-1)+sum_d(j+1,n)*t累加到状态值中,然后点i-1的修理费用就只有ci-1了。即用d(i,j,k)+(sum_d(1,i-1)+sum_d(j+1,n))*t+ci-1来更新d(i-1,j,0)。其中sum_d(i,j)表示点i~j的所有d值之和。
决策2:往右走,修理点j+1,转移到d(i,j+1,1)。和决策1很类似,方程略。
状态有O(n2)个,每个状态只有两个决策,因此时间复杂度为O(n2)。
例题9-22 越大越好(Bigger is Better, ACM/ICPC Xi’an 2006, UVa12105)
你的任务是用不超过n(n≤100)根火柴摆一个尽量大的,能被m(m≤3000)整除的正整数。例如,n=6和m=3,解为666。无解输出-1,如图9-17所示。
图9-17 火柴数字
【分析】
一般来说,整数是从左往右一位一位写的,因此不难想到这样的动态规划算法:用d(i,j)表示用i根火柴能拼出的“除以m余数为j”的最大数,然后用刷表法,枚举在最右边添加的数字k,用d(i,j)*10+k更新d(i+c(k), (j*10+k)%m),其中c(k)表示数字k需要的火柴数。状态有O(nm)个,每个状态只有“在右边添加数字0~9”这10个决策,看上去不错。可惜这个算法有个缺点:状态值是高精度整数,因此实际计算量比较大。
还有一个算法,虽然有些难想,但是效率很高:用d(i,j)表示拼出一个“除以m余数为j的i位数”至少需要多少火柴(若无解,d(i,j)为正无穷)。状态转移方程和上面类似,留给读者思考。因为此处只关心位数,这个算法并不涉及高精度整数。
如何根据d(i,j)计算出题目要求的答案呢?首先确定最大的位数w(即让d(i,0)不是正无穷的最大i),因为位数越大,整数就越大(不允许有前导0,因为不划算)。接下来从左到右依次确定各个数字。
例如,假定m=7,并且已经确定最大的整数是3位数。首先试着让最高位为9。如果可以摆出形如9ab的整数,它一定是最大的。是否可以摆出9ab呢?因为900除以7的余数为4,后两位"ab"除以7的余数应为3。如果d(2,3)+c(9)≤n,说明火柴足够摆出9ab,否则说明最高位不能是9。重复这个过程,直到所有数字都被确定为止。这个过程需要快速算出形如x000…的整数除以m的余数,可以通过一个预处理完成,留给读者思考(12)。
例题9-23 有趣的游戏(Fun Game, ACM/ICPC Beijing 2004, UVa1204)
一些小孩(至少有两个)围成一圈做游戏。每一轮从某个小孩开始往他左边或右边传手帕。一个小孩拿到手帕后(包括第一个小孩)在手帕上写下自己的性别,男孩写B,女孩写G,然后按相同方向传给下一个小孩,每一轮可能在任何一个小孩写完后停止。现在游戏已经进行了n轮,已知n轮中每轮手帕上留下的字,求最少可能有几个小孩。2≤n≤16。每轮手帕上的字数不超过100。
例如,若3轮的手帕上分别留下BGGB,BGBGG,GGGBGB,则至少有9个小孩。一种可能性是GGGBGBGGB。
【分析】
首先可以看出,如果有一个字符串完全包含于其他某个字符串,那么这个字符串将对结果没有影响,所以先预处理去掉这些字符串。后面将看到这会给动态规划带来方便。
在解决原题之前,先看一个简化版:小孩排成一行(而不是一圈),且传递手帕总是从左到右的。那么问题就等价于:找一个最短的字符串,使得输入的n个字符串都是它的连续子串。
可以把这个问题转化为一个多阶段决策过程:每次选择一个字符串“粘”在当前最后一个字符串的“尾巴”上(重叠部分必须相等)。因为之前已经排除了“相互包含”的情况,所以每次选择的字符串的头部一定可以“粘”在当前最后一个字符串的内部,并且可以露出一部分“尾巴”。例如题目中的例子,s1=BGGB, s2=BGBGG, s3=GGGBGB,则决策过程如图9-18所示。
最终得到的字符串长度等于所有n个字符串的长度之和,减去每个串(除了第一个串)与前一个串的最大重叠长度。对于上面的例子,s1, s2, s3的长度之和为15,s2和s3的最大重叠长度为3,s1和s2的最大重叠长度为3,因此最终得到的字符串长度为15-3-3=9。注意上述“最大重叠长度”不是对称的,例如,若s2在右边,s2和s3可以重叠3个字符,但如果s2在左边,则只能重叠2个字符。
这个过程启发我们使用动态规划。用d(i,j)来表示已经选过的字符串集合为i,最后一个串为j时,可以减去的重叠部分总长。如图9-19所示,假设已经选择了字符串1, 6, 4,其中最后一个字符串为4,即状态d({1,4,6}, 4)。假设接下来选择字符串3,并且已经得到了3粘在4尾巴上时的最大重叠长度为5,则可以用d({1,4,6},4)+5来更新d({1,3,4,6},3)。
| ![]() |
| 图9-18 决策过程 | 图9-19 已选字符串1,6,4的情况 |
现在已经解决了简化版问题,原题只有两点不同:
(1)原题中,手帕有两种不同的方向,因此选择每个串之后,还要确定是把它直接粘上呢,还是反过来粘,因此状态d(i,j)中的j有2n种可能,每次的决策也变成2n个,时间复杂度不变,只是常数略有增加。
(2)原题中,所有小孩组成一个圈,因此需要考虑如何把链变成圈。一种方法是在状态中增加一维,用来记录第一个串是哪个,这样就可以在最后一次决策时计算最后一个串和第一个串的公共部分。这样做并没有错,但是因为状态多了一维,时间复杂度也将变大。其实,不需要给状态增加一维,而只需规定第一个串的正向串放在最前面,在动态规划结束之后检查所有i为全集的状态,考虑第一个串和最后一个串的重叠部分即可,细节请参考代码仓库。另外还有一个地方要注意:输入字符串不一定是圈的一部分,它可能绕了好几圈(想一想,上述算法是否能正确处理这种情况)。本题还有一个小陷阱:题目明确说明至少有两个小孩,所以如果算出的结果为1,应输出2。
这样,即把简化版问题的解扩展成了原题的解法,时间复杂度仍是O(n2*2n)。
例题9-24 书架(Bookcase, ACM/ICPC NWERC 2006, UVa12099)
有n(3≤n≤70)本书,每本书有一个高度Hi和宽度Wi(150≤Hi≤300,5≤Wi≤30)。现在要构建一个三层的书架,你可以选择将n本书放在书架的哪一层。设三层高度(该层书的最大高度)之和为h,书架总宽度(即每层总宽度的最大值)为w,则要求h*w尽量小。
【分析】
如果所有书的高度都相等,本题就是“分成3个子集,使得元素和的最大值尽量小”,而这是0-1背包类型的问题。这提示我们需要把宽度写到状态里。
首先将所有的书按照高度从大到小排序。不妨设高度最大的书安排在第1层,且第2层的高度大于等于第3层的高度,然后设状态d(i,j,k)表示安排完前i本书,第2层书的宽度之和为j,第3层书的宽度之和为k时,第2层高度和第3层高度和的最小值。
为什么不记录第1层的高度?因为最高的书在第1层,意味着这一层永远都不会比它更高了;为什么不记录第1层的宽度?因为目前3层的总宽度等于前i本书的总宽度,只要知道了第2、3层的宽度,就能算出第1层的宽度。另外,因为这些书已经按照高度从大到小排序了,一旦3层都放了书,3层的高度都不会变了,因此:
用刷表法,每个状态d(i,j,k)有3种方式更新其他状态:
这个算法看上去不错,但是仔细一算,状态总数为70 * 2100 * 2100,太大了——就算作用时间能接受,所占用的空间也无法接受,因此无法使用记忆化搜索,而只能用递推,配合滚动数组(由于是0-1背包式的递推,i那一维可以完全省略)。
如何优化呢?出乎大多数选手的意料(13),本题的“标准优化”并没有降低理论时间复杂度,只是让程序的实际运行效率高了很多。优化有两种:
强烈建议读者实现优化前后的两个版本,比较二者的效果。
例题9-25 轻松爬山(Easy Climb, NWERC 2008, UVa12170)
输入正整数d和n个正整数h1, h2,…, hn,可以修改除了h1和hn的其他数,要求修改后相邻两个数之差的绝对值不超过d,且修改费用最小。设hi修改之后的值为h'i,则修改费用为|h1-h'1|+|h2-h'2|+…+|hn-h'n|。无解输出-1。N≤100,d≤109。
【分析】
本题是一个多阶段决策过程:依次确定每个hi修改成什么数。可惜d的范围太大,如果用f(i, x)表示已经修改i个数,其中第i个数改成x时还需要的最小费用,则状态总数高达O(nd)。
为了更好地分析问题,先来看看简化版:n=3时,只有h2是可以修改的,而且修改之后必须同时在[h1-d, h1+d]和[h3-d, h3+d]内,即[max(h1,h3)-d, min(h1,h3)+d]。如果这个区间是空的,说明无解;否则h2要么不变,要么改成max(h1,h3)-d或者min(h1,h3)+d。
这个例子至少说明了:修改后的值并不是随便选的,至少在n=3时,修改后的值只有3种选择:h2, max(h1,h3)-d和min(h1,h3)+d。
用类似的推理,可以得到这样的结论:每个数在修改之后一定可以写成hp+kd,其中1≤p≤n,-n<k<n,这样,上述状态f(i,x)中的“x”就只有O(n2)种可能了,状态总数为O(n3)。
不难写出状态转移方程:f(i,x) = |hi-x|+min{f(i-1, y) | x-d≤y≤x+d}。如果按照x从小到大的顺序计算,满足x-d≤y≤x+d的f(i-1, y)就是i-1阶段状态值序列的一个滑动窗口。使用前面介绍过的单调队列,可以在平摊O(1)的时间复杂度内计算出f(i,x),因此本题的总时间复杂度为O(n3)。
例题9-26 一个调度问题(A Scheduling Problem, ACM/ICPC Kaoshiung 2006, UVa1380)
有n(n≤200)个恰好需要一天完成的任务,要求用最少的时间完成所有任务。任务可以并行完成,但必须满足一些约束。约束分有向和无向两种,其中A→B表示A必须在B之前完成,A-B表示A和B不能在同一天完成。输入保证约束图是将一棵n(n≤200)个结点的树的某些边定向后得到的。例如,图9-20表示1和2不能在同一天完成,1必须在3之前,3必须在5之前,2必须在4之前,4必须在6之前。
可以使用如下定理:忽略无向边之后,设图上的最长链(即包含点数最多的路径)包含k个点,则答案为k或者k+1。对于上面的例子,忽略无向边后的最长链是2->4->6,包含3个结点。
【分析】
如果树中所有边都为有向边,那么答案就是最长链上的点数:先将度为0的点全部安排在第一天,将这些点删去,然后将新的度为0的点安排在第二天,这样就可以在k天内安排完。这样,原问题转化为:将树中的所有无向边定向,使得树中的最长链最短。
根据题目中的定理,设原图中有向边组成的最长链上有k个点,那么最终的答案不是k就是k+1,接下来只需要判断是否可以通过无向边定向使得最长链的点数为k。即使没有题目中的那个定理,也可以二分答案x,然后判断是否能让最长链的点数不超过x。不管是哪种情况,问题的关键就是:给定一个x,判断是否可以给无向边定向,使得最长链点数不超过x。
设f(i)表示以i为根的子树内的边全部定向后,最长链点数不超过x的前提下,形如“后代到i”(如图9-21中的u'->u->i)的最长链的最小值,同理可以定义g(i)表示形如“i到后代”(如图9-21中的i->v->v')的最长链的最小值。达不到的状态(即“最长链点数不超过x”这个前提无法满足)定义为正无穷。
| ![]() |
| 图9-20 调度问题示意图 | 图9-21 最长链 |
如何计算f(i)和g(i)呢?为了叙述方便,用w表示i的某个子结点,则w和i之间的边有3种情况:w->i,i->w和i-w,其中前两种是有向边,最后一种是无向边。按照从易到难的顺序,分两种情况讨论。
情况1:如果i与w的所有边都是有向边,直接计算即可。令f'(i)等于形如w->i的w的f(w)的最大值加1,g'(i)等于形如i->w的w的g(w)的最大值加1,则以i为根的子树内,经过i的最长链点数等于f'(i)+g'(i)。如果这个值大于x,则f(i)和g(i)为正无穷,否则f(i)=f'(i),g(i)=g'(i)。
情况2:如果i与某些w之间存在无向边,则需要确定每条i-w定向成w->i还是i->w。由于定向完成之后,仍需要按照情况1的方法计算,所以问题的关键是分析“定向”操作会如何影响f'(i)和g'(i)。
求f(i)时,目标是f'(i)+g'(i)≤x的前提下f'(i)最小。首先把所有没定向的f(w)从小到大排序。假定把f值第p小的w定向为w->i,那么最好“顺便”把前p小的全部变成w->i的,因为这样做不会让f'(i)变大,但有可能让g'(i)变小,百利而无一害。所以只需要枚举p,把f值前p小的w都定向为w->i,其他定向为i->w,然后计算f(i)。用相同的方法可以计算g(i)。最后判断根结点的f值是否无穷大即可。
值得一提的是:因为本题规模较小,还有一个更为简单的动态规划算法,不用关心有向链,而是直接设状态表示d(i,j)能否给根节点为i的子树安排时间,使得根节点i恰好在第j天完成,状态转移方程留给读者思考。
例题9-27 方块消除(Blocks, UVa10559)
有n(n≤200)个带颜色方格排成一列,相同颜色的方块连成一个区域。游戏时,可以任选一个区域消去。设这个区域包含的方块数为x,则将得到x2个分值,然后右边所有方块就会向左移一格。如图9-22所示是一个游戏局面和最优消除方式。
图9-22 游戏局面和最优消除方式
你的任务是求出最高可能的得分。
【分析】
为了叙述方便,设左数第i个方块的颜色为A[i]。按照线性结构动态规划的常见思路,设d(i,j)表示子序列i~j的最大得分,但是似乎无法用d(i,k)和d(k,j)来计算d(i,j),因为可能i~k和k~j各剩下一些,拼起来以后消除。如XAXBXCXDXEX,实际上是把A和E全部单个消除以后再消除X的。怎么办呢?
在最优矩阵链乘中,枚举的是“最后一次乘法”的位置。本题是不是也可以枚举“最后一个方块什么时候消掉”呢?这个问题的答案有两种可能:直接把它所在的一段消掉;把它和左边的某段拼起来以后一起消。第一种情况容易处理,但第二种情况就没那么简单了。
具体来说,设与j同色的方块可以向左延伸到p(即A[p]=A[p+1]=…=A[j]),且A[q]=A[j],A[q]不等于A[q+1],则上述第二种情况就是指先把q+1~p-1这一段消掉,把p~j这一段和以q为右端点的那一段拼起来,如图9-23所示。注意i~j全部同色时找不到这样的q,但此时可以直接计算出结果。下面忽略这种情况。
图9-23 消掉与拼接方块
不过,把这两段拼起来以后仍然不一定立刻消除,还可能要和更左边的另一段拼起来……是不是很复杂?但有一点是可以肯定的,那就是q+1~p-1这一段肯定可以先消掉(拖到后面再消也得不到什么好处)。那么现在就把它消掉(得分是d(q+1,p-1)),得到一个“子序列i~q的右边再拼上j-p+1个与A[q]同色的方块”的奇怪状态,如图9-24所示。
图9-24 消掉后的奇怪状态
由此可知,在状态中增加一维,来表达“右边拼上一些方块”,即用d(i,j,k)表示“原序列中的方块i~j右边再拼上k个颜色等于A[j]的方块所得到的新序列”的最大得分,则决策有两种。
决策1:直接消去方块j,转移到d(i,p-1,0)+(j-p+k+1)2。
决策2:枚举q<p使得A[q]=A[j]且A[q]不等于A[q+1],转移到d(q+1,p-1,0)+d(i,q, j-p+k +1)。
状态有O(n3)个,决策有O(n)个,时间复杂度为O(n4)。如果采用记忆化搜索,很多状态都达不到,而且q的取值范围往往很小,所以对于大部分数据,这个算法的的运行效率都很高。
例题9-28 独占访问2(Exclusive Access 2, ACM/ICPC NEERC 2009, UVa1439)
在一个庞大的系统里运行着n(1≤n≤100)个守护进程。每个进程恰好用到两个资源。这些资源不支持并发访问,所以这些进程通过锁来保证互斥访问。每个进程的主循环如下:
loop forever
DoSomeNonCriticalWork()
P.lock()
Q.lock()
WorkWithResourcesPandQ()
Q.unlock()
P.unlock()
end loop
注意,P和Q的顺序是至关重要的。如果某进程用到了消息队列和数据库,“先获取数据库的锁”与“先获取消息队列的锁”可能会产生截然不同的效果。给定每个进程所需要的两种资源,你的任务是确定每个进程获取锁的顺序,使得进程永远不会死锁,且最坏情况下,等待链的最大长度最短。
在本题中,一个长度为n的等待链是一个不同资源和不同进程的交替序列:R0 c0 R1 c1…Rn cn Rn+1,其中进程ci已经获取Ri的锁,正在等待Ri+1的锁。当R0=Rn+1时死锁,否则说明已获取Rn+1的锁的进程正在执行操作(而非等待中)。
输入n和每个进程需要的两个资源,用两个L~Z之间的大写字符表示(因此一共有15种资源)。输出包含两行,第一行为最坏情况下等待链的最大长度m,以下n行每行输出两个字符,表示该进程获取锁的顺序(先获取第一个字符对应资源的锁)。
【分析】
本题初看起来毫无头绪,甚至连数学模型都难以建立。注意,每个进程恰好需要两个资源,而等待链的定义是资源和进程的交替序列,可以联想到图论中的概念:每条边恰好连接两个点,路径的定义是点和边的交替序列。
因此,可以把资源看成点,进程看成无向边,此时的任务实际上就是把无向边定向,使得不存在圈(它对应于死锁),且最长路(即最长等待链)最短。
接下来需要一点创造性思维:把结点分成p层,从左到右编号为0, 1, 2,…,使得同层结点之间没有边。对于任意一条边u-v,把它定向成“从层编号小的点指向层编号大的点”。例如,若u在第5层,v在第2层,则定向为v->u。定向之后的有向图肯定没有圈,且最长路包含的点数不超过p(想一想,为什么),所以直观上,p应该是越小越好。
事实上,可以证明(14)当p取最小值时,最长路恰好包含p个结点,而且这个结果是所有定向方案中最优的。这样,就成功地把问题转化为了“结点分层”问题,而这个“结点分层”问题实际上就是之前学过的色数问题:把图中的结点染成尽量少的颜色,使得相邻结点颜色不同。套用前面学过的动态规划算法,在O(3k)时间内即解决了问题,其中k≤15,为资源的最大数目。
本题是关于“建模与问题转换”的一道经典问题,请读者仔细体会。
例题9-29 整数传输(Integer Transmission, ACM/ICPC Beijing 2007, UVa1228)
你要在一个仿真网络中传输一个n比特的非负整数k。各比特从左到右传输,第i个比特的发送时刻为i。每个比特的网络延迟总是为0~d之间的实数(因此从左到右第i个比特的到达时刻为i~i+d之间)。若同时有多个比特到达,实际收到的顺序任意。求实际收到的整数有多少种,以及它们的最小值和最大值。例如,n=3,d=1,k=2(二进制为010)时实际收到的整数的二进制可能是001(1)、010(2)和100(4)。1≤n≤64,0≤d≤n,0≤k<2n。
【分析】
为了简化问题,首先可以规定:所有0按照原来的顺序依次收到,所有的1也按照原来的顺序依次收到,只是0和1可能交错。这个规定非常重要,请读者仔细体会。
最小值和最大值可以用贪心法得到(留给读者思考),关键在于统计可能收到的整数数目。给定一个整数P,如何判断它是否可能被收到呢?来看一个例子。
例如,k=11001010,d=3,需要判断P=00111001是否可以得到。一共有8个比特,则发送时刻为1~8,接收时刻是1~12。不难发现,接收时刻可以限制为1~8,因为同一时刻接收的比特可以任意排列,所以把一个比特延迟到时刻9~12不会有任何好处。可以手算出一种方案,如图9-25所示。
图9-25 手算方案
上图的意思是:k的比特1和比特2均延迟到时刻4,比特7延迟到时刻8。不难发现,对于任意给定的P,都可以用贪心法求解:从左到右依次考虑P的每一个比特。如果是0,则接收k中没有收到的最左边的0;如果是1,则接收k中没有收到的最左边的1。
仔细推敲这个过程,可以得到一个结论:在任意时刻,k中已收到的比特中最右边的那个比特一定没有延迟(理论上可以延迟,但不会得到更优的解)。如图9-26所示,k=111011001110,框中的比特是已收到的比特,则最右边那个已收到比特(即左数第3个0)无延迟,即接收时刻和发送时刻均为8。
这样就可以动态规划了(15):用d(i,j)表示k的前i个0和前j个1收到以后可能形成的整数个数,则只有两种转移方式:
如果下一个收到的比特可以是0,则d(i+1,j)需要加上d(i,j)。
如果下一个收到的比特可以是1,则d(i,j+1)需要加上d(i,j)。
所以问题的关键就是:如何判断下一个收到的比特是否可以为0或者1?还是刚才那个例子,因为已经收到了3个0和4个1,所以状态是d(3,4)。假设下一个收到的比特是0,则左数第5个1(发送时刻为6)至少得延迟到第4个0的发送时刻(即时刻12),如图9-27所示。如果d<12-6=6,说明假设不成立。
| ![]() |
| 图9-26 已收到的比特 | 图9-27 判断收到的比特 |
一般地,设第i(i≥0)个0的发送时刻为Zi,第i个1的发送时刻为Oi,则当且仅当Oj+d≥Zi时d(i,j)可以转移到d(i+1,j),即下一个收到的比特为0。同理,当且仅当Zi+d≥Oj时,d(i,j)可以转移到d(i,j+1)。另外,使用上述公式时别忘了判断1和0是否已经全部收完。
状态有O(n2)个,每个状态只有两个决策,因此总时间复杂度为O(n2)。
例题9-30 给孩子起名(The Best Name for Your Baby, ACM/ICPC Yokohama 2006, UVa1375)
给一个包含n条规则的上下文无关文法和长度l(1≤n≤50,0≤l≤20),求出满足该文法的串中,长度恰好为l的字典序最小串。如果不存在,输出单个字符“-”。
“满足文法”是指可以不断使用规则,把单个大写字母S变成这个串。每条规则形如A→a,其中A是一个大写字母(表示非终结符),a是一个由大小写字母组成的字符串(长度不超过10,且可以为空串)。该规则的含义是可以用字符串a来替换当前字符串中的大写字母A(如果有多个A,每次只替换一个)。
例如,有4条规则:S→aAB,A→空串,A→Aa,B→AbbA,那么aabb满足该文法,因为S→aAB(规则1)→aB(规则2)→aAbbA(规则4)→aAabbA(规则3)→aAabb(规则2)→aabb(规则2)。
【分析】
题目中的文法比较复杂,首先把它们简化一下。例如S->ABaA拆成3个:S->AP1,P1->BP2,P2->aA。这样,所有规则都变成了A->BC的形式,规则总数不超过50*10=500。为了叙述方便,文法拆分后的所有大小字母和小写字母统称为符号。
接下来试着动态规划:d(i,L)表示符号i能变成的、长度为L且字典序最小的串。如果符号i不能变成长度为L的串,则d(i,L)无定义。例如,符号i是小写字母且L不等于1时,d(i,L)无定义。是不是可以这样转移呢:
d(i,L) = min{d(j,p) + d(k,L-p) | 存在规则i->jk, 0≤p≤L}
逻辑上没问题,但是如果直接写一个记忆化搜索,程序可能会无限递归:如果有两个规则A->BC,B->AC,则d(A,L)的计算需要调用d(B,L),而计算d(B,L)时又会调用d(A,L)……
怎么办呢?注意到上述情况只有p=0或者p=L时才会出现,大多数情况下还是可以按照L从小到大的顺序计算的。所以可以特殊处理L相同时的所谓“同层状态转移”。
具体做法如下:首先从小到大枚举L。对于给定的L,先只考虑0<p<L,计算出所有d(i,L)的中间结果,然后把所有有定义的d(i,L)放到一个优先队列中,按照从小到大的顺序处理。处理d(i,L)时,看看是否有符号j满足:d(j,0)为空串,并且存在规则t->ij或者t->ji。如果存在,把d(t,L)赋值为d(i,L)并加入优先队列中。这个过程类似第11章中将要介绍的Dijkstra,请读者仔细体会(16)。
例题9-31 送匹萨(Pizza Delivery, ACM/ICPC Daejeon 2012, UVa1628)
你是一个匹萨店的老板,有一天突然收到了n个客户的订单(n≤100)。你所在的小镇只有一条笔直的大街,其中位置0是你的匹萨店,第i个客户的家在位置pi。如果你选择给第i个客户送餐,他将会支付你ei-ti元,其中ti是你到达他家的时刻。当然,如果你到的太晚,使得ei-ti<0,你可以路过他家但是不进去给他送餐,免得他反过来找你要钱。
你只有一个送餐车,因此只能往返地送餐,如图9-28所示就是一个路线。图中的第一行是位置,第二行是ei。图上的路线对应的总收益为12(c4付3元,c2付3元,c5付5元,c1付1元)。
图9-28 送餐路线
不过图9-28所示路线并不是最优的。最优路线是0->c3->c2->c1->c5,总收益是32。你的任务是求出最大收益。
【分析】
本题是不是似曾相识?没错,本节开头的“修缮长城”一题和本题很像,但是有一个重大的不同:在本题中,可以“放弃”一些订单,所以无法像“修缮长城”那样规定“路过的点总是顺便修好”,也无法“准确地提前累加未来的费用”。如果要准确地判断每个客户是否有“未来费用”,必须记录当前时间,因为无法“提前知道”某个客户是否要送餐,只有等到达一个客户时发现收益变“负”,才会决定放弃它。
看上去很麻烦对吗?其实也不必过于沮丧。本题并不是纯粹的“加强版”,也有条件在本题中被弱化了。例如,所有客户的“单位时间罚款”是一样的,所以并不需要知道具体还有哪些客户没有到达,而只需要知道有多少客户没有到达。另一个弱化条件是:n的范围变小,所以时间复杂度可以略有提高。如果本题的解法仍是动态规划,这就意味着每个状态的决策数可以增加,或者维数可以增加。
上述分析方式其实与动态规划本身并没有什么关系,但却是一种非常重要的思维过程,值得读者仔细体会。下面是本题的解法,建议读者自行思考片刻以后再看。
设d(i,j,k,p)表示不考虑i~j的客户(已经送过餐或者已经决定放弃),目前位置是p(p=0表示在i,p=1表示在j),还要给k个人送餐的最大收益。第一个送餐的人i以及送餐总人数k都需要枚举,最终答案是max{d(i,i,k-1,0) + (ei-|pi|)*k | 1≤k≤n},这里的(ei-|pi|)*k就是指从0到pi的过程中,所有k个送餐客户的罚款总和。状态转移方程留给读者思考——对于已经阅读到这里的读者,相信这不难做到的。