AcWing: 单调队列

单调栈

单调栈的用处:给定一个序列,求序列中某个元素的左边离它最近的数在什么位置。

一个应用:

求下列给定序列中的某个元素的左边离他最近且小于它的数,若没有则返回-1
  3  4  2  7  5
 -1  3 -1  2  2

上述例子可以使用栈的数据结构完成,先把下标为i的数的所有左边的数放入栈中,然后从上往下遍历栈,直到找到一个小于下标为i的数为止。这种思路类似于双指针,j指针从j = i一路遍历到j == 0。

这样暴力的搜索时间复杂度较高,于是思考怎么进行优化:

可以发现在栈中,一旦出现a[x] >= a[y],且x < y时,那么a[y]既离i更近,值又比a[x]小,所以a[x]无论如何都不会被选中,所以我们可以将它删除。最后会得到一个值单调上升的栈,此时又可以判断栈顶和a[i]的大小关系,再次进行删除优化,直到栈顶的数小于a[i]为止,此时我们并不需要情况栈,而是将a[i]的值插入栈中,然后继续进行i + 1位置的相应操作。

AcWing 830.单调栈

#include <iostream>

using namespace std;

const int N = 100010;
int stk[N], num;
int n;

int main(){
    scanf("%d", &n);
    for(int i = 0; i < n; i++){
        int x;
        cin >> x;
        while(num > 0 && stk[num] >= x){
            num--;
        }
        if(num > 0) cout << stk[num] << " ";
        else cout << -1 << " ";
        stk[++num] = x;
    }
    return 0;
}

单调队列

求滑动窗口里的最大值和最小值。

要保证队列存入的是当前窗口中的所有元素:将新的元素插入队尾,将队首的元素弹出。

暴力做法是每次都遍历一遍队列中的全部元素,很好理解。

优化的思路和单调栈类似,找到队列中是不是会有元素永远不会被用到。例如要找到滑窗里的最小值,如果存在a[x] > a[y]且x < y,那么a[x]元素就永远不可能被选中。

单调栈和单调队列的做法都是一致的,先使用朴素算法模拟整个过程,然后寻求优化的可能性。

AcWing 154.滑动窗口

#include <iostream>

using namespace std;

const int N = 1000010;
int n, k;
int q[N], a[N];

int main(){
    scanf("%d %d", &n, &k);
    for(int i = 0; i < n; i++) scanf("%d", &a[i]);

    int ll = 0, rr = -1;
    for(int i = 0; i < n; i++){
        if(ll <= rr && i - k + 1 > q[ll]) ll ++;
        while(ll <= rr && a[q[rr]] >= a[i]) rr--;
        q[++rr] = i;
        if(i >= k - 1){
            printf("%d ", a[q[ll]]);
        }
    }
    puts("");

    ll = 0, rr = -1;
    for(int i = 0; i < n; i++){
        if(ll <= rr && i - k + 1 > q[ll]) ll ++;
        while(ll <= rr && a[q[rr]] <= a[i]) rr--;
        q[++rr] = i;
        if(i >= k - 1){
            printf("%d ", a[q[ll]]);
        }
    }

}

大综合题-单调队列、动态规划、贪心、双指针、STL Multiset

AcWing 299. 裁剪序列

本题数据范围是0 <= n <= 10^5,可见我们需要将时间复杂度控制在O(nlogn)以内。

给定长度为n的序列A,要求把该序列分成若干段,要求在满足“每段中所有数的和不超过给定的M值”的前提下,让“每段中所有数的最大值之和”最小,计算这个最小值。

首先序列A有n个数,于是我们可以在其中插入n-1个空隙,于是总共的分段方式有2^(n-1)种(相当于每个空隙有存在和不存在两种情况)。n最大为10^5,所以本题不可能暴力枚举,考虑dp或者贪心优化

  • 贪心的基本思路:

在题目分析过程中发现答案一定出现在某些子集中,那么就可以使用贪心。

  • dp的分析思路:

从两个角度考虑dp问题:

  1. 状态表示 -- f(i)

  1. 考虑f(i)表示的是什么集合

根据题目的定义,本题f(i)可以表示所有前i个数的合法划分方案的集合。那么f(n)即题目所求。

  1. 考虑f(i)存的是集合的哪些属性

f(i)存的是上述集合的“每段中所有数的最大值之和”的最小值。

  1. 状态计算

|___________________________|_____________|
0                           k             i
上述线段表示前i个数的集合,这里使用k来划分该集合的最后一段合法划分方案和前面所有的合法划分方案的集合
这里的[k, i]段就是最后一段的合法划分方案,易知它的长度区间是[1, i],即0 <= k < i
当然这里k的取值范围不一定有这么大,可能大于某个数时该集合中的所有数的和大于M了,就不合法了
k的选择是一个区间,直到k变成k - 1时里面的数大于M

所以我们得到了:每一种合法的划分方案必然有最后一段,且最后一段的大小范围在一个区间内

考虑最后一段的长度为k的划分方案,这意味着最后一段中的所有数最大值为固定的,前面的划分方案是随意的。
                            |<---- k ---->|
|___________________________|_____________|
0                                         i
设最后一段的所有数的最大值为a,为定值,那么想让总和最小,就要求前面划分方案所得的最大值最小
即f(i-k),所以有:f(i) = f(i-k) + a;

如果就按照这种做法枚举的话:首先枚举每个i,再在每一个i中枚举一个k,再在k里寻找一个最大值,于是此时的时间复杂度为O(n^3),需要优化。可以发现在第二个循环里可以从小到大枚举k,然后用一个变量来维护k的最大值,这样就减去了一重循环,就优化到了O(n^2)。

但是离我们要求的O(nlogn)还有距离,下面就要寻找内部的规律来进行优化了:

                            |<---- k ---->|
|___________________________|_____________|
0                           j             i
先找到k能取到的最大值(即里面的所有数之和小于M),假设上图k已经取到了最大值,最大值对应下标为j
设[j, i]中最大值为a_max
有:f(i) = f(j-1) + a_max

                                a_max
                                 |
                                 |
|___________________________|____|_________|
0                           j    g         i
可以发现无论最后一段的起点不管是从j开始还是从a_max对应的下标g前的任何一个位置开始,最后一段的最大值均为a_max

这里开始引入贪心的思想:
假设有N的两个子序列[0, i]和[0, j],其中i > j,任取第一段的一种划分方案应用于第二段上:
|________|___________|________|
0                    k        i
|________|___________|___|
0                    k   j
可以发现前两段的最大值都是一样的,而最后一段[0, i]序列要长于[0, j]序列,所以最后一段的最大值一定有:
[k, i]_max >= [k, j]_max
所以可以得到第一个集合的f(i)一定大于等于第二个集合的f(j)
所以有:从一个序列一端开始裁剪的某个集合,其长度越长,则“各段合理划分方案的最大值之和”的最小值一定更大

所以当最后一段的起点取在[j, g]中的任意一点时,a_max始终不变,而f()值随着起点越小它越小。
所以当遇到这种情况时,可以不用枚举,直接取区间中的第一个点即可,即j点。

如果最后一段的起点位于下标g之后呢?
那么就要先求[g+1, i]上的最大值:

                                a_max
                                 | a_max2
                                 |   |
|___________________________|____|___|______|
0                           j    g   h      i
其下标为h(需要注意如果在[j, i]上有多个最大值a_max时,那么就取更靠近i的那个最大值)
类似上面的思路:最优解是f(g) + a_max2

如果最后一段的起点位于下标h之后呢?
同理先求[h+1, i]上的最大值:

                                a_max
                                 | a_max2
                                 |   | a_max3
|___________________________|____|___|__|___|
0                           j    g   h  l   i
易知最优解是:f(h) + a_max3
以此类推........

所以区间中能作为答案的点一定是最大值附近的点,很容易联想到我们需要维护一个单调队列(单减)

维护单调队列:

先考虑维护一个合法的区间,即找到j。于是问题拆分成在一个序列中,给定一个i,有i > j,要求找到一个最小的j,使用[j, i]之间的数的总和小于M,这是一个经典的双指针问题。

这个区间有两个特征:这个区间长度不固定、两个指针一定是单调移动的,即i向后移动,j也会向后移动

然后考虑维护单调队列,单调队列的入口是i,出口是j。根据上述分析的逻辑,容易知道当j向后移动时,说明最后一段序列的所能取到的最左的下标右移,那么直接将在出队列的元素移除即可;当i向后移动时,需要进行判断:如果新入队的元素的值小于队列入口处的元素,则它仍然满足单调递减的单调队列的性质,那么将其加入。如果新入队的元素的值大于队列入口处的元素,则说明这个新入队的元素才是“局部最大值”,则需要将队列入口处小于它的所有元素全部移除,再将其加入队列。

再次进行判断,发现时间复杂度仍是O(n^2)。我们上述的操作的本质其实是维护单调队列中每个元素所代表的f() + a_max,然后求出这些{ f() + a_max }中的最小值。同时在改变单调队列时,是对每个元素的入队出队进行操作且每个元素只会入队一次出队一次。

所以可以发现:我们只需要维护一个集合,使得这个集合可以动态求最小值并且可以动态添加一个数和删除一个数。考虑:

std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。

当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset

本题可能包含相同值,所以要用multiset。

所以时间复杂度最终优化成了O(nlogn)。

#include <bits/stdc++.h>

using namespace std;

// m - 10^11 > 10^9
typedef long long LL;

const int N = 100010;
int n;
LL m;
// w[N]序列中的值, q[N]单调队列
int w[N], q[N];
// dp状态数组(n = 10^5, m = 10^11) 可能会溢出
LL f[N];
// 维护单调队列中的最小值
multiset<LL> S;

void remove(LL x){
    auto it = S.find(x);
    S.erase(it);
}

int main(){
    scanf("%d %lld", &n, &m);
    
    for(int i = 1; i <= n; i++){
        scanf("%d", &w[i]);
        if(w[i] > m){
            puts("-1");
            return 0;
        }
    }
    
    // 单调队列
    int hh = 0, tt = -1;
    LL sum = 0;
    for(int i = 1, j = 1; i <= n; i++){
        sum += w[i];
        // 单调队列出队判断
        while(sum > m){
            sum -= w[j++];
            if(hh <= tt && q[hh] < j){
                // hh < tt 说明multiset中至少有两个数,在这个前提下才能进行计算f() + a的操作,删除set中对应单调队列出队的f()+a的值
                if(hh < tt) remove(f[q[hh]] + w[q[hh + 1]]);
                hh++;
            }
        }
        // 单调队列入队判断,同时等大最值取更右的
        while(hh <= tt && w[q[tt]] <= w[i]){
            if(hh < tt) remove(f[q[tt - 1]] + w[q[tt]]);
            tt--;
        }
        // 加入新的元素
        q[++tt] = i;
        if(hh < tt) S.insert(f[q[tt - 1]] + w[q[tt]]);
        
        f[i] = f[j - 1] + w[q[hh]];
        if(S.size()) f[i] = min(f[i], *S.begin());
    }
    printf("%lld", f[n]);
    return 0;
}

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值