简简单单单调栈,精精巧巧巧解题

巧用单调栈解决题目

写在之前

在我们了解单调栈的数据之前,我们先来看一道题目,热热身吧

496. 下一个更大元素 I

看到题目,我一开始想到的便是暴力解法,我们用一个指针i遍历数组nums1的元素,再用一个指针jnums2数组的头遍历到尾,当nums1[i] == nums2[j],我们再使用一个指针k去寻找nums2中在nums2[j]之后第一个大于它的元素。

力扣中通过的暴力代码如下:

class Solution {
public:
    vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size(), m = nums2.size();
        vector<int> res(n);
        for (int i = 0; i < n; i++) {
            int j = 0;
            while (j < m && nums2[j] != nums1[i]) {
                ++j;
            }
            int k = j + 1;
            while (k < m && nums2[k] < nums2[j]) {
                ++k;a
            }
            res[i] = k < m ? nums2[k] : -1;
        }
        return res;
    }
};

但是我们不难发现,就是暴力解法的思路虽然简单直接,但是时间复杂度达到O(m*n),空间复杂度为O(1)

我们都知道,在算法当中往往都可以用空间换时间,那么这道题能不能略微提高其空间复杂度,来缩短时间复杂度呢?要解决这个问题,就要我们隆重介绍一下今天的主角——单调栈

单调栈的定义

单调栈的分类

栈作为一种数据结构,单调栈也拥有其先进后出的特点(我们称弹出元素的位置为栈顶),因为名字里面的单调嘛,我们不难猜出栈中存储的内容是有相关顺序的:

  • 单调递增栈:栈底元素到栈顶元素的数据存储是从小到大的
  • 单调递减栈:栈底元素到栈顶元素的数据存储是从大到小的

单调栈的存储顺序

单调递减/增栈:只有比栈顶元素更小/大的元素才能进行入栈操作,当遍历到比栈顶元素更大/小的元素,必须不断的将栈顶元素弹出,直到栈顶元素比当前元素更小/更大或者栈中的元素为空之后,才将当前元素进行入栈操作。

我们现在以单调递增栈为例子,来模拟一下单调栈的工作原理:

假设现在有数组arr[] = {2,1,5,6,2,3}

步骤当前元素操作栈中元素(右侧为栈顶元素)
12栈为空将 2 入栈【2】
21栈顶元素为2 ,2 > 1,元素2弹出,1入栈【1】
35栈顶元素为1 ,1 < 5 ,5入栈【1,5】
46栈顶元素为6 ,5 < 6 ,6入栈【1,5,6】
526 出栈,5 出栈, 2入栈【1,2】
63栈顶元素为2 ,2 < 3 ,3入栈【1,2,3】

对于单调递减栈,其实与单调递增栈的存储思路大致,只是想要达到的目的不同,在这里不多赘述,由读者自行摸索。

单调栈能解决的问题

我们不难发现,通过单调栈我们似乎很容易通过一次遍历就能找到某个元素在该序列中,第一个大于或者小于它的元素。

因此单调栈的运用场景大致如下:

  • 寻找左侧第一个比当前元素大的元素。

    • 使用单调递增栈,找到左侧第一个比当前元素大的元素,即遍历到当前元素的位置时,栈顶元素就是它左侧第一个大于它的元素
    • 若栈为空,就是说明这个数组内它的左侧没有比当前元素更大的元素
  • 寻找左侧第一个比当前元素小的元素。

    • 使用单调递减栈,找到左侧第一个比当前元素小的元素,即遍历的当前元素时,单调栈中存储的栈顶元素
    • 与上同理,若栈为空,则找不到左侧第一个比当前元素小的元素。
  • 寻找右侧第一个比当前元素大的元素。

    • 使用单调递减栈,当我们遍历到比栈顶元素大的元素,当前元素即为栈顶元素右侧第一个大于栈顶元素自身的元素。
    • 若栈顶元素没有被弹出栈,那么显而易见,栈顶元素的右侧没有比其更大的元素了。
  • 寻找右侧第一个比当前元素小的元素。

    • 使用单调递增栈,如果当前元素小于栈顶元素,当前元素即为栈顶元素右侧第一个小于它的元素
    • 同上,栈为空,在栈顶元素右侧就没有比它更小的元素了

注:根据笔者写过的题目而言,绝大多数使用单调栈解决的题目,都是用单调栈存储数组元素的下标,方便我们进行访问。

单调栈的模版

经过我个人的总结,我发现单调栈的题目都是能够总结为一个类似的模版

代码如下:

int monotonicstack(vector<int>& arr) {
        int n = arr.size();
        stack<int> s;
    	//在此处可以初始化一些需要的变量
        for (int i = 0;i < n; i++) {
            while (!s.empty() && arr[i] > / < arr[s.top()])
            // > 代表该栈为单调递减; < 代表单调递增
            {
                int temp = s.top();//将栈顶元素弹出
                s.pop();
                //当遇到不符合其单调性的元素,便根据题意进行操作
            }
            s.push(i);//元素入栈
        }
        return;//根据题意进行返回
    }

解决算法问题

经过以上的介绍,相信大家一定会对单调栈有更加深刻的认识,我们现在趁热打铁,先来看一道仅用单调栈就能解决的问题,来巩固一下吧。

巩固


739. 每日温度

找到右侧第一个大于当前元素的距离,其实就是运用单调递减栈,我们只需要将元素的下标存入栈中,当找到大于栈顶元素的当前元素,就将当前元素的下标减去栈顶元素的下标,就是两者相隔的距离,也就是题目中所说的间隔天数。

class Solution {
public:
    vector<int> dailyTemperatures(vector<int>& temperatures) {
        int n = temperatures.size();        
        vector<int> res(n);
        stack<int> stk;
        for (int i = 0; i < n; i++) {
            while(!stk.empty() && temperatures[i] > temperatures[stk.top()]) {
                int temp = stk.top();
                res[temp] = i - temp;//将间隔天数存储在相应的下标中
                stk.pop();
            }
            stk.push(i);
        }
        return res;
    }
};

纯单调栈;


496. 下一个更大元素 I

​ 在我们了解了单调栈的工作原理之后,我们就能知道,前面这道题目,其实也可以用单调栈的解决,如果我们只是想找到nums2数组中比当前元素左侧第一个更大的元素,那么这道题其实十分简单,我们前面也介绍过了,只需要一个单调递减栈就可以解决。

​ 那么我们怎么将这道题目与,与寻找右侧更大的元素这道题目联系起来呢?

​ 我们就需要加一点动作将问题进行转化,其实这道题的难度就在于要找到nums1中元素在nums2中的位置。我们就不难想到哈希表,我们可以用哈希表来存储在nums2中左侧第一个更大的元素的下标作为映射,然后再对nums1进行遍历,以当前元素为下标访问哈希表,就能够成功找到nums1元素在nums2中左侧更大的元素的下标了。

class Solution {
public:
    vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
        int n = nums1.size(), m = nums2.size();
        vector<int> res(n);
        stack<int> s;
        unordered_map<int, int> hash;
        for (int i = 0; i < m; i++) {
            hash[nums2[i]] = -1;//将所有map的初始映射值改为-1
            while (!s.empty() && nums2[i] > nums2[s.top()])//构造单调递减栈
            {
                hash[nums2[s.top()]] = nums2[i];//将栈顶元素作为下标构造哈希表的映射
                s.pop();
            }
            s.push(i);
        }
        for (int i = 0; i < n; i++) {
            res[i] = hash[nums1[i]];
        }
        return res;
    }
};

单调栈 + 哈希表

​ 我们观察这个解题思路,多使用了一个哈希表的内存O(m),让我们,通过一次时间复杂度的O(m+n)的遍历就解决问题。


402. 移掉 K 位数字

​ 看到这道题,我们可能不会第一时间想到要用单调栈来解决,不过在写题之前,我们都应该对题目的题意进行分析

​ 我们要怎么求得移除后最小的数字呢?我们先试着将问题拆解为一个个较小的问题,即如果我移除一个数字能得到的最小数字是多少呢?

​ 用给出的数字1432219来看,若是移除这串数字的最大值9 似乎也得不到最小的数字。那我们移除4 或者移除3, 得到的数字是 132219 < 142219,不难看出我们的删除决策就是将靠前的数字尽可能的小。假如我们移除的是4那么后面无论移除多少次,数字串都是13开头的,假设我们移除的数字为3,那么后面在怎么移除都是13开头的了。

​ 经过分析,我们得出了我们删除所需要的贪心策略。即在由k个数字所组成的(ABCDEFG……),从左往右进行遍历,当遍历到当前元素小于它的上一位,那么就将当前元素删去,如果操作的最后还剩余操作次数的话,就数字从尾到头进行删去。

​ 看到这个算法策略,我们就会发现,单调递增栈很方便进行这个操作。

class Solution {
public:
    string removeKdigits(string num, int k) {
        vector<char> s;
        for(auto ch : num) {
            while(!s.empty() && ch < s.back() && k) {
                s.pop_back();
                k--;
            }
            s.push_back(ch);
        }
        for(; k > 0; k--) {
            s.pop_back();
        }
        string ans = "";
        int flag = 1;
        for (auto& ch : s) {
            if (flag && ch == '0') //由于需要删除前导零,所以需要进行此操作进行删除
            {
                continue;
            }
            flag = 0; //标记使用判断0是否为前导零
            ans += ch;
        }
        return ans == "" ? "0" : ans;
    }
};

单调栈 + 贪心


提高

​ 写了这么多道单调栈的题目,似乎都是去找当前元素一侧的第一个大于或者小于它自身的值。而我们返回到我们单调栈所能解决的问题,我们可以发现,对于栈顶元素来说,当遇到需要其弹出的条件时,只要栈顶元素的下一位不为空的话,我们不仅找到它右边第一位 大于/小于 它的数,而且栈顶元素的栈中下一个元素还是左边第一位 大于/小于 它的数。

​ 我们仍然以单调递增栈和数组arr[] = {2,1,5,6,2,3}为例子:

image-20240416151520754

当我们看到步骤4和步骤5,当元素2打算入栈时,由于2 < 6不符合单调递增栈的定义,那么其实我们就找到了元素6 右侧第一个小于它的元素——2。此时的6即使弹出,栈中元素也不为空,说明栈顶元素的下一位存在。栈顶元素的下一位是 5 恰好就是元素 6 左侧第一个小于它的元素。

从分析中我们可以知道,单调栈的一次弹出判断其实可以很容易巧妙地确定左右两边第一个 大于/小于 栈顶元素的元素位置。我们通过研究又深入的理解了单调栈的相关知识,那么通过单调栈一次来确定栈顶元素左右两边第一个 大于/小于 元素的特点,又能在写题里面帮助到我们什么呢?

​ 请看此题:

42. 接雨水

​ 我们对题目进行分析,究竟是一个怎么样的情况,我们能存住雨水呢?对于当前元素高度,我们需要去寻找左右两侧都有元素高于当前元素才能接住雨水。那我们将问题分成一小个的问题,即当前元素的高度,能够存入多少水,只要将所有当前高度的接水量,进行不断的累加,我们就可以统计出该图形能够存入多少雨水了。

​ 我们要找到当前元素的存水量,其实就是找到当前元素左右第一个大于其高度的位置,另他们为左右边界(我们称之为变量leftright),h[i]是当前位置的高度,存水量就是为,(min(h[left], h[right]) - h[i]) * (right - left + 1) ,我们将值存储在变量sum中。通过以上的分析,那我们可以确定我们是采用单调递增栈来存储高度的相关信息,当读到高于栈顶元素时,就可以进行计算存水量的操作。注意将计算的过程完成之后就应该将当前元素入栈。

class Solution {
public:
    int trap(vector<int>& height) {
        int sum = 0;
        int n = height.size();
        stack<int> s;
        int width = 0, temp = 0, h = 0;
        for (int i = 0;i < n; i++) {
            while (!s.empty() && height[i] > height[s.top()]) //构建单调递减栈
            {
                int temp = s.top(); // 保存当前元素的高度
                s.pop();
                if (s.empty()) break;
        //当栈中没有下一位元素的时候,则说明左侧没有比栈顶元素更高的元素了,那么栈顶元素高度就存不住水
                width = i - s.top() - 1; //计算宽度,即左右边界的距离,记得左右边界时不取的
                h = min(height[s.top()], height[i]) - height[temp];
                //此处的s.top()为左边界的下标, i为右边界的下标
                sum += h * width;
            }
            s.push(i);
        }
        return sum;
    }
};

注:我们在这里并不用去处理,那些栈中还存在的元素,因为当 i 遍历至数组 height 结尾之后,元素还未弹出,就说明栈顶元素的右边没有比它更大的元素了,所以存不住水。


84. 柱状图中最大的矩形

​ 初看此题,似乎很难下手。难点就在于我怎么知道当前的两个指针指向的两个边界应该取什么高度。想到正难则反,我们可以思考,究竟满足什么条件的情况下,我们能以当前元素的高度构造矩形,求取矩形面面积。

​ OK!思考到这里,其实我们又将问题给转化成为单调栈能够解决的问题,这是为什么呢?

​ 如果我们想以当前元素的高度构造矩形,是不是要保证它的左右边界内的矩形高度要不低于当前元素的高度。那么我们的左右的边界就是找到当前元素左右两端第一个小于当前元素的位置,那么就构建一个单调递增栈,通过遍历去找到栈顶元素的左右边界。只不过这道题与接雨水不同的是,我们还需要在意遍历结束之后栈中的内容,我们应该如何操作,请看以下代码:

class Solution {
public:
    int largestRectangleArea(vector<int>& heights) {
        stack<int> s; //栈用于存储栈的下标
        int n = heights.size();
        int max_area = 0;
        for (int i = 0; i < heights.size(); i++) {
            while (!s.empty() && heights[i] < heights[s.top()]) {
                int temp = s.top();//将栈顶元素弹出
                s.pop();
                int width = (s.empty())? i : i - s.top() - 1; 
                // 当栈为空,则说明当前元素的左侧全部高于该元素,那么宽度直接为 i
                //非空则是左右元素下标相减,注意左右边界是不取的
                max_area = max(max_area, (width * heights[temp]));
                //矩形的高度就是heights[temp]
            }
            s.push(i);
        }
        while (!s.empty()) {
                int temp = s.top();
                s.pop();
                int width = (s.empty())? n : n - s.top() - 1;
                max_area = max(max_area, (width * heights[temp]));
            }
        //此处我们要关心栈内未被弹出的元素,由于遍历到数组的结尾还未被弹出,右边界就是数组的结尾
        //由于已经确定了右边界,那么栈中存储的就是构建的矩形高度以及它的左边界
        //如果栈顶元素弹出之后,栈为空,说明这个元素就是最小的元素,左边界为数组开头
        return max_area;
    }
};

总结

​ 经过这两周对算法的集中学习,单调栈作为借助数据结构完成算法的一种方式,在算法中还是有很重要的地位。以上的内容作为两周对单调栈专题的总结,希望自己能够在算法中继续进步,如有任何错误也请各位读者不吝赐教!

  • 25
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值