如何优雅地使用单调栈(一):基础篇
1. 关于“单调栈”这个数据结构
单调栈(monotonous stack)是指栈的内部从栈底到栈顶满足单调性的栈结构。
其实单调栈就是“栈 + 维护单调性”。
1.1 入栈操作
此处假设单调栈是一个从栈底到栈顶单调递减的栈。为了避免分歧,后文的单调递增和单调递减均指从栈底到栈顶的顺序,后面不再说明。
向单调栈内插入元素时,可能需要弹出元素以保证栈内的单调性。
- 如果要插入的元素x是小于栈顶元素的,说明x小于栈内任何一个元素,因此无需其他操作,直接插入即可。
- 如果要插入的元素x是大于栈顶元素的,说明需要弹出部分元素,以确保插入该元素x后栈的单调性。
插入操作的基本模板代码如下:
// insert element x in mono_stack
void insert(stack<int>& mono_stack, int x) {
while (!mono_stack.empty() && mono_stack() > x) {
// operations #1
mono_stack.pop();
}
// operations #2
mono_stack.push(x);
}
1.2 维护单调栈时产生的元素关系
上面的代码里有两处可以增加附加操作(即operations #1和operations #2)。如果说前面的插入和使用栈都是基础,那在两个附加操作位置上产生的元素关系就是单调栈的精髓。
单调栈的附加操作有两处,分别是被遍历到的元素入栈时(对应operations #2),以及栈内元素出栈时(对应operations #1)。这一系列操作其实可以分成两部分来看待,即元素入栈时和元素出栈时。例如一个元素要入栈时,发现栈内单调性不再满足,需要pop出一系列元素,然后再入栈。这个过程既包含了该元素的入栈,也包含了其他元素的出栈。为了分析简便,我们把两种操作分开考虑,分别分析两种操作发生时蕴含的元素关系。
下面以创建一个单调递减栈为例讲解一下两种情况。被遍历到的元素入栈时:
如上图所示,当我们要添加元素1入栈时,由于元素1能直接入栈且不破坏单调性,因此可以直接push进入。在1入栈前,我们可以观察到,栈中最小的元素即为栈顶的3,因此即将入栈的1在原数组位置的左侧比自己大的元素只能是3,而不会是4或6(因为3是栈中最小,4和6已经被3“截断”了)。
因此,我们可以抽象一下:如果一个元素入栈后不破坏单调栈的单调性,那么栈顶元素就是待入栈元素在原数组位置左侧第一个比自己大的元素。
这里需要再强调一下,这个结论只存在于单调递减栈,数组自左向右遍历的情况。
当栈内元素出栈时:
如上图,当元素5要入栈时,发现入栈后不能满足单调性。栈顶的1比5小,因此必须pop出栈顶元素1。这里即将被pop出的1和待入栈的5也蕴含一种关系:5是打破1所在位置单调性的原因,而(原数组中)1和5之间的元素并没有打破这种关系(这里1和5之间并没有元素),因此待入栈的5是即将出栈的1在原数组位置右侧第一个比自己大的元素。
抽象一下这种关系:由于一个待入栈元素要入栈,导致一个元素出栈时,这个待入栈元素是该出栈元素右侧第一个比自己大的元素。
这个结论只存在于单调递减栈,数组自左向右遍历的情况。
上图的例子中,当1出栈后,3和4也必须出栈才能让5入栈(原数组中3与5之间,4与5之间,都没有元素打破单调性)。我们可以发现,5是3和4右侧第一个比自己大的值。接着5会入栈,此时栈内元素6是5在原数组中左侧第一个比自己大的值。这些也都验证了前面抽象出来的两个性质。
1.3 小结
接着我们总结一下单调栈具有的性质。当我们从左向右遍历一个数组,维护一个单调递减栈时,我们有:
- 首先是单调性:栈内元素满足单调性,但是这一点并没有很大的作用。
- 如果一个元素入栈后不破坏单调栈的单调性,那么栈顶元素就是待入栈元素在原数组位置【左边】第一个比自己【大】的元素。
- 由于一个待入栈元素要入栈,导致一个元素出栈时,这个待入栈元素是该出栈元素【右边】第一个比自己【大】的元素。
前面说的都是单调递减栈,当面对单调递增栈时,则直接推广为:
- 如果一个元素入栈后不破坏单调栈的单调性,那么栈顶元素就是待入栈元素在原数组位置【左边】第一个比自己【小】的元素。
- 由于一个待入栈元素要入栈,导致一个元素出栈时,这个待入栈元素是该出栈元素【右边】第一个比自己【小】的元素。
从上面这些性质我们可以看出,单调栈的作用更多在于维护这个栈过程中所得到的的信息,而不是最终得到的栈本身。因此我们可以根据需求在operations #1和operations #2两个地方获取不同的信息。
2. 使用单调栈的基本问题
2.1 基本问题
问题1:
对于给定的整数数组nums,找到每个元素右侧第一个比自己大的数的下标,如果没有,填-1
example:
input:[2, 1, 5, 6, 2, 3, 1]
output:[2, 2, 3, -1, 5, -1, -1]
分析:
根据我们前面得到的单调栈特征:
- 我们关注的是比自己大的元素下标,因此我们维护一个单调递减栈。
- 要找到右侧的第一个比自己大的元素下标,这是出栈时才能获取的信息,因此只需要在元素被pop前,获取当时待入栈元素的信息,这就是待出栈元素右侧第一个比自己大的元素。
- 如果一个元素一直没有被出栈,就说明没有比自己大的元素,因此在初始化的时候赋值-1即可。
经过以上简单的分析就可以轻松得到答案。
代码:
#include <iostream>
#include <stack>
#include <vector>
using namespace std;
// 问题1
vector<int> solve(vector<int>& nums) {
int n = nums.size();
vector<int> ret(n, -1);
stack<int> st;
for (int i = 0; i < n; ++i) {
while (!st.empty() && nums[i] > nums[st.top()]) {
ret[st.top()] = i;
st.pop();
}
st.push(i);
}
return ret;
}
void printVec(vector<int> vec) {
for (auto& x : vec) {
cout << x << " ";
}
cout << endl;
}
int main() {
vector<int> nums = {2, 1, 5, 6, 2, 3, 1};
auto ret = solve(nums);
printVec(ret);
return 0;
}
2.2 问题延伸
关于“右侧第一个比自己大”这个问题解决了,那么我们就可以轻松延伸出三个类比问题,即“右侧第一个比自己小”,“左侧第一个比自己大”,“左侧第一个比自己小”。具体如下:
问题2:
对于给定的整数数组nums,找到每个元素右侧第一个比自己小的数的下标,如果没有,填-1
example:
input:[2, 1, 5, 6, 2, 3, 1]
output:[1, -1, 4, 4, 6, 6, -1]
分析:
- 我们关注的是比自己小的元素下标,因此我们维护一个单调递增栈。
- 要找到右侧的第一个比自己大的元素下标,这是出栈时才能获取的信息,因此只需要在元素被pop时,获取当时待入栈元素的信息,这就是待出栈元素右侧第一个比自己小的元素。
代码:
相比于问题1,只需要将pop上面的判断由>
改成<
即可:
// 问题2
vector<int> solve(vector<int>& nums) {
int n = nums.size();
vector<int> ret(n, -1);
stack<int> st;
for (int i = 0; i < n; ++i) {
while (!st.empty() && nums[i] < nums[st.top()]) {
ret[st.top()] = i;
st.pop();
}
st.push(i);
}
return ret;
}
问题3:
对于给定的整数数组nums,找到每个元素左侧第一个比自己大的数的下标,如果没有,填-1
example:
input:[2, 1, 5, 6, 2, 3, 1]
output:[-1, 0, -1, -1, 3, 3, 5]
分析:
- 我们关注的是比自己大的元素下标,因此我们维护一个单调递减栈。
- 要找到左侧的第一个比自己大的元素下标,这是入栈时就能得到的信息。因此当一个元素能顺利入栈时,我们获取待入栈元素和入栈前的栈顶元素即可。栈顶元素就是入栈元素左侧第一个比自己大的元素。
- 如果一个元素入栈时栈为空,则是因为没有比该元素大的下标。
代码:
相比于问题1的代码,改动有三点:
- 去掉pop前面的操作
- pop所在循环改成
>=
- 在push前面增加所需要的操作(注意防止栈空的情况)
// 问题3
vector<int> solve(vector<int>& nums) {
int n = nums.size();
vector<int> ret(n, -1);
stack<int> st;
for (int i = 0; i < n; ++i) {
while (!st.empty() && nums[i] >= nums[st.top()]) {
st.pop();
}
ret[i] = st.empty() ? -1 : st.top();
st.push(i);
}
return ret;
}
问题4:
对于给定的整数数组nums,找到每个元素左侧第一个比自己小的数的下标,如果没有,填-1
example:
input:[2, 1, 5, 6, 2, 3, 1]
output:[-1, -1, 1, 2, 1, 4, 4]
分析:
- 我们关注的是比自己小的元素下标,因此我们维护一个单调递增栈。
- 要找到左侧的第一个比自己小的元素下标,这是入栈时就能得到的信息。因此当一个元素能顺利入栈时,我们获取待入栈元素和入栈前的栈顶元素即可。栈顶元素就是入栈元素左侧第一个比自己小的元素。
代码:
相比于问题3,只需要把pop所在循环的判断改成<=
即可。
// 问题4
vector<int> solve(vector<int>& nums) {
int n = nums.size();
vector<int> ret(n, -1);
stack<int> st;
for (int i = 0; i < n; ++i) {
while (!st.empty() && nums[i] <= nums[st.top()]) {
st.pop();
}
ret[i] = st.empty() ? -1 : st.top();
st.push(i);
}
return ret;
}
通过上面四个很简单的问题,就可以轻松搞懂单调栈的核心操作。
3 单调栈的使用总结
通过前面的讲解和四个问题,下面我们总结一下单调栈的使用。
问题 | 单调栈的使用 | 更新的主体 | 产生关系的元素 |
---|---|---|---|
求右侧第一个比自己大的元素 | 从左向右遍历,单调递减栈,出栈时操作 | 出栈元素 | 待入栈元素 |
求右侧第一个比自己小的元素 | 从左向右遍历,单调递增栈,出栈时操作 | 出栈元素 | 待入栈元素 |
求左侧第一个比自己大的元素 | 从左向右遍历,单调递减栈,入栈时操作 | 入栈元素 | 栈顶元素 |
求左侧第一个比自己小的元素 | 从左向右遍历,单调递增栈,入栈时操作 | 入栈元素 | 栈顶元素 |
求左右比自己小的元素,用单调递增栈,入栈时更新左侧最近比自己小的元素,出栈时更新右侧第一个比自己小的元素。同理,求左右比自己大的元素,用单调递减栈,入栈时更新左侧最近比自己大的元素,出栈时更新右侧第一个比自己大的元素。
为了理解上述规律的原因,可以这样思考:一个元素的生命周期就是被遍历到时入栈,之后遍历到其他元素的时候才有机会出栈。入栈时不知道该元素右侧信息,所以当前只知道左侧信息(即更新左侧第一个比自己大/小的元素)。出栈时知道了是因为右边某个待入栈的元素导致的,是右侧等信息(更新右侧第一个比自己大/小)的元素(如果出栈的时候再不更新,以后也就没机会了)。
以上都是从左往右遍历,如果允许从右往左遍历,那么表格就会变成下面这样:
问题 | 单调栈的使用 | 更新的主体 | 产生关系的元素 |
---|---|---|---|
求右侧第一个比自己大的元素 | 从右向左遍历,单调递减栈,入栈时操作 | 入栈元素 | 栈顶元素 |
求右侧第一个比自己小的元素 | 从右向左遍历,单调递增栈,入栈时操作 | 入栈元素 | 栈顶元素 |
求左侧第一个比自己大的元素 | 从右向左遍历,单调递减栈,出栈时操作 | 出栈元素 | 待入栈元素 |
求左侧第一个比自己小的元素 | 从右向左遍历,单调递增栈,出栈时操作 | 出栈元素 | 待入栈元素 |
可以发现:只是原来从左向右遍历的表格,把入栈和出栈操作的顺序变换了。为了理解这种变化,还是要从遍历方向与得到信息顺序的角度去看。从右往左遍历时,对于正在遍历的元素,我们只知道该元素和它的右侧信息。因此对于这个被遍历的元素,第一次操作是入栈时,得到的是右侧的第一个比自己大或小的值。第二次操作是出栈时,得到的是该元素左侧第一个比自己大的值(此时出栈是因为该元素左侧的某个元素引起的,而这个元素就是第一个比自己大或者小的值)。
4. 参考
【1】https://zhuanlan.zhihu.com/p/26465701
【2】https://oi-wiki.org/ds/monotonous-stack/