[算法学习笔记][刷题笔记] 单调队列优化 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\) 个数的前缀和,状态转移方程如下:

\[f_{i,0}=max(f_{i-1,1},f_{i-1,0}) \]

\[f_{i,1}=max(f_{l,0}+sum_i-sum_l)(i-l\le k) \]

对于第一个状态转移方程,直接继承即可。对于第二个式子,我们发现它每次取一个固定长度区间的最大值,可以使用单调队列维护 \(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;
}
posted @   SXqwq  阅读(56)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 易语言 —— 开山篇
点击右上角即可分享
微信分享提示