浅谈单调栈,单调队列(C++实现,配合lc经典习题讲解)

【前言】

队列是我们刚开始接触数据结构与算法这门课时会学到的一种数据结构。在刚开始刷leetcode时,我肤浅地认为栈和队列的使用场景很少,只是一种改变元素输出顺序的手段罢了。但是后来发现栈和队列在很多其他的场景,如图论算法,递归,贪心中都有广泛的应用,也是我们通过额外数据结构来优化算法时间复杂度的绝佳方式。本文介绍两种队列的进阶类型:单调栈单调队列顾名思义,单调栈和单调队列就是要保证数据结构中元素排列顺序的的单调性,而且很多情况下单调栈和单调队列中存储的元素其实是数组的下标,而非实际的值在本文中,会结合一些leetcode的相关经典题目进行讲解,并给出代码实现。

【单调栈】-“上/下一个更大/小元素”

单调栈在涉及区间比较,区间最值等场景下被广泛使用,该数据结构的核心便是栈中元素的单调性的维护。需要注意的是,单调栈中元素的单调性是经过入栈和出栈操作进行维护的,是一个动态的过程!

单调栈分为两种类型:

单调递增栈:从栈底到栈顶元素依次递增

单调递减栈:从栈底到栈顶元素依次递减

模板题:496.下一个更大元素I-力扣(LeetCode)

在进行下文的介绍之前,请朋友们一定先做一做上面给出的这道题目,可以说是单调栈类型的模板题了。在题目刷的足够多之后,当看到或联想到“下一个更大元素”关键词,就应该立即反应出利用单调栈来解题,这样就说明单调栈的题目练到位了。为了方便理解,这里我举一个实例来介绍:

例:我们已知数组{5,4,3,6,7,2,0,7}。对于每一个数组中的数,能否用时间复杂度O(n)的方法分别找出左侧第一个比它大,和右侧第一个比它大的数?

结果提示:对于数字5而言,左侧第一个比它大的数不存在,记作-1;右侧第一个比它大的数为6

对于数字3而言,左侧第一个比它大的数为4,右侧第一个比它大的数为6

根据给出的时间复杂度O(n)要求,不能采用双指针遍历的O(n^2)暴力解法。而降低时间复杂度的一个有效方案就是拿空间换时间,即引入辅助数据结构。我们维护一个单调递减栈,且栈中存放的是数组元素的下标,我们可以利用一个哈希表来存储元素与下标之间的映射关系

首先声明一点,单调栈这个数据结构有它的内核:栈中元素的排列必须保持单调!

刚开始的时候栈是空的,我们将“5”对应的下标0压入栈底;接下来4准备入栈,因为4<5,满足单调递减栈的单调性要求,所以我们将“4”对应的下标1也入栈。同理,将“3”对应的下标2也入栈。

此时栈中下标所指的实际元素从栈底到栈顶分别为5,4,3。对于栈底元素"5"而言,其左侧不存在比它更大的数;对于元素“4”而言,其左侧第一个比它大的数就是“5”;对于元素“3”而言,其左侧第一个比它大的数就是“4”。通过观察我们不难发现:对于单调递减栈中的元素,其左侧第一个比它大的元素,就压在该元素下方的相邻位置。

接下来”6“准备入栈,但是”6“大于栈中所有的元素。如果此时将”6“压入栈顶,会破坏栈中的单调性。这里我们可以把这个元素“6”类比成一个入侵者,它会打破这个栈中的平衡(单调性),为了维持这个平衡,此时应该将栈中所有元素全部出栈,之后再将“6”对应的元素下标4压入栈底。接下来”7“准备入栈。后续过程与前面同理,篇幅有限将不再赘述。对于上面的过程,我们可以看到,正是元素”6“的入栈,迫使元素”5“,”4“,”3“出栈。所以我们得出结论:对于单调递减栈中的元素,其右侧第一个比它大的元素,就是迫使它出栈的那个元素。

值得注意的是,当最后一个元素”7“准备入栈时,”0“,”2“两元素均因不满足单调性而被弹出,而栈底的元素也是”7“。此时我们引入一个原则:对于两个相等的元素,”后来者居上“,也就是说我们应当让下标为5的”7“弹出,让下标为8的”7“入栈。结合下面的图,我们便能清楚的看到单调栈是如何动态维护其内部单调性的。

接下来我们来证明一下上文给出的两条结论:

由上文引出的结论可知:a左边大于它的第一个元素就是b,而a右边第一个大于它的元素就是c(假设c会使a弹出)。那么如何证明上述结论呢?我们来看[b,a]区间上的特征:首先由于入栈顺序的限制,b一定在a的左边,且由于单调性的限制,b一定小于a。在[b,a]上的元素有可能小于a,但是不可能大于b,否则b就被弹出了。它也不可能大于a同时小于b,否则该元素不会被出栈,而是保留在栈中。同理,由入栈顺序可以确定c一定在a的右边,且[a,c]中不会有大于a的元素,否则轮不着c来让a出栈。

在了解单调栈的实现原理之后,我们回头来看文章开头的那道模板题:给定了两个数组nums1和nums2,且nums1是nums2的子集。题目要求我们找出满足 nums1[i] == nums2[j] 的下标 j其实我们可以将其理解为一个从nums1到nums2的一个映射过程,我们可以考虑用一个哈希表来维护这层映射关系。也就是说,这道题最核心的点在于:在nums2上的给定位置去找“下一个更大元素”。“下一个更大元素”我们可以用一个单调递减栈来处理,原理与上文的介绍完全相同。这里我们直接给出代码实现,细节方面请结合注释来看:

class Solution {
public:
    vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
    vector<int> res;//存储结果的容器,这个容器元素与nums1一一对应
    stack<int> st;//单调递减栈
    unordered_map<int,int> Hash;//利用哈希表来存储nums1到nums2的映射关系
    //在nums2的基础上建立单调栈,求出每个元素的“下一个更大元素”
    for(int i=0;i<nums2.size();i++){
    //单调递减栈的实现
        while(!st.empty()&&nums2[i]>st.top()){
            int temp=st.top();
            st.pop();
            //哈希表中插入一条key为nums2[i],value为temp的记录
            Hash[temp]=nums2[i];//这句理解为:temp的下一个更大元素是nums2[i]
        }
        st.push(nums2[i]);
    }
    //利用哈希表在nums1中进行查找
    for(int num:nums1){
        if(Hash.count(num)){//如果找到了num的“下一个更大元素”
            //将这个“更大元素”插入在对应位置
            res.push_back(Hash[num]);
        }else{//否则在对应位置插入-1
            res.push_back(-1);
        }
        
    }
    return res;
    }
};

经​​​​​​典题:42.接雨水-力扣(LeetCode)

这是一道经典的在柱状图背景下利用单调栈的题型,有很多以它为原型而引申出的题目,这里我们便对这道“模板”来进行介绍。假设每个柱子宽度为1,我们结合下面图例来看:

我们用一个数组height来存储柱状图中黑色柱子的高度,图例中这个数组为{0,1,0,2,1,0,1,3}。并且我们将这些黑色的柱子视为一堵""(后文为了方便都用“墙”来表述黑色柱子)。由图例我们不难看出,只有被"两堵墙"完全包裹的部分能够积蓄“雨水”。我们将这些部分描红,我们要求解的便是这些红色区域的面积总和(在本例中为4)。经过分析,解决该问题的关键在于:如何界定“雨水”区域的左右边界?“雨水”的量受什么因素的影响?

首先我们假定某个区域(“雨水”)它的左侧有一堵“墙”(用l标记)高度为h,且在这堵“墙”的右侧区域能够容纳高度也为h的“雨水”。实现这个假设的重要依据就是:“雨水”右侧的“墙”(用r标记)的高度不能低于h。否则由(l,r)所包围的区域无法容纳高度为h的“雨水”。由此我们想到了对每一堵高度为h的“墙”,找到下一堵不低于它的“墙”。由这个特征我们便能很自然的联想到利用单调递减栈来辅助我们解决这个问题,确定一个“雨水区域”的左右边界。

那么“雨水”量又受什么因素的影响呢?这时候我们联想到"木桶效应",即一个木桶的最大容量取决于它最短的那块木板的高度。同理,“雨水”量取决于将它包围的两堵“墙”中较低的那一堵。我们采用左右指针来指向将“雨水”包围的两堵“墙”,然后取二者之中较小的作为“雨水”的高。最后结合两堵“墙”的相对位置进行面积的求解。但是这里又出现了一个新问题:如果“雨水”区域不止一层?且上下层宽度不同该如何解决:

我们结合上面的图例来看。在[4,6]范围上确实存在一块两层且上宽下窄的“雨水”区域。我们使用变量i来按列遍历。这时候会发现当i=6的时候,只有下方的“雨水”区域被围起来了,而上方更宽的“雨水”区域要等到i=7才被包围。我们来关注i=7的时候,此时由[4,6]所包围的,高度为1的“雨水”量已经被结算,我们不再关注。因此当需要算新的“雨水”区域([3,7]所包含的区域)的高度,我们求的实际上是一个“相对高度”,就好比我们站在一个木桶上量身高,最后我们的净身高是求得的高度要减去木桶的高度。接下来看代码实现:

class Solution {
public:
    int trap(vector<int>& height) {
    int res = 0;
    stack<int> st;//单调栈
    //对每一堵“墙”,寻找下一组不比它矮的“墙”
    for (int i = 0; i < height.size(); i++)
    {
    //构建单调递减栈
        while (!st.empty() && height[i]>=height[st.top()])//发现了符合需求的“墙”
        {
            int cur = st.top();//记录下“当前高度”
            st.pop();
            int l = st.top();
            int r = i;
            //求“相对高度”
            int h = min(height[r], height[l]) - height[cur];
            res+= (r - l - 1) * h;
        }
        st.push(i);
    }
    return res;
    }
};

经典题:84.柱状图中的最大矩形-力扣(LeetCode)

做完上面“接雨水”的题目,相信大家对于如何在一个题目中抽取关键点,联想到利用单调栈来解决问题有了直观的认识。我自己的总结是:先分析问题,当问题中存在“求下一个更大/更小元素”这样的需求时,我们考虑利用单调栈来辅助我们解题。接下来的题目同样很经典,也是leetcode的hard难度。在看后文介绍之前请大家先浏览链接中的题目,看懂大概的题意。接下来我们结合下面图例来讲解思路:

规定每个小矩形的宽度为1,我们用一个数组height来存储柱状图中每个小矩形的高度。首先我们可以肯定,所求的最大矩形的高度,一定是height数组中的元素。由此,我们欲得到面积最大的矩形,就要保证在矩形的高不能减小的基础上,使得矩形的宽度最大。我们用一个变量i遍历height数组,记height[i]为i位置处矩形的高度,现在的问题就是如何在保证height[i]不减小的情况下,使得矩形的宽度最大。

这个问题的关键就在于如何保证height[i]在一个连续的区域内不再减小。所以我们需要以i位置为中心向两边发散,在左边找到某位置(记作left)满足height[left]<height[i],即使得height[left]是height[i]的“上一个更小元素”。同理,在右侧找到某位置(记作right)满足height[right]<height[i],即使得height[right]是height[i]的“下一个更小元素”。由于我们要对每个i位置求left和right位置,于是我们用数组来存储求得的结果。由此我们便能确定在[left,right]区间上矩形的最大高度为height[i],也因此计算出对于每个i位置的最大矩形面积为(right[i]-left[i]-1)*height[i]正是因为我们捕捉到了“上/下一个更小元素的字眼,我们考虑利用单调栈来进行解题。如果没有看懂原理的介绍,没关系,我们再来看上面的图例:

假设i=2,此时height[2]=5,我们选择它做这个矩形的高。由图例可知,height[i]的“上一个更小元素”(left[2])为1(此时height[1]=1<height[2]);height[i]的“下一个更小元素”(right[2])为4(此时height[4]=2<height[2]),所以以height[2]为高能构成的最大矩形面积为(4-1-1)*5=10。对于每一个位置i,我们都能计算出以height[i]为高能构成的最大矩形面积。最后在这些“最大面积”中取最大值,便是本题所求结果。关于如何利用单调栈求解“上/下一个更小元素”的位置,前文有原理性的讲述提供参照,篇幅有限此处将不再展开。接下来看代码实现:

class Solution {
public:
    int largestRectangleArea(vector<int>& height) {
    int res=0;
    int n=height.size();
    //存储每个元素的“上/下一个更小元素”的位置
    vector<int> left(n,0),right(n,n);
    stack<int> stkl,stkr;//构建单调递增栈
    //对i位置的左半边,寻找i的“上一个更小元素”的位置
    for(int i=0;i<n;++i){
        while(!stkl.empty()&&height[i]<=height[stkl.top()]){
            stkl.pop();
        }
        if(!stkl.empty()){
            left[i]=stkl.top();
        }
        stkl.push(i);
    }
    //对i位置的右半边,寻找i的“下一个更小元素”的位置
    for(int i=n-1;i>=0;i--){
        while(!stkr.empty()&&height[i]<=height[stkr.top()]){
            stkr.pop();
       }
       if(!stkr.empty()){
           right[i]=stkr.top();
       }
       stkr.push(i);
    }
    //求最大矩形的面积
    for(int i=0;i<n;i++){
        res=max(res,(right[i]-left[i]-1)*height[i]);
    }
    return res;
    }
};

经典题:85.最大矩形-力扣(LeetCode)

这道题是“柱状图中的最大矩形”的进阶版本,也是一个升维。通过这道题我们来学习在二维网格结构中如何利用单调栈来辅助我们解题,本题也是leetcode的hard难度题。在看后文介绍之前请大家先浏览链接中的题目,看懂大概的题意。接下来我们结合下面图例来讲解思路:

题目给了我们一个二维数组matrix,C++中我们利用二维柔性向量vector进行存储。这个网格区域被数字“0”和“1”填充,我们需要求解只包含1的最大矩形的面积。老实说,最开始看到这道题,我联想到的是“岛屿问题”,即网格图上的DFS或BFS,根本没有想到利用单调栈来解题。这也正是这道题的陷阱(网格DFS或BFS在大多数情况下处理的区域是不规则的),就连看到“单调栈”的提示我也依旧没有任何想法。但是实际上,假设这个网格图有n行。我们可以以这个网格图每一行的最左边的位置作为原点,建立平面直角坐标系,将其拆分成n张柱状图!将填有“1”的位置视为有效区域,将填“0”的位置视为无效区域。来看下面的图例,相信可以让你一目了然:

依照上图展示的原理,我们可以将这个网格图分解为若干张柱状图。与84题不同的是,84题直接给出了每个小矩形的“有效高度”,而本题的有效高度需要我们自行计算并存储。所谓“有效高度”便是针对每张柱状图的每个位置,求每一列中“连续1”的个数。之后的解题过程就和84题几乎一模一样,连实现代码都是高度复用的。我们直接来看代码实现:

class Solution {
public:
    int maximalRectangle(vector<vector<char>>& matrix) {
        int res = 0;
        int m = matrix.size();
        if (m == 0) {
            return 0;
        }
        int n = matrix[0].size();
        // 构建heights数组,计算每列连续'1'的高度(“有效高度”)
        vector<vector<int>> heights(m, vector<int>(n, 0));
        for (int i = m - 1; i >= 0; --i) {
            for (int j = 0; j < n; ++j) {
                if (matrix[i][j] == '1') {
                    //最下面一行,如果区域中数字为1,则“有效高度”就是1
                    if (i == m - 1) {
                        heights[i][j] = 1;
                    } else {
                        //从第二行开始,需要累加下面的结果
                        heights[i][j] = heights[i + 1][j] + 1;
                    }
                } else {
                    heights[i][j] = 0;
                }
            }
        }
        //对每一张柱状图进行“最大面积”的求解,思路类似84题,代码片段高度复用
        for (int i = 0; i < m; ++i) {
            // 新增left和right数组,记录每个位置往左和往右能延伸到的最远边界
            vector<int> left(n,0), right(n,n);
            stack<int> stk;
            // 计算每个位置的“上一个更小元素”
            for (int j = 0; j < n; ++j) {
                while (!stk.empty() && heights[i][j] <= heights[i][stk.top()]) {
                    stk.pop();
                }
                left[j] = stk.empty()? 0 : stk.top() + 1;
                stk.push(j);
            }
            stk = stack<int>();
            // 计算每个位置的“下一个更小元素”
            for (int j = n - 1; j >= 0; --j) {
                while (!stk.empty() && heights[i][j] <= heights[i][stk.top()]) {
                    stk.pop();
                }
                right[j] = stk.empty()? n : stk.top();
                stk.push(j);
            }
            // 根据left和right计算每个位置对应的最大矩形面积并更新全局最大面积
            for (int j = 0; j < n; ++j) {
                int width = right[j] - left[j];
                int area = heights[i][j] * width;
                res = max(res, area);
            }
        }
        return res;
    }
};

【单调队列】-局部最值问题

模板题:239.滑动窗口最大值-力扣(LeetCode)

对于单调栈而言,有时候我不仅要通过处理栈顶元素来保持数据结构内部的单调性,还需要对栈底的元素进行某种处理,显然栈结构并不满足我们的要求。单调队列可以视为单调栈结构的一个引申,更准确的说,应该称为单调双端队列。在C++中一般采用deque容器来实现。单调队列与单调栈由相同的内核:栈中元素的排列必须保持单调!双端队列在求解局部最值问题上有广泛的应用,接下来我们结合上面这道题来介绍单调队列的运作过程。相信有了之前对单调栈结构的理解,学习单调队列会轻松很多。请大家先浏览链接中的题目,看懂大概的题意。接下来我们结合下面图例来讲解思路:

假设我们得到一个nums数组[1,3,-1,-3,5,3,6,7],窗口的大小为3。则局部的最值情况如下图所示:

由于我们要求的是一个局部最大值,于是我们构建一个单调递增队列。也就是说,从队尾到队头的元素大小是依次递增的,队列中存放的是nums数组的下标索引。若我们使用变量i来遍历数组,会发现只有当i>=2时,才会形成“窗口”(因为窗口大小为3)。对于单调双端队列,我们缺省从尾部插入数据,从头部删除数据。假设当前在i位置,即数据为nums[i]。在“窗口”形成之前,我们首先查看队列中是否存在小于nums[i]的元素,如果发现有,为了保证单调性,将其从队头移出之后再将i加入队列,由此我们可以保证队头元素便是局部最大值。在窗口形成之后,我们检查队头元素是否还在队列中,如果不在,则说明它“过期了”,将其从队头移出。接下来结合下面实例来看:

如图所示,此时元素“5”进入窗口。首先检查队列,发现队头元素“3”已经不在窗口内了,故将其从队头移出。我们发现“5”比队列中存储的两个元素-1,-3都大,直接入队会破坏队列的单调性,所以我们将分别指向元素“-1”“-3”的下标“2”,“3”都从队头移出。之后再将指向元素“5”的下标“4”入队。由此这个窗口中的最大值就是队头元素,也就是5,我们将队头元素记录并存储到数组当中。我们对每个位置形成的窗口都可以实现该操作,于是我们可以通过此方法得到由窗口最大值构成的集合。接下来看代码实现:

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        vector<int> res;
        int n=nums.size();
        //建立单调递减队列
        deque<int> MaxArr(n);
        if(n==0||k<1||n<k){
            return res;
        }
        for (int i = 0; i < n; ++i) {
            // 如果队列的第一个元素已经不在当前窗口中了,就移除它
            if (!MaxArr.empty() && MaxArr.front() == i - k) {
                MaxArr.pop_front();
            }
            // 从队列尾部开始移除所有小于当前元素的索引,保证队列从队首到队尾递减
            // 我们只需维护局部最大值,比较小的值我们不需要,直接丢弃
            while (!MaxArr.empty() &&nums[i]>=nums[MaxArr.back()]) {
                MaxArr.pop_back();
            }
            // 将当前元素的索引加入队列
            MaxArr.push_back(i);
            
            // 当窗口形成后(即 i >= k - 1),记录窗口的最大值
            // 因为当i>=k-1后,每一次移动都一定会形成一个窗口
            if (i >= k - 1) {
                res.push_back(nums[MaxArr.front()]);
            }
        }
        
        return res;
    }
};

以上便是对于单调栈和单调队列的介绍。 栈和队列不仅在进入的顺序对元素起到约束作用,也可以方便我们对一些特定的位置实现各种操作,比如栈顶,队头,队尾等。更重要的是我们要培养出在问题中抽象出单调栈,单调队列的能力,并配合当前需求来解决实际问题。我是小高,一名非科班转码的大二学生,水平有限认知浅薄,有不当之处期待批评指正,我们一起成长!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值