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

单调队列Monotonic Queue

  • 单调队列本质上就是队列,但在使用队列的过程中,程序逻辑保证队内的元素是单调的(单调递增或单调递减,视具体情况而定)
  • 用于维护一段区间(滑动窗口)的最大值/最小值
    (或更一般的,某个序列只有首尾元素变动,维护该序列的最值)
  • 也可用于优化动态规划:例如优化多重背包

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

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

单调队列的具体实现

  • 具体做法是入队时,删除一些队尾元素从而保证入队后整个队列单调
  • 另外,由于队首队尾都需要出队操作,这就要求使用双端队列collections.deque

LeetCode 239. 滑动窗口最大值
给定长度的窗口在一个数组中逐位滑动,求在各个位置上窗口内元素的最大值

暴力维护窗口内的最大值的思路:
①加入元素:新加入的元素和当前窗口最大值比较即可;②移除元素:如果移出窗口的元素刚好是最大值,就会出问题:因为不知道第二大的值,就需要重新扫描窗口得出新的最大值

单调队列具体思路:

  • 窗口移动时,元素符合FIFO规律,因此容易想到用队列模拟
  • 根据上面的分析,我们需要维护窗口中第一大、第二大…的值,从而保证最大值被移除窗口后,能迅速找到新的最大值,因此使用单调队列:始终保持队首最大,且维护区间内最大、第二大…的值
  • 新元素入队时如果队尾元素比他小,删除这些元素(这样就可保证队列中保存窗口内第一大、第二大…的值)
  • 有元素移出窗口时,检查其是不是队首的最大元素,如果是要记得删除
from collections import deque
class Solution:
    def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
        """用单调队列模拟窗口移动,
        维护队列中第一大、第二大...的值,
        从而保证最大值被移除窗口后,能迅速找到新的最大值"""
        dq = deque()  # 用双端队列实现单调队列,右侧入队左侧出队
        ans = []

        def put(n):
            """单调队列的入队操作,
            新元素n入队前先删除所有小于它的队尾元素"""
            while len(dq) > 0:
                if dq[-1] < n:
                    dq.pop()
                else:
                    break
            dq.append(n)

        for i in range(len(nums)):
            if i < k - 1:  # 初始化:先填入窗口的前k-1个元素
                put(nums[i])
            else:
                # 新元素nums[i]移入窗口
                put(nums[i])
                # 当前窗口内的最大值
                ans.append(dq[0])
                # 旧元素nums[i-k+1]移出窗口
                # (单调队列中最大值一定是最先入队的,因此只在最大值移出窗口时,需要更新队列)
                if nums[i - k + 1] == dq[0]:
                    dq.popleft()
        return ans

应用例题

下面的题目较困难,隐式地用到了单调队列

LeetCode 862. 和至少为 K 的最短子数组
从数组中找出一个长度最短的非空子数组,其子数组和>=k,返回其长度

from collections import deque
class Solution:
    def shortestSubarray(self, nums: List[int], k: int) -> int:
        # 暴力思路:枚举每一个i,作为子数组右边界,尝试满足sum(nums[i...j])的最大左边界j,在所有i中取最优的min(i-j)
        # 性能:O(N),显然需要优化
        # 优化一:数组求和问题,用前缀和preSum[i]计算nums[0...i)的和
        # 优化二:遍历到i位置作为右边界时,对于[i...j]区间和,需满足的条件为preSum[i+1]-preSum[j]>=k,在此基础上需要j尽可能大
        #       条件变形为preSum[j]<=preSum[i+1]-k
        #       考虑到前缀和preSum[j]越大,同时下标j越大,对我们越有利(尽管数字有正有负,但是前面部分的前缀和越大,后面要达到总和为k的压力肯定越小)
        #       对于preSum[j]维护单调队列,从 队头 到 队尾,依次存储从小到大的的下标j,其表示的前缀和preSum[j]严格单调递增

        # 策略:①当结算i位置的答案时,求得此时preSum[i+1]-k,我们寻找尽可能大的满足要求的j
        #       ②从队首从小到大依次弹出元素,直至不再满足preSum[j]<=preSum[i+1]-k的条件,最后一个符合条件的j即为当前右边界i的最大左边界
        # (满足preSum[j]<=preSum[i+1]-k的前提下,j尽量大;且由于踢出的都是较小的preSum[j],这样做不影响后面的i的左边界j的求解)
        # (当然,后面位置i处的preSum[i+1]-k阈值可能变小,意味当前的j可能不再满足preSum[j]<=preSum[i+1]-k的条件,
        #   反而需要回退到前面丢弃的那些更小的前缀和preSum[j],但当前的i-j肯定比之后的(i+x)-(j-y)这个答案更好)
        #       ③把此时的preSum[i]从队尾入队,作为后面的左边界j的备选,入队前注意先从队尾弹出若干个元素,以保持单调递增的结构
        # (之前更大的preSum[j]如果都能满足preSum[j]<=preSum[i+1]-k,当前的preSum[j]也可以,且i-j更小)
        L = len(nums)
        preSum = [0]
        for i in range(L):
            preSum.append(preSum[-1] + nums[i])
        # 对于preSum[j]维护单调队列,从 队头 到 队尾,依次存储从小到大的的下标j,其表示的前缀和preSum[j]严格单调递增
        # 右侧入队左侧出队
        dq = deque()
        ans = float('inf')
        for i in range(L):
            bound = preSum[i + 1] - k
            # 本位置作为头:如果队列为空则直接加入。否则,如果队尾的前缀和不小于本位置,弹出队尾。重复此判断,至队列为空或队尾前缀和小于本位置为止。将本位置从队尾加入
            while len(dq) > 0 and preSum[dq[-1]] >= preSum[i]:
                dq.pop()
            dq.append(i)
            # print(preSum, bound, dq)
            # 本位置作为尾:如果队列为空则跳过。否则,从队首从小到大依次弹出元素,直至不再满足preSum[j]<=preSum[i+1]-k的条件,最后一个j即最大左边界
            while len(dq) > 0 and preSum[dq[0]] <= bound:
                j_max = dq.popleft()
                # 更新i-j
                ans = min(ans, i - j_max + 1)
        return ans if ans != float('inf') else -1
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值