单调队列的学习 - 滑动窗口求最大/小值 (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;
    }
};

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值