单调队列:从一道题说起_2023CCPC广东省赛B.基站建设
今天遇到一道题:
给定长度为n的数组a,a[i]表示在第i点建立基站的开销。
同时给出m个区间[li,ri],要满足给定的m个区间内都至少有一个基站,求最小的开销。
正解是单调队列优化dp,那么什么是单调队列?我们先看另外一道题:
显然最小值和最大值是互相独立的,我们可以先考虑最大值。用手模拟一下这个过程:
-
[1 3 -1 -3 5 3 6 7]
最开始我们的窗口里什么也没有,我们把1加入备选答案。
现在的答案备选:[1] -
[1 3 -1 -3 5 3 6 7]
新进来一个3,发现3比1大,并且3比1还更有未来!!更有未来指的是在窗口往右滑动的过程中,1会先淘汰掉,3在1后面,3的"有效期"更久。那3不仅比1大,有效期还更久,1两个都比不过,永远不会对最大值有贡献了,我们的备选答案里就可以淘汰掉1,加入3
现在的答案备选:[3] -
[1 3 -1 -3 5 3 6 7]
新进来一个-1,-1并没有比3大,但是我们现在还不知道后面的数会有多小,来个-100,-inf什么的,在往右滑动的过程中3会被踢掉,此时-1就可能成为最大值。所以暂且把-1加入备选答案。
现在的答案备选:[3,-1] -
[1 3 -1 -3 5 3 6 7]
新进来一个-3,同理,-3比-1小,但是有效期是最新的,我们现在还不知道后面的数会有多小,来个-100,-inf什么的,在往右滑动的过程中3和-1会被踢掉,此时-3就可能成为最大值。所以暂且把-3加入备选答案。
现在的答案备选:[3,-1,-3] -
[1 3 -1 -3 5 3 6 7]
新进来一个5,发现此时的5不仅比-3大,还比-1、3都大,且有效期最新,所以把3/-1/-3都淘汰掉,只留一个5.
现在的答案备选:[5] -
[1 3 -1 -3 5 3 6 7]
新进来一个3,虽然3比5小,但有效期新,加进来。
答案备选[5,3] -
[1 3 -1 -3 5 3 6 7]
新进来一个6,比现有的答案都大,且有限期更新,淘汰掉现在的答案备选。
答案备选[6]
......
在这个过程中大家也发现,如果新加进来一个数,当前答案备选里的“队尾”哪哪都比不过人家时(不仅有效期短,数值还更小),就永远不会对答案有贡献,我们就可以踢掉队尾。但也不一定只踢掉一次,为什么呢?
假设现在答案备选里有[1000,666,233,-1,-7],新加进来一个7。
比较当前的队尾-7:数值更小,“有效期”更短,用7来代替-7肯定会是更优的解,把-7踢掉。答案备选变成[1000,666,233,-1]
比较当前的队尾-1:同理,用7代替-1,答案备选变成[1000,666,233]
比较当前的队尾233:虽然233可能很快就失效了(窗口要滑过去,233要没了),但是它的值更大,在失效前可能会成为最优解,先保留着。这时在队尾加入新进来的7,答案备选就变成了[1000,666,233,7]。
也就是说,我们在维护一个单调递减的答案备选队列,每新加进来一个数 x,就考虑能否用 x 替代掉队尾,如果发现可以,则一直这么做,直到当前的队尾比 x 大。
这部分的代码是:
int h = 1, t = 0,q[1000005],p[1000005]; //q存储值,p存储位置
for (int i = 1; i <= n; i++) {
while (h <= t && q[t] <= a[i]) t--; //如果队尾不优,就一直淘汰掉
t++; //加入新的数
q[t] = a[i];
p[t] = i;
}
你可能会发现我们上面一直没有考虑过k的限制,k怎么处理?不就是要让当前的队头不能过期,否则就要踢出去嘛。那就是:
while (p[h] <= i - k) h++; //如果p[h]<= i-k 说明当前的h离i超过了k,已经离开了窗口 ,就h++。
这样我们就用单调队列解决了这道滑动窗口。再回来看
给定长度为n的数组a,a[i]表示在第i点建立基站的开销。
同时给出m个区间[li,ri],要满足给定的m个区间内都至少有一个基站,求最小的开销。
我们能想到最暴力的dp表达式:令 dp[i] 表示强制在 i 建立基站,且前面所有线段都满足要求的最小花费,则
dp[i]=max(dp[j]+a[i]) ,其中[j+1,i-1]之间不能有完整的区间(不然就有区间没被覆盖到了)
看起来这个方程是O(N^2)的,因为我们要一层for循环枚举i,再一层for循环枚举j。但事实上由于"[j+1,i-1]之间不能有完整的区间"这一限制,想象一下,对于一个i,它的j只能是往前一小段。
比如上图,合法的区间只有绿色那段,如果再从更前面的点转移过来,就会导致最上面的那段区间没被覆盖到。
因此我们可以这么处理:
cin>>m;
while(m--){
int l,r; cin>>l>>r;
pre[r+1]=max(pre[r+1],l);
}
读入一段区间[l,r]后,对于r+1来说最小的合法前驱必须至少是l,再往前就会导致区间[l,r]不被覆盖。因为可能同一个r对应很多的l,所以取个最大值。
但只考虑端点还是不够的,可能出现这种情况:
本来对于r'+1来说最小的合法前驱至少得是l',对于R+1来最小的合法前驱至少得是L,但这显然是错的。如果要在R+1建立基站,最小的合法前驱得是l',否则[l',r']这段无法覆盖到。也就是说当前点的合法前驱还跟前面的点有关,取一个前缀max即可。
for(int i=1;i<=n;i++) pre[i]=max(pre[i-1],pre[i]);
然后正式开始dp,和刚才的滑动窗口类似:
处理队头的操作:
while(hh<=tt and q[hh]<pre[i]) hh++; //如果当前队头超出了合法的范围,也就是说“过期了”,把队头踢掉
处理队尾的操作:
while(hh<=tt and dp[q[tt]]>=dp[i]) tt--; //如果当前的答案比队尾还优(这里更小是更优),且因为是新加进来的,有效期更新,更不容易超出合法的范围。
//当前的答案怎么看都更好,就把队尾踢掉。
//最后队列里就是合法的、且答案单调递增的备选答案
dp转移操作:
dp[i] = dp[q[hh]] + a[i]; //因为我们的队列里维护的是合法的、答案单调递增的备选答案,所以直接取队首就是最优解
最后合起来完整代码就是:
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
int n,m;
void solve(){
cin>>n;
vector<int> a(n+5),pre(n+5),q(n+5);
vector<LL> dp(n+5);
for(int i=1;i<=n;i++) cin>>a[i];
n++;
cin>>m;
while(m--){
int l,r; cin>>l>>r;
pre[r+1]=max(pre[r+1],l);
}
for(int i=1;i<=n;i++) pre[i]=max(pre[i-1],pre[i]);
int hh=0,tt=0;
q[0]=0;
for(int i=1;i<=n;i++){
while(hh<=tt and q[hh]<pre[i]) hh++;
dp[i] = dp[q[hh]] + a[i];
while(hh<=tt and dp[q[tt]]>=dp[i]) tt--;
q[++tt]=i;
}
cout << dp[n] << endl;
return ;
}
int main(){
int t;cin>>t;
while(t--){
solve();
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 【杭电多校比赛记录】2025“钉耙编程”中国大学生算法设计春季联赛(1)