LeetCode 42. Trapping Rain Water 题解
题目描述
Given n non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it is able to trap after raining.
For example,
Given[0,1,0,2,1,0,1,3,2,1,2,1]
, return6
.
题目大意
这个题目的意思大概是, 每个数字代表高度为 nums[i]
宽度为1
的矩形, 将这些矩形依次排列在一条水平线上, 问其能够储存的水量。
就如这幅图中所示(真的是一图胜千言)。这幅图是样例的一个注解。
思路分析
首先看到这个题, 最先的想法该是简单模拟。如何做呢? 首先,如果下一个比当前高, 那么当前不可能能够作为储水的起点。如果下一个比当前低, 不可能作为储水的终点。依据此, 我们可以先假定一个储水起点,进行扫描。 如果之后发现储水起点可以取到更优值, 那么更新之并重新从该起点扫描。直到扫描完整个数组为止。
这种方法是最先想到的, 但是我并没有写, 原因不是复杂度多高(看上去像是O(n2))而是在于这种做法大体思路很好想, 但是具体写起来有很多细节的处理会比较痛苦。 例如, 什么时候认为当前的起点不对? 又该回退到哪里? 什么时候可以看做一个储水过程结束? 这些处理起来十分棘手, 因此我重新思考其他方法。
思考切入点是从这个问题开始: 什么时候我们能确定一个储水段,其不会再被后续扫描所更新?
再更进一步的想, 一个储水段在扫描的时候, 起点是被定下, 变的是终点。 如果是这样的变, 是可以接受的。因为这种更新十分简便。
再更进一步, 就涉及到一个很有趣的问题了。 这并不是我随便胡诌: 要想确定起点, 必须要先确定终点。 原因在于, 如果上一个储水段 不能确定结束, 那么某一个点就不能确定是否具有当起点的权利。这里就有循环推理的成分了, 不过好在这个循环有一个起点: 我们可以自由的假设第一个点为一个储水的起点。那么就是要确定终点的条件。接下来可以递推做下去就完成了。
再来考虑终点。 可以很简单的发现, 如果扫描到一个点比起点低, 那么我们是不知道这个点是否是最后这一段的终点的。因为只要后面出现一个比这个点高的,就会改变这个终点。但是基于此的重要结论是, 一旦一个点比我假设的起点高, 那么我们可以将其设为这一段的终点。这是因为, 这时这一段储水能力的限制不在于终点了,而是起点。 而起点已经固定。 所以后续无论如何, 这一段储水量都不会变了。
好。到这里其实问题的基本解决思路已经有了。 下面是仅剩的一个小问题: 如果一直到结尾, 都没有出现比起点高的点, 那怎么处理呢?
很明显这时候, 我们可以找出这个区间次高的点来计算。 每次找出一个次高的点需要复杂度是 O(k) ,k 为剩余数组长度。 每次找到之后计算的复杂度也是
这样做事实上是一个不错的办法。 根据上面的分析, 不难得出整个方法的平均复杂度也是 O(n). 几乎时间复杂度上达到了最好。但是, 不够简洁。(好吧要是没有更简单的算法, 其实这样也不算复杂了) 关键是, 从过程的分析可以知道, 虽然复杂度不高,但是其前面的常系数可能会偏大。 因为多次进行过扫描。 **最关键的是, 如果整个序列排列是最坏情况——不难知道, 最坏情况就是整个序列递减, 那么复杂度会达到O(n2)
下面,对于刚才那个小问题, 做出另一个可行的方案。
在此之前, 我们先思考: 起点和终点有区别吗? 想象一下, 假设对于某个序列, 我们已经得到了其储水的每个起点和终点, 那么我们把整个序列反转, 那么每个起点和终点是不是也反转了呢?
再回到刚才那个问题, 如果一直到结尾, 都没有出现比起点高的点。 这是什么意思呢? 就是把剩余数组反转后, 至少有一个终点,就是这个点。 因为反转之后这个点会被在最后, 而这个点值又是最大。 根据前面的讨论, 这个时候至少这个最大值的点可以作为终点
说明到这里就已经很明显了, 只需要把剩下的数组反转, 再执行刚才说的那种方法, 即可处理完毕。
这里巧妙的运用了代码重用, 是不是比刚才的简洁了许多呢? 更重要的是, 这种方法, 很明显最坏情况下仍能够保持O(n)的时间复杂度。
下面是具体实现代码, 我自认为是比较简洁的。
class Solution {
public:
int calculate_lr(vector<int> & height, int l, int r) {
int sum = 0, k = height[l];
for (int i = l + 1; i < r; ++i) {
if (height[i] >= k) {
k = height[i];
}
else {
sum+= k - height[i];
}
}
return sum;
}
int calculate_rl(vector<int> & height, int l, int r) {
int sum = 0, k = height[r];
for (int i = r - 1; i > l; --i) {
if (height[i] >= k) {
k = height[i];
}
else {
sum+= k - height[i];
}
}
return sum;
}
int trap(vector<int>& height) {
if (height.empty()) return 0;
int max_index = 0;
for (int i = 1; i < height.size(); ++i) {
if (height[i] > height[max_index]) max_index = i;
}
return calculate_lr(height, 0, max_index) + calculate_rl(height, max_index, height.size() - 1);
}
};
其他细节
- 整篇都是在讨论如何找到起点与终点, 而没有谈具体计算过程。 这是因为有起点终点之后,计算相对非常简单。 特别是经过上面这种反转处理后, 每个储水段的起点都比终点低。这就使得直接可以边扫描边计算了。
- 这段代码有一个求maximum的过程。 事实上, “如果一直到结尾, 都没有出现比起点高的点” 指的就是从maximum 到数组结尾。 因此反转的数组我们可以很清楚的在一开始就知道, 不必等待第一阶段推算完。
- 这段代码并没有真正代码复用, 只是我写起来不费劲(用的复制粘贴大法)。 真正复用的方法改起来也很简单。无非是用下
reverse()
方法,统一下接口。