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

本文深入讲解了单调队列的基本概念、实现方式及其在滑动窗口最大值问题中的应用,并对比了单调队列与单调栈的区别。

这几天在学习单调队列和单调栈,感觉下面几篇博客讲的比较好。
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,因为size()只是表示里面有多少元素,跟dq.back()-dq.front()没有关系。
            //if (dq.back() - dq.front() >= k) {  //也对,==k也对
                dq.pop_front();
            }
            
            if (i >= k - 1) {
                res.push_back(nums[dq.front()]);
            }
        }
        
        return res;
    }
};

<think>嗯,用户之前问了关于单调栈的问题,现在又接着问单调队列的情况,特别是当插入新元素破坏单调性时如何处理。首先,我需要确认用户是否已经理解了单调栈的基本概念,因为单调队列和单调栈有很多相似之处,但也有区别。用户可能是在学习算法时遇到了相关问题,或者在解决某些算法题目时需要用到这些数据结构。 用户的问题是关于单调队列在插入时如何处理破坏单调性的情况,以及如何删除元素。我需要先回顾单调队列的定义和操作步骤。单调队列通常用于维护一个区间内的最大最小值,比如在滑动窗口问题中。与单调栈不同,队列需要支持两端的操作,即队头和队尾都可以进行删除操作。 接下来,我需要考虑用户可能的疑问点。比如,单调队列和单调栈的区别是什么?在插入新元素时,单调队列需要从队尾开始比较,并删除不符合单调性的元素,这类似于单调栈的栈顶操作。但队列还需要处理队头元素的过期问题,比如当窗口滑动时,队头元素可能不再属于当前窗口,需要被移除。这一点可能是用户容易忽略的地方,需要在回答中特别指出。 然后,我需要用具体的例子来说明单调队列的操作流程,例如维护一个递减队列,插入元素时从队尾删除比新元素小的元素,保持队列的单调性。同时,当队头元素超出窗口范围时,需要从队头删除。这样的例子能帮助用户更直观地理解处理过程。 另外,用户可能关心应用场景,比如滑动窗口最大问题,或者优化动态规划中的状态转移。我需要简要提到这些应用,让用户明白学习单调队列的实际用途。 还要注意比较单调队列和单调栈的区别,比如栈只能在一端操作,而队列需要在两端操作,这使得队列更适合处理滑动窗口这类需要考虑元素过期的情况。同时,性能方面,单调队列的时间复杂度通常是O(n),因为每个元素最多入队和出队一次,这点也需要在回答中强调,以帮助用户理解其高效性。 最后,用户的问题可能隐含了更深层次的需,比如如何在实际编码中实现单调队列,或者如何处理边界条件。虽然问题中没有直接提到,但提供清晰的步骤和示例代码结构可能会有帮助,不过用户没有明确要代码,所以可能不需要,但可以简要说明实现的关键点。 总结下来,回答需要结构清晰,分点讲解插入和删除的步骤,举例说明,比较与栈的区别,并提到应用场景和性能,确保用户能够全面理解单调队列的运作机制。</think>单调队列(Monotonic Queue)是一种在队列的基础上维护元素单调性的数据结构,通常用于高效处理**滑动窗口**中的最问题(如滑动窗口最大/最小值)。与单调栈类似,其核心在于插入新元素时动态调整队列结构,保证队列的单调性。但与栈不同的是,队列需要支持两端的操作(队头出队、队尾入队/出队),且可能涉及元素“过期”(如超出窗口范围)的删除。 --- ### **单调队列的核心操作** 假设我们维护一个**递减单调队列**(队头是当前窗口的最大),当插入新元素时: #### **1. 插入新元素** - **步骤**: 1. **从队尾开始比较**:若新元素比队尾元素大(破坏递减性),则不断从队尾删除比它小的元素。 2. **插入新元素**:删除完成后,将新元素插入队尾。 - **目的**:保证队列始终递减,队头是当前窗口的最大。 **示例**: 队列当前为 `[5, 3, 2]`,插入新元素 `4`: - `4 > 2` → 删除 `2` → 队列变为 `[5, 3]`。 - `4 > 3` → 删除 `3` → 队列变为 `[5]`。 - 插入 `4` → 队列变为 `[5, 4]`。 #### **2. 删除过期元素** - **步骤**: - 当队头元素超出窗口范围(如窗口左边界移动后),从队头删除该元素。 - **目的**:保证队列中的元素始终属于当前窗口。 **示例**: 窗口范围是 `[i, i+2]`,当前队列为 `[5, 4, 3]`。若窗口左边界右移,导致索引 `i` 对应的元素 `5` 过期,则删除队头 `5`,队列变为 `[4, 3]`。 --- ### **具体操作流程(以递减队列为例)** 1. **初始化空队列**。 2. **遍历数组,依次处理每个元素**: - **插入新元素**: - 从队尾删除所有比当前元素小的元素。 - 将当前元素插入队尾。 - **删除过期元素**: - 检查队头元素是否超出窗口范围,若超出则删除。 3. **记录当前窗口的最**(队头元素)。 --- ### **与单调栈的区别** | **特性** | **单调栈** | **单调队列** | |----------------|---------------------------|---------------------------| | **结构** | 只能操作栈顶(一端) | 可操作队头和队尾(两端) | | **过期删除** | 不需要处理元素过期 | 需要删除超出窗口范围的元素 | | **典型应用** | 下一个更大元素、柱状图问题 | 滑动窗口、动态规划优化 | --- ### **应用场景** 1. **滑动窗口最大/最小值** 例如 LeetCode [239. 滑动窗口最大](https://leetcode.cn/problems/sliding-window-maximum/),时间复杂度可优化至 \(O(n)\)。 2. **动态规划优化** 在状态转移方程中,若需要快速获取某个区间的最,可用单调队列减少重复计算。 --- ### **实现关键点** 1. **双端队列(Deque)**:需支持队头和队尾的高效插入、删除操作。 2. **元素索引存储**:通常存储元素的索引而非,便于判断元素是否过期。 3. **边界条件**:注意窗口大小与队列长度的关系。 --- ### **示例代码(Python)** ```python from collections import deque def maxSlidingWindow(nums, k): q = deque() # 存储元素索引(维护递减队列) result = [] for i, num in enumerate(nums): # 插入新元素:删除队尾比当前元素小的元素 while q and nums[q[-1]] <= num: q.pop() q.append(i) # 删除过期元素(队头超出窗口左边界) while q[0] <= i - k: q.popleft() # 当窗口形成后,记录队头(最大) if i >= k - 1: result.append(nums[q[0]]) return result ``` --- 通过维护单调队列,可以在 \(O(n)\) 时间内高效解决滑动窗口问题,核心在于插入时动态调整队列,并处理元素的过期逻辑。
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值