单调队列优化 dp

· · 算法·理论

基础概念

顾名思义,单调队列优化 dp 当然是用来优化动态规划的。

单调队列主要用于维护两端指针单调不减的区间最值。

--OI WIKI

我个人感觉单调队列优化 dp 代码不长,考场还是能写,只要你想到了,并注意了细节就行。

我们先用一道例题引入吧。

模板 1

P1440 求m区间内的最小值。

题目描述

一个含有 n 项的数列,求出每一项前的 m 个数到它这个区间内的最小值。若前面的数不足 m 项则从第 1 个数开始,若前面没有数则输出 0

对于 100\% 的数据,保证 1\le m\le n\le2\times10^61\le a_i\le3\times10^7

样例:

6 2
7 8 1 4 3 2
0
7
7
1
1
3 

给一点时间思考一下。

我因为实在找不到模板题,就只能选这一道当模板题了🤔。

个人认为这道题还是比较板的,它可以让你想到什么是单调队列优化 dp,然后了解它的本质。

思路

首先,我们发现这道题好像不能用动态规划,因为这道题问的是最小值。

但是,我们发现这个的时间复杂度足足 O(n^2),过不了一点。

那么我们能不能模拟队列?

然后,我们可以想到,用一个单调队列去优化,也就是每次放一个数进去,查询时找出最小的符合条件的。

我们发现能影响一个数的只有它的时间和大小。

我们把大小更小的放前面,大小更大的但是能留的时间更长的放后面。

也就是每次更新时将比它大的所有数弹出(因为这个数能留的时间长,而且小,一定更优),然后将时间过老的弹出。

这样,我们只需要每次查询队头就行了。

来模拟一下样例:

6 2
7 8 1 4 3 2

首先,在第一次查询时,没有一个符合条件,直接输出 0

然后:

7:
7
lr

8:  7 更小,8 能保留更久
7 8
l r

1:  将 7,8 弹出,因为 1 更小
1
lr

4:
1 4
l r

3:  1 时间过了,3 比 4 小
3 lr

然后最后一个数是 2,对任何一个查询都没有影响,直接忽略。

代码

来看一下代码,还是比较形象。

#include <bits/stdc++.h>
/*
我个人比较习惯手写队列,所以这份代码是手写的队列。
*/
#define int long long
using namespace std;
int n,m,a[2000010];
int f[2000010];//动态规划数组
int q[2000010],l=1,r=0;//手写队列
signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>m;
    for(int i=1; i<=n; i++) {
        cin>>a[i];
    }

    cout<<0<<endl;//第一个一定是 0
    for(int i=2; i<=n; i++) {
        while(a[q[r]]>a[i-1]&&l<=r)r--;
        /*保证这个队列是单调的,a[i] 一定要是最大的*/
        q[++r]=i-1;
        while(i-q[l]>m)l++;
        /*其实要用 if,只不过很多题都要用 while,这里也用 while 吧*/
        f[i]=a[q[l]];
    }

    for(int i=2; i<=n; i++) {
        cout<<f[i]<<endl;
    }

    return 0;
}
/*
10 5
8 5 18 12 18 12 10 7 15 1

*/

模板升级

P2034 选择数字。

题目描述

给定一行 n 个非负整数 a_1 \cdots a_n。现在你可以选择其中若干个数,但不能有超过 k 个连续的数字被选择。你的任务是使得选出的数字的和最大。

样例:

5 2
1
2
3
4
5
12

大家可以用上一道题的思路接着思考一下。

思路

这道题比较妙。

他说:不能有超过 k 个数字被选择,这个条件有一点复杂,我们可以转换一下,变成:每 k 个数必须删除 1 个数。

首先,我们很容易列出朴素 dp 式子:

f_{i} 表示选择 i(而且 i 会影响到旁边的距离 k 以内的数),最小的和。

当然,转移的式子:

f_{i}=a_i+\min\limits_{j=i-k}^{i-1}\{f_{j}\}

一算复杂度:O(nm)

然后我们发现,复杂度都是被 \min\limits_{j=i-k}^{i-1}\{f_{j}\} 撑爆的,所以我们考虑优化。

因为这是最小值,我们可以用单调队列优化。

我们在单调队列中存下距离 ik 以内的数,然后每次用这个数更新。

总体跟上一题一样。

代码

#include <bits/stdc++.h>
#define int long long
using namespace std;
int n,k,sum=0;
int a[100010];
int f[100010];
int q[100010],l=1,r=0;
signed main() {
    cin>>n>>k;
    for(int i=1;i<=n;i++)cin>>a[i],sum+=a[i];

    q[++r]=0;
    for(int i=1;i<=n;i++){
    /*每一个 f[i] 代表 i 附近的 k 个数选了 i*/ 
        while(i-q[l]>k+1&&l<r)l++;
        f[i]=f[q[l]]+a[i];
        while(l<=r&&f[q[r]]>f[i])r--;
        q[++r]=i;
    }

    int ans=1e18;
    for(int i=n-k;i<=n;i++){
        ans=min(ans,f[i]);
    } 

    cout<<sum-ans<<endl;

    return 0;
}
/*

*/

总结 1

我们遇到这种题,需要找到要维护的最值,然后用队列维护。

我们需要维护 l,表示时间是否有效。

我们还需要维护 r,表示这个值是不是又大又新的,同样大小,一定是新的更优。

什么时候能用单调队列优化 dp 呢?

当然是 l,r 都是单调的,也就是能对当前值做出贡献的,一定不会被前面的删除。

练习题 1

P3572 [POI 2014] PTA-Little Bird。

这道题好像原本是蓝题,然后不知道为什么掉绿了。

这里就不详细讲解了,可以当作之后的练习题来做。

题目描述

n 棵树排成一排,第 i 棵树的高度是 d_i

q 只鸟要从第 1 棵树到第 n 棵树。

当第 i 只鸟在第 j 棵树时,它可以飞到第 j+1, j+2, \cdots, j+k_i 棵树。

如果一只鸟飞到一颗高度大于等于当前树的树,那么它的劳累值会增加 1,否则不会。

由于这些鸟已经体力不支,所以它们想要最小化劳累值。

样例:

9
4 6 3 6 3 7 2 6 5
2
2
5
2
1

这是一道比较容易的练习题,给点时间想一下。

思路

其实这道题跟上一道题很像,只是 j+k_i 相当于在一个节点,有他前面的 k 个数对他有贡献,其他的跟上一道题差不多,就是要注意细节。

这里不多说。

代码

应该不用放了。

练习题 2

P6563 [SBCOI2020] 一直在你身旁。

前天,有学长来到我们的机房,说:这道题很厉害。

题目描述

题目描述

回到这座小镇后,她的新工作是维修电线。

现在,有一根电线坏了。已知电线长度可能为 1,2,\cdots,n 中的一个数。现在,她需要知道电线的长度。

她可以花费 a_i 块钱购买长度为 i 的电线。购买这根电线后,她能知道所需要的电线长度是否 大于 i

保证 a_1 \le a_2 \le \cdots \le a_n \le 10^9
问她至少要花多少钱才能保证知道需要电线的长度。

样例: ``` 1 2 1 2 ``` ``` 1 ``` ------------ ## 思路 首先,我们发现这道题问的是查询区间 $1-n$,所以我们想到了区间 dp。 我们设 $f_{i,j}$ 表示区间 $i$ 到 $j$ 至少要花多少钱。 转移方程式: $$f_{i,j}=\min\limits_{i\le k<j} \max\{f_{i,k},f_{k+1,j}\}+a_k$$ 这一步应该没问题吧,就相当于在区间 $i$ 到 $j$ 中询问了 $k$,选择其中的一部分。 然后分析时间复杂度:$O(Tn^3)$,当然过不了。 我们想着优化。 --------- 然后,我们找到了 $\min$ 这个关键的东西,然后又看到了形如 $f+a$ 的式子,想到了单调队列优化。 但是,我们发现有一个 $\max\{f_{i,k},f_{k+1,j}\}$,貌似它不是单调的。 我们分析一下,发现随着 $k$ 的增大,它先减小,再增大,因为 $f_{i,k}$ 一直增大,$f_{k+1,j}$ 一直减小。 但是这个 先减小再增大 没什么用,我们只能分类讨论 $f_{i,k}$ 和 $f_{k+1,j}$ 哪个大。 然后我们发现:要知道这两个那个大,只需要知道他们的差是否是正如,然后,我们发现他们的差是单调的! 所以我们只需要找到第一个使 $f_{i,k}>f_{k+1,j}$ 的点,作为分界点,然后分类讨论。 先想想怎么找分界点。 ### 找分界点 首先,因为这道题很像二分,所以我们想到了二分查找,但是它有一个 $\log$,都到 $6*10^8$ 级别了,还是要想想更优的方法。 然后我们发现,我们是在做 dp,是可以通过旁边的东西转移的。 我们令这个分界点为 $d_{i,j}$,那么 $d_{i,j}\le d_{i,j+1}$,这应该没问题吧。 这一步能理解,但是很难想到。 好的,然后我们找到了分界点,开始考虑怎么分类讨论。 ### 当 $f_{i,k}>f_{k+1,j}$ 时: 这时候,我们只需要找到 $\min \{f_{i,k}+a_k\}$,但貌似不能维护。但瞪一下题目:保证 $a_1 \le a_2 \le \cdots \le a_n \le 10^9$,再加上 $f_{i,k}$ 也是单调递增的,所以最小值在第一个 $k$。 ### 当 $f_{i,k}\le f_{k+1,j}$ 时: 这时候,我们需要找到 $\min \{f_{k+1,j}+a_k\}$,我们发现,$f_{k+1,j}$ 是单调递减的,但是 $a_k$ 又是单调递增的,所以很难找到最值。 我们想到可以用单调队列优化。 在单调队列中,我们只需要找到最小的 $k$ 就行了。 ----------- 总体来说,这道题思维量不小,但是代码还是很短的。 还想吐槽一下,不开 long long $0$ 分,你猜我怎么知道的? ## 代码 试问上面的过程短还是代码短? ```cpp #include<bits/stdc++.h> #define int long long//不开 long long 过样例,有 0 分 using namespace std; int n,a[7110]; int f[7110][7110]; int q[7110],l=1,r=0; void solve() { cin>>n; for(int i=1; i<=n; i++)cin>>a[i]; /*这道题的枚举顺序也是别样的*/ for(int j=2; j<=n; j++) { l=1,r=0; q[++r]=j; int d=j;//分界点 for(int i=j-1; i>=1; i--) { if(j-i==1) { f[i][j]=a[i]; continue; } while(d>i&&f[i][d-1]>f[d][j])d--; /*找到分界点(注意,这不是单调队列的 while)*/ f[i][j]=f[i][d]+a[d]; while(q[l]>=d&&l<=r)l++;//判断是否在区间内 if(l<=r)f[i][j]=min(f[i][j],f[q[l]+1][j]+a[q[l]]);//不要把 j 打成 r while(f[q[r]+1][j]+a[q[r]]>=f[i+1][j]+a[i]&&l<=r)r--; q[++r]=i; } } cout<<f[1][n]<<endl; } signed main() { int T; cin>>T; while(T--) { solve(); } return 0; } ``` # 总结 经过上面的讲解,我们发现了以下规律: 当转移式中有形如: $$f_{i}=\min$$ 或 $$\max \{f_j+a_j\}$$ $$f_{i}=\min$$ 或 $$\max \{f_j\}+a_i$$ 这种式子时,我们可以用单调队列优化掉 $\min$ 或 $\max$,让复杂度更优。 这种优化应用比较广泛,我觉得应该背包什么的都能用这个优化。 我认为这个算法最好的地方是代码短。 不难发现,刚刚的那几道例题都只有 $30$ 行左右,难的是转化与推导。 但是,最后的最后还是提醒大家:注意细节,稍稍不注意就会调很久,特别是 $q_i$ 一般记录的是下标。 ------ # 习题 [CF1077F2 Pictures with Kittens (hard version)](https://www.luogu.com.cn/problem/CF1077F2)。 这道题是 CF 的,洛谷交不了。但是和例题长得很像。 [P3422 [POI 2005] LOT-A Journey to Mars](https://www.luogu.com.cn/problem/P3422)。 # 补充-单调栈优化 dp 我在 oiwiki 看单调队列优化 dp 时,偶然发现了单调栈优化 dp,跟这个很类似,但好像没那么出名,找不到很多题。 ## 例题 由于我实在找不到例题,所以这道题就是例题(只是给一个思维的启发),有兴趣的可以自己研究。 [P5788 【模板】单调栈](https://www.luogu.com.cn/problem/P5788)。 ----------- # upd 20250711 找到单调栈例题了。 [P6503 [COCI 2010/2011 #3] DIFERENCIJA](https://www.luogu.com.cn/problem/P6503)。 ## 题目描述 给出一个长度为 $n$ 的序列 $a_i$,求出下列式子的值: $$\sum_{i=1}^{n} \sum_{j=i}^{n} \{\max_{i\le k\le j} a_k-\min_{i\le k\le j} a_k\}$$ ## 思路 首先这个式子很难做,所以我们来观察它的含义。 就是区间 $l$ 到 $r$ 中的最大值减去最小值。 该怎么转化呢? 我们发现,区间有 $n^2$ 个,但是数只有 $n$ 个,所以我们可以想想怎么转化问题。 这个问题可以转化为对于每个 $a_i$ 有多少个区间中它是最小/大的。 所以我们将这个数向左查找,找到所有比它小/大的数,右边同理,然后我们可以用左边的长度乘上右边的长度,就是其贡献。 那么怎么快速求这个长度呢? 我们发现,单调队列行不通了,因为每个数大小不同,难以维护。 所以我们想到了单调栈。 比如求最大值时,每次将比这个数小的全部弹出,这里对当前是没有问题的,对后面的话我们发现,如果这个数比前面弹出的数小,那么一定比当前的小,所以弹出对答案没有影响。 这种操作做 $4$ 遍就行了: 从前向后,最小值。 从后向前,最小值。 从前向后,最大值。 从后向前,最大值。 ------- 提示:这道题讨论区有五倍经验。 ------------ [题单](https://www.luogu.com.cn/training/796462#information)。 ----- 完结撒花 🎉🎉🎉。