[算法学习笔记][刷题笔记] 单调队列优化 dp
前置知识 · 单调队列
单调队列顾名思义,一般用于解决 滑动RMQ问题。它的原理非常简单。我们维护一个双端队列,这个双端队列 只维护可能成为区间最值的元素。
最基础的单调队列,例如滑动窗口。直接依据题意维护即可。
一般来说,对于单调队列优化 dp 类题目,我们一般先写出朴素 dp,然后再观察状态转移方程中类似于“取max”“取min”操作。观察这类操作经过一系列转化后是否单调,如果单调考虑用单调队列维护。
单调队列一般维护 固定长度的区间最值,需要注意维护的极值是否具有这种特点。
单调队列可以维护的内容很多,需要综合考虑运用。
这里提供单调队列模板(STL deque 版)
单调队列模板(STL deque 版)
for(int i=1;i<=n;i++)
{
if(!q1.empty()&&i-q1.front() >= k) q1.pop_front(); //越界“退役”
while(!q1.empty()&&a[q1.back()] > a[i]) //比当前元素大, 不可能成为区间最小值。直接pop
{
q1.pop_back();
}
q1.push_back(i);
if(i>=k) cout<<a[q1.front()]<<" "; //依据题意输出答案即可,不同题目处理不同。
}
单调队列具体内容见:SXqwq的单调队列学习笔记
单调队列优化 dp
单调队列优化 dp,往往是将朴素 dp 方程转换成可以用单调队列维护的形式。这里给出几个例题。
例题1:Luogu P1714 切蛋糕
在数列 \(\{p_n\}\) 中,找出一个子段 \([l,r](r-l+1\le m)\),最大化 \(\sum\limits_{i=l}^rp_i\)。
Solution
此类问题属于最大不定长字段和问题。
朴素做法是对于每一个 \(l\),枚举长度,时间复杂度 \(O(n^2)\),无法接受。
如果我们预处理 \(sum_i\) 表示数组前 \(i\) 项的前缀和,则\(max(\sum\limits_{i=l}^rp_i)=max(sum_r)-min(sum_l)(r > l)\)
对于每次处理 \(sum_r\) 固定,也就是求 \(r\) 前面最小的 \(sum_l\)。我们知道单调队列用于位于区间最值,排除无用决策。所以我们可以用单调队列维护区间长为 \(k\) 的最小 \(sum_l\)。
至此,我们就可以将本题使用单调队列维护,由于每个元素只会入队出队一次,时间复杂度 \(O(n)\) 。
实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <deque>
using namespace std;
const int N = 1000010;
int sum[N];
int n,m;
deque <int> q;
int maxn = -1;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)
{
int a;
cin>>a;
sum[i] = sum[i-1] + a;
}
q.push_back(0);
for(int i=1;i<=n;i++)
{
if(q.size() && i - q.front() > m) q.pop_front();
while(q.size()&&sum[q.back()] > sum[i]) q.pop_back();
maxn = max(maxn,sum[i]-sum[q.front()]);
q.push_back(i);
}
cout<<maxn<<endl;
return 0;
}
例题2:Luogu P2629 好消息,坏消息
题目链接:Luogu P2629
Solution
首先,本题存在环,直接断环成链。处理更加方便。
因为在任何时候老板的心情不能小于0,初始心情为0,所以任何时候位置 \(i\) 的前缀和不能小于0(断环为链后)。同理设 \(sum_i\) 表示前 \(i\) 个 数的前缀和。
接下来,我们就将题目转化为:求有多少个 \(k\) 满足 \(\forall sum_i \geq sum_k(i \in \{1,n\times 2\})\) (显然这里已经断环为链)
那么如何处理呢?我们需要对于每个 \(i\) 都判断一遍吗?显然不需要,因为中间哪怕出现一个小于 \(0\) 的数,就不合法。所以我们使用单调队列维护一个最小的 \(sum_i\) 即可。
需要注意我们每次需要维护区间 \([k,n+k-1]\) 的最小值,然后判断 \(sum_i-sum_k\) 是否小于 \(0\) 即可。
实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <deque>
using namespace std;
const int N = 100000010;
int n,m;
int sum[N];
int ans[N];
int a[N];
deque <int> q;
int cnt = 0;
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>a[i];
a[i+n] = a[i];
sum[i] = sum[i-1] + a[i];
}
for(int i=n+1;i<=n*2-1;i++) sum[i] = sum[i-1] + a[i];
q.push_back(1);
for(int i=2;i<=n*2-1;i++)
{
while(q.size() && sum[i] < sum[q.back()]) q.pop_back();
q.push_back(i);
while(q.size() && i - q.front() > n) q.pop_front(); //超过 n,即开始统计答案,越界。
if(i >= n) {ans[i] = sum[q.front()] - sum[i-n];}
}
for(int i=n;i<=n*2-1;i++) if(ans[i] >= 0) cnt++;
cout<<cnt<<endl;
return 0;
}
例题3:Luogu P1725 琪露诺
题目链接Luogu P1725
Solution
我们先回忆一下朴素线性 dp 是如何实现的。
对于朴素的线性 dp,我们设 \(f_i\) 表示跳到 \(i\) 时的最大值。则满足:
\(f_i = max(f_i,f_j+a_i)(i>j,j+l>i>j+r)\)
观察一下这个状态转移方程是否可以优化。
我们发现每次我们选择在 \(i\) 之前的最大的 \(f_j\)。因此可以使用单调队列维护 \(f_j\)。
出队条件显然就是 \(j+r<i\)。
注意,在实现的时候也可以参照 跳房子 的做法,也就是维护左端点,只有当前节点跳最远能跳到或者跳过当前点 \(i\) 是才可以入队。
反之 跳房子 不可以使用本题方法, 本题由于具有 每一格都相邻 的特殊性质,使得我们可以从 \(l\) 开始搜,确保后面入队都是合法的。而 跳房子 给定了每个点的坐标,格子并不是相邻的,所以需要特判入队是否合法。
实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <deque>
using namespace std;
const int N = 100010;
typedef pair<int,int> PAIR;
deque <PAIR> q;
PAIR a[N];
int n,l,r;
int f[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>l>>r;
for(int i=0;i<=n;i++)
{
cin>>a[i].second;
a[i].first = i;
}
int p = 0;
for(int i=l;i<=n;i++)
{
while(!q.empty() && q.back().second < f[p]) q.pop_back();
q.push_back(PAIR(p,f[p]));
while(q.front().first + r < i ) q.pop_front();
f[i] = q.front().second + a[i].second;
++p;
}
int ans = 1<<31;
for(int i = n-r+1;i<=n;i++) ans = max(ans,f[i]);
cout<<ans<<endl;
return 0;
}
例题4:Luogu P3957 跳房子
题目链接:Luogu P3957
Solution
如果单看本题的移动方式,对于一个点 \(i\),她可以移动到 \(x\in[i+d-g,i+d+g]\) 。
和上题不同的是,本题并不一定每个点都有价值,当然这也比较好处理。
那么对于如何确定这个 \(g\) 呢?
观察数据发现,\(d\) 是确定的,如果对于一个固定值 \(g\) 可以满足答案,那么 \(g+1\) 显然也可以满足答案。因为 \(d-g-1\) 的可移动范围更大!
至此,我们证明出本题的 \(d\) 具有单调性。可以二分处理。
如何写 check 呢?
我们琪露诺是枚举跳到的位置,然后维护每个位置前面最大的 \(f\)。对于本题,\(x_i\leq 10^9\),此方案无法直接解决。
不妨换个方式考虑,枚举从一个点 \(now\) 是否可以对当前的 \(i\) 产生贡献,若设 \(dist_i\) 表示从起点到 \(i\) 的距离,则必须满足:
\(dist_{now}+MAXL \leq dist_i(MAXL=d-g)\)
因为题目保证输入的 \(dist_i\) 按照顺序单调递增。所以如果当前的 \(now\) 跳最少距离仍然大于当前节点,那么它至少对于当前节点没有贡献。
否则,用 \(dist_{now}\) 更新单调队列中的内容。
上述维护了左端点,对于右端点,我们需要确保当前节点最远是否还跳不到当前节点。由于节点距离单调递增,因此如果当前节点没有贡献,则后面也无法利用。pop 即可。
我们发现上述操作的基础是每个节点距离起点的距离满足单调性。因此可以使用单调队列维护最大值。
至于赋值直接令 \(f_i=f_{q.front()}+a_i\)(队列满足单调性,队头元素一定是最大的!)
实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <deque>
#include <cstring>
using namespace std;
const int N = 10001000;
const int INF = 0x3f3f3f3f;
int n,d,k;
struct Node
{
int dist,val;
}a[N];
long long f[N];
deque <int> q;
bool check(int kk)
{
memset(f,-INF,sizeof(f)); //初始化为-INF,因为本题存在负数
int noww = 0;
f[0] = 0;
q.clear();
long long l = max(1,(int)(d-kk)),r = d+kk;
for(int i=1;i<=n;i++)
{
while(a[noww].dist + l <= a[i].dist) //控制左端点
{
while(!q.empty() && f[noww] >= f[q.back()]) q.pop_back();
q.push_back(noww);
noww ++;
}
while(q.size() && a[q.front()].dist + r < a[i].dist) //控制右端点
if(q.size() ) f[i] = f[q.front()] +a[i].val;
if(f[i] >= k) return true;
}
return false;
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>d>>k;
for(int i=1;i<=n;i++) cin>>a[i].dist>>a[i].val;
int l = 0,r = INF;
while(l < r)
{
int mid = (l+r) / 2;
if(check(mid)) r = mid;
else l = mid + 1;
}
if(l == INF) cout<<"-1"<<endl;
else cout<<l<<endl;
return 0;
}
例题5:Luogu P2034 选择数字
给定一行 \(n\) 个非负整数 \(a_1 \cdots a_n\)。现在你可以选择其中若干个数,但不能有超过 \(k\) 个连续的数字被选择。你的任务是使得选出的数字的和最大。
Solution
一般来说,对于单调队列优化 dp 类题目,我们一般先写出朴素 dp,然后再观察状态转移方程中类似于“取max”“取min”操作。观察这类操作经过一系列转化后是否单调,如果单调考虑用单调队列维护。
对于本题,我们的状态设计很容易想到记录当前选了前 \(i\) 个数,由于规定 不能超过 \(k\) 个连续数字被选择,我们还需要记录第 \(i\) 个数字选或不选。
具体地,定义 \(f_{i,j}(j\in{0,1})\) 表示数组前 \(i\) 个数,其中第 \(i\) 个数选或不选时的最大和。
考虑转移。
对于不选,直接取 \(max(f_{i-1,0},f_{i-1,1})\) 即可。对于选,直接对于前 \(i-1\) 个数决策未免太过片面,因为可能受到 不能连续选 \(k\) 个数 的影响。我们一定保证 \(f\) 数组中的最大值时满足题意的。不妨枚举连续选择的区间,对于 \(i\) 之前的,我们只能枚举 \((i-k,i]\)。此外我们不能选 \(i-k\),起到了隔断连通块的作用。无后效性。
我们预处理 \(sum_i\) 表示前 \(i\) 个数的前缀和,状态转移方程如下:
对于第一个状态转移方程,直接继承即可。对于第二个式子,我们发现它每次取一个固定长度区间的最大值,可以使用单调队列维护 \(MAX(f_{l,0}+sum_i-sum_l)\),显然只需要维护 \(i\) 即可。
实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <deque>
#define int long long
const int N = 10000010;
using namespace std;
deque <int> q;
int n,k;
int sum[N];
int num[N];
int f[N][5];
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>k;
for(int i=1;i<=n;i++)
{
cin>>num[i];
sum[i] = sum[i-1] + num[i];
f[i][1] = num[i];
}
q.push_back(0);
for(int i=1;i<=n;i++)
{
f[i][0] = max(f[i-1][0],f[i-1][1]); //直接继承
while(q.size() && i-k > q.front()) q.pop_front(); //越界,退役
f[i][1] = f[q.front()][0]-sum[q.front()]+sum[i]; //更新值
while(!q.empty()&&sum[i]-f[i][0] < sum[q.back()]-f[q.back()][0]) q.pop_back(); //维护队尾
q.push_back(i);
}
cout<<max(f[n][1],f[n][0])<<endl; //输出第 n 个元素是否选择即可
return 0;
}
本文作者:SXqwq,转载请注明原文链接:https://www.cnblogs.com/SXqwq/p/17660493.html
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 易语言 —— 开山篇