【学习笔记】单调队列/斜率优化动态规划
DP优化算法-单调队列/斜率优化
参考了这篇文章,这篇文章还有这篇文章以及《算法竞赛-进阶指南》By LYD
DP转移优化的指导思想就是及时排除不可能的决策,保持候选集合的高度有效型和秩序性
单调队列优化DP
单调队列请自行学习,这里只介绍动态规划的单调队列优化
大概就是用借助单调性及时排除非法状态,思想很简短,所以主要是例题
单调队列优化DP的方程一般长成a[i]+b[j]+c(c为常数)每一项(除常数项外)只与i或j这样中的一个有关
Fence
n个木板,m个粉刷匠,每个人只能刷一次,第i个人两种选择:不刷,或刷长度不超过
,且要包含 的连续的木板,第i个人刷一块木板获得报酬 ,求所有人获得的报酬总和的最大值
设状态dp[i][j]表示前i个人涂到第j个木板所能收到的最大报酬,分为以下三种情况
-
第i个人不涂,dp[i][j]=dp[i-1][j]
-
第i个人涂了,但是没涂第j块,dp[i][j]=dp[i][j-1]
-
上一个人涂到了第k块,第i个人从第k+1块涂到了第j块,
因为每次枚举j时,可将j看作一个定值,可以把
柿子可以化简为
对于以上三种情况,每次枚举i时前两种可以通过O(n)的复杂度搞定,无需优化,而第三种则需要再枚举一层k,考虑使用单调队列优化至O(n)
同时也可以引入单调队列的一个性质:其维护的下标与权值均为单调的
代码实现应该都会吧,整体时间复杂度
AC code
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
#include<deque>
#include<cmath>
#include<algorithm>
using namespace std;
const int maxn=16010;
const int maxm=110;
inline int read()
{
int w=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9')
{
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9')
{
w=(w<<3)+(w<<1)+(ch^48);
ch=getchar();
}
return w*f;
}
int n,k;
struct worker
{
int l,s,p;
}w[maxm];
int dp[maxm][maxn];
bool cmp(worker a,worker b)
{
return a.s<b.s;
}
int calc(int i,int k)
{
return dp[i-1][k]-w[i].p*k;
}
int main()
{
n=read();k=read();
for(int i=1;i<=k;i++)
{
w[i].l=read();
w[i].p=read();
w[i].s=read();
}
sort(w+1,w+k+1,cmp);
for(int i=1;i<=k;i++)
{
deque <int> q;
for(int j=max(0,w[i].s-w[i].l);j<=w[i].s-1;j++)
{
while(!q.empty() && calc(i,q.back())<=calc(i,j))
{
q.pop_back();
}
q.push_back(j);
}
for(int j=1;j<=n;j++)
{
dp[i][j]=max(dp[i][j-1],dp[i-1][j]);
if(j<w[i].s) continue;
while(!q.empty() && j-w[i].l>q.front())
{
q.pop_front();
}
if(q.empty()) continue;
dp[i][j]=max(dp[i][j],w[i].p*j+calc(i,q.front()));
}
}
cout<<dp[k][n];
return 0;
}
选择数字
之前Sunny_r学姐讲课的时候的例题,还算经典题,但是挺板的,比上道题简单
先求出所有数字的和,然后让选择数字变为删除数字,且连续k个数必须删除一个
用dp[i]表示删除第i个数字时前i个数字中删除的数的最小总和,dp[i]应该由
即
AC code
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
#include<cmath>
#include<deque>
#include<algorithm>
#include<cstdlib>
#include<climits>
#define int long long
using namespace std;
const int maxn=1e5+5;
inline int read()
{
int w=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9')
{
if(ch=='-')
{
f=-1;
}
ch=getchar();
}
while(ch>='0' && ch<='9')
{
w=(w<<3)+(w<<1)+(ch^48);
ch=getchar();
}
return w*f;
}
int ans;
int n,k,sum;
int a[maxn];
int dp[maxn];
deque <int> dq;
signed main()
{
n=read(),k=read();
for(int i=1;i<=n;i++)
{
a[i]=read();
sum+=a[i];
}
for(int i=0;i<=n;i++)
{
dp[i]=dp[dq.front()]+a[i];
while(!dq.empty() && dp[i]<=dp[dq.back()])
{
dq.pop_back();
}
dq.push_back(i);
while(!dq.empty() && dq.front()<i-k)
{
dq.pop_front();
}
}
for(int i=n-k;i<=n;i++)
{
ans=max(ans,sum-dp[i]);
}
cout<<ans;
return 0;
}
Tower of Hay G
首先贪心,因为最后进来的干草堆在最上面,我们倒序读入然后按照塔从上往下找,但是正确性并不对,因为限制高度的是底层的宽度,底层宽度越小,塔的高度应该越高
我们考虑DP,设状态f[i]表示从上到下第i个干草堆时的高度,g[i]表示此时最下层的宽度,我们可以发现只要找到上一层的最后一个干草堆,问题就很好解决,所以现在的问题转化为了如何找到上层的最后一个干草堆,考虑单调队列优化
预处理宽度的前缀和,设上一层的最后一个干草堆为j,现在的干草堆为i,那么现在这层的宽度g[i]应该为sum[i]-sum[j],而宽度从上到下单调不降,所以上一层的宽度
AC code
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
#include<deque>
#include<cmath>
#include<algorithm>
using namespace std;
const int maxn=1e5+5;
inline int read()
{
int w=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9')
{
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9')
{
w=(w<<3)+(w<<1)+(ch^48);
ch=getchar();
}
return w*f;
}
int n,w[maxn];
int f[maxn];
int g[maxn];
int sum[maxn];
int calc(int x)
{
return sum[x]+g[x];
}
int main()
{
n=read();
for(int i=n;i>=1;i--)
{
w[i]=read();
}
for(int i=1;i<=n;i++)
{
sum[i]=sum[i-1]+w[i];
}
deque <int> q;
int last=0;
for(int i=1;i<=n;i++)
{
while(!q.empty() && sum[i]-sum[q.front()]>=g[q.front()])
{
last=q.front();
q.pop_front();
}
g[i]=sum[i]-sum[last];
f[i]=f[last]+1;
while(!q.empty() && calc(i)<calc(q.back())) q.pop_back();
q.push_back(i);
}
cout<<f[n];
return 0;
}
Cut the Sequence
给出一个长度为n的序列,分成若干段,每段数字的和不能大于m,让每段的最大值的和最小,输出这个最小值
POJ真TM的【】,设状态dp[i]为前i个数分成若干段后数字的和的最大值,转移方程好推
柿子看着长很好理解,但不好优化,我们可以从j考虑如何优化
我们发现j为上一段的结尾,一个j的存在有意义需要满足两种条件
我们可以将其理解为最优性条件与限制性条件(自己造的词)
对于第一个条件,如果不在j处分段,而a[j]不是j-1结尾的子段内的最大值,却是[j,i]内的最大值,很明显[j,i]内最终统计进答案的最大值为a[j],也就是说最终的答案会加上一个更大的数,明显是不优的结果
而对于第二个条件,就是题目限制,没啥好说的
我们可以维护一个j单调递增,
这样我们可以找到最优的j,而如何找到
这里建议不要用deque,因为我们要找到j之后的第一个在单调队列内的值,同时j又要被维护,deque很容易搞混
AC code
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
#include<cmath>
#include<algorithm>
#include<set>
#define int long long
using namespace std;
const int maxn=1e5+5;
int dp[maxn];
int n,m,a[maxn];
int sum[maxn],j;
int q[maxn],l,r;
multiset <long long> s;
signed main()
{
scanf("%d%lld",&n,&m);
for(int i=1;i<=n;i++)
{
scanf("%d",&a[i]);
if(a[i]>m){puts("-1");return 0;}
sum[i]=sum[i-1]+a[i];
}
l=1,r=0,j=0;
for(int i=1;i<=n;i++)
{
while(sum[i]-sum[j]>m)++j;
while(l<=r && q[l]<=j)
{
if(l<r)s.erase(a[q[l+1]]+dp[q[l]]);
l++;
}
while(l<=r && a[q[r]]<=a[i])
{
if(l<r)s.erase(a[q[r]]+dp[q[r-1]]);
r--;
}
q[++r]=i;
if(l<r)s.insert(dp[q[r-1]]+a[i]);
dp[i]=dp[j]+a[q[l]];
if(l<r)dp[i]=min(dp[i],*s.begin());
}
printf("%lld\n",dp[n]);
return 0;
}
斜率优化的引入
为什么我们要先看单调队列优化DP呢,我们可以发现在转移方程中,每一项都只与i或j中的一个有关,而如果我们遇到了形如
斜率优化动态规划
依旧从例题入手
【HNOI 2008】玩具装箱
被称为斜率优化的模板题,先分别从数形结合,线性规划的角度推导
数形结合
刚刚画了一大张草稿纸,现在来梳理下思路(目前还没推完)
这个题转移方程很好推
设dp[i]为安排前i个物品的最小花费,初始柿子随随便便就能写出来,
设sum[i]表示
即
在一次遍历i的过程中,i是固定不变的,而L也是常量,变化的只有j,按此划分
设两个决策点
柿子太长了自己带进去就好,这里展不开
相同的消掉得到下面的柿子
移项(用j表示i)
即
设x[i]为sum[i],y[i]为
右面的柿子可以看做
也就是说两点
现在给出三个决策点
显然
-
,则C优于B,B优于A,即C优于B优于A -
,则A优于B,C优于B -
则B优于C,A优于B,即A优于B优于C
总而言之,B点没用,叉出去
现在就是这样的了
如果平面上有一堆点,那么将所有不优的点叉出去,最后剩下的斜率将是单调递增(不一定严格)的,而剩下的点将构成一个下凸包,如图
如何找到最优决策?
设某一点P,其与左侧的点的连线斜率
拿上图中的E点举例,假设
综上所述,找到最优决策点就是找到斜率第一个大于
线性规划
对于初始转移方程
移项规则如下,只含i的项全都放在b里,只含j的项全都放在y里,含有
举个栗子,
我们想找到一个点j,使这条直线经过它让dp[i]最小,而
回到上图,依旧可以发现经过E点的时候b最小,即最优决策
维护凸包
上述两种方法已经找到了最优决策,那么我们如何维护?主要由以下三步
-
找到最优决策点
-
更新dp[i]
-
将dp[i]加入图形,更新凸包
可以用队列维护凸包中的点,而更新操作就是让i与队尾比较,如果需要删点(斜率不单增)就pop队尾,直到斜率单增,因为二分查找,总体时间复杂度O(nlogn)
有两种比较方式,都差不多啦
第一种
slope(q[tail-1],q[tail])>=slope(q[tail],i);
第二种
slope(q[tail-1],q[tail])>=slope(q[tail-1],i)
决策单调性与四边形不等式
决策单调性,简单来说就是随着i的增大,dp[i]的最优决策点是非严格递增的
四边形不等式,整数集合上二元函数w(x,y),有∀a⩽b⩽c⩽d,w(a,c)+w(b,d)⩽w(a,d)+w(b,c),那么函数w满足四边形不等式
四边形不等式两个主要定理如下
- 设w(x,y)是定义在整数集合上的二元函数,对于∀a<b,都有
,则函数满足四边形不等式(又叫四边形不等式的另一种定义)
证明:
对于a<c,有
对于a+1<c,有
将上下两式相加,化简得到
类推下去,对于任意
,都有 同理类推c,c+1,c+2...易得到一定有对于任意
,都有 ,即满足四边形不等式
- 在状态转移方程
中,若val满足四边形不等式,则dp具有决策单调性
证明:
自己上网搜吧我敲,LaTeX太难用了
这道题的决策单调性证明
【数据删除】
总之这玩应具有决策单调性,用单调队列维护一下就行了
单调队列维护
用单调队列实现上述三种操作,后两种如上,而第一种操作可以判断第一条斜率的线段是否合法,不合法了就pop掉,从而找到斜率大于
斜率优化相关例题
【HNOI 2008】玩具装箱
上面说过了,这里放代码,单调队列优化,时间复杂度O(n)
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
#include<cmath>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=5e4+5;
inline int read()
{
int w=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9')
{
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9')
{
w=(w<<3)+(w<<1)+(ch^48);
ch=getchar();
}
return w*f;
}
int n,L;
int head;
int tail;
int q[maxn];
int dp[maxn];
int sum[maxn];
int x(int k)
{
return sum[k];
}
int y(int k)
{
return dp[k]+(sum[k]+L)*(sum[k]+L);
}
long double slope(int a,int b)
{
return (long double)(y(b)-y(a))/(x(b)-x(a));
}
signed main()
{
n=read();L=read();
L++;
for(int i=1;i<=n;i++)
{
int c=read();
sum[i]=sum[i-1]+(c+1);
}
head=1,tail=1;
for(int i=1;i<=n;i++)
{
while(head<tail && slope(q[head],q[head+1])<=2*sum[i])
{
head++;
}
dp[i]=dp[q[head]]+(sum[i]-sum[q[head]]-L)*(sum[i]-sum[q[head]]-L);
while(head<tail && slope(q[tail-1],q[tail])>=slope(q[tail-1],i))
{
tail--;
}
q[++tail]=i;
}
cout<<dp[n];
return 0;
}
任务安排
这题看起来好像和斜率优化没有什么关系 而事实上(从数据范围来看)确实不需要斜率优化
但是下面的另一个任务安排就需要啦!这里主要引入一个经典的思想——费用提前计算
自己很容易口胡一个转移方程
但是由于那个j受到了s的影响不太好预处理,更不好转移
我们并不需要计算出每批任务结束的时间,而是在每批任务开始之后先把s对后面的影响加进去
这种经典的思想被叫做费用提前计算
转移方程
AC code
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
#include<cmath>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=5010;
inline int read()
{
int w=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9')
{
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9')
{
w=(w<<3)+(w<<1)+(ch^48);
ch=getchar();
}
return w*f;
}
int n,s;
int dp[maxn];
int sumt[maxn];
int sumc[maxn];
signed main()
{
n=read();s=read();
for(int i=1;i<=n;i++)
{
int t=read();sumt[i]=sumt[i-1]+t;
int f=read();sumc[i]=sumc[i-1]+f;
}
memset(dp,0x3f3f3f,sizeof(dp));
dp[0]=0;
for(int i=1;i<=n;i++)
{
for(int j=0;j<i;j++)
{
dp[i]=min(dp[i],dp[j]+sumt[i]*(sumc[i]-sumc[j])+s*(sumc[n]-sumc[j]));
}
}
cout<<dp[n];
return 0;
}
【SDOI 2012】任务安排
这题真TM【】啊,样例都这么【】
题意与上面的题目是一样的,区别就是这题
把上道题的转移方程按照步骤推柿子即可
已知
按照i,j以及常量来分,去掉min得到
依旧是找到两个点
然后用j表示i,最后得到
然后问题就出现了——证明决策单调性!
这个蒟蒻当然不会,不然就不会用单调队列维护半天然后WA声一片,但凡早点看题解/标签也不至于
总之有大佬证明了这玩应不满足四边形不等式,因此在找最优决策点时需要二分(悲),时间复杂度O(nlogn)
然后按步骤和柿子转移就行了,这也告诉我们学好斜率优化要理解好四边形不等式和决策单调性的证明
AC code
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
#include<cmath>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=3e5+5;
inline int read()
{
int w=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9')
{
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9')
{
w=(w<<3)+(w<<1)+(ch^48);
ch=getchar();
}
return w*f;
}
int n,s;
int head=1;
int tail=1;
int q[maxn];
int dp[maxn];
int sumt[maxn];
int sumc[maxn];
int Binary_Search(int l,int r,int k)
{
while(l<=r)
{
int mid=(l+r)>>1;
if(dp[q[mid+1]]-dp[q[mid]]<=k*(sumc[q[mid+1]]-sumc[q[mid]]))
{
l=mid+1;
}
else
{
r=mid-1;
}
}
return q[l];
}
signed main()
{
n=read(),s=read();
for(int i=1;i<=n;i++)
{
sumt[i]=sumt[i-1]+read();
sumc[i]=sumc[i-1]+read();
}
memset(dp,0x3f,sizeof dp);dp[0]=0;
for(int i=1;i<=n;i++)
{
int pos=Binary_Search(head,tail,s+sumt[i]);
dp[i]=min(dp[i],dp[pos]+sumt[i]*(sumc[i]-sumc[pos])+s*(sumc[n]-sumc[pos]));
while(head<tail && (dp[q[tail]]-dp[q[tail-1]])*(sumc[i]-sumc[q[tail]])>=(dp[i]-dp[q[tail]])*(sumc[q[tail]]-sumc[q[tail-1]]))
{
tail--;
}
q[++tail]=i;
}
cout<<dp[n];
return 0;
}
【APIO 2010】特别行动队
自己推推柿子就能徒手切掉的大水题(可能是因为斜率优化写多了比较上手)
和上边的题目都是差不多的而且也满足四边形不等式(做多了就能蒙出来)
柿子也很好推,就放代码了
AC code
点击查看代码
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
#include<cmath>
#include<algorithm>
#define int long long
using namespace std;
const int maxn=1e6+5;
inline int read()
{
int w=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9')
{
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9')
{
w=(w<<3)+(w<<1)+(ch^48);
ch=getchar();
}
return w*f;
}
int head=1;
int tail=1;
int n,a,b,c;
int q[maxn];
int dp[maxn];
int sum[maxn];
int x(int i) { return sum[i]; }
int y(int i) { return dp[i]+a*sum[i]*sum[i]-b*sum[i]; }
bool check_first(int a,int b,int lim)
{
return ((long double)(y(a)-y(b)))<=2.0*lim*(x(a)-x(b));
}
bool check_second(int a,int b,int c)
{
return ((long double)(y(a)-y(b)))*(x(b)-x(c))<=((long double)(y(b)-y(c))*(x(a)-x(b)));
}
signed main()
{
n=read(),a=read(),b=read(),c=read();
for(int i=1;i<=n;i++)
{
int x=read();
sum[i]=sum[i-1]+x;
}
for(int i=1;i<=n;i++)
{
while(head<tail && check_first(q[head],q[head+1],a*sum[i])) head++;
dp[i]=dp[q[head]]+a*(sum[i]-sum[q[head]])*(sum[i]-sum[q[head]])+b*(sum[i]-sum[q[head]])+c;
while(head<tail && check_second(q[tail-1],q[tail],i)) tail--;
q[++tail]=i;
}
cout<<dp[n];
return 0;
}
【CEOI 2017】Building Bridges
CDQ分治套斜率优化,不会的说
【NOIP 2018】摆渡车
Cats Transport
这两道题就留做练习吧,自己去独立完成
个人总结
个人认为这东西有点套路,按照步骤推推柿子就行了,代码也很短
但是关键就在于代码短细节多,还要决策单调性啥的,多做题(应该)就好了
UPD 2022/10/15
明明都还没有写完这篇博客
主要是针对决策单调性的证明的
因为考场上太费时间了的,而且很大可能还证不出来
也问了一下东区的dalao,感性理解吧
生まれつき何も持っていないからこそ、私たちはすべてを持つことができます
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!