单调栈(monotonic stack)揭秘

单调栈,英文 monotonic stack.
如果你常刷LeetCode的话,百题之内至少见2次单调栈。
本文尝试揭秘单调栈的关键点。

单调栈的定义

单调栈分为单调递增栈和单调递减栈。

  • 单调递增栈: 从栈顶往栈底看,是单调递增的关系(含相等);
  • 单调递减栈: 从栈顶往栈底看,是单调递减的关系(含相等);

严格来说,含了“相等”的,应该就不能说是“单调”了,但在这里应作宽泛的理解,即这里的“单调”也包括“相等”。

以单调递增栈为例, 从栈顶到栈底的数据,从右向左看,可能是这样的: 3, 2, 1. 1是栈顶。
此时,若要压栈0,可以顺利压栈: 3, 2, 1, 0.
但是若要压栈5,则要和栈里的元素一个个比较,如果栈顶比其小,则出栈,继续比较下一个,直到有元素大于等于5或全部出栈。
因此,压栈5的结果就是,1, 2, 3 依次出栈,然后 5 入栈。

通用伪代码及关键点

看了上面的数据结构,大家可能还是不明白单调栈这样的数据结构有什么用。
接下来,我们先看看单调栈入栈和出栈的通用伪代码。
然后,直接给出结论 – 单调栈的关键点到底在哪里。
最后,在下一节中,我们通过实例来说明如何应用单调栈解决难题。在这里,大家将能体会到单调栈化腐朽为神奇的能力。

伪代码以单调递增栈为例,如下:

vector<XXX> vec;    // 业务数据
stack<int> st;      // 存放 vec 的下标,此例为单调递增栈

for (int i=0; i<vec.size(); ++i)
{
    // 如果栈不为空且栈顶元素比压栈元素小,则栈顶元素出栈并作计算与更新结果,然后继续下一轮比较,直至入栈;
    // 否则,(即栈为空,或栈顶元素大于等于压栈元素),直接入栈即可
    while ( !st.empty() && vec[st.top()] < vec[i] ) {
        st.pop();
        // Do calculation and Update the result
    }
    st.push(i);  // vec内的元素都要入栈,但入栈的是其下标
}

注意2点:

  1. 业务数据集 vec 中的所有元素都要入栈一次和出栈一次;
  2. 为了保证栈内元素最后都能出栈,需要在 vec 的结尾添加一个特殊的数字:
    对单调递增栈,在vec末尾添加一个最大数,如 INT_MAX;
    对单调递减栈,在vec末尾添加一个最小数,如 INT_MIN.

最关键点:
整段代码并不难理解,而最关键的部分就是在元素出栈时候做的 “Do calculation and Update the result”.
这部分内容是因问题而异的,因此没法提前写出来,要具体问题具体分析。

单调栈有几个隐藏的信息需要注意:

  1. 所有元素都要入栈一次和出栈一次,除了最后加的特殊元素
  2. 当一个元素出栈的时候,去做计算和更新结果;
  3. 当一个元素即将出栈时,整个栈是单调递增或递减的,很多时候需要利用此特性去做计算;
  4. 一个元素可能在淘汰了栈顶的若干个元素之后入栈,但并不一定会令所有元素出栈;

接下来看2个具体的例子,相信很快就能理解单调栈的妙用了。

具体应用

实例-1. 单调栈的最经典案例 - 共看到多少矮人

问题: n个人站队,所有人向右看,个子高的可以看到个子矮的人的头顶;给出每个人的身高,问所有人能看到其他人的头顶数量的总和是多少。
输入: 4 3 7 1
输出: 2
解释: 4能看到3,7能看到1,所以1+1=2

思路:
首先想普通解法,那就是从左到右,每个元素扫描过去,直到遇到比自己高的元素,这样的算法复杂度是 O(n^2).
再看单调栈的解法,
由题意,容易确定是用单调递增栈。当遇到更高的人的时候,栈顶的人出栈。
每个元素出栈的时候做什么? 出栈的时候,计算此元素能看到的人的数量。
比如,3遇到7而出栈,此时计算3看见了多少人,就用7的下标减去3的下标再减1.
那么,因为每个元素入栈一次和出栈一次,计算的时候也只是简单的下标相减,因此,算法复杂度是 O(n).
由上可见,此题是最经典的单调栈的应用,几乎没有什么变化。

代码:

#include <iostream>
#include <stack>
#include <vector>
#include <climits>
using namespace std;

int peopleNumber(vector<int>& heights) 
{
    heights.push_back(INT_MAX);
    int result = 0;
    stack<int> st;

    for (int i=0; i<heights.size(); ++i)
    {
        // 单调递增栈,遇大则出栈
        while ( !st.empty() && heights[st.top()] < heights[i] ) {
            result += (i - st.top() - 1);
            st.pop();
        }
        st.push(i);
    }
    return result;
}

int main()
{
    vector<int> vec{4, 3, 7, 1};
    int result = peopleNumber(vec);
    cout << "result = " << result << endl;
    return 0;
}

实例-2. 求柱状图中的最大矩形面积 (LeetCode hard 级)

问题:
给定一个正整数数组,里面的数字代表柱状图中从左到右各个格子的高度,所有格子的宽度都是1.
求: 连续的最大矩形的面积是多少?

举例:
数组为 {2, 1, 5, 6, 2, 3} (别问为什么是这个数组,看LeetCode原题去)
结果为: 10
原因是: 5和6可以勾勒出的最大矩形面积就是 5*2 = 10

思路:
先看普通思路: 要求最大矩形面积,就要求出每一个格子所能勾勒出的最大矩形面积,然后选出最大的。
每一个格子所能勾勒出的最大矩形面积是什么呢?就是把这个格子分别向左看和向右看,直到看到比它低的格子或到数组边界,然后求面积。
普通思路的编程并不难,几分钟可以搞定。算法复杂度呢? 就是 O(n^2).

再看单调栈的思路:
首先要想清楚是用单调递增栈还是单调递减栈。选哪一个呢?
我们想到,在元素出栈的时候,要“进行计算和更新结果”。那么,元素出栈的时候,计算什么呢?
就是计算这个元素向左横扫和向右横扫所得到的最大面积是多少!
向左横扫,就是看栈内,如果栈内有比它高的,那就麻烦了,因为栈内不知道还有多少元素,没法横扫;
但如果栈内都是比它低的,那就好办多了,只要看压栈元素到它的距离即可,因为那就是向右横扫的距离。
由此,结论就出来了 – 用单调递减栈!
但是当一个元素出栈的时候,栈内所有的元素都比它小,是不是就无法做向左横扫这个动作了呢?
不是的,有办法!栈内紧贴着刚出栈元素的那个元素,也就是新栈顶元素的下标此时就起作用了。
事实上,向右横扫+向左横扫(包含自身),就等于 (压栈元素下标 - 新栈顶元素下标 - 1).
因为新栈顶元素是一定比刚出栈元素小的,但它和刚出栈元素在下标的关系上,却并不一定是紧邻的,因此向左横扫这个动作就要通过 “减去新栈顶元素下标” 这个动作来做了。

另外,还有一种特殊情况,就是一个元素出栈后,栈为空了,那么这个元素的向左横扫和向右横扫怎么做呢?
一个元素出栈后栈为空,这代表的含义是什么呢?
含义就是,这个元素是有史以来到至今压栈之前的最小元素。压栈的元素更小,但在它之前的所有元素中,就是这个出栈元素最小了,因为它出栈后栈就空了。所以,压栈元素的下标,就代表了这个出栈元素能横扫的范围。

举例:
{3, 2, 1, 4, -1} 数组

  • 3入栈,2压栈,3出栈,3是有史以来最小的,所以3能横扫的范围就是2的下标1;
  • 2入栈,1压栈,2出栈,2是有史以来最小的,所以2能横扫的范围就是1的下标2;
  • 1入栈,4压栈,4入栈;
  • -1压栈,4出栈,此时栈不空,4的横扫范围就是: (-1)的下标 - 1的下标 - 1 = 1
  • -1继续压栈, 1出栈,栈为空,1是有史以来最小的,1的横扫范围就是-1的下标,即4.

最后看代码:

#include <iostream>
#include <stack>
#include <vector>
#include <climits>
using namespace std;

int largestRectangleArea(vector<int>& heights) 
{
    heights.push_back(INT_MIN);
    
    int result = 0;
    stack<int> st;
    
    int h=0, w=0;
    int top_id = 0;

    for (int i=0; i<heights.size(); ++i)
    {
        // 单调递减栈,因为出栈的那一刻,左边(即栈内)和右边(即压栈)的元素都比此元素小
        // 此时,方便计算此出栈元素的能够横扫的最大范围
        while ( !st.empty() && heights[st.top()] > heights[i] ) {
            top_id = st.top();
            st.pop();
            
            h = heights[top_id];
            
            // w = ( st.empty() ? i : (i-st.top()-1) ); // 以下9行可缩为此1行
            if (st.empty()) {
                // 当栈为空时,代表刚刚出栈的元素是自最初始以来至当前压栈元素之前的最小元素
                // 因此,当前压栈元素的下标i,代表了刚出栈元素能横扫自0至i-1的所有的格子。
                w = i;
            }
            else {
                // 刚刚出栈的那个元素能横扫的矩形区域
                w = i - st.top() - 1;
            }
            result = max(result, w*h);
        }
        st.push(i);
    }
    
    return result;
}

int main()
{
    vector<int> vec{2, 1, 5, 6, 2, 3};
    int result = largestRectangleArea(vec);
    cout << "result = " << result << endl;
    return 0;
}

总结:

  • 遇到要扫描一个数组,且算法复杂度为 O(n^2) 的问题的时候,要想想能否用单调栈解决;
  • 然后思考,当元素出栈的时候,应该做什么,这是最关键所在
  • 上述问题的答案往往也会决定是用单调递增栈还是单调递减栈

(END)

  • 19
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值