最近在做一个动态规划相关的题目,发现了有一些动态规划题目中可以使用单调队列来简化计算的复杂度,本来以为动态规划以及很厉害了,看到了单调队列才不禁发现,原来是算法或者结构还可以这么玩。
定义
单调队列是指一个队列内部的元素具有严格单调性的一种数据结构,分为单调递增队列和单调递减队列。
单调队列满足两个性质:
1.单调队列必须满足从队头到队尾的严格单调性。
2.排在队列前面的比排在队列后面的要先进队。
元素进队列的过程对于单调递增队列,对于一个元素a 如果 a > 队尾元素, 那么直接将a扔进队列, 如果 a <= 队尾元素 则将队尾元素出队列,直到满足 a 大于队尾元素即可;同理对于单调递减队列,也是类似的,这里不再详细说了。
举个栗子:
[1,4,3,5,2,7,8]构造单调队列 注意:这里不考虑单调队列的长度
1进队列 【1】
4>1 所以4进列 【1,4】
3<4 所以4出列 【1,3】
5>3 所以5进列 【1,3,5】
2<5 所以5,3出列 然后2进【1,2】
7>2 所以进列 【1,2,7】
8>7 所以进列 【1,2,7,8】
对于要考虑队列长度的,可以看看如下栗子(https://www.cnblogs.com/tham/p/8038828.html)
数列为:6 4 10 10 8 6 4 2 12 14
N=10,K=3;
那么我们构造一个长度为3的单调递减队列:
首先,那6和它的位置0放入队列中,我们用(6,0)表示,每一步插入元素时队列中的元素如下
插入6:(6,0);
插入4:(6,0),(4,1);
插入10:(10,2);
插入第二个10,保留后面那个:(10,3);
插入8:(10,3),(8,4);
插入6:(10,3),(8,4),(6,5);
插入4,之前的10已经超出范围所以排掉:(8,4),(6,5),(4,6);
插入2,同理:(6,5),(4,6),(2,7);
插入12:(12,8);
插入14:(14,9);
在动态规划中用到单调队列的主要就是滑动窗口最大最小值问题了,什么是滑动窗口问题呢?如下:对于数列a=[1,3,2,7,4,5],选取3的滑动窗口,则每次滑动后的结果为:
第一次: (1 3 2) 7 4 5
第二次: 1 (3 2 7) 4 5
第三次: 1 3 (2 7 4) 5
那么这个动态规划问题就是要求每次滑动的最大或者最小值。好吧,无不无聊,每次滑动后直接比较不就ok了吗?我曹,好像可以,那就直接每次滑动后直接比较呗,但是会发现超时。因为第一次和第二次是不是多比较了一次,人呐,总想要更加简化,那怎么简化这个问题,就是上面说的单调队列。也就是搞一个长度为3的单调队列来跑,看看代码吧,代码中我只显示了下降的单调队列。
#include<iostream>
#include<cstring>
#include<cstdio>
using namespace std;
int n, K, i, a[10];
int q1[10], q2[10], ans1[10], ans2[10];
int l1 = 1, l2 = 1, r1, r2;
int main()
{
scanf("%d%d", &n, &K);
for (i = 1; i <= n; i++)
scanf("%d", &a[i]);
for (i = 1; i <= n; i++){
while (l1 <= r1&&q1[l1] <= i - K) //因为我们这个窗口为3,每次都要判断一下l1(队列的头是不是太远了,太远了就没用了。这里l1<=r1好像要不要都没关系)
l1++;
//while (l2 <= r2&&q2[l2] <= i - K)l2++;
while (l1 <= r1&&a[i]<a[q1[r1]])//重点,如果来了一个新的值,这个新的值必队尾的值还小,果断不要队尾,注意这里q1保存的是下标,不是具体的那个位置的值
r1--;
q1[++r1] = i; //更新下标的值
//while (l2 <= r2&&a[i]>a[q2[r2]])r2--;
//q2[++r2] = i;
ans1[i] = a[q1[l1]]; //每次保存队首元素
//ans2[i] = a[q2[l2]];
}
for (i = K; i <= n - 1; i++)printf("%d ", ans1[i]); printf("%d\n", ans1[n]);
return 0;
//for (i = K; i <= n - 1; i++)printf("%d ", ans2[i]); printf("%d", ans2[n]);
}
大家可以调试一下,主要看一下q1是怎么变化的。大致就可以明白了。
这道题对应的leetcode题目是https://leetcode.com/problems/sliding-window-maximum/
在youtube上的视频是 https://www.youtube.com/watch?v=ShbRCjvB_yQ
下面给出了几个动态规划的例子
案例1 烽火传递
一道动态规划的题目,不用我说,肯定也是用单调队列来实现的,先介绍题目吧。
Description
烽火台又称烽燧,是重要的军事防御设施,一般建在险要或交通要道上。一旦有敌情发生,白天燃烧柴草,通过浓烟表达信息;夜晚燃烧干柴,以火光传递军情,在某两座城市之间有n个烽火台,每个烽火台发出信号都有一定代价。为了使情报准确地传递,在连续m个烽火台中至少要有一个发出信号。请计算总共最少花费多少代价,才能使敌军来袭之时,情报能在这两座城市之间准确传递。
Input
第一行:两个整数N,M。其中N表示烽火台的个数,M表示在连续m个烽火台中至少要有一个发出信号。接下来N行,每行一个数Wi,表示第i个烽火台发出信号所需代价。
Output
一行,表示答案。
Sample Input
5 3
1
2
5
6
2
Sample Output
4
Data Constraint
对于50%的数据,M≤N≤1,000 。 对于100%的数据,M≤N≤ 100,000,Wi≤100。
博客 https://blog.youkuaiyun.com/A1847225889/article/details/77777009
先用简单的动态规划来试试吧,动态转移方程为
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n,m;
int w[100001];
int f[100001];
int main()
{
scanf("%d%d",&n,&m);
int i,j;
for (i=1;i<=n;++i)
scanf("%d",&w[i]);
memset(f,127,sizeof f);
f[0]=0;
for (int i = 1; i <= n; i++)
{
for (j = max(0, i - m); j<i; ++j)//这里把博客中的合并了
f[i] = min(f[i], f[j]);
f[i] += w[i];
}
int ans = 0x7f7f7f7f;
for (i = n - m + 1; i <= n; ++i)
ans = min(ans, f[i]);
printf("%d\n", ans);
}
来看看我们的单调队列是怎么做的吧?
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
int n, m;
int w[10];
int que[10], head = 0, tail = 0;
int f[10];
int main()
{
scanf("%d%d", &n, &m);
int i, j;
for (i = 1; i <= n; ++i)
scanf("%d", &w[i]);
memset(f, 127, sizeof f);
f[0] = 0;
que[0] = 0;
for (i = 1; i <= n; ++i)
{
if (que[head]<i - m)
++head;//将超出范围的队头删掉
f[i] = f[que[head]] + w[i];//转移(用队头)
while (head <= tail && w[que[tail]]>w[i])
--tail;//将不比它优的全部删掉
que[++tail] = i;//将它加进队尾
}
int ans = 0x7f7f7f7f;
for (i = n - m + 1; i <= n; ++i)
ans = min(ans, f[i]);
printf("%d\n", ans);
}
对比一下就可以发现这个代码是怎么一步一步写到这里的。
个人比较喜欢Python的代码,简洁表达如下:
# -*- coding: utf-8 -*-
##code2
##用来求滑动窗口最小值问题
a = [0, 4, 2, 5, 6, 1, 7]
n = len(a) - 1
m = 3 #窗口大小
head = 1
tail = 0 #这里初始化为0是有必要的
dp = [0] * 10
queue = [0] * 10
ansl = [0] * 10
for i in range(1, n + 1) :
while (head <= tail and queue[head] <= i - m) :
head = head + 1 #head的递增是有条件的,即不能让下标的距离太大,m = 3, 超过肯定不行,前初始化tail = 0也是有道理的
dp[i] = dp[queue[head]] + a[i]
while (head <= tail and a[i]<a[queue[tail]]) : #发现比队尾还小的情况,那就果断出队
tail = tail - 1
tail = tail + 1
queue[tail] = i
ansl[i] = a[queue[head]]
for ii in range(m, n + 1) :
print ansl[ii]
print min(dp[n - m + 1:n]) #输出总体最小值
## code2方法一
## 用来求烽火传递问题
a = [0, 4, 2, 5, 6, 1, 7]
n = len(a) - 1
m = 3 #窗口大小
head = 0
tail = 0 #这里初始化为0是有必要的
dp = [0] * 10
queue = [0] * 10
ansl = [0] * 10
for i in range(1, n + 1) :
while (head <= tail and queue[head]< i - m) :
head = head + 1 #head的递增是有条件的,即不能让下标的距离太大,m = 3, 超过肯定不行,前初始化tail = 0也是有道理的
dp[i] = dp[queue[head]] + a[i]
while (head <= tail and dp[i]<dp[queue[tail]]) : #发现比队尾还小的情况,那就果断出队
tail = tail - 1
tail = tail + 1
queue[tail] = i
ansl[i] = a[queue[head]]
# dp[i] = dp[queue[head]] + a[i]
print(a[queue[head]])
for ii in range(m, n + 1) :
print(ansl[ii])
print(min(dp[n - m + 1:n])) #输出总体最小值
## code2方法二
import queue
a = [1, 2, 5, 6, 3, 7]
queuearray = []
dp = [0] * 10
for i in range(len(a)) :
queuearray.append(a[i])
while a[i] < queuearray[max(len(queuearray) - 2, 0)] :
queuearray.remove(queuearray[len(queuearray) - 2])
if i>2 :
dp[i] = temp + a[i]
if len(queuearray) > 3:
queuearray.remove(queuearray[0])
temp = queuearray[0]
print(queuearray)
print(dp)
#这里我用的是一个list来做的,比方法一中的que以及索引要简单且好理解,中间也打印出了queuearray方便观察整个过程
案例2 琪露诺
在幻想乡,琪露诺是以笨蛋闻名的冰之妖精。某一天,琪露诺又在玩速冻青蛙,就是用冰把青蛙瞬间冻起来。但是这只青蛙比以往的要聪明许多,在琪露诺来之前就已经跑到了河的对岸。于是琪露诺决定到河岸去追青蛙。小河可以看作一列格子依次编号为0到N,琪露诺只能从编号小的格子移动到编号大的格子。而且琪露诺按照一种特殊的方式进行移动,当她在格子i时,她只会移动到i+L到i+R中的一格。你问为什么她这么移动,这还不简单,因为她是笨蛋啊。每一个格子都有一个冰冻指数A[i],编号为0的格子冰冻指数为0。当琪露诺停留在那一格时就可以得到那一格的冰冻指数A[i]。琪露诺希望能够在到达对岸时,获取最大的冰冻指数,这样她才能狠狠地教训那只青蛙。但是由于她实在是太笨了,所以她决定拜托你帮它决定怎样前进。开始时,琪露诺在编号0的格子上,只要她下一步的位置编号大于N就算到达对岸。
输入输出格式
输入格式:
第1行:3个正整数N, L, R
第2行:N+1个整数,第i个数表示编号为i-1的格子的冰冻指数A[i-1]
输出格式:
一个整数,表示最大冰冻指数。保证不超过2^31-1
输入输出样例
输入样例#1:
5 2 3
0 12 3 11 7 -2
输出样例#1:
11
说明
对于60%的数据:N <= 10,000
对于100%的数据:N <= 200,000
对于所有数据 -1,000 <= A[i] <= 1,000且1 <= L <= R <= N
动态规划转移方程如下:
如果使用动态规划,每次转移都要查找长度为r-l的窗口内的最大值,非常耗费时间,会TLE,为了快速求出最大值,我们可以利用单调队列优化.可以发现我们只需要维护一个长度为r-l的窗口即可,做法和滑动的窗口类似.
C++代码如下:
#include<iostream>
#案例2代码
int f[10], que[10], head, tail, m, ans;
int main(){
int n = 5, left = 2, right = 3;
int a[] = { 0, 3, -13, 2, 1, 17 };
for (int i = 0; i<left; i++) f[i] = 0;
head = tail = 1;
que[head] = 0;
for (int i = left; i <= n; i++){
while (que[head]<i - right) head++;
while (head <= tail && f[que[tail]] <= f[i - left]) tail--;
tail++;
que[tail] = i - left;
f[i] = f[que[head]] + a[i];
}
ans = f[n - right + 1];
for (int i = n - right + 2; i <= n; i++) if (ans<f[i]) ans = f[i];
printf("%d", ans);
return 0;
}
Python代码如下:
# #-*- coding: utf-8 -*-
#案例2代码
## 用来求间距最大和
a=[0,1,5 ,6, 2, 1, 7] #这里就随便定义了一个数组
n=len(a)-1
head=0
tail=0
left=1
right=3
dp=[0]*10
queue=[0]*10
ansl=[0]*10
for i in range(left,n+1):
while (head<=tail and queue[head]< i-right):
head=head+1
while (head<=tail and dp[i-left]>dp[queue[tail]]):
tail=tail-1
tail=tail+1
queue[tail]=i-left
dp[i] = dp[queue[head]] + a[i]
print(max(dp[n-right+2:n])) #输出总体最大值
仔细观察可以发现,案例2和案例1基本上是一样的,也就是一个最大和最小的差异而已,案例2相当于left=1 right=m的案例1.对此,我们可以写出类似于案例2中python代码的案例1的另一种代码。
# #-*- coding: utf-8 -*-
#案例1代码
a=[0,1,5 ,6, 2, 1, 7]
n=len(a)-1
m=3 #窗口大小
head=0
tail=0
left=2
right=3
dp=[0]*10
queue=[0]*10
ansl=[0]*10
for i in range(left,n+1):
while (head<=tail and queue[head]< i-right):
head=head+1
while (head<=tail and dp[i-left]>dp[queue[tail]]):
tail=tail-1
tail=tail+1
queue[tail]=i-left
dp[i] = dp[queue[head]] + a[i]
print(min(dp[n-right+2:n])) #输出总体最大值
可以看到上述代码中的dp更新的位置与先前的是有所不同的,这里可以仔细想一下。
案例3 切蛋糕
题目描述
今天是小Z的生日,同学们为他带来了一块蛋糕。这块蛋糕是一个长方体,被用不同色彩分成了N个相同的小块,每小块都有对应的幸运值。
小Z作为寿星,自然希望吃到的第一块蛋糕的幸运值总和最大,但小Z最多又只能吃M小块(M≤N)的蛋糕。
吃东西自然就不想思考了,于是小Z把这个任务扔给了学OI的你,请你帮他从这N小块中找出连续的k块蛋糕(k≤M),使得其上的幸运值最大。
输入输出格式
输入格式:
输入文件cake.in的第一行是两个整数N,M。分别代表共有N小块蛋糕,小Z最多只能吃M小块。
第二行用空格隔开的N个整数,第i个整数Pi代表第i小块蛋糕的幸运值。
输出格式:
输出文件cake.out只有一行,一个整数,为小Z能够得到的最大幸运值。
输入输出样例
输入样例#1: 复制
5 2
1 2 3 4 5
输出样例#1: 复制
9
输入样例#2: 复制
6 3
1 -2 3 -4 5 -6
输出样例#2: 复制
5
说明
对20%的数据,N≤100。
对100%的数据,N≤500000,|Pi|≤500。 答案保证在2^31-1之内。
其中,qzh[i]表示前i个数的前缀和,记录数组为 qzh[i]。需要求的就说用单调队列来维护一个最小值,因为对于每一步的qzh[i]是一定的,只需要求出qzh[i-k]的最小值。
C++代码如下
#include<cstdio>
#include<iostream>
#define N 500005
int n,m;
int maxn;
int qzh[N];
int q[N],hd,tail;
signed main(){
scanf("%d%d",&n,&m);
for(int x,i=1;i<=n;i++) scanf("%d",&x),qzh[i]=qzh[i-1]+x;
hd=1,tail=0;
for(int i=1;i<=n;i++){
while(hd<=tail&&i-q[hd]>m) hd++;
maxn=std::max(maxn,qzh[i]-qzh[q[hd]]);
while(hd<=tail&&qzh[q[tail]]>=qzh[i]) tail--;
q[++tail]=i;
}
printf("%d\n",maxn);
return 0;
}
Python代码后续
案例4 好消息坏消息
uim在公司里面当秘书,现在有n条消息要告知老板。每条消息有一个好坏度,这会影响老板的心情。告知完一条消息后,老板的心情等于之前老板的心情加上这条消息的好坏度。最开始老板的心情是0,一旦老板心情到了0以下就会勃然大怒,炒了uim的鱿鱼。uim为了不被炒,知道了了这些消息(已经按时间的发生顺序进行了排列)的好坏度,希望研究如何不让老板发怒。uim必须按照时间的发生顺序逐条将消息告知给老板。不过uim可以使用一种叫“倒叙”的手法,例如有n条消息,小a可以从k,k+1,k+2…n,1,2…k-1这种顺序通报。他希望知道,有多少个k,从k开始通报到n然后从1通报到k-1可以让老板不发怒。
输入输出格式
输入格式:
第一行一个整数n(1 <= n <= 10^6),表示有n个消息。
第二行n个整数,按时间顺序给出第i条消息的好坏度Ai(-1000 <= Ai <= 1000)
输出格式:
一行一个整数,表示可行的方案个数。
输入输出样例
输入
4
-3 5 1 2
输出
2
这道题主要的做法还是端环为链来进行求解,对于-3 5 1 2从哪里分开会使得中间不会出现0值,可以将
-3 5 1 2加上-3 5 1变成 -3 5 1 2 -3 5 1 然后求前缀和为
index | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
value | -3 | 5 | 1 | 2 | -3 | 5 | 1 |
前缀和s | -3 | 2 | 3 | 5 | 2 | 7 | 8 |
这样可以根据index来求任意两个数之间的和了,s[j]-s[i-1]表示[i,j]之间的和。令k=4,则表示用4的窗口去滑动,当窗口在index在[2 3 4 5]的时候表示的是s[5]-s[1]也就是index为[2 3 4 5]的和。怎么判断index为[2 3 4 5]这个做法是不是合理的呢,那就看s[2-5]中的最小值是不是大于是s[1],如果是那就认为这个组合是合理的。原因是这样的:如果中间某个值比s[1]还小的话,说明中间肯定出现了负的和,也就说中间这个k=4的窗口中,肯定求和的过程中出现了负值,那就GG了,uim要被炒鱿鱼了。
所以问题就变成了一个单调队列问题,即如果想看index=i是不是合理的,就看i-k+1-i这个窗口的最小值,可笑,这不是一个维护k=4窗口最小值的问题了吗?庸俗,我把别人代码先放这了。
#include<cstdio>
#include<iostream>
using namespace std;
int n,head=1,tail,ans;
long long a[2000001],s[2000001],q[2000001];
int main()
{
scanf("%d",&n);
for(register int i=1;i<=n;i+=1)
scanf("%lld",&a[i]);
for(register int i=1;i<=n-1;i+=1)
a[i+n]=a[i];
for(register int i=1;i<=2*n-1;i+=1)
s[i]=s[i-1]+a[i];
for(register int i=1;i<=2*n-1;i+=1)
{
while(head<=tail&&max(i-n+1,1)>q[head])head++;
while(head<=tail&&s[i]<=s[q[tail]])tail--;
q[++tail]=i;
if(i-n+1>0&&s[q[head]]-s[i-n]>=0)ans++;
}
printf("%d\n",ans);
return 0;
}
后续会加其它的