从栈到单调栈
0、什么是栈?
用一个很贴切的例子,简述一下什么是栈:
想象一摞叠在一起的盘子。
我们平时放盘子的时候,都是从下往上一个一个放;取的时候,我们也是从上往下一个一个地依次取,不能从中间任意抽出。后进者先出,先进者后出,这就是典型的“栈”结构。
从栈的操作特性上来看,栈是一种“操作受限”的线性表,只允许在一端插入和删除数据。
至于其如何实现,以及更多元的应用方面,这里就不再阐述。(好多大佬的资源,小弟看后真感自己才疏学浅.jpg)
1、什么是单调栈?
那有了栈的概念后,什么是单调栈。
所谓单调栈,也就是存放有序数据的栈,为此单调栈也分为单调递增栈和单调递减栈:
- 单调递增栈:单调递增栈就是从栈底到栈顶数据是从大到小
- 单调递减栈:单调递减栈就是从栈底到栈顶数据是从小到大
名字听上去可能也不太好记,这里我为大家画了个图来理解记忆一下:
2、单调栈应用和伪代码
理解单调栈的概念后,我们如何在应用中使用这种数据结构?又或者在什么情况下适合使用这种数据结构呢?
先回答后一个问题。
在这一段时间的LeetCode之旅中,我发现这样一大类问题适合用单调栈解决:
比当前元素更大(小)的前(后)一个元素。
举个例子说明一下。
这里有一个数组,其假定为[5, 6, 8, 2, 7, 4],我们要在其中寻找从左向右第一个小于5的元素。
那么,你可能会想,遍历一次不久好了,算法复杂度也才O(n)。
可是如果,改成对数组中的所有元素都要做类似处理呢?那上面的算法不就是O(n2)了吗。
所以借用这个例子来阐述一下单调栈的思想。单调栈解决这道题目的算法复杂度时O(n)的。(你也可以自己先思考一下,结合前面讲的单调栈的原理)这里我给大家画了一幅图,你可以尝试着看一下。
可以看到一个典型现象是,我们一直都保持栈中的数据从栈底到栈顶是递增的。遇到比栈顶元素大的,我们就将其压入;比栈顶元素小的,我们就将栈顶元素弹出,并且将该元素(非栈顶元素的比较元素)记录为栈顶元素的第一个小于其的元素。
并且值得注意的是,这个比较的过程是持续进行的,也就是当栈顶元素不严格小于当前元素时,才停止下来。
为了让你更加明白上述处理的过程,这里我针对该问题谢了一段代码,你可以配合图看下是否更好理解。
int nums[6] = {5, 6, 8, 2, 7, 4};
int ans[6] = {-1}; // 用于存储结果的向量
stack<int> data; // 用来模拟单调栈
for (int i=0; i<6; i++) {
while (!data.empty() && nums[data.top] > nums[i]){
int index = data.top(); // 取出栈顶元素
data.pop(); // 栈顶元素出栈
ans[index] = nums[i]; // 进行操作
}
data.push(i);// 当前元素入栈
}
或许你在有了上面的思考后,对单调栈应该有了初步的印象和理解。那么就单调栈的应用,或许可以给出其大致的运行框架,日后刷题后遇到也就处理部分不同,稍微变动一下也八九不离十。给出模板如下:
stack<int> st; // 用于模拟单调栈
for (循环遍历数组){
while (栈中有元素 && 当前元素与栈顶元素满足一定关系(需判断是哪种单调栈)){
// 取出栈顶元素,并且栈顶元素出栈
// 进行操作
}
// 当前元素入栈
}
对于该框架,你可以对照前面一个例子看看,再熟悉一下,后面会有几个例题,进一步加深理解和记忆。
3、单调栈的应用例题
3.1、视野总和
3.1.1、题目描述
描叙:有n个人站队,所有的人全部向右看,个子高的可以看到个子低的发型,给出每个人的身高,问所有人能看到其他人发现总和是多少。
输入:4 3 7 1
输出:2
解释:个子为4的可以看到个子为3的发型,个子为7可以看到个子为1的身高,所以1+1=2
3.1.2、解题思路
- 需要设置一个单调栈(从栈底到栈顶数据大小递减)
- 如果栈为空或者当前元素大于栈顶元素,则将该元素入栈
- 反之,循环将栈顶元素弹出,并计算栈顶所能看到比其小的元素的个数,累加进结果,直到栈顶元素不再严格小于当前指向元素
3.1.3、结果代码
int FieldSum(vector<int>& v)
{
v.push_back(INT_MAX);/这里可以理解为需要一个无限高的人挡住栈中的人,不然栈中元素最后无法完全出栈
stack<int> st;
int sum = 0;
for (int i = 0; i < (int)v.size(); i++)
{
while (!st.empty() && v[st.top()] <= v[i])
{
int top = st.top();//取出栈顶元素
st.pop();
sum += (i - top - 1);//这里需要多减一个1
}
st.push(i);
}
return sum;
}
3.2、柱状图中的最大矩形
3.2.1、题目描述
3.2.2、解题思路
3.2.2.1、双重循环
- 对于每一个矩形,可以分别向左或者向右扩展其最大宽度。
- 用这个最大宽度与高度的乘积作比较,保存大的作为结果。
3.2.2.2、单调栈
这道题可以用单调栈来解题,并且是单调栈比较典型的应用。相信有了前面的基础,这道题的大致框架是不是有在脑海中呢,再来一起回顾一下。
- 我们需要借助栈,来完成单调栈的模拟(初始化一个数据结构栈)
- 依次查看每个元素(一个for循环遍历)
- 在查看的过程中,如果栈不是空的,并且当前元素与栈顶元素满足一定关系,我们将做处理
- 否则,我们将该元素压入栈中
那么对于该题,如何利用这个框架解题呢,这得具体看这个问题,可以抽象出哪种模型。还记得前面所讲的,一个大类,比当前元素更大(小)的前(后)一个元素吗,仔细思考一下。
可以发现,某一个高度可以组成的最大矩形,其左右两边必然是连续的并且大于等于其高度的矩形的集合。
有点绕口,我给你画个图,你尝试这理解一下。
这里有图啊
那么有了这个基础后,在单调栈中,栈顶元素的左边第一个元素,一定是比其小的(大于等于其的已经弹出,或者左边第一个元素本身就比其小),而右边也一定是比起小的(遇到了比其小的元素,此时需要对其做处理了。当然,这也表明在前面比其大的元素已经全部弹出,或者当前元素本身就比栈顶元素小)。那么这样,隐式的,可以通过下标来计算这个最大矩形的宽度,最后求出面积。这里你可以结合我画的图,再看一下。
所以,上面的理论,再结合单调栈的框架,我们来看看其具体处理过程:
- 我们需要借助栈,来完成单调栈的模拟(初始化一个数据结构栈)
- 依次查看每个元素(一个for循环遍历)
- 在查看的过程中,如果栈不是空的,并且当前元素小于栈顶元素所代表的值,我们将弹出并保存栈顶元素所代表的矩当前最大矩形高度,然后将其与宽度相乘,并与保存的最大值作比较,并更新。
- 否则,我们将该元素(元素下标)压入栈中
当然这里,还需要哨兵的存在,才能处理好边界调节。这里可以看下这副图,也许更好理解。
这里有图。
3.2.2.3、结果代码
结合上面的思想,再来看看代码,也许更清晰。
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
heights.push_back(0);
heights.insert(heights.begin(), 0);
int n = heights.size();
stack<int> data; // 借助栈来模拟单调栈
int ans = 0;
for (int i=0; i<n; i++) {
while (!data.empty() && heights[data.top()] > heights[i]) {
int index = data.top();
data.pop();
ans = max(ans, heights[index] * (i - data.top() - 1));
}
data.push(i);
}
return ans;
}
};
4、总结
单调栈这里就结束了,核心思想也就是单调和栈两部分,时刻谨记在栈中数据为有序的,这个数据结构也就明白一半了。剩下的一半,需要各位在实践中找真理了。
这里将LeetCode上可以利用单调栈解决的题目链接给出,大家可以多多练习,实践出真理。
这些不是全部,今后会继续补充,题解也才写了两道,不够完善。
初出茅庐,也请各位大佬不吝赐教,有误的地方帮忙指出,万分感谢。
5、致谢引用
《数据结构与算法之美》-- 王争