本文搬运自本人初中博客园博客,若图片加载不出来,可到原文查看:https://www.cnblogs.com/zhangtingxi/p/15990701.html

Part A 单调队列

何为单调队列?

单调队列(Monotone queue )即单调递减或单调递增的队列。

例:滑动窗口

T1

题目

对于一个长为 NN 的序列,求所有从左到右长为 KK 的区间最大值最小值

N,K106N,K\leqslant 10^6

思路

以最大值为例,维护一个从大到小的队列,从队首到队尾单调不升。

这个队列维护的是 [i,i+k1][i,i+k-1] 的值,则此时最大值为队首。

然后考虑 [i+1,i+k][i+1, i+k] 区间的最大值。

上一个区间的最大值能不能用呢?

我们只需要判断当前队首的编号是否大于 ii 即可。

如果不大于 ii 就pop掉。

这里主要是运用一种思想,删掉多余的元素。

考虑两个值 ai,aja_i,a_j,若 i<ji<jai<aja_i<a_j,则 aia_i 是完全没有意义的。

Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include<bits/stdc++.h>
using namespace std;
int n, m, i, j, k;
int dui[1000010],front,rear;
int a[1000010];

int main() {
scanf("%d%d", &n, &k);
for(i=1; i<=n; ++i) scanf("%d", &a[i]);
//维护最小值
for(i=1; i<=k; ++i) {//第一个窗口的处理
while(rear && a[i]<a[dui[rear]]) --rear;
dui[++rear]=i;
}
printf("%d ", a[dui[1]]);
for(i=k+1; i<=n; ++i) {//每个窗口的处理
while(rear>front && a[dui[rear]]>a[i]) --rear;
dui[++rear]=i; //新的塞进来
while(dui[front]<i-k+1 && front<rear) ++front; //不合要求的弄走
printf("%d ", a[dui[front--]]);
}
printf("\n");
memset(dui, front=rear=0, sizeof(dui));
//维护最大值
for(i=1; i<=k; ++i) {
while(rear && a[i]>a[dui[rear]]) --rear;
dui[++rear]=i;
}
printf("%d ",a[dui[1]]);
for(i=k+1; i<=n; ++i) {
while(rear>front && a[i]>a[dui[rear]]) --rear;
dui[++rear]=i;
while(dui[front]<i-k+1&&front<rear) ++front;
printf("%d ",a[dui[front--]]);
}
return 0;
}

T2

题目

给出一个长为 NN 的序列,求长度不超过 KK 的连续子序列的最大和。

N,K2×105N, K\leqslant 2\times 10^5

思路

方法一:双指针

方法二:前缀和+堆优化

我们先对整个序列求一遍前缀和

我们考虑当前以位置 rr 结尾,则位置 ll 要满足什么?

  1. 满足区间长度小于等于 kk,即:rk+1lrr-k+1\leqslant l\leqslant r
  2. 满足区间 [l,r][l,r] 之和最大,就是 SrSl1S_r-S_{l-1} 最大。

先考虑如何满足条件2?

我们可以拿个小根堆来维护,那么就可以保证 SlS_l 最小。

如何满足条件1?

每次对于小根堆的堆顶,使得其满足条件1即可。

方法三:前缀和+单调队列

让我们考虑刚刚那种方法,我们能不能把优先队列改为单调队列。

要使能用单调队列,就必须满足两个条件。

由于单调队列满足单调性,所以条件2显然可以满足。

而我们刚刚是每次对小根堆的堆顶进行条件1判断,而此时我们同样可以对单调队列的队首进行类似维护。

时间复杂度 O(n)O(n)

Part B 单调队列优化dp的引入

T3

题目

求一个长为 NN 的序列选出任意多个数的最大和。需满足不存在连续 MM 个数。

N,M105N,M\leqslant 10^5

思路

思路一

考虑暴力做法,设 dpidp_i 表示前 ii 个数选且第 ii 个数不选的最大和。

dpi=maxj=iM1i1(dpj+Si1Sj)(1iN+1)dp_i=\max_{j=i-M-1}^{i-1}(dp_j+S_{i-1}-S_j)\,\,\,(1\leqslant i\leqslant N+1)

答案即为:

ans=maxi=1n+1dpjans=\max_{i=1}^{n+1} dp_j

Code1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 100010
int n, m, i, j, k, p;
int a[N], dp[N], sum[N];

signed main()
{
scanf("%lld%lld", &n, &k);
for(i=1; i<=n; ++i)
{
scanf("%lld", &a[i]);
sum[i]=sum[i-1]+a[i]; //求前缀和
}
for(i=1; i<=n+1; ++i) //区间右端点
for(j=max((long long)(0), i-k-1); j<=i-1; ++j) //区间左端点
dp[i]=max(dp[i], dp[j]+sum[i-1]-sum[j]); //dp过程
printf("%lld",dp[n+1]);
return 0;
}

思路二

我们看回刚刚的式子:

dpi=maxj=iM1i1(dpj+Si1Sj)(1iN+1)dp_i=\max_{j=i-M-1}^{i-1}(dp_j+S_{i-1}-S_j)\,\,\,(1\leqslant i\leqslant N+1)

可以变换一下:

dpi=maxj=iM1i1(dpjSj)+Si1(1iN+1)dp_i=\max_{j=i-M-1}^{i-1}(dp_j-S_j)+S_{i-1}\,\,\,(1\leqslant i\leqslant N+1)

观察可以得到 dpjSjdp_j-S_j 这一段只有 jj 这一个变量,所以可以用单调队列来维护。

Code2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 100010
struct node
{
int x, id;
}dui[N];
int n, m, i, j, k, p;
int a[N], dp[N], sum[N];
int front, rear;

signed main()
{
scanf("%lld%lld", &n, &k);
for(i=1; i<=n; ++i)
{
scanf("%lld", &a[i]);
sum[i]=sum[i-1]+a[i]; //求前缀和
}
dui[++rear].x=0; dui[rear].id=0;
for(i=1; i<=n+1; ++i) //区间右端点
{
while(front<rear && dui[front+1].id<i-k-1) ++front;
dp[i]=max(dp[i], dp[dui[front+1].id]+sum[i-1]-sum[dui[front+1].id]);
//求当前dp值
while(front<rear && dui[rear].x+sum[i]-sum[dui[rear].id]<dp[i]) --rear;
dui[++rear].x=dp[i]; dui[rear].id=i; //把当前的值塞进队列里
}
printf("%lld",dp[n+1]);
return 0;
}

T4

题目

给定一个长为 NN 的序列,对其中一些数进行操作,满足所有长度为 MM 的连续子序列中都至少存在一个数进行过操作。

N,M2×105N,M\leqslant 2\times 10^5

思路

先把暴力dp弄出来。

dpidp_i 表示前 ii 个数中满足对 ii 个数进行操作后的最少操作次数。

dpi=minj=iMi1dpj+aidp_i=\min_{j=i-M}^{i-1}dp_j+a_i

显然,对 dpjdp_j 进行单调队列维护即可。

Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include<bits/stdc++.h>
using namespace std;
#define N 1000010
int n, m, i, j, k;
int dui[N], a[N], f[N];
int front=1, rear=1;

int main()
{
scanf("%d%d", &n, &m);
for(i=1; i<=n; ++i) scanf("%d", &a[i]);
dui[1]=0;
for(i=1; i<=n+1; ++i)
{
while(rear>=front&&dui[front]<i-m) ++front;
f[i]=a[i]+f[dui[front]];
while(rear>=front&&f[i]<f[dui[rear]]) --rear;
dui[++rear]=i;
}
printf("%d", f[n+1]);
return 0;
}

Part C 单调队列优化dp的综合应用

T5

题目

给出一个有 NN 个车站的环形公路,每个车站有一定的汽油可供行驶一段距离,相邻两个车站之间也有一定距离,问从每个车站出发是否能环游一周

N106N\leqslant 10^6

思路

环形问题难搞,考虑破环成链。

破成一个长度为 2n2n 的车站序列,让我们先考虑顺时针的情况。

让我们对汽车油量和距离分别做前缀和 s1,s2s1, s2,如果从第 ii 个点出发可以回到起点,就意味着在 [i,i+n1][i, i+n-1] 的区间内,满足所有油量与距离之差的前缀和都大于等于0,即满足 (s1js1i1)(s2js2i1)0(j[i,i+n1])(s1_j-s1_{i-1})-(s2_j-s2_{i-1})\geqslant 0\,\,\,(j\in [i, i+n-1])

可是这样子不好维护,于是我们可以尝试对汽车油量与距离之差做前缀和,记为 ss

有了 ss 数组,我们就可以求区间最小值减去 si1s_{i-1} 与0作比较,相当于做一个长度为 nn 的滑动窗口。

逆时针同理。

Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 1000010
int n, m, i, j, k;
int o[N], d[N], s[N<<1], ans[N];
pair<int, int>p;
deque<pair<int, int> >q;

signed main() {
scanf("%d", &n);
for(i=1; i<=n; ++i) scanf("%d%d", &o[i], &d[i]);
for(i=1; i<=2*n; ++i) s[i]=s[i-1]+o[(i-1)%n+1]-d[(i-1)%n+1];
for(i=1; i<=n; ++i) {
while(!q.empty() && q.back().first>s[i]) q.pop_back();
p.first=s[i];
p.second=i;
q.push_back(p);
}
for(i=n+1; i<=2*n; ++i) {
ans[i-n]=q.front().first-s[i-n-1];
while(!q.empty() && i-q.front().second+1>n) q.pop_front();
while(!q.empty() && q.back().first>s[i]) q.pop_back();
p.first=s[i];
p.second=i;
q.push_back(p);
}
q.clear();
for(i=1, j=2*n; i<=2*n; ++i, --j) s[i]=s[i-1]+o[(j-1)%n+1]-d[((j-1>0 ? j-1 : n)-1)%n+1];
for(i=1; i<=n; ++i) {
while(!q.empty() && q.back().first>s[i]) q.pop_back();
p.first=s[i];
p.second=i;
q.push_back(p);
}
for(i=n+1; i<=2*n; ++i) {
ans[2*n-i+1]=max(ans[2*n-i+1], q.front().first-s[i-n-1]);
while(!q.empty() && i-q.front().second+1>n) q.pop_front();
while(!q.empty() && q.back().first>s[i]) q.pop_back();
p.first=s[i];
p.second=i;
q.push_back(p);
}
for(i=1; i<=n; ++i) printf(ans[i]>=0 ? "TAK\n" : "NIE\n");
return 0;
}

总结

这道题的思路挺巧妙的。

首先对于这类题,可以先考虑破环成链,这是这类题目的常见思考方向。

然后,我们对于题目的本质进行分析,然后可以想出差值前缀和。

最后,我们发现求最小值的过程中可以使用数据结构,即单调队列进行优化,然后就能做出此题。

T6

题目

NN 道题,每道题需要 aia_i 分钟来做。现在有 tt 分钟来做题,问最短空题段为多少。

n2000,t108n\leqslant 2000 ,t\leqslant 10^8

思路

由于最长长度和最短时间都不确定。我们可以假设其中一项确定来思考。

考虑二分答案,二分最长空题段。

假设二分当前最长空题段为 kk,我们就可以尝试dp处理。

dpidp_i 表示第 ii 题做,且前 ii 题的最长空题段小于等于 kk 时的最短时间,我们可以枚举上一条做了的题,即:

dpi=minmax(j=ik1,0)i1dpj+aidp_i=\min_{\max(j=i-k-1, 0)}^{i-1}dp_j+a_i

答案我们可以在 [nk1,n][n-k-1, n] 里枚举最后一道题的位置,时间复杂度 O(n2logn)O(n^2\log n)

显然,minmax(j=ik1,0)i1dpj\min_{\max(j=i-k-1, 0)}^{i-1}dp_j 可以用单调队列优化,时间复杂度 O(nlogn)O(n\log n)

Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 500010
int n, m, i, j, k;
int l, r, mid, ans;
int dp[N], a[N], t;
pair<int , int>p;
deque<pair<int, int> >q;

int check(int k) {
q.clear();
p.first=0; p.second=0;
q.push_back(p);
for(i=1; i<=n; ++i) {
dp[i]=q.front().first+a[i];
while(!q.empty() && i-q.front().second>k) q.pop_front();
while(!q.empty() && dp[i]<=q.back().first) q.pop_back();
p.first=dp[i];
p.second=i;
q.push_back(p);
}
ans=0x7fffffffffffffff;
for(i=n-k-1; i<=n; ++i) ans=min(ans, dp[i]);
return ans<=t;
}

signed main() {
scanf("%lld%lld", &n, &t);
for(i=1; i<=n; ++i) scanf("%lld", &a[i]);
l=1, r=n;
while(l<r) {
mid=(l+r)>>1;
if(check(mid)) r=mid;
else l=mid+1;
}
printf("%lld", l);
return 0;
}

T7

题目

在一个 a×ba×b 的矩阵中找出一个 n×nn×n 的正方形区域,使得该区域所有数中的最大值和最小值的差最小。

2a,b1000,na,nb,n1002\le a,b\le 1000,n\le a,n\le b,n\le 100

思路

显然我们可以枚举上界,则此时上下界可以确定。

那么在这种情况下,这个正方形可以为如下图中的红色区域。

image

那哪个红色区域是最优的呢?

这里我们可以把它看作一个二维的滑动窗口,最大最小值预处理一下即可。

枚举上下界是 O(n)O(n) 的,横着扫一遍是 O(n)O(n) 的,总复杂度为 O(n2)O(n^2)

Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include<bits/stdc++.h>
using namespace std;
#define N 1010
int n, m, i, j, k;
int a, b, c, ans=0x7fffffff;
int l, r, mid;
int mn[N][N][11], mx[N][N][11];
int mxx, mnn;

signed main() {
scanf("%d%d%d", &a, &b, &n);
for(i=1; i<=a; ++i)
for(j=1; j<=b; ++j)
scanf("%d", &mx[i][j][0]), mn[i][j][0]=mx[i][j][0];
for(i=1; i<=a; ++i)
for(k=1; k<=10; ++k)
for(l=1, r=l+(1<<k-1); l+(1<<k)-1<=b; ++l, ++r) {
mn[i][l][k]=min(mn[i][l][k-1], mn[i][r][k-1]),
mx[i][l][k]=max(mx[i][l][k-1], mx[i][r][k-1]);
}
c=log2(n);
for(i=1; i+n-1<=a; ++i)
for(j=1; j+n-1<=b; ++j) {
mxx=0xffffffff;
mnn=0x7fffffff;
for(k=i; k<=i+n-1; ++k)
mxx=max(mxx, max(mx[k][j][c], mx[k][j+n-1-(1<<c)+1][c])),
mnn=min(mnn, min(mn[k][j][c], mn[k][j+n-1-(1<<c)+1][c]));
ans=min(ans, mxx-mnn);
}
printf("%d", ans);
return 0;
}

T8

题目

现在已知 TT 天内每天股票的入价 APiAP_i,出价 BPiBP_i,每天最多买 ASiAS_i 股,最多卖 BSiBS_i 股,每次交易之间至少隔 WW 天,任意时候手上持股数量不得超过 MaxPMaxP,问 TT 天后最多赚多少钱。

0W<T2000,1MaxP20000\leq W<T\leq 2000,1\leq\text{MaxP}\leq2000

思路

明显的dp。

dp[i][j]dp[i][j] 表示第 ii 天持股为 jj 的最大钱数,首先可以从上一天或者第0天推导过来。

然后考虑买入的情况,由于我们每次由上一天推导过来,具有传递性,所以我们可以由第 iw1i-w-1 天推导过来。

假设第 iw1i-w-1 天持股数量为 kk,那么需满足 jasikj1j-as_i\leqslant k\leqslant j-1,转移为:

dp[i][j]=dp[iw1][k](jk)×apidp[i][j]=dp[i-w-1][k]-(j-k)\times ap_i

可是这样的时间复杂度达到三次方级别,于是我们对上面的式子变换一下:

dp[i][j]=(dp[iw1][k]+k×api)j×apidp[i][j]=(dp[i-w-1][k]+k\times ap_i)-j\times ap_i

明显,前面那一部分可以用单调队列优化。

卖出情况同理。

Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 2010
int n, m, i, j, k;
int ap[N], bp[N], as[N], bs[N];
int dp[N][N], ans, w, mx;
pair<int, int>p;
deque<pair<int, int > >q1, q2;

signed main() {
memset(dp, 0x80, sizeof(dp));
scanf("%lld%lld%lld", &n, &m, &w);
for(i=1; i<=n; ++i)
scanf("%lld%lld%lld%lld", &ap[i], &bp[i], &as[i], &bs[i]);
for(i=1; i<=n; ++i) {
for(j=0; j<=m; ++j) {
dp[i][j]=dp[i-1][j];
if(j<=as[i]) dp[i][j]=max(dp[i][j], -j*ap[i]);
}
if(i<=w) continue;
for(j=0; j<=m; ++j) {
while(!q1.empty() && q1.front().second<j-as[i]) q1.pop_front();
while(!q1.empty() && q1.back().first<=dp[i-w-1][j-1]+(j-1)*ap[i]) q1.pop_back();
p.first=dp[i-w-1][j-1]+(j-1)*ap[i];
p.second=j-1;
q1.push_back(p);
dp[i][j]=max(dp[i][j], q1.front().first-j*ap[i]);
}
for(j=m; j>=0; --j) {
while(!q2.empty() && q2.front().second>j+bs[i]) q2.pop_front();
while(!q2.empty() && q2.back().first<=dp[i-w-1][j+1]+(j+1)*bp[i]) q2.pop_back();
p.first=dp[i-w-1][j+1]+(j+1)*bp[i];
p.second=j+1;
q2.push_back(p);
dp[i][j]=max(dp[i][j], q2.front().first-j*bp[i]);
}
q1.clear();
q2.clear();
}
for(i=0; i<=m; ++i) ans=max(ans, dp[n][i]);
printf("%lld", ans);
return 0;
}

总结

这是一道非常经典的dp加单调队列优化。

dp方面,传递性的思想很巧妙。

有些时候,dp式子需要进行一定变换才能使用单调队列优化。

T9

题目

nn 种面值的硬币,分别为 b1,b2,,bnb_1, b_2,\cdots , b_n 。要凑出面值 kk,最少要用多少个硬币。

n2000,k2×104n\leqslant 2000,k\leqslant 2\times 10^4

思路

单调队列做法略。主要是我没想懂,这里介绍一种二进制做法。

单调队列做法留给读者自己探究。

这题我本来不想写的,但是由于在老师布置的题单上,就写了另一种做法。

首先不考虑时间复杂度,这个问题应该是可以用多重背包求解的。

同时,我们也可以把每种面值的货币拆成 cic_i 个,用01背包求解。时间复杂度为 O(nm2)O(nm^2)

这时我们可以考虑每种面值。设 t=log2cit=log_2c_i,对于 cic_i 以内的任何一个数,我们都可以用 20,21,,2t2^0, 2^1,\cdots,2^t 凑出,所以我们并不需要把每种面值的硬币拆成 cic_i 份,拆成 log2cilog_2c_i 份即可。

时间复杂度 O(nmlogm)O(nm\log m)

这是一道经典的多重背包优化题目。

对于多重背包来说,数量的个数限制往往可以用二进制的方法组合表示。

如果一道多重背包的题能拆成01背包,那么它必然可以用二进制的方法进行优化,这是一种常见题型。

Part D 结语

最后,我想以今日英才的一段激励语来结束这篇文章。

人最宝贵的是生命,人的生命只有一次。人的一生应当这样度过:当他回首往事时,他不会因为虚度年华而悔恨;也不会因为生活的庸俗而羞愧;临死的时候,他能够说,我把我的整个生命和全部精力,都献给了全中国最辉煌的事业——OI事业,为中国在21世纪成为世界第一OI强国而奋斗和努力!

让我们用这段光彩夺目的话来鞭策和激励自己!

让自己成为一个无愧于时代的高尚的人!

谢谢您们!

没错,这段是我抄的