题目链接:leetcode42
题目大意
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
示例:
输入: [0,1,0,2,1,0,1,3,2,1,2,1]
输出: 6
解题思路
首先要先明白一个通识,即木桶原理。木桶的容量取决于最低的挡板高度。
暴力枚举
对于每一个柱子,我们都可以单独计算它对整体容量的贡献,这个贡献取决于它左右两侧分别最高的柱子,其中较小的一个与本位置的柱子的差值,大致的处理式子为 ∣ h [ i ] − m i n [ m a x ( 1 , 2 , . . . , i − 1 ) , m a x ( i + 1 , i + 2 , . . . , n ) ] ∣ |h[i] - min[max(1,2,...,i-1), max(i+1,i+2,...,n)]| ∣h[i]−min[max(1,2,...,i−1),max(i+1,i+2,...,n)]∣。时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1)。
预处理
对于暴力枚举的算法,我们尝试优化它。由于需要每次扫描一个位置左右两侧的最大值,我们可以开一个数组 lmax[n] ,对于 lmax[i] 表示:从 1 ~ i-1 的结点中最大的节点;同理,可以开一个 rmax[n] 预处理每一个节点右侧的最大值。对于预处理可以在 O ( n ) O(n) O(n) 的时间复杂度完成,思想类似于一维的动态规划,只不过进行状态转移的方向是相反的,其状态转移方程分别为:
- l m a x [ i ] = m a x ( l m a x [ i − 1 ] , h [ i ] ) , ( i > 1 , l m a x [ 1 ] = h [ 1 ] ) lmax[i] = max(lmax[i-1], h[i]),(i>1, lmax[1]=h[1]) lmax[i]=max(lmax[i−1],h[i]),(i>1,lmax[1]=h[1])
- r m a x [ i ] = m a x ( r m a x [ i + 1 ] , h [ i ] ) , ( i < n , r m a x [ n ] = h [ n ] ) rmax[i] = max(rmax[i+1],h[i]),(i<n,rmax[n]=h[n]) rmax[i]=max(rmax[i+1],h[i]),(i<n,rmax[n]=h[n])
剩余的部分进行一次线性扫描,计算每个柱子的贡献即可。
时间复杂度为 O ( n ) O(n) O(n),空间复杂度为 O ( n ) O(n) O(n)。
单调栈1(水平分块)
根据盛水的分布特点,对于每一块独立的盛水区域(即水的联通块),我们可以把水的区域按行区分,如果不同行但起点和终点相同的区域,那么我们将他们划分为同一块(实际就是左挡板和右挡板相同的盛水区域),于是,盛水区域就被划分为多个矩形。如何计算这些矩形呢?显然 要找到各个矩形的宽和高。
我们可以维护一个各个柱子高度的非递增栈,当然,为了便于计算宽度,栈的内容是每个柱子的位置。每当一个元素(记为id)因后续元素(我们记这个新来的后续元素为nd)破坏了单调性而被弹出栈时,计算其左边的挡板(因为左边的挡板一定不小于 id )和 nd 中较小的挡板高度,和 id 的高度作差,该矩形的高度就为该值;而对于矩形的宽度则是 n d − s t a c k [ t o p ] − 1 nd - stack[top] - 1 nd−stack[top]−1,即左右挡板的中间部分的长度。把矩形的面积加到答案中,迭代所有柱子。
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( n ) O(n) O(n)。
单调栈2(联通块/单调队列)
对于单调栈1的解法是把水的区域按行划分,而本做法是把区域按列划分。但严格地讲这种做法不能称之为单调栈,因为栈中的柱子高度不是单调的。实际上栈中存的是独立盛水区域的联通块。具体做法:栈底存的是整块区域的左挡板,当且仅当找到一块比这个左挡板大的挡板,我们将他标记为右挡板。与此同时,将栈中的元素弹出计算每个列对于总盛水量的贡献,即它与左挡板的差值。结束后,这个右挡板又作为一个新的联通块的左挡板插入栈中。迭代所有柱子即可。
但这个算法发现可能存在一个情况,遍历完所有柱子后栈中仍存在柱子,也就是说从某个柱子开始,其后续的柱子都比它低,但可能这些柱子并不是递减的,也就是可能存在部分盛水区域,这些盛水区域我们可以将他们分成不同的联通块,把栈中的元素一一弹出,对于每个列,找到他们的右挡板 hmax ,即该列右侧的最高的挡板。计算高度差即这个柱子对于总盛水容量的贡献。
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( n ) O(n) O(n)。
双指针1
根据暴力枚举的算法,我们还可以用双指针对它进行优化。但此时我们不是优先考虑每个列的左右侧的挡板,而是考虑先找到最高的挡板,然后再从这个点的两侧往中间探测,每发现柱子呈递减趋势(相对于之前找到的挡板)就可以计算该柱子的贡献。
为什么可以马上计算?因为根据最大的挡板已经确定了其中一侧的不影响盛水容量的挡板,而每次发现递减时说明之前挡板正是影响该列容量的挡板,计算差值后即该列的贡献。但要注意的是,对于一个列的 容量影响挡板 可能不是相邻的,所以这个递减的判定不是相对于前一个的,而是相对于本遍历方向之前找到的最大挡板,和 单调栈2 的剩余挡板处理方法类似,当最大挡板被更新时才不用计算,否则都要。
实际上,如果按 单调栈2 所定义的联通块的概念,这种双指针也是对于栈做法的优化。因为当挡板更新时实际上就是切换到下一个联通块。
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)。
双指针2
对于前面这个做法,我们发现似乎最高的挡板没有被特别地利用到,反而两侧的指针最后都会到达这个位置,那么可以不去寻找这个 maxidx 吗?答案是肯定的。我们回头想想这个『maxidx』的作用到底是什么?显然,它只是用来保证某一方向的遍历,在计算一个列的容量时,另一侧有一个不低于本遍历方向之前已经找到的最高的挡板。通俗地讲,就是保证本列的贡献不会因另一侧都低于之前的挡板而影响。
根据这个『maxidx』的作用,我们比如在两个指针的移动时,当前未移动的指针就可以充当这个挡板,显然,每次只要移动那个较小的一侧。
举个例子,比如:h[l]=2,h[r]=5,lmax=3,rmax=7,(首先,我们一定要保证h[l]<=lmax,h[r]<=rmax),此时肯定要更新h[l],因为对于h[l]的右侧至少有一个h[r]的挡板,而h[r]当前暂时还没有左侧的挡板,等到h[l]更新到足以成为h[r]的挡板时,显然此时lmax = h[l] > h[r],那么则移动r。于是,我们不需要确定『idxmax』也可以处理每一个列的贡献。
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)。
容斥定理1
该思路来源:容斥,下图来源于LeetCode官方题解图。
- 我们记从左到右遍历最高挡板的投影总面积为S1:
- 记从右到左最高挡板的投影总面积为S2:
- 记S1和S2面积叠加后的并集为U,其中线条的阴影为S1和S2的交集Inc:
- 记以最高挡板为高,挡板的数量为宽的矩形面积为S,显然S==U;记挡板的总面积为T
根据容斥公式, I n c = S 1 + S 2 − S − T Inc = S1 + S2 - S - T Inc=S1+S2−S−T。分别计算这四个的值即可。
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)。
容斥定理2/双指针3
这个做法是基于上面的容斥思想改进的,但计算面积的时候使用到了双指针。我们发现,上述的容斥公式中, S 1 + S 2 − S S1+S2-S S1+S2−S 恰好为所有挡板的『凸轮廓图』,即补全了盛水面积的图的面积。而T又比较容易求得,所以问题的难点转向如何求这个『凸轮廓图』。
根据前面做法的启发,我们可以把这个面积按行或者按列分片,用双指针维护即可。对于按列分块,可以和 双指针2 的逻辑进行判定移动指针并计算面积;对于按行分块,我们可以分别从两侧进行指针探测,按从1到第k层进行计算,因为有个显而易见的结论是:底层的轮廓面积一定不小于高层的轮廓面积,即 底层的长度>=高层的长度,两个指针分别找出每层的边界即可。
时间复杂度
O
(
n
)
O(n)
O(n),空间复杂度
O
(
1
)
O(1)
O(1)。
代码实现
单调栈1(水平分块)
class Solution {
public:
int trap(vector<int>& h) {
int s[h.size()], top = -1; //单调栈 存下标
int id, dis, fh, all = 0;
for (int i = 0; i < h.size(); i++) {
while (top >= 0 && h[s[top]] < h[i]) { //遇到极值更新之前的单调部分,计算贡献
id = s[top--];
if (top == -1) break;
dis = i - s[top] - 1; //计算两个边界中间部分的长度
fh = min(h[s[top]], h[i]) - h[id]; //计算要被出栈的元素的低行贡献,高行部分会被它前面的元素计算到,故不在这里计算
all += dis * fh; //小矩形
}
s[++top] = i;
}
return all;
}
};
单调栈2(联通块/单调队列)
class Solution {
public:
int trap(vector<int>& h) {
int s[h.size()], top = -1; //栈
int all = 0;
for (int i = 0; i < h.size(); i++) {
while (top >= 0 && h[i] >= s[0]) all += (s[0]-s[top--]); //计算贡献
s[++top] = h[i];
}
//剩余节点处理,计算栈中每一块独立的联通块的贡献
int hmax = 0;
while(top >= 0) {
if (s[top] >= hmax) hmax = s[top];
else all += (hmax - s[top]);
top--;
}
return all;
}
};
双指针1
class Solution {
public:
int trap(vector<int>& h) {
int all = 0;
int maxidx = 0, lmax = -1, rmax = -1;
for (int i = 1; i < h.size(); i++) if (h[maxidx] < h[i]) maxidx = i;
for (int i = 0; i < maxidx; i++) {
if (lmax < h[i]) lmax = h[i];
else all += (lmax - h[i]);
}
for (int i = h.size()-1; i > maxidx; i--) {
if (rmax < h[i]) rmax = h[i];
else all += (rmax - h[i]);
}
return all;
}
};
双指针2
class Solution {
public:
int trap(vector<int>& h) {
int all = 0;
int l = 0, r = h.size()-1, lmax = 0, rmax = 0;
while(l < r) { //无需考虑l==r的情况,因为一定有一块作为挡板
if(h[l] < h[r]) { //r作为l的备胎挡板 因为l的右侧挡板一定满足:x>=h[r]>h[l]
if (lmax <= h[l]) lmax = h[l]; //l作为左侧挡板
else all += (lmax - h[l]);
l++;
}else {
if (rmax <= h[r]) rmax = h[r];
else all += (rmax - h[r]);
r--;
}
}
return all;
}
};
容斥定理1
class Solution {
public:
int trap(vector<int>& h) {
int N = h.size(), lmax = -1, rmax = -1;
int S1 = 0, S2 = 0, S, T = 0;
for (int i = 0; i < N; i++) {
if (lmax < h[i]) lmax = h[i];
if (rmax < h[N - i - 1]) rmax = h[N - i - 1];
S1 += lmax; S2 += rmax; T += h[i];
}
S = N * lmax;
return S1 - T + S2 - S;
}
};
容斥定理2/双指针3
按列分块
class Solution {
public:
int trap(vector<int>& h) {
int T = 0, S = 0;
int l = 0, r = h.size()-1, lmax = 0, rmax = 0;
while(l < r) { //由于退出条件一定是最高的挡板,所以可以省略这个挡板的计算
if(h[l] < h[r]) {
if (lmax <= h[l]) lmax = h[l];
S += lmax;
T += h[l];
l++;
}else {
if (rmax <= h[r]) rmax = h[r];
S += rmax;
T += h[r];
r--;
}
}
return S - T;
}
};
按行分块
class Solution {
public:
int trap(vector<int>& h) {
int T = 0, S = 0; //挡板的总面积 挡板和水的总面积
int l = 0, r = h.size()-1, storey = 1;
//按行分块不能忽略l==r的情况,因为底层的都已经被计算;如果要剔除这个情况则需要把T少加最高的挡板,并且S计算出来要扣除最高挡板目前已加入的部分
while (l < r) {
//找到对应层次的边界,并计算低楼层的挡板面积
while (l < r && h[r] < storey) T += h[r--];
while (l < r && h[l] < storey) T += h[l++];
//计算该层对S的贡献
S += (r - l + 1);
storey++; //计算下一个楼层
}
return S - T - storey + 1;
}
};