算法学习笔记——特殊数据结构:单调栈

单调栈Monotonic Stack

  • 单调栈本质上就是栈,但在使用栈的过程中,程序逻辑保证栈内的元素是单调的(单调递增或单调递减,具体视情况而定)
  • 单调栈用于在数组中维护各元素的左侧/右侧第一个比自己大/小的数,即下一个更大元素(Next Greater Element)问题
  • 单调栈时间复杂度为O(N)
  • 具体做法是当前元素入栈时,若栈顶元素比当前元素大/小,则将其弹出,从而保证入栈后整个栈单调

可以把单调队列视为单调栈的升级版

  • 单调栈维护[0, i)区间内的单调序列及其最大/最小值(因而我们说单调栈维护各元素的左侧/右侧第一个比自己大/小的数);
  • 而单调队列还能够弹出队首的元素,因此维护[lastpop, i)区间内的单调序列及其最大/最小值,即左边界可以变动,因而可以维护滑动窗口的最值

单调栈模板

下一个更大元素:给出一个数组,返回一个等长数组,其中各索引出存储着原数组的该处元素的下一个更大元素(不存在则为-1),例如,输入[2,1,2,4,3],返回[4,2,4,-1,-1]

如何用O(n)复杂度解决问题?(相当于只线性扫描一遍数组)

思路:把数组元素想象成站成一队的人,前面的人往后看,视线向上,第一个可见的人就是下一个更大元素(矮的都被高的挡住了,只看得到比自己高、更高、更更高的人…)

单调栈就是模拟这个过程:

  1. 从后往前扫描元素(倒着入栈,就是正着出栈)
  2. 每个元素入栈前,必须让栈中比自己小的元素都出栈(排除比自己矮的人),此后栈顶元素就是第一个比自己高的人(空栈代表后面没人比自己高)
  3. 最终,当前元素入栈,入栈后整个栈必定是单调的,栈顶最小(从而下一个人只看得到高、更高、更更高的人…并重复第2步,排除比自己矮的,留下栈顶为第一个比自己高的)
class Solution:
    def nextGreaterElements(self, nums: List[int]) -> List[int]:
        """寻找下一个更大元素
        从后往前扫描,同时维护单调栈
        模拟每个元素向后看,只能看到高度依次递增的人
        当前元素将高度小于自己的人全部出栈,栈顶就留下下一个更大元素"""
        ans = [None for _ in range(len(nums))]  # 答案
        stk = []  # 单调栈

        for i in range(len(nums) - 1, -1, -1):
            # 当前元素为nums[i]
            while len(stk) > 0 and stk[-1] < nums[i]:  # 当前元素将高度小于自己的人全部出栈
                stk.pop()
            ans[i] = stk[-1] if len(stk) > 0 else -1  # 栈顶元素就是下一个更大元素
            stk.append(nums[i])  # 入栈

        return ans

应用

  • LeetCode 496. 下一个更大元素 I(模板题)
  • LeetCode 739. 每日温度(模板题,输出下一个更大元素的位置,栈中变为保存下标)
  • LeetCode 901. 股票价格跨度(类似上题,维护上一个更大元素的下标,返回两者之间的距离,用虚拟下标-1处理没有上一个更大元素的特殊情况,称为哨兵
  • LeetCode 503. 下一个更大元素 II(题目变为循环数组,可以物理上拼接一个两倍长的数组/在逻辑上求模)

升级变式:

  • LeetCode 581. 最短无序连续子数组(隐含的单调栈,希望找出一个子数组,其左侧所有元素 <= 子数组内所有元素 <= 其右侧所有元素,①从左往右遍历,寻找第一个有【右侧下一个更小元素】的位置,则这里是左边界②从右往左遍历,寻找第一个有【左侧下一个更大元素】的位置,则这里是右边界)
  • LeetCode 84. 柱状图中最大的矩形(求柱形图中能勾勒出的最大面积,依次枚举每个柱子的高度作为矩形高度,并尽可能向左右延伸,利用单调栈遍历两次求出[某位置左侧/右侧第一个比他小的柱子下标],两者之差作为宽)
    ps. 题目数据改为二维01矩阵形式,求最大全1矩形面积,就是【最大子矩阵的大小】的问题,同样可以转为柱形图的问题来解决
  • LeetCode 316. 去除重复字母/402. 移掉 K 位数字(隐含的单调栈问题,要获得字典序最小/数字最小的子序列,则应该尽量保证序列靠前的的部分单调递增)

部分题解

LeetCode 402. 移掉 K 位数字
给出一个数组,从中挑选k个数字,构成数字序列,求字典序最小的可能序列

最小字典序原则:对于11a5和11b5,前面的数字确定,则当前位上,选择a和b中更小的,字典序也一定最小

思路:

  • 从左往右遍历,维护数字序列
  • 每次有新数字,考虑要用这个数 / 序列中前一个数 (选择a/b)
  • 前一个数>这个数,则应该用这个数 -> 符合单调栈特性:入栈前将更大的出栈
  • 但是要注意,也不能无限制的出栈,否则最后可能什么也不剩,故使用del_chance维护还能丢弃的个数(保留k个->最多丢弃L-k个),丢弃机会用完后,后面的只能乖乖拼接,但要注意,前面部分已经保证是可能的最小字典序
  • 原数组递增时,最终的栈大小大于k,这时截取前k个即可
class Solution:
    def mostCompetitive(self, nums: List[int], k: int) -> List[int]:
        """同LeetCode 402. 移掉 K 位数字
        要保留k个,等效于删除L-k个"""
        # 对于11a5和11b5,前面的数字确定,则当前位上,选择a和b中更小的那个一定更好
        # 从左往右遍历,维护数字序列->序列单调递增最好
        # 每次有新数字,考虑是否要丢弃前面的更大数->单调栈
        # 最多只能删除L-k次, 将L-k次机会用完后,再后面的数字只能直接拼接
        stk = []
        del_chance = len(nums) - k
        for i, n in enumerate(nums):
            # 入栈前删除所有更大的
            while del_chance > 0 and stk and stk[-1] > n:
                stk.pop()
                del_chance -= 1
            stk.append(n)
        return stk[:k]

LeetCode 316. 去除重复字母
取出字符串s中的重复字符,返回所有可能结果中字典序最小的

分析:
首先,最理想的“最小字典序”是abcdefg...,实际情况肯定不是这样,但是我们能发现构造答案的策略:如果可以的话,应该尽量让字母递增排列,从而满足“最小字典序”的特性(例如abcd字典序小于abdc,后者出现了“递减”,相对而言字典序更大的字符d出现在了前面)
因此,我们的策略是:从左到右遍历,尽可能构造“递增”的序列 -> 单调栈

这样看来,这题就和之前的问题有相似之处,区别在于这里要保证每个字符出现且仅出现1次,因此入栈前的出栈有限制:仅当后面还有同样的字符时,才能将前一个字符出栈

  • 字符入栈有限制:前面没有这个字符,才能入栈
    (前面已有该字符,由于是单调栈,前面的字符一定处在一个使整体字典序最小的合理位置,不需要再处理这个字符,如abca的最后一个a,前面已是递增的最小字典序排列)
  • 入栈时,前一个更大字符的出栈有限制:前一个字符在后面还有出现才能删除
from collections import Counter
class Solution:
    def removeDuplicateLetters(self, s: str) -> str:
        """同LeetCode 402. 移掉 K 位数字"""
        # 前面的数字确定,则当前位上,字母更小的那个一定更好
        # 维护单调栈,有新的字母入栈时:若前一个字符比他大,应该考虑丢掉前一个
        # (但注意前提:前一个字符在后面还有出现,才能丢弃)

        cnt = Counter(s)  # 记录当前位置之后,某个字符还会出现几次
        inStk = set()
        stk = []
        for i, ch in enumerate(s):
            # 当前字符入栈有限制:前面没有这个字符,才能入栈(考虑bcab的后一个b)
            if ch not in inStk:
                # 入栈前删除所有更大的
                while stk and stk[-1] > ch:
                    pre_ch = stk[-1]
                    if cnt[pre_ch] > 0:  # 后面还有出现,可以删除
                        stk.pop()
                        inStk.remove(pre_ch)
                    else:  # 不能删除
                        break
                stk.append(ch)
                inStk.add(ch)
            cnt[ch] -= 1
        return ''.join(stk)

LeetCode 321. 拼接最大数
给出两个数组,从这两个数组中选出 k个数字,拼接成一个新数组,求可能的字典序最大的数组(拼接时,同一数组内的相对位置要保持不变)
如nums1 = [3, 4, 6, 5],nums2 = [9, 1, 2, 5, 8, 3],k = 5
返回[9, 8, 6, 5, 3]

基础:Python提供数组的大小比较,比较大小标准也是字典序
如[0,1,0]<[0,1,7],[]<[1]

分析:

  • 拆解问题,分别解决:将取k个拆分为子问题 [ 从nums1中取i个、nums2中取k-i个 ]
  • 尝试所有可能的i,取最优解
  • 如何从数组中取k个数,其字典序最大
    这是上文解决过的问题
  • 分别从两数组中取出可能的最大字典序的序列,然后合并为一个最大字典序的序列
  • 得到两个序列后,如何合并出最大字典序的数组
    每次从两序列开头取更大的,注意如果相等要考虑后面的部分,优先揭露更大的(如[0,1,0]<[0,1,7],应先取后一个的0,从而露出更大的1,7可供选择)
class Solution:
    def maxNumber(self, nums1: List[int], nums2: List[int], k: int) -> List[int]:
        # 将取k个拆分:从nums1中取i个+nums2中取k-i个,然后将两个结果合并为最大的
        # 尝试所有可能,取最大值
        def chooseNum(nums, k):
            """从nums中取k个数,使得其最大(等价于删除L-k个数)
            同LeetCode 1673. 找出最具竞争力的子序列"""
            if k == len(nums):
                return nums
            stk = []
            del_chance = len(nums) - k
            for i, n in enumerate(nums):
                # 入栈前删除所有更大的
                while del_chance > 0 and stk and stk[-1] < n:
                    stk.pop()
                    del_chance -= 1
                stk.append(n)
            return stk[:k]

        def merge(n1, n2):
            """合并两个数组,得到最大的答案
            策略:每次取出最大的拼接"""
            res = []
            l1, l2 = len(n1), len(n2)
            i, j = 0, 0
            while i < l1 and j < l2:
                if n1[i] > n2[j]:
                    res.append(n1[i])
                    i += 1
                elif n1[i] < n2[j]:
                    res.append(n2[j])
                    j += 1
                else:  # 两数相等,应该看后面的部分,优化揭露更大的(如[0,1,0]<[0,1,7],应取后一个)
                    if n1[i:] > n2[j:]:
                        res.append(n1[i])
                        i += 1
                    else:
                        res.append(n2[j])
                        j += 1
            if i < l1:
                res += n1[i:]
            if j < l2:
                res += n2[j:]
            return res

        L1, L2 = len(nums1), len(nums2)
        ans = []
        for i in range(0, k + 1):
            j = k - i
            if i <= L1 and j <= L2:
                now = merge(chooseNum(nums1, i), chooseNum(nums2, j))
                if not ans or now > ans:
                    ans = now
        return ans
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值