接雨水
Related Links
- LeetCode 42:接雨水
- 借鉴题解:https://leetcode.cn/problems/trapping-rain-water/solutions/185678/trapping-rain-water-by-ikaruga
- LeetCode 84:柱状图中最大的矩形
- 借鉴题解:https://leetcode.cn/problems/largest-rectangle-in-histogram/solutions/108083/84-by-ikaruga
Solution
单调栈
Chapter Start
单调栈即栈内元素保持单调的栈结构,以单调递增栈为例,在操作时,新元素比栈顶元素大,直接入栈成为新“站长”,反之,则循环将栈内元素弹出,直至栈顶元素小于新元素,再入栈。
//以下是单调递增栈的代码实现,后续分析也将依靠这个代码
stack<int> st;
vector<int> nums = {3,1,4,2};
for(int i=0;i<(int)nums.size();i++){
while(!st.empty() && nums[st.top()]>nums[i]){
st.pop();
}
st.push(i);
}
单调递增栈的维护有很多不错的性质[1]:
- 栈顶元素出栈时,新元素是其右边第一个比他小的数
- 栈顶元素出栈时,新栈顶元素可以帮助我们确定其左侧边界
我们可以很惊奇的发现,对于每一个栈顶元素出栈,我们都可以同时确定它左右两侧的边界。
// 以下是单调递增栈的改良,我们想求每个元素左右的边界
// 很遗憾这是一个错误的代码,要写对这一串代码,还要考虑边界如何控制
stack<int> st;
vector<int> nums = {3,1,4,2};
vector<int> left(nums.size()), right(nums.size());
int j = 0;
for(int i=0;i<(int)nums.size();i++){
while(!st.empty() && nums[st.top()]>nums[i]){
j = st.top();
st.pop();
right[j] = i;
left[j] = st.top();
}
st.push(i);
}
我们可以通过加哨兵节点来规避两个问题:
- 开头:在第一次循环将哨兵压入栈底,此后弹出普通元素后
st.top()不会越界,省掉弹栈后的st.empty()判空。 - 结尾:它比所有柱子都小,会把栈里剩余的原始元素全部弹出来,省掉 for 结束后的收尾循环。
vector<int> nums = {3,1,4,2};
nums.insert(nums.begin(), INT_MIN); // 开头哨兵:比所有元素都小
nums.push_back(INT_MIN); // 结尾哨兵:触发所有元素出栈
int n = (int)nums.size();
vector<int> left(n), right(n);
stack<int> st;
int j = 0;
for(int i = 0; i < n; i++){
while(!st.empty() && nums[st.top()] > nums[i]){
j = st.top();
st.pop();
right[j] = i;
left[j] = st.top(); // 不用判空
}
st.push(i);
}
这样 LeetCode 84 应该很轻易地解出来。
然后我们再来看 LeetCode 42 万恶之源的接雨水就很 easy 了,单调递减栈 + 判空,甚至不需要哨兵节点。
84:pop 一个柱子,是为了找“这个高度最多能向左右延伸多远”。 42:pop 一个柱子,是因为找到了“这个坑的左右挡板”。
Comment
[1] 单调栈中 > 与 >= 往往都能写出正确算法,但它们处理重复元素的方式不同。因此,与其机械记忆“左右第一个更小”,不如始终分析元素出栈时栈中究竟保持着什么关系。
单调栈真正解决的,是「什么时候可以结算」
单调栈第一次学的时候,很容易留下一个非常模糊的印象:
找左边第一个更大、右边第一个更小,好像都能用它。
然后就是一堆模板:
while(!st.empty() && nums[st.top()] > nums[i]){
st.pop();
}
问题是,这种记法只能帮你“认出”单调栈,却很难帮你“推导”单调栈。
真正值得理解的不是栈里到底递增还是递减,而是:
为什么一个元素需要留在栈里?又为什么会在某一刻被弹出去?
如果把这个问题想明白,LeetCode 84 和 42 这两道看起来完全不同的 Hard,反而会变成同一套逻辑的两个版本。
1. 先从一个最简单的问题开始
给一个数组:
[2, 1, 4, 3, 5]
我们想知道:
每个元素右边第一个比它小的元素在哪里?
比如 4:
2 1 4 3 5
↑ ↑
4 3
答案是 3。
最直接的方法当然是:对于每个位置,都向右扫描。
for(int i = 0; i < n; i++){
for(int j = i + 1; j < n; j++){
if(nums[j] < nums[i]){
// 找到了
break;
}
}
}
最坏情况下是 O(n²)。
问题出在,我们对右侧区间进行了大量重复扫描。
换个角度想。
当我们遍历到 4 时:
2 1 4
↑
它的答案还不存在。
因为右边还没有看。
那怎么办?
很简单:
先把它留下来,等。
接下来 3 出现:
2 1 4 3
↑ ↑
终于有:
3 < 4
这时候 4 已经等到了自己的答案。
它不需要继续留下来了。
于是可以把整个过程理解成:
元素进入某个容器
↓
等待未来的元素
↓
条件满足
↓
答案确定
↓
离开容器
这个“容器”,就是栈。
2. 栈里放的,其实是「还没等到答案的人」
还是从左往右遍历:
[2, 1, 4, 3, 5]
先看 2:
stack: [2]
它右边还没有更小的数,所以继续等待。
然后来了 1。
因为:
1 < 2
2 的答案出现了。
所以 2 出栈:
stack: []
再把 1 放进去:
stack: [1]
接下来 4:
4 > 1
它不会解决 1 的问题,所以:
stack: [1, 4]
再来 3:
3 < 4
4 的答案出现,弹出:
stack: [1]
但是:
3 > 1
所以 1 继续等。
最后 3 入栈:
stack: [1, 3]
你会发现一个有意思的现象:
1 < 3
栈里的元素天然保持了单调关系。
不是我们一开始“决定要维护一个单调栈”。
而是因为:
所有已经不满足等待条件的元素都被弹掉了。
剩下来的,自然就是单调的。
这两者的因果关系最好不要记反。
3. push 是等待,pop 是结算
于是下面这段代码:
for(int i = 0; i < n; i++){
while(!st.empty() && nums[st.top()] > nums[i]){
int cur = st.top();
st.pop();
// cur 的答案在这里被确定
}
st.push(i);
}
可以换一种方式阅读。
st.push(i):
我现在还不知道
i的答案,把它留下来。
st.pop():
它等待的东西出现了,现在可以结算。
这个视角非常重要。
因为真正做题时,我们关心的通常不是“怎么维护单调性”,而是:
在
pop的这一瞬间,我究竟知道了什么?
这才是单调栈最值钱的信息。
4. 为什么通常存下标,而不是存数字?
单调栈里最常见的是:
stack<int> st;
st.push(i);
而不是:
st.push(nums[i]);
原因并不复杂。
如果保存下标:
nums[st.top()]
照样能拿到值。
同时还能计算:
i - st.top()
获得距离。
而像柱状图面积、接雨水这些问题,最终恰恰都需要:
高度 + 宽度
所以保存下标通常能一次保留两类信息:
值
位置
后面会看到,这个选择直接决定了很多公式可以一行写出来。
5. 第一次升级:不仅要右边界,还要左边界
假设现在某个元素 cur 被当前元素 i 弹出:
while(!st.empty() && nums[st.top()] > nums[i]){
int cur = st.top();
st.pop();
}
由于 i 是从左往右第一个让 cur 出栈的位置,因此可以确定:
i
就是 cur 的右侧边界。
那么 cur 左边的信息在哪?
答案就在:
st.top()
因为 cur 弹掉以后,新的栈顶就是当前仍然留在栈里的最近元素。
于是一个非常关键的结构出现了:
新的栈顶 cur 当前 i
↓ ↓ ↓
left middle right
一次 pop,同时把三个位置联系到了一起。
这也是为什么很多单调栈题真正的核心代码都长这样:
int cur = st.top();
st.pop();
int left = st.top();
int right = i;
接下来做什么,就取决于题目赋予这三个位置什么意义。
LeetCode 84 和 42 的区别,就藏在这里。
6. LeetCode 84:不要枚举矩形,枚举「最矮的柱子」
柱状图最大矩形最直接的想法,是枚举所有矩形。
但这显然太多。
更聪明的枚举方式是:
枚举哪一根柱子作为矩形中的最低高度。
假设某根柱子高度是:
h
如果我们已经决定矩形高度就是 h,那问题只剩:
这个高度能够向左和向右扩展多远?
例如:
█
█ █
█ █
█ █ █ █
---------------------
↑
h
只要旁边的柱子高度:
>= h
矩形就可以继续延伸。
一旦遇到:
< h
就必须停下来。
于是对 h 来说,我们需要的恰好是:
左边第一个更矮的位置
右边第一个更矮的位置
这和前面的单调栈模型完全一致。
7. 为什么柱子出栈时,面积正好可以算?
假设当前遍历到 i,并且:
heights[st.top()] > heights[i]
那么栈顶柱子 cur 被弹出。
此时:
heights[i] < heights[cur]
因此 i 是:
cur向右第一个不能继续跨过去的位置。
弹掉 cur 后:
st.top()
又提供了左边界。
所以:
left cur i
↓ ↓ ↓
█ █ █ █ ▂
真正能够覆盖的位置是:
(left, i)
左右两个边界本身不能算。
因此:
int width = i - st.top() - 1;
矩形高度:
int h = heights[cur];
面积:
h * width
于是算法真正发生的事情是:
柱子入栈
↓
等待右边更矮的柱子
↓
更矮柱子出现
↓
出栈
↓
最大宽度确定
↓
结算面积
这时候再看单调栈,会比“84 用单调递增栈”自然得多。
8. 两个 0 到底是干什么的?
标准代码里常见:
heights.insert(heights.begin(), 0);
heights.push_back(0);
很多人会直接把这两个 0 当模板记住。
其实没有必要。
先看:
1 2 3 4
遍历结束后,这四根柱子都没有遇到右边更矮的柱子。
所以它们一直留在栈中。
问题是,它们并不是没有答案。
例如 4 可以延伸到数组末尾。
3 也可以。
所以这些元素虽然没有等到真实的右边界,却依然需要结算。
那我们就人为制造一个:
1 2 3 4 0
↑
虚拟边界
因为 0 比所有柱子都矮,最终会把栈中剩余柱子全部弹出来。
左边的 0 则是为了让最左边也存在一个统一的虚拟边界。
于是完整代码:
int largestRectangleArea(vector<int>& heights) {
heights.insert(heights.begin(), 0);
heights.push_back(0);
stack<int> st;
int ans = 0;
for(int i = 0; i < (int)heights.size(); i++){
while(!st.empty() && heights[st.top()] > heights[i]){
int cur = st.top();
st.pop();
int h = heights[cur];
int width = i - st.top() - 1;
ans = max(ans, h * width);
}
st.push(i);
}
return ans;
}
所以哨兵不是“某些单调栈题必须加的神秘东西”。
它表达的是:
当真实边界不存在时,用一个虚拟边界把剩余元素统一结算掉。
9. 换一道题:接雨水为什么也能 pop?
现在看接雨水。
这次先不要想:
单调递减栈
先想水是怎么形成的。
最基本的结构一定是:
左挡板 右挡板
█ █
█ ~ █
█ █ █
坑底
也就是说,一块水至少需要:
左挡板
坑底
右挡板
假设当前有一个较低的柱子 bottom。
它什么时候能够确定自己上方有水?
答案是:
右边终于出现一根比它高的柱子时。
于是它也在“等待”。
只是它等待的东西,和 84 恰好相反。
84 的柱子在等:
右边更矮的柱子
42 的坑底在等:
右边更高的柱子
因此条件自然变成:
height[i] > height[st.top()]
一旦成立:
int bottom = st.top();
st.pop();
坑底开始结算。
10. 一次 pop,为什么代表一层水?
bottom 被弹出以后:
int left = st.top();
新的栈顶就是左挡板。
当前:
i
则是右挡板。
于是三个角色全部出现:
left bottom i
↓ ↓ ↓
█ ~ █
█ ~ ~ █
█ █ ~ █
---------------------------
这一层水的宽度:
int width = i - left - 1;
水面高度由较矮的挡板决定:
min(height[left], height[i])
减掉坑底高度:
height[bottom]
得到这一层水真正的高度:
int h =
min(height[left], height[i])
- height[bottom];
所以:
ans += width * h;
注意这里计算的是:
横着的一层水。
这和另一种经典的“逐列算水”思路完全不同。
单调栈是按水平方向分层结算的。
如果坑里还有更高的台阶:
继续 pop
就继续计算下一层。
11. 为什么弹出坑底以后,栈空了就不能算?
代码里一定有:
if(st.empty()){
break;
}
这不是单纯为了避免程序崩溃。
它有明确的物理意义。
假设:
1 3
3 出现时,可以把 1 弹掉。
但弹掉以后:
左边什么都没有
也就是:
没有左挡板
那么即便右边有再高的柱子:
水也会从左边流走
所以:
if(st.empty()){
break;
}
真正表达的是:
没有左挡板,就不存在水坑。
12. 为什么接雨水反而不需要右哨兵?
这里和 84 有一个很漂亮的区别。
84 中:
右边没有更矮柱子
不代表矩形不存在。
它只是意味着:
可以一直延伸到数组末尾
所以需要人为制造一个虚拟右边界,帮剩余柱子结算。
但接雨水中:
右边始终没有更高挡板
意味着什么?
意味着:
就是没有右挡板。
没有右挡板,自然没有水。
所以根本不需要强行让这些元素出栈。
完整代码:
int trap(vector<int>& height) {
stack<int> st;
int ans = 0;
for(int i = 0; i < (int)height.size(); i++){
while(!st.empty() && height[i] > height[st.top()]){
int bottom = st.top();
st.pop();
if(st.empty()){
break;
}
int left = st.top();
int width = i - left - 1;
int h =
min(height[left], height[i])
- height[bottom];
ans += width * h;
}
st.push(i);
}
return ans;
}
这时候,“84 有哨兵而 42 没有”就不再是一条需要记忆的规则。
它只是题意的自然结果。
13. 两道题真正相同的地方
把两道题放在一起。
柱状图最大矩形
栈里的柱子在等待:
更矮的右边界
一旦出现:
pop
然后:
新栈顶 = 左边界
当前元素 = 右边界
结算:
矩形
接雨水
栈里的坑底在等待:
更高的右挡板
一旦出现:
pop
然后:
新栈顶 = 左挡板
当前元素 = 右挡板
结算:
一层水
所以两道题虽然一个维护递增关系,一个维护递减关系,但真正共享的结构其实是:
某个元素进入栈
↓
它的答案暂时不完整
↓
继续等待未来
↓
某个关键元素出现
↓
当前元素出栈
↓
需要的信息终于完整
↓
立即结算
这才是单调栈的统一模型。
14. > 和 >= 为什么总让人头疼?
因为重复元素会影响“谁留下来”。
例如:
2 2 1
如果写:
nums[st.top()] > nums[i]
两个 2 都可以同时留在栈中。
如果写:
nums[st.top()] >= nums[i]
第二个 2 到来时,第一个就会被弹掉。
所以 > 和 >= 真正决定的是:
相等元素到底由哪一个位置负责后续的边界。
这会进一步影响:
左边界是谁
右边界是谁
宽度怎么算
重复高度由谁结算
因此最好不要独立记:
这题一定用 >
或者:
那题一定用 >=
而是先把题目的边界语义确定下来。
只要:
弹栈条件
边界定义
最终公式
三者保持一致,重复元素就不会成为玄学。
15. 以后遇到单调栈,先问这四个问题
比起背模板,我更建议按下面的顺序思考。
第一问:栈里的元素正在等什么?
是:
右边第一个更小?
右边第一个更大?
还是某种能够完成区间的边界?
第二问:什么出现时,它应该被弹出?
这个答案基本直接决定:
while(...)
里的比较关系。
第三问:它被弹出以后,我得到了什么?
通常:
当前元素
+
新的栈顶
+
被弹出的元素
会共同构成一个可以立即计算的结构。
第四问:如果一直等不到怎么办?
如果代表:
可以延伸到数组边缘
考虑哨兵。
如果代表:
答案本身不存在
那就不要强行结算。
结语
单调栈的代码其实短得离谱:
for(int i = 0; i < n; i++){
while(!st.empty() && ...){
int cur = st.top();
st.pop();
// calculate
}
st.push(i);
}
真正难的从来不是这几行。
难的是理解:
为什么 cur 要留在栈里?
为什么恰好现在把它弹出去?
弹出去以后,为什么答案突然就能算了?
一旦把这三个问题搞明白,所谓的“单调递增栈”“单调递减栈”反而只是最终代码表现出来的形态。
对 LeetCode 84 来说:
柱子在等待一个更矮的右边界。
对 LeetCode 42 来说:
坑底在等待一个更高的右挡板。
所以我更喜欢把单调栈看成一种:
让未完成的信息留在栈中,直到它恰好可以被结算的数据结构。
栈的单调性只是手段。
真正的核心,是那一声 pop。