单调队列的学习 - 滑动窗口求最大/小值 (Leetcode 239, Sliding Window Maximum)

2023-11-05

这几天在学习单调队列和单调栈,感觉下面几篇博客讲的比较好。
http://www.cnblogs.com/saywhy/p/6726016.html
http://dping26.blog.163.com/blog/static/17956621720139289634766/

单调队列不是真正的队列。因为队列都是FIFO的,统一从队尾入列,从对首出列。但单调队列是从队尾入列,从队首队尾出列,所以单调队列不遵守FIFO。

  1. 对于单调(递增)队列,每次添加新元素时跟队尾比较,如果队尾元素值大于待入队元素,则将对尾元素从队列中弹出,重复此操作,直到队列为空或者队尾元素小于待入队元素。然后,再把待入队元素添加到队列末尾。
    单调递增是指从队首到队尾递增。举个例子[1,3,4,5],1是队首,5是队尾。
    单调(递减)队列与之类似,只是将上面的大,小两字互换。

2)单调(递增)队列可以用来求滑动窗口的最小值。同理,单调(递减)队列可以用来求滑动窗口的最大值。其算法复杂度都是O(n)。注意,如果我们用最小堆或是最大堆来维持滑动窗口的最大/小值的话,复杂度是O(nlogn),因为堆查询操作是O(1),但是进堆和出堆都要调整堆,调整的复杂度O(logn)。

  1. 单调队列还有用途是利用其滑动窗口最值优化动态规划问题的时间复杂度,比如可以利用单调队列优化多重背包的时间复杂度。下面是一个链接。
    https://www.cnblogs.com/BingweiHuang/p/15976681.html
  2. 单调队列和单调栈区别:

单调队列:

  1. 从尾部入队
  2. 把违反单调性的元素从尾部踢出
  3. 过期元素从头部踢出

单调栈:

  1. 从顶部入栈
  2. 把违反单调性的元素从顶部弹出
  3. 不过滤过期元素 //即单调队列的头部被堵死

单调队列如何实现呢? 以单调递增队列为例,我看到网上有些实现代码类似如下:

int q[maxn];
int front, tail, cur;
front = tail = 0;
while (front < tail && q[tail-1] > cur)
     --tail; 
q[tail++]=cur; 

单调递减队列类似,只是q[tail-1]元素的比较改为<即可。

上面的代码适用于求解固定查询区间尾部的RMQ问题(没有窗口大小的限制)。RMQ(x,y)就是询问数组[x,y]区间内部的最小值。如果y固定,那么可以用单调队列来求解。但实际上如果有滑动窗口的话,以上代码是不够的,因为窗口是有限的,所以当tail移动时,也会带动front移动,如果它们之间的距离超过了窗口大小的话。所以我们必须记得更新front。注意,单调队列的front和tail两个指针是不断往同一个方向移动的。

怎么更新呢? 我们必须保存队列中每个元素在原数组中的下标。我们可以把这些坐标保存在q[]中(注意上面的代码里面的q[]是保存的元素的值),然后a[q[i]]就是对应元素的值了。当我们发现head和tail所指向的数组元素的gap大于sliding window的大小时,就必须调整head了。

以单调递增队列求滑动窗口最大值为例,代码如下:

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        int n = nums.size();
        vector<int> vec(n, 0);
        int head = 0, tail = 0;
        vector<int> res;

        for (int i = 0; i < n; ++i) {
            while(head < tail && nums[vec[tail - 1]] < nums[i]) {
            //while(tail > 0 && nums[vec[tail - 1]] < nums[i]) {     //错误, 光tail>0不够,因为head可能等于tail,这里vec里面只有1个数据, tail-1就没有意义了。
                tail--;
            }
            vec[tail] = i;
            tail++;
          
            if (tail > 0 && vec[tail - 1] - vec[head] + 1 > k) head++;
            //if (head < tail && vec[tail - 1] - vec[head] + 1 > k) head++;     也可以
            //if (head < tail && i - vec[head] + 1 > k) head++;   也可以 
            if (i >= k - 1) {
                res.push_back(nums[vec[head]]);
            }
        }
        
        return res;
    }
};

有几点需要注意的地方:

  1. vec一开始就分配好,而不是空vector往里面push_back数据。
  2. head和tail都往增大的方向移动,tail永远>=head。 注意这里跟queue有点区别:queue是tail进队,head出队,而这里是tail进队也可以出队,head出队。
  3. 注意tail-1的用法,因为tail++。
  4. 调整head的时候一步就够了,但是因为tail-1,所以要加一个判断条件tail>0以防止刚开始的时候head就被调整。注意这里用head<tail也可以
  5. 如果我们要实现单调递减队列求sling window的最小值,则将上面的
 while(head < tail && nums[vec[tail - 1]] < nums[i]) {

改成

 while(head < tail && nums[vec[tail - 1]] > nums[i]) {

即可,其它不变。
5) 上面的算法还有优化空间。在

 while(head < tail && nums[vec[tail - 1]] < nums[i]) {

里面可以用binary search。因为是单调队列嘛。

下面讨论一下该算法的复杂度。上面的代码for循环里面又有while循环,看起来复杂度好像是O(nk),如果采用binary search来找也是O(nlogk)。但是我们换个角度想,每个元素最多入队一次,出队一次,所以出队入队操作一共2n个,这2n个操作平摊到n个元素中,所以复杂度实际上是O(k)。具体分析可以参考算法里面的amortized analysis。如果我们在while循环里面用binary search, 对于slidiing window很大的情况,可以加快点速度,但复杂度还是O(k)。

另外,单调队列也可以用deque实现。因为deque支持队尾和队头两边的出列和入列的操作。下面是用单调递减队列求滑动窗口最大值的代码:

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        int n = nums.size();
        deque<int> dq;
        vector<int> res;

        for (int i = 0; i < n; ++i) {
            while(dq.size() > 0 && nums[dq.back()] < nums[i]) {
                dq.pop_back();
            }
            dq.push_back(i);
            if (i - dq.front() + 1 > k) dq.pop_front(); //也可以用if (dq.back() - dq.front() + 1 > k) dq.pop_front();
            if (i >= k - 1) res.push_back(nums[dq.front()]);
        }
        
        return res;
    }
};

注意:
1) 在pop_front()的操作中,必须逐一pop出front(),不能直接调整front的位置,因为会破坏deque内部的iterator。这里复杂度仍然是O(n),详见上面的amortized analysis。
2)用deque不用操心tail-1的问题,因为deque内部会处理。
3)若用单调递减队列求滑动窗口最小值,只需将下面while循环内的

     nums[dq.back()] < nums[i]

<改成>即可,其它不变。

下面是最经典的单调队列问题。
239. Sliding Window Maximum
You are given an array of integers nums, there is a sliding window of size k which is moving from the very left of the array to the very right. You can only see the k numbers in the window. Each time the sliding window moves right by one position.

Return the max sliding window.

Example 1:

Input: nums = [1,3,-1,-3,5,3,6,7], k = 3
Output: [3,3,5,5,6,7]
Explanation:
Window position Max


[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
Example 2:

Input: nums = [1], k = 1
Output: [1]
Example 3:

Input: nums = [1,-1], k = 1
Output: [1,-1]
Example 4:

Input: nums = [9,11], k = 2
Output: [11]
Example 5:

Input: nums = [4,-2], k = 2
Output: [4]

Constraints:

1 <= nums.length <= 105
-104 <= nums[i] <= 104
1 <= k <= nums.length

解法1:单调队列用数组vector实现。

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        int n = nums.size();
        if (n < k) return {};
        vector<int> q(n, 0), res;
        int tail = 0, head = 0;
        
        for (int i = 0; i < n; ++i) {
            while (tail > head && nums[q[tail - 1]] < nums[i]) tail--;
            q[tail++] = i;
   
            if (i - q[head] + 1 > k) { //i is q[oldtail],也可以用q[tail-1]-q[head]+1>k
                head++;
            }
            if (i >= k - 1) res.push_back(nums[q[head]]);
        }
        
        return res;
    }
};

注意:

  1. 如果这里用数组的话,一定要分清head, tail和q[head], q[tail]的区别。
    q[head], q[tail]是原数组nums[]的坐标,它们的范围是0…n-1。
    head和tail是针对单调队列而言的,它们的范围是0…k-1。
    如果用deque,就省去了这些容易混淆的地方。
  2. 我们要分清楚这道题里面的两个数据结构,滑动窗口单调栈
    首先这两个东西的size不一样!
    滑动窗口size一开始会从1涨到k,然后就固定为k。
    而单调队列的size会一直在1到k之间变化,其值取决于输入数据的分布。
    (i - q[head] + 1 > k)表示单调队列的大小,i就是q[oldtail]的值,也可以用q[tail-1].
    另外,上面代码里面的tail和head是指对单调队列而言,不是滑动窗口的头和尾!

那么滑动窗口的头和尾怎么看呢?如果我们把最先插入的位置看成尾的话,那当i>=k-1后,滑动窗口的尾就是i, 其头部就是i-k+1的值,这样,其size恒为k。滑动窗口的头和尾的值都在0…n-1之间变化,一开始尾长到k-1,然后头尾顺序往右移动,尾-头的值恒为k。
3.

以input=[1,3,-1,-3,5,3,6,7,-1,6,8], 3 为例,如果我们在for循环内部最后加上下面的打印代码

 cout<<"i = "<<i<<" head="<<head<<" tail="<<tail-1<<" q[head]="<<q[head]<<" q[tail]="<<q[tail-1]<<"     ";
 for (int j = q[head]; j <= q[tail-1]; j++) cout << nums[j] << " ";
 cout << endl;

则输出为:
i = 0 head=0 tail=0 q[head]=0 q[tail]=0, 1
i = 1 head=0 tail=0 q[head]=1 q[tail]=1, 3
i = 2 head=0 tail=1 q[head]=1 q[tail]=2, 3 -1
i = 3 head=0 tail=2 q[head]=1 q[tail]=3, 3 -1 -3
i = 4 head=0 tail=0 q[head]=4 q[tail]=4, 5
i = 5 head=0 tail=1 q[head]=4 q[tail]=5, 5 3
i = 6 head=0 tail=0 q[head]=6 q[tail]=6, 6
i = 7 head=0 tail=0 q[head]=7 q[tail]=7, 7
i = 8 head=0 tail=1 q[head]=7 q[tail]=8, 7 -1
i = 9 head=0 tail=1 q[head]=7 q[tail]=9, 7 -1 6
i = 10 head=0 tail=0 q[head]=10 q[tail]=10, 8

每行后面即为单调队列的内容,前面是head,后面是tail。
可见i=4,5,6,7,8,10时,单调队列长度都不为3,但滑动窗口的大小固定为3。

最终结果为

[3,3,5,5,6,7,7,7,8]
  1. 在i循环中,i是单调队列tail的位置,也是滑动窗口tail的位置!。
  2. 与deque对应,q[head]=>deque.front(), q[tail]=>dq.back()。
    tail–对应dq.back(), q[tail++]=i对应dq.push_back(i)
    head++对应dq.pop_front()。

再解释一下上面为什么不能用

             if (tail - head > k) head++;   //old tail is tail - 1, (tail - 1) - head + 1 > k

替换

            if (i - q[head] + 1 > k) { //i is q[oldtail],也可以用q[tail-1]-q[head]+1>k
                head++;
            }

以[7,2,4], 2为例,假设我们把上面的head++都去掉,加上打印语句可得
i = 0 head=0 tail=0 q[head]=0 q[tail]=0 7
i = 1 head=0 tail=1 q[head]=0 q[tail]=1 7 2
i = 2 head=0 tail=1 q[head]=0 q[tail]=2 7 2 4 //不对,应该是4

可以看到,i=2时,tail实际上先–,然后再++,这个时候,整个单调队列的长度还是2 (内含元素是7, 4),但是它在原数组的跨度已经是3了 (7, 2, 4),这个时候我们应该以原数组上的跨度(i - q[head] + 1 > k)为准,所以应该head++ (此时,7已经过期,head指向4)。采用原数组的跨度的判断后,新的打印信息为
i = 0 head=0 tail=0 q[head]=0 q[tail]=0 7
i = 1 head=0 tail=1 q[head]=0 q[tail]=1 7 2
i = 2 head=1 tail=1 q[head]=2 q[tail]=2 4

解法2:单调队列用deque实现。

class Solution {
public:
    /**
     * @param nums: A list of integers.
     * @param k: An integer
     * @return: The maximum number inside the window at each moving.
     */
    vector<int> maxSlidingWindow(vector<int> &nums, int k) {
        int len = nums.size();
        vector<int> res;
        if (len < k) return res;
        deque<int> dq; //mono decreasing queue (from head to tail)
        for (int i = 0; i < len; ++i) {
            while (!dq.empty() && (nums[i] > nums[dq.back()])) {
                dq.pop_back();
            }
            dq.push_back(i);
            
            if (i - dq.front() == k) {  //不能用dq.size()>k
                dq.pop_front();
            }
            
            if (i >= k - 1) {
                res.push_back(nums[dq.front()]);
            }
        }
        
        return res;
    }
};

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

单调队列的学习 - 滑动窗口求最大/小值 (Leetcode 239, Sliding Window Maximum) 的相关文章

随机推荐

  • Python:UnicodedecodeError编码问题解决方法汇总-彻底解决

    今天真的被编码问题一直困扰着 午休都没进行 也真的见识到了各种编码 例如 gbk unicode utf 8 ansi gb2312等 如果脚本程序中编码与文件编码不一致 就会报出UnicodedecodeError的错误 1 情景一 读文
  • python语法-面向对象(构造方法、魔术方法)

    python语法 面向对象 构造方法 魔术方法 1 构造方法 构造方法 python类可以使用 init 方法 称之为构造方法 可以实现 在创建类对象时 会自动执行 在创建类对象时 将传入参数自动传递给 init 方法使用 演示使用构造方法
  • Android中的定时器Timer、AlarmManager、CountDownTimer的使用

    1 Timer和TimerTask的使用 java util Timer定时器 实际上是个线程 定时调度所拥有的TimerTasks 1 创建一个Timer code class hljs cs has numbering style di
  • 解析 Linux 内核可装载模块的版本检查机制

    解析 Linux 内核可装载模块的版本检查机制 王 华东 系统工程师 自由职业者 简介 为保持 Linux 内核的稳定与可持续发展 内核在发展过程中引进了可装载模块这一特性 内核可装载模块就是可在内核运行时加载到内核的一组代码 通常 我们会
  • js获取到的时间减1秒或加1秒

    如题 使用时间戳来计算 function setDate time isAdd var date getCurTime time 也可以直接透传如 2021 5 8 var d new Date date var t s d getTime
  • 闲鱼把各种玩法做成了一个平台:哆啦A梦

    玩法平台背景 在闲鱼内我们把供给用户的闲鱼红包 支付宝红包 包邮券 宝卡等统称为用户权益 是闲鱼用户运营的重要策略 在拉新 留存 促活 裂变等方面都展现了其重要价值 在阿里内部管理权益的平台是拉菲 拉菲对外提供概率抽奖和领奖两种能力 各个业
  • 为什么gbk编码常用抽取正则表达式无法抽取“嘚瑟“的“嘚”字

    根据 GBK汉字内码扩展规范编码表 http ff 163 com newflyff gbk list 可以查到 嘚 字的编码为874e 而我们常用的gbk汉字抽取正则表达式为 x80 xff x80 xff 以python正则为例 抽取汉
  • Python基础--入门基础和数据类型测试题(二)

    Made By Zly All Right Reversed 上一篇 篇四 Python 入门基础和数据类型测试题 二 1 以下不属于Python语言保留字的是 A do B pass C while D def 2 表达式3 4 2 8
  • 第一讲 检索系统与数据库编程

    第一讲 检索系统与数据库编程 准备工作 1 检索系统 1 1 检索系统初识 1 1 1 什么是检索系统 1 1 2 从认知心理学看待检索系统 1 2 检索系统的四大法宝 1 2 1 检索的工具 结构化查询语言 SQL 1 2 2 检索的环境
  • Electron-builder打包和自动更新

    前言 文本主要讲述如何为 electron 打包出来软件配置安装引导和结合 github 的 release 配置自动更新 electron builder 是将 Electron 工程打包成相应平台的软件的工具 我的工程是使用 elect
  • C语言小知识点

    1 LPCSTR被定义成是一个指向以 0 结尾的常量字符指针 LPWSTR是wchar t字符串 例子 LPWSTR lpwstr NULL LPWSTR lp T asdfasgaf 2 之所以能够实现条件编译是因为预编译指令是在编译之前
  • HTTP请求头和响应头详解【转】

    最近老猿在开始学习爬虫相关的知识 由于老猿以前只做非web的后台应用 发现相关知识太过匮乏 导致学习很困难 为此不得不从一些基础知识恶补开始 对于这些知识 老猿会将网上找到的比较认可的内容直接转发 下面文章关于http头部信息讲解的非常详细
  • springboot笔记总结

    springboot 1 Javaconfig Configuration 放在类的上面 这个类相当于 xml 配置文件 可以在其中声明 bean Bean 放在方法的上面 方法的返回值是对象类型 这个对象注入到 spring ioc 容器
  • 字典排序算法(通俗易懂)

    我们先看一个例子 示例 1 2 3的全排列如下 1 2 3 1 3 2 2 1 3 2 3 1 3 1 2 3 2 1 我们这里是通过字典序法找出来的 那么什么是字典序法呢 从上面的全排列也可以看出来了 从左往右依次增大 对这就是字典序法
  • Java中 ? extends T 和 ? super T 的理解

    通配符类型
  • (转)如何有效地管理好技术团队?

    转自 https cn 100offer com blog posts 307 技术管理是一个综合性岗位 要求你具有技术能力 管理能力 也要懂一些心理学 情商也要高一些 说实话 你想做好这个岗位 真的不容易 尤其是在中国 我相信今天的分享过
  • 策略+工厂+反射记录一次switch代码简化过程

    遇到的问题 一张记录表 记录了10个业务的字段 一个入参type说明了要修改哪个字段 最初是通过switch type case 来做的 但是涉及这样子的判断多了 每次都要不断的switch 并且case里面不同方法有不同的处理 一个公共的
  • 河南省历年高考人数(2004-2021)

    河南省历年高考人数 2004 2021 年份 人数 万人 2004 59 55 2005 72 2006 78 2007 87 88 2008 98 8 2009 95 9 2010 95 24 2011 85 5 2012 80 5 20
  • 系统还原服务器,服务器系统还原

    服务器系统还原 内容精选 换一换 1 说明2 制作系统还原盘3 测试恢复还原1 说明http clonezilla nchc org tw clonezilla live download clonezilla live 2 6 7 28
  • 单调队列的学习 - 滑动窗口求最大/小值 (Leetcode 239, Sliding Window Maximum)

    这几天在学习单调队列和单调栈 感觉下面几篇博客讲的比较好 http www cnblogs com saywhy p 6726016 html http dping26 blog 163 com blog static 1795662172