算法竞赛入门经典:习题与解答
上QQ阅读APP看书,第一时间看更新

2.6 高效算法设计

本节选解习题来源于《算法竞赛入门经典(第2版)》一书的第8章。

习题8-1 装箱(Bin Packing,SWERC 2005,UVa1149)

给定NN≤105)个物品的重量Li,背包的容量M,同时要求每个背包最多装两个物品。求至少要多少个背包才能装下所有的物品。

【分析】

先对N个物体按照重量从大到小排序。依次进行决策。在没放好的物体中,考虑最重的i,它应该和谁放一起呢?选择最轻的j,如果ij都放不到一个背包里,那i只能单独放,否则就把ij放在一起。

在考虑i时,如果有多个j都可以和i放在一起,那么选择最轻的j是不是最优策略?对于所有比j重的k来说,在i决策完之后考虑的物体,重量小于i,所以k依然可以和i之后的物体放在一起,不会多占背包。完整程序如下:

习题8-2 聚会游戏(Party Games,Mid-Atlantic 2012,UVa1610)

输入一个n(2≤n≤1000,n是偶数)个字符串的集合D,找一个字符串(不一定在D中出现)S,使得D中恰好一半串≤S,另一半串>S。如果有多解,输出字典序最小的解。例如,对于{JOSEPHINE,JERRY},输出JF;对于{FRED,FREDDIE},输出FRED。

【分析】

n=2k。首先对D进行递增排序,所求的字符串P一定满足Dk≤P<Dk+1,则问题就变成寻找符合这个条件的最短并且字典序最小的字符串。记Dk的长度为L,则P的长度一定不大于L。参考暴力搜索的思路,从P的最左边第0位到第L-1位逐步从小到大尝试构造每一位的字符。对于第i位,开始令P[i]=‘A‘,只要满足P[i]≤‘Z‘且P<Dk,就一直令P[i]递增。这样构造出来的P[i]如果满足P[i]≤‘Z‘且Dk≤P<Dk+1,则说明构造完成,否则继续构造下一位。每一位构造完成之后所得的P就是所求的解。完整程序如下:

习题8-4 奖品的价值(Erasing and Winning,UVa11491)

你是一个电视节目的获奖嘉宾。主持人在黑板上写出一个N位整数(不以0开头),并邀请你恰好删除其中的D个数字。剩下的整数便是你所得到的奖品的价值。自然地,你希望这个奖品价值尽量大。1≤DN≤105

【分析】

END,则本题就是要从输入的整数中选择E个数字,使得组成的整数最大。可以连续选择E次最左边的最大数字,还要给后续的选择留够位数,每次选择的位置为pos,则下次选择的范围就从pos+1开始。

注意有一个子操作:从一个区间内选择最左边的最大数字。因为ND的规模都比较大,如果每次线性查找,最坏情况下的时间复杂度会达到ON2)级别,对于输入的规模是无法接受的(1)。可以做如下的预处理:对于每个位置i以及d∈[0,9],记录在区间[i…]内第一个d出现的位置NEXT[i][d]。这个预处理可以在O(10n)的时间内完成,然后上述子操作就可以在O(9)的时间内完成。

完整程序如下:

习题8-5 折纸痕(Paper Folding, UVa177)

你喜欢折纸吗?给你一张很大的纸,对折以后再对折,再对折……每次对折都是从右往左折,因此在折了很多次以后,原先的大纸会变成一个窄窄的纸条。现在把这个纸条沿着折纸的痕迹打开,每次都只打开“一半”,即把每个痕迹做成一个直角,那么从纸的一端沿着和纸面平行的方向看过去,会看到一个美妙的曲线,如图2.43所示。

图2.43

例如,如果你对折了4次,那么打开以后你将看到如图2.43所示的曲线。注意,该曲线是不自交的,虽然有两个转折点重合。给出对折的次数,请编程绘出打开后生成的曲线。

【分析】

看到题目很多读者应该会和笔者一样,首先想到去直接模拟折叠过程。

但经过思考可以发现一个关键点:可以忽略折叠的过程。直接从一条水平的单位长度的线段开始张开,每次张开都是把已有的所有线段首先围绕一个点顺时针旋转90°,然后再和旋转之前的线段一起组合成为新的图形。每次旋转的过程中,需要维护两个点的坐标:起始点s和旋转中心点rot。每次旋转,原先的s不变,s经过旋转之后的那个点成为新的rot,如图2.44所示。

图2.44

旋转的次数就是输入的n

当把所有最终线段的坐标模拟出来之后,就剩下输出的问题。有一种比较简便的处理方法就是扫描所有的线段,将其x坐标放大一倍,然后垂直类型线段的x坐标再减1。判断平面上每个点是否有垂直或者水平线段,记录对应的字符。最后再扫描平面上的每个点,输出之前记录的字符即可。

完整程序如下:

习题8-6 起重机(Crane,ACM/ICPC CERC 2013,UVa1611)

输入一个1~n(1≤n≤10000)的排列,用不超过96次操作把它变成升序。每次操作都可以选一个长度为偶数的连续区间,交换前一半和后一半。如输入5, 4, 6, 3, 2, 1,可以执行1, 2先变成4, 5, 6, 3, 2, 1,然后执行4, 5变成4, 5, 6, 2, 3, 1,然后执行5, 6变成4, 5, 6, 2, 1, 3,然后执行4, 5变成4, 5, 6, 1, 2, 3,最后执行操作1,6即可。

提示:

2n次操作就足够了。

【分析】

这道题目要求排序,但是基本操作却是“交换一个偶数长度的连续区间的前后两半”,依然可以使用冒泡排序的思路,依次把1到n的每个数归位。

遍历到数字i时,此时1~i-1已经排好序,在未排序的区间(长度是n-i+1)中,记i前面的元素个数为ci,则有以下两种情况。

(1)ci≤(n-i+1)/2,那么可以通过将i前面的未排序区间(记其长度为ci)和从i开始长度为ci的区间进行交换,即可把i交换到第i个位置。

(2)否则,如果n-i+1是偶数,交换前后两半,然后按照情况1处理即可。如果n-i+1是奇数,则忽略第一个元素(肯定不是i),交换剩下长度为偶数的区间的前后两半。

这样在≤2n次操作之内就可以完成。

对于输入数据(5 4 6 3 2 1),算法的运行过程示意图如下,其中灰色表示未排序区间。

完整程序如下:

习题8-8 猜名次(Guess, ACM/ICPC Beijing 2006, UVa1612)

nn≤16384)位选手参加编程比赛。比赛有3道题目,每个选手的每道题目都有一个评测之前的预得分(这个分数和选手提交程序的时间相关:提交得越早,预得分越大)。接下来是系统测试。如果某道题目未通过测试,则该题的实际得分为0分,否则得分等于预得分。得分相同的选手,ID小的排在前面。

给出所有3n个得分以及最后的实际名次,问是否可能。如果可能,输出最后一名的最高可能得分。每个预得分均为小于1000的非负整数,最多保留两位小数。

【分析】

考虑每个选手,每道题目的得分有两种可能(预得分或0),那么总得分就是23=8种可能。输入时,就计算每个选手的这8种得分并且从大到小排序。

第一名的得分选择为8个得分中的最大值,然后按照名次从前到后遍历每个选手,从其8个可能得分中选择满足以下条件的最大得分:

(1)小于上个选手的得分。

(2)或者等于上一个选手的得分且ID小于其ID。

如果选择成功,则这个得分必定是当前选手符合输入名次的最大可能得分。如果失败,说明这个名次无法构造,直接退出。所有选手选择成功后,直接输出最后一个选手选择的得分。

需要注意的是,虽然分数是浮点数,但是题目标明了只有小数点后两位。所以可以直接乘以100转换为整数,最后输出时再除以100。为了避免转换误差,在输入时作为字符串输入,然后再读取为两个int。输出时做类似的处理。完整程序(C++11)如下:

习题8-11 高速公路(Highway, ACM/ICPC SEERC 2005, UVa1615)

给定平面上nn≤105)个点和一个值D,要求在x轴上选出尽量少的点,使得对于给定的每个点,都有一个选出的点离它的欧几里得距离不超过D

【分析】

对于每个指定的点P,X轴上符合要求的点刚好组成X轴和圆(P,D)相交的那根弦所对应的区间。那么题目就转换为,在一系列的区间内选择最少的点,使得每个区间内都有点被选中。具体的思路可参考《算法竞赛入门经典(第2版)》中的第8.4.2节。

习题8-14 商队抢劫者(Caravan Robbers, ACM/ICPC NEERC 2012, UVa1616)

输入n条线段,把每条线段变成原线段的一条子线段,使得变之后所有线段等长且不相交(但是端点可以重合)。输出最大长度(用分数表示)。例如,有3条线段[2,6]、[1,4]、[8,12],则最优方案是分别变成[3.5,6]、[1,3.5]、[8,10.5],输出5/2。

【分析】

首先把所有线段按照左端点从小到大进行排序,然后使用二分法来计算子线段的最大长度。对于长度L,针对每一个原线段[a,b],尽量选择靠左的区间作为子线段,如果子线段的左端点max(a,lb)+L > b,那么选择失败,其中lb是上一个选择的子线段的右端点。如果每个线段选择成功,那么L有效。

但是需要注意以下几点:

(1)二分开始要选择尽量窄的一个起始区间,可以选择为[1, 1000000/n]。

(2)二分的迭代次数50次就足够,无须将区间缩小到足够小的范围,否则会超时。

(3)选择输出目标的有理数时,遍历所有可能的分母(就是1~n),然后根据分母以及算出的长度来选择分子。最后选择误差最小的分子分母组合来输出,输出之前都要除去二者的最大公约数。

完整程序如下:

习题8-16 弱键(Weak Key,ACM/ICPC Seoul 2004,UVa1618)

k(4≤k≤5000)个整数组成的序列Ni,判断是否存在4个整数NpNqNrNs(1≤pqrsk),使得NqNsNpNr或者NqNsNpNr

【分析】

首先对输入序列做预处理,对于每个Ni,记录两个序列HiLiHi包含所有的jjiNjNiLi包含所有jj满足jiNjNi

然后就是遍历所有的p,依次选择可能的qrs

NqNsNpNr为例:

(1)q肯定在Lp中,在其中进行遍历。

(2)pq确定之后,r肯定在Hp中并且rq,使用upper_bound在Hp中查找所有可能的r

(3)pqr确定之后,s肯定在Hq以及Lp中,并且sr,使用upper_bound查找结合binary_search判断。

依次进行查找,如果查找到s说明有解。查找的过程中判断数字是否在某个序列中并且大于一个数,都可以使用二分查找。可以使用STL中的binary_search和upper_bound。完整程序如下:

习题8-17 最短子序列(Smallest Sub-Array,UVa11536)

nn≤106)个0~m-1(m≤1000)的整数组成一个序列。输入kk≤100),你的任务是找一个尽量短的连续子序列(xaxa+1xa+2,…,xb-1xb),使得该子序列包含1~k的所有整数。

例如n=20,m=12,k=4,序列为1(2 3 7 1 12 9 11 9 6 3 7 5 4)5 3 1 10 3 3,括号内部分是最优解。如果不存在满足条件的连续子序列,输出“sequence nai”(不含引号)。

【分析】

使用滑动区间的思路,维护一个区间[L,R],以及一个map,其中包含了[L,R]所有1~k的整数,及其出现的次数。需要注意的是,map中只插入1~k的整数。则当map大小为k时[L,R]就是一个符合要求的区间。

首先L=0,不断增加R,直到map的大小等于k为止,当map的大小为k时,只要L对应的整数x不在1~k的范围内或者x在map中出现的次数大于1,就可以安全地从区间中删除x,如此反复,当无法继续删除L时,就是一个潜在的最小区间。

当发现一个最小区间之后,就把L加1,让区间向右滑动,然后再寻找小区间。如此在滑动的过程中记录下出现的最短区间。完整程序如下:

习题8-18 感觉不错(Feel Good,ACM/ICPC NEERC 2005,UVa1619)

给一个长度为nn≤100000)的非负整数序列ai,求出一段连续子序列al,…ar,使得(al+…+ar)*min{al,…,ar}尽量大。

【分析】

对于一个区间[l,r]来说,我们称所求的值为它的权值。假如ar+1不是[l,r+1]区间的唯一最小值,那么[l,r+1]的权值一定大于[l,r]的。对于每个i,假如能让ai成为最小值的最大区间是[liri],则只需要对所有的[liri]求权值比较即可。

首先需要预处理出以下数组:

(1)A的前缀和数组S,其中

(2)L和R数组,其中Li表示i左边离i最近且比ai小的元素位置。Ri就是i右边离i最近的比ai小的元素。让ai成为最小值的最大连续区间就是[Li,Ri]。

其中第二个预处理的单调栈算法如下(以Li为例)。

首先,在数组A前后各附加1个0。使用一个栈S,初始为空。从左到右把所有下标i=1~n入栈,每个下标i入栈之前,首先把所有ajai的下标j出栈,之后,栈顶就是左边最接近并且小于ai的元素下标,也就是Li的值。处理完即可得到L数组。

以A={3,1,6,4,5,2}为例,演示此算法的运行过程。

初始S={},A={0,3,1,6,4,5,2,0}

i=1:A[1]=3, L[1]=0, S={1}

i=2:A[2]=1, L[2]=0, S={2}

i=3:A[3]=6, L[3]=2, S={2,3}

i=4:A[4]=4, L[4]=2, S={2,4}

i=5:A[5]=5, L[5]=4, S={2,4,5}

i=6:A[6]=2, L[6]=2, S={2,6}

再用类似的逻辑从右到左处理一次即可得到R数组。预处理完成后,计算所有ai*(SLi+1-SRi)的最大值即可。需要注意的是,可能会有一种特殊情况就是全部元素为0,则所求结果就是0,所在区间为[1,1],需要进行特殊处理。完整程序(C++11)如下:

习题8-19 球场(Cricket Field,ACM/ICPC NEERC 2002,UVa1312)

一个W*H(1≤WH≤10000)网格里有n(0≤n≤100)棵树,要求找一个最大空正方形,如图2.45所示。

【分析】

注意网格的长宽都很大,直接枚举正方形的边界(时间复杂度为100003)会超时。但是树只有最多100棵,且正方形内部肯定不能包含树。另外,最大正方形一定是有至少两条边都经过一棵树(包括网格边界),如图2.46所示,否则很容易构造出更大的正方形。

图2.45

图2.46

遍历正方形的边界。首先对所有树的Y坐标排序去重(使用set即可),得到一个正方形可能的上下边的Y坐标集合,再对所有树的坐标按照X进行排序。

枚举正方形的上下边的Y坐标。对于每一对Y坐标(minY,maxY),初始的正方形左边界为left=0(操场左边)。对于所有的树坐标P,如果P.y恰好正在枚举的上下边界之间,那么就发现一个新的正方形位于(left,P.x)以及(minY,maxY)相交形成的区域内,更新答案。同时更新P.x为下一个正方形的左边界。时间复杂度是On3)。注意整个网格的边界也可以理解为树,不要忘记处理。完整程序如下:

习题8-24 龙头滴水(Faucet Flow,UVa10366)

x=0的正上方有一个水龙头,以每秒1单位体积的速度往下滴水。x=-1,-3,…,leftx和x=1,3,5,…,rightx处各有一个挡板,高度已知。求经过多长时间以后水会流出最左边的挡板或者最右边的挡板。如图2.47所示,leftx=-3,rightx=3,4个挡板高度分别为4、3、2、1,则6秒钟之后水会从最右边的挡板溢出。

图2.47

输入第一行为两个奇数leftx和rightx(leftx≤-1,rightx≥1),接下来的各个正整数表示从左到右各个挡板的高度。挡板个数不超过1000。

【分析】

首先引入一个关键的结论:

如果有两个挡板X和Y(如图2.48所示),X不高于Y,那么从X左边的水如果要流到Y,在接触到Y之前,会形成一个阶梯形状。也就是说,从X流到Y所需要的时间就是阶梯下方的面积。

回到题目本身,考虑X=0的左右两边最高的挡板高度LH、RH,以及其位置LHi、RHi(如果有多个,就取离X=0最近的那个)。

如果LH=RH,那么说明水流会在两边都溢出来。那么就计算水从LHi流到最左边挡板所需要的时间Lt,以及从RHi流到最右边挡板所需要的时间Rt。因为两边同时在流,所求的结果就是水把LHi-RHi这个区间的矩形装满所需要的时间再加上min(Lt+Rt)*2。

如果LH<RH,假设Ti是X=0右边第一个高度≥LH的,如图2.49所示。

图2.48

图2.49

那么水一定是首先接触到左边缘。而且水流分两部分,一部分从Ti流到Ti右边第一个高度大于LH的挡板的位置这里,另一部分从LHi向左溢出流到左边缘。假设前者所需水量为rt,后者所需为lt。

(1)如果lt>rt,那么所求结果就是:LHi和Ti之间高度为LH的矩形对应的水量也就是(LHi+Ti)*LH+lt+rt。

(2)如果lt≤rt,rt对应的部分只能灌满lt的水,左边就已经溢出了,所求结果就是:(LHi+Ti)*LH+2*lt。

LH> RH时的讨论可以类比以上情况。

为了简化处理,可以先假设两个挡板之间距离为1,最后求出结果之后再乘以2输出即可。完整程序如下:

习题8-25 有向图D和E(From D to E and back,UVa11175)

对一个有n个结点的有向图D,可以构造这样一个图E,即D的每条边对应E的一个结点(例如,若D有一条边uv,则E有个结点的名字叫uv),对于D的两条边uv和vw,E中的两个结点uv和vw之间连一条有向边。E中不包含其他边。

输入一个m个结点k条边的图E(0≤m≤300),判断是否存在对应的图D。E中各个结点编号为0~m-1。

提示:

虽然题目中m≤300,实际上可以解决规模远超过这个限制的问题。

【分析】

在E图中,如果存在ij结点到x都有边,而ij中只有一个结点到y有边,则这个图E不可能转化来,因为在D中一条边不可能指向两个点(读者可以参考图2.50来思考)。因此暴力枚举ijx判断是否可行即可。

图2.50

注意:

笔者无法构造上述做法的严格性证明,但是也无法找到反例(2)

完整程序(C++11)如下:

习题8-26 找黑圆(Finding [B]lack Circles,Rujia Liu's Present 6,UVa12559)

输入一个h*w的黑白图像(30≤wh≤100),你的任务是找出图像中的圆。每个像素都是1*1的正方形,左上角像素的中心坐标为(0,0),右下角像素的中心坐标为(w-1,h-1)。对于一个圆,它的圆周穿过(只是接触到像素边界不算)的像素都会被涂黑(用1表示)。没有被任何圆穿过的像素仍然是白色(用0表示)。圆心保证在整点处,半径保证是1~5的整数。最多有2%的黑点会变成白点。

提示:

方法有多种,尽情发挥创造力吧。

【分析】

因为数据量不是特别大,所有可能的圆的个数上限是50*30*100,可以考虑进行暴力搜索每一种可能的半径以及坐标(rxy)。

对于(rxy)来说,要循环判断圆上的每个点,因为边上的点最多也就是100个。可以遍历100个圆心角角度对应的点来判断,为了提高速度,可以采用如下技巧:

(1)随机取100个角度,看看这个角度对应的边上的点是不是存在。

(2)如果已经判断超过10个且有一半不符合,说明一定不存在对应的圆,直接返回即可。

完整程序如下: