本期非编程题考察更多是对原书的阅读理解,可能还是因为自己理解不够,翻了半天书,还是错了两道。失之我命,不多废话。
本期编程题比较符合我的胃口,有陷阱,有技巧,窃以为是最近不少期里比较有意思的中等难度的题目了。美中不足的是两道题都没有给出数据范围,从而在判断时间复杂度及选择算法上存在一定的迷惑性。
第一题 路灯亮度
有一条长度为n米的街道,上面分布着一些亮度不等的路灯。每离开某盏路灯1米,该位置受该盏路灯影响的亮度就比该盏路灯的原始亮度减少1个单位,而某个特定位置的最终亮度则等于所有影响该位置的路灯亮度的最大值,而亮度的最小值为0。
输入描述:第一行输入两个正整数n和m分别表示街道长度和路灯数量,从第二行开始的m行每行两个正整数p和l分别表示路灯的位置和光亮程度(1<=p<=n)。再随后的q行每行都有一个正整数i,表示一个位置。
输出描述:输出q行表示所有的i对应的位置的亮度值
输入样例:
10 2
1 5
4 3
1
3
10输出样例:
5
3
0
解析
题意不难理解,就是如下图这样,一条横坐标(街道)上在某些坐标点有 “路灯”,使得左右的坐标点都被不同程度地 “照亮”。因为某些坐标点可能同时被多个 “路灯” 照到(比如下图中 a 点和 b 点),所以问题就是给出 “路灯” 的坐标以及 q 个坐标点,让你找出这 q 个坐标点能被照到的最大的亮度。
一般类似这种题目,都有两种解答方式,如果查询次数较少,我们没有必要知道 “街道” 上每个点的亮度,不需要准备查询数组,只需要查询的时候,再去找出答案好了;反之,如果查询次数很多,则会把算法的大部分时间用在实现查询数组上,这样后面的查询将以 的方式(每次查询都是常数级)完成。
本题数据有点水(所以第一种解法得以实现),但又不是那么水(见解法二)。
解法一
题目没有给出 q 的数据范围,在用例中也没有给出 q 的大小,所以我们无法判断 q 到底有多大。在实际测试中,可以发现 q 的范围并不大。所以我们可以不用准备查询数组,而是当需要查询某坐标位置的时候,再去穷举 p 个“路灯”,查看在每个 “路灯” 的 “照射” 下,要查询的坐标点的亮度是多少,再找出其中的最大值即可。
而每个 “路灯” 对某个坐标点的 “照射亮度”,根据题意(随着距离向两侧递减),存在很简单的数学公式:路灯亮度 - 路灯与坐标点的距离,而且这个值最小为 0。
所以使用 python 一句代码就可计算得到答案。
print(max(0, max(l - abs(p - q) for p, l in lights)))
这种方法其实也是穷举,可以清楚地看到,每次查询的时间复杂度为 ,p 为 “路灯” 的数量。而如果有 q 次查询,整体复杂度为 。
解法二
如上所述,当查询次数不多(q 不大)时,上面的方法完全没问题。但是如果 p 和 q 都非常大,上面的方法就存在超时的风险。所以,当查询次数很多时,通常我们会先创建查询数组。以题目为例,由一条有 n 个坐标点的 “街道”,我们可以创建一个长度为 n 的数组。然后根据每个 “路灯” 的亮度,找出每个坐标点的最大 “亮度”。这样当后面查询的时候,只要从这个数组里取出相应的答案即可。
这种方法的难点在于查询数组的建立。如何降低这个过程的复杂度,是这种方法能否成功的关键。理论上通过暴力方法也可以实现,即每当发现一个 “路灯”,就把所有的坐标点的 “亮度” 都更新一遍,时间复杂度为 。本题数据倒还没有水到这种程度,不出意外的超时。
实际上,大多数类似的题目,尤其是线性数组,都存在 复杂度的方法创建查询数组,本题也不例外。通常的办法是使用动态规划,假设 “街道” 为 数组,则可得转移方程如下:
从左往右:
从右往左:
由于 “路灯” 的 “亮度” 是向两边递减,所以需要从两边两个方向各遍历一次——当然也可以同时进行,只要注意好边界问题。(由于 Python 中的 函数操作长列表时依然比较耗时,Python 选手直接套用依然可能超时,改成 语句手动判断即可)
这样,只需要 的时间复杂度,就可以更新完成查询数组。如果后面有 q 次查询的话,整体复杂度将是 。
综上所述,两种方法的优劣取决于 q 值的大小。鉴于本题并未给出 q 的范围,经实测,两种方法皆可。(从测试数据来看,貌似用例中的 p 和 q 的值都比较小)
本题不难,加上已经详细解释思路及解题步骤,代码故此省略。
第二题 池塘水量
某地有n个池塘,编号为i的池塘最大容积为i(从1开始编号),一开始都没有水。每天的天气状况决定了所有水池同时的水量变化,如果某天下了容积为v的雨表示所有池塘都会增加容积v的水,当然池塘满了的话水就会溢出而流走,不会影响到次日以后。如果某天天气炎热而蒸发了容积为v的水表示所有池塘都会减少容积v的水,当然池塘最多把所有水蒸发干而变成容积为0,也不会影响到次日以后。
输入描述:第一行输入两个正整数n和m分别表示池塘数量n和天气的天数m,接下来的m行每行一个数表示每天天气导致的水容积的变化,正数表示增加、负数表示减少,0表示没有变化。
输出描述:m行,每行表示当天池塘的总水容积。
输入样例:
5 3
1
3
-2输出样例:
5
14
5
解析
看懂题目之后,我想大部分选手想到的都是暴力模拟的解法吧?甚至会觉得此题怎会如此简单?而当进行完暴力模拟的尝试后,就会发现原来测试数据里暗藏玄机 —— 总有那么几个点 T(timeout)了。
实际上,本题正确的解法是单调栈。这么说可能并不准确,因为单调栈实际上只是一种数据结构,真正能够优化算法的是借助单调栈实现的数形结合。
暴力模拟的方法就不多说了,相信大家也已经都尝试过了吧,时间复杂度为 。(使用 Python 能够通过的用例相较编译型语言会更少,因为 Python 的数组 / 列表在实现灵活度的同时牺牲了性能,当处理大数组 / 长列表时,如果不使用 Numpy 等第三方模块,速度要比 C 慢上十倍都不止。)
暴力模拟只能通过部分用例,让我们来看看单调栈+数形结合该怎么做。
根据题意,我们可以在纸上画出类似下面的 “池塘” 模拟图。
但是不知为什么(可能是直觉),我在读完题后,画出的模拟图是下面这个样子:所有 “池塘” 的底部都在同一水平。其实不难证明,这两张图在本题的各种情况下都等价。由于我后面的分析都是基于此图,大家可以先跟着我的思路,待充分理解后再带入上面的图形里。(也许是先入为主,窃以为这样的图更有利于分析)
观察此图,它的底(“池塘” 总个数)与高(最深的 “池塘” 深度)相等,所以在几何数学里可以将其看做一个等腰直角三角形。
先考虑最基本的情形,当部分 “池塘” 因 “落雨” 而注水时,该图形变成下面的样子:
要计算所有 “池塘” 积水的容积,相当于计算图中蓝色部分的面积 。而这部分面积可以像下面这样拆分成两部分,从而得到 :
根据题意可知, 等于降水 。很显然, 也是一个等腰直角三角形,所以 。
是平行四边形,所以它的面积 。而 的面积计算稍微有点特殊,这里不能使用等腰直角三角形的计算公式 。因为实际上,从微积分的角度来看,等腰直角三角形可以看成是 个从 0 到 排列的一组数字的集合,从而它的面积计算公式等价于高斯求和的形式 。但在本题里,因为这个 “三角形” 实际上是从 1 到 的排列,所以它的面积应该是 。
从而得到总面积计算公式 ,也就是所有 “池塘积水” 的容积。
于是,就单次 “降雨” 来看,我们已经通过数形结合的方法,将暴力模拟的 线性复杂度的算法优化成了 的常数阶算法。接下来,我们来分析一下更复杂的情形。
第二天,假设 “池塘” 的水被 “蒸发” 时,所有 “池塘” 水位同时下降,我们将会得到下面的图形(左边的 “池塘” 干涸了):
这时我们注意到,蓝色部分的面积仍然等于 ,只不过 的数值发生了变化,而 却并没有改变——这就是我们为什么要在上一步里多增加一个变量,而不是直接使用 的原因。所以,我们只要将原先的 减去这一天 “蒸发”的水量 ,得到新的 ,依然能通过上述公式一步计算出当前所有 “池塘积水” 的容积。
假设第三天又发生了 “降雨”,却又回不到最初的水量,这时产生的图形长这个样子:
重点来了,这时产生了一个新的平行四边形,它的面积 。很显然 等于这天的降水量 ,但 等于多少呢?我们放大来看:
应该等于前一天平面没有水的长度减去 ,而回到前一天的图形,可以看到,这个长度实际上等于前一天 “蒸发” 的水量 。
并且这个高度为 的小等腰直角三角形,与大的高度为 的等腰直角三角形(由所有 “池塘” 组成)是等比关系。
我们暂停一下,从头捋一下至今为止用到的变量,用 表示三天的 “降雨” / “蒸发” 的体积:
- 最开始第 0 天,所有 “池塘” 为空,“三角形” 的底和高都是
- 第一天 “降雨”,,
- 第二天 “蒸发”,, 不变
- 第三天 “降雨”,, 不变,,,
由于第二天和最开始第 0 天的情形是等比关系,所以我们要在第 0 天记录 ,第二天记录 ,这样在第三天的时候就可以通过公式一步计算出总面积 ,其中 等于左边平行四边形的个数。
如果后面的 “降雨” 如此反复,将会形成下面的图形:
重点,更复杂的情况来了。很显然 “降雨” / “蒸发” 不可能这么规律,当 “降雨” 超过最左边的平行四边形的边长 时,它将会被 “淹没”,而当 “蒸发” 的容积大于最左边平行四边形的高度 时,它将会 “消失”:
轮到我们的单调栈出场了。由于左边的平行四边形的高度是单调递减的,在 “降雨” 和 “蒸发” 的过程中最左边的平行四边形还会 “增加” 或 “减少”,对于这种情况,我们可以维护一个单调栈。很显然,栈里要维护的变量有两个, 和 ,可以用列表表示 。还记得我们前面提到过要在第 0 天和第二天记录 和 吗?这就相当于完全无水和部分水面 “干涸” 的时候,栈顶有一个高度为 0 的元素 或 ,因为高度为 0,它的存在并不会影响计算结果,反而使得栈的维护更加容易。
本题数形结合的分析过程到这里就结束了,相信聪明的你一点就通。
综合上述所有分析,总结栈的维护过程,并用代码实现:
- “降雨” 时,
- 最左边(栈顶)的平行四边形的 会发生变化,当 “降雨” 的容积 大于栈顶的 时,栈顶元素 出栈,然后循环检查新栈顶的 ;
- 在此过程中,依次将弹出的栈顶的 减去 以抵消变化,得到最后的水位以更新栈顶的 ;
- 最后一步,再把栈里所有元素的 都加上此次 “降雨” 的容积 。完成更新。
- 如果在第一步里栈内所有元素都弹出,说明所有 “池塘” 都满了,入栈 。
- “蒸发” 时,
- 最左边(栈顶)的平行四边形的 会发生变化,当 “蒸发” 的容积 大于栈顶的 时,栈顶元素 出栈,然后循环检查新栈顶的 ;
- 在此过程中,依次累加弹出的栈顶的 以得到 “干涸” 的水面宽度,得到最后的宽度 ,入栈
- 最后一步,再把栈里所有元素的 都减去此次 “蒸发” 的容积 。完成更新。
- 如果在第一步里栈内所有元素都弹出,说明所有 “池塘” 都干涸了,入栈 。
而计算每一天的所有 “池塘积水” 的容积,使用公式即可得到:
带入变量:
代码(部分)
n, m = map(int, input().split())
h = [[0, n]] # 初始化栈,所有池塘都干涸的情况
for _ in range(m):
v = int(input())
if v >= 0: # 水位上升
temp = v
while h and h[-1][1] < temp:
temp -= h.pop()[1]
if not h: h.append([n, 0]) # 所有池塘都满了
else:
h[-1][1] -= temp # 更新栈顶 “水面宽度”
for i in h: i[0] += v # 更新栈内所有 “平行四边形” 的 “高度”
else:
# 请参考题解试着自己补全水位下降的代码
s = h[0][0]*(1+h[0][0])//2 + sum(i[0]*i[1] for i in h)
print(s)
该算法的时间复杂度为 ,其中 是给定的天数, 是栈内元素的数量。说实话,这个 我不太好判断,理论上,当每天的 “降雨” / “蒸发” 极度变态时(形成锯齿状), 。但我用本题所有用例实测发现, 的值不超过 7,相当于常数了。或可说明平均情况下(或单就本题而言),复杂度为 。
本题或许还存在别的解法,不过相信思路大同小异,画图出来,一切就了然了。
码字不易,画图更不易,如果觉得上述题解对你有所帮助,希望留下你的点赞评论与鼓励。