单调队列优化dp

XyzL

2020-08-25 16:50:15

Personal

## 前置知识: 1:**基础DP** 2:**单调队列的运用** ## 何为单调队列? 从字面意思上来看,无非就是有某种**单调性**的队列。 一般的解释是 “不断地向缓存数组里读入元素,也不时地去掉最老的元素,不定期的询问当前缓存数组里的最小/大的元素。” 其实本质上就是一种特殊的**双端队列**,一般解决类似于[滑动窗口](https://www.luogu.com.cn/problem/P1886)的最值问题。 那么我们应该如何去维护这样一个单挑队列呢? ------------ ## 如何解决? 以上题为例, 题意为给定一个序列 $a$ ,一个长度为 $k$ 的窗口,窗口从左至右滑动,求每个区间的大小最值。 ### 方法1:朴素算法(暴力) 我们可以对每个区间进行直接扫描,每次枚举元素 $a_x$ 至 元素 $a_{x+k-1}$ 的最大值和最小值,时间复杂度为 $O(nk)$ 。 ### 方法2: $RMQ$ 和线段树 熟悉数据结构的奆佬知道,显然这是个静态区间最值问题。 我们可以使用线段树等数据结构将此算法优化到 $O(n$ $log_2{n})$ ,虽然在此基础上还可以进行**修改**,但此题的 $n, k$ 为 $1e6$ ,显然会超时,这时候就可以用到我们的单调队列。 ### 方法三: 单调队列 线段树和 $ST$ 算法都是求**任意长度**区间最值的通用算法,而这题有个很重要的信息,我们每次去询问的区间长度都是**固定**为 $k$ 的,且每个区间都是**连续**的,那么对于每两个“相邻”的区间 $(L, R)$ 与 $(L + 1, R + 1)$ ,都会存在以下性质: $Max/Min$ {$a_L, a_{L+1}, a_{L+2}...,a_{R-1}, a_R$} $=$ $Max/Min$ $( a_L, $ $Max/Min$ {$a_{L+1}, a_{L+2}...,a_{R-1}, a_R$} $)$ $Max/Min$ {$a_{L+1}, a_{L+2}...,a_R, a_{R+1}$} $=$ $Max/Min$ $( $ $Max/Min$ {$a_{L+1}, a_{L+2}...a_{R-1},a_R$}, $a_{R+1}$ $)$ 经过观察得,两个方程都有相同的地方 :$a_{L+1}. a_{L+2}...,a_{R-1}, a_R$ 所以,可以得出我们在求区间 $(L+1, R+1)$ 时,我们完全没有必要再扫描一次,只要当上一次的最值落在了 $a_L$ 上时,我们才需要重新扫描,这样,我们的算法就得到了极大地优化。 我们以最大值为例,对于任意的 $L≤i≤j≤R$ ,如果 $a_i<a_j$ ,那么我们的窗口在向右滑动时,最大值永远不会移动到 $a_i$。**因为** $a_i$ **会比** $a_j$ **先过期,而能用** $a_i$ **的时候,就一定可以用** $a_j$。**此时我们就可以不需要** $a_i$ **了。** ~~我们常说一句话:“奆佬 ,比我小,还比我强,我要被pop_back了。”这句话和很明显与单调队列的本质相符合~~。 我们可以拿[此题](https://www.luogu.com.cn/problem/P1886)的样例来看: ``` 8 3 1 3 -1 -3 5 3 6 7 ``` ![](https://cdn.luogu.com.cn/upload/pic/688.png) 以最小值为例,我们将 $Q$ 表示为单调队列,$P$ 表示其所对应的在原列表里的序号。 1. 出现 $1$ 。此时队中没有元素,$1$ 进队。此时,$Q={1}; P={1}$。 2. 出现 $3$ 。是否可以进队?下面基于这样一个思想: 假如把 $3$ 放进去,如果后面 $2$ 个数都比它大,那么 $3$ 或许可以成为最小的。此时,$Q={1,3}; P={1,2}$ 3. 出现 $-1$ 。队尾元素 $3$ 比 $-1$ 大,那么意味着只要 $-1$ 进队,那么 $3$ 必定成为不了最小值,原因很明显:因为当下面 $3$ 被框起来,那么 $-1$ 也一定被框起来,所以 $3$ 永远不能当最小值。所以, $3$ 从队尾出队。同理,$1$ 从队尾出队。最后 $-1$ 进队,此时 $Q={-1},P={3}$ 4. 出现 $-3$ .同上面分析,$-1>-3$,$-1$ 从队尾出队, $-3$ 从队尾进队。$Q={-3};P={4}$。 5. 出现 $5$ 。因为 $5>-3$ ,同第二条分析, $5$ 或许可以成为最小值,所以 $5$ 进队。此时,$Q={-3,5};P={4,5}$ 6. 出现 $3$ 。 $3$ 先与队尾的 $5$ 比较,$3<5$ ,按照第 $3$ 条的分析, $5$ 从队尾出队。 $3$ 再与 $-3$ 比较,同第二条分析,$3$ 进队。此时,$Q={-3,3};P={4,6}$ 7. 出现 $7$ 。队尾元素 $6$ 小于 $7$ ,$7$进队。此时,$Q={3,6,7};P={6,7,8}$。 所以,当我们将窗口 $(L, R)$ 滑动到 $(L+1, R+1)$ 时,若队首元素不在 $(L+1, R+1)$ 的区间中,则删除,将 $a_{R+1}$ 插入进单调队列中,这样便可维护出最小值。 由于每个元素进队出队只有一次,所以综合时间复杂度为$O({n})$。 ### 代码: ```cpp #include<bits/stdc++.h> using namespace std; inline int read() { int x = 0, f = 1; char c = getchar(); while(c < '0' || c > '9') { if(c == '-') { f = -1; } c = getchar(); } while(c <= '9' && c >= '0') { x = (x << 1) + (x << 3) + (c ^ 48), c = getchar(); } return x * f; } const int Maxn = 1 * 1e6 + 1; int n, k, l, r, a[Maxn], q[Maxn]; int main() { n = read(), k = read(); for(register int i = 1; i <= n; ++i) { a[i] = read(); } l = 1, r = 0; for(register int i = 1; i <= n; ++i) { //最小值 while(l <= r && a[q[r]] >= a[i]) { --r; } q[++r] = i; while(q[l] + k - 1 < i) { ++l; } if(i >= k) { printf("%d ", a[q[l]]); } } puts(""); l = 1, r = 0; for(register int i = 1; i <= n; ++i) { //最大值 while(l <= r && a[q[r]] <= a[i]) { --r; } q[++r] = i; while(q[l] + k - 1 < i) { ++l; } if(i >= k) { printf("%d ", a[q[l]]); } } puts(""); return 0; } ``` ------------ ## 如何应用? [POJ1821 Fence](http://poj.org/problem?id=1821) ### 题意: 现在有连续 $n(1≤n≤16,000)$ 块木板,编号为 $1$ — $n$ , $k(1≤k≤100)$ 个粉刷匠,每个粉刷匠只能刷一次。第 $i$ 个人有两种选择:一是不刷,二是必须完成长度不超过 $L_i$ $(1≤L_i≤n)$ 并且包含 $S_i$ $(1≤S_i≤n)$ 的连续的木板。每完成一个任务第 $i$ 个人需要获得 $P_i$ 的 $(1≤P_i≤10,000)$ 的报酬,不同人的 $S_i$ 是不相同的。 求如何安排能够使得所有粉刷匠的报酬总和最大? ### 分析: 我们可以定义 $f[i][j]$ 代表前 $i$ 个粉刷匠粉刷完成至多前 $j$ 个木板的最大利益。 经观察,得状态转移有一下三种: 1. 不需要第 $i$ 个粉刷匠,即前 $i-1$ 个粉刷匠完成前 $j$ 个木板的工作:$f[i][j]=f[i-1][j]$ 2. 不需要粉刷第 $j$ 块木板,即前 $i$ 个粉刷匠完成前 $j-1$ 个木板的工作:$f[i][j]=f[i][j-1]$ 3. 前 $i-1$ 个粉刷匠粉刷到了第 $k$ 块木板,然后第 $i$ 个粉刷匠从第 $k+1$ 开始一直粉刷到第 $j$ 个木板。 $f[i][j]=Max(f[i][j],f[i-1][k]+p[i]×(j-k))$ 前两种状态的决策点只有一个,时间复杂度为$O({n})$,没有必要优化。 而第三种情况,即第 $i$ 个粉刷匠要涂,我们可以假设我们是从 $k+1$ 涂到 $j$ 的,根据题意可以求出 $k$ 的取值范围,然后状态转移的条件限制了 $j$ 的取值范围。 我们考虑每次 $j$ 从小到大增加的过程,$j$ 对应的 $k$ 的取值是一个**上界不变下届递增的区间**,是一个滑动窗口,那我们可以用单调队列来**维护决策点** $k$ 的最优候选。 我们又可以发现,我们**在单调队列中,所维护的下标和权值均是单调的**。 ### 代码: ```cpp #include <map> #include <cmath> #include <cstdio> #include <cstring> #include <iostream> #include <algorithm> #include <functional> using namespace std; inline int read() { int x = 0, f = 1; char c = getchar(); while(c < '0' || c > '9') { if(c == '-') { f = -1; } c = getchar(); } while(c <= '9' && c >= '0') { x = (x << 1) + (x << 3) + (c ^ 48), c = getchar(); } return x * f; } const int Maxn = 16061; const int Maxk = 161; int n, m, f[Maxk][Maxn], q[Maxn]; struct Node { int l; int p; int s; } a[Maxk]; inline bool Cmp(Node a, Node b) { // 计算单调队列要维护的值 return a.s < b.s; } inline int Calc(int i, int k) { return f[i - 1][k] - a[i].p * k; } int main() { n = read(), m = read(); // f[i][j]表示前i号工匠粉刷前j块木板的最大收入 for(register int i = 1; i <= m; ++i) { a[i].l = read(), a[i].p = read(), a[i].s = read(); } sort(a + 1, a + m + 1, Cmp); for(register int i = 1; i <= m; ++i) { // 工匠 int l = 1, r = 0; // 初始化单调队列 for(register int k = max(0, a[i].s - a[i].l); k <= a[i].s - 1; ++k) { // 把可能的决策点插入deque while(l <= r && Calc(i, q[r]) <= Calc(i, k)) { // 剔除队尾不合法的元素 r--; } q[++r] = k; // 当前状态k插入队尾 } for(register int j = 1; j <= n; ++j) { f[i][j] = max(f[i - 1][j], f[i][j - 1]); // i木匠不粉刷,或 j木板不粉刷 // 粉刷第k+1~j块木板时的转移 if(j >= a[i].s) { // j比s大粉刷才合法,必须包括s while(l <= r && q[l] < j - a[i].l) { //队首 < 当前下标j - 粉刷距离L,不合法的决策点 l++; } if(l <= r) { // 队列非空时,利用队首转移 f[i][j] = max(f[i][j], Calc(i, q[l]) + a[i].p * j); } } } } printf("%d\n", f[m][n]); return 0; } ``` ------------ [LG4954 Tower of Hay G](https://www.luogu.com.cn/problem/P4954) ### 题意: 现在有 $n(1≤n≤16,000)$ 包干草,编号为 $1$ — $n$ ,每个干草堆的宽度为 $a_i$ ,先要按照严格的摆放(不能打乱顺序摆放,不能不摆)来建立一个高度为 $h$ 干草堆,干草堆要使上层干草的宽度不能超过下层的宽度。 求在所有可行的方案中,$h$ 的最大值。 ### 分析: 首先,我们看到这道题,想到的第一个方法或许是**贪心**。 我们让高层尽可能的小,从后往前倒叙贪心。 但是我们可以举出个**反例**: ``` 6 9 8 2 1 5 5 ``` 如果我们按照**倒叙读入**的思路,贪心出来的结果为 $5; 5; 1, 2, 8, 9$ 共三层。 但正确的结果却是 $5; 5, 1, 2; 8; 9$ 共四层。 可以得出,此贪心的错误之处在于前面的贪心使得**底层过大,难以扩展**。 然后,根据[抽屉原理](https://xyzl.blog.luogu.org/chou-ti-yuan-li),我们可以得到一个较为贪心的结论。 构成最优解的所有方案中,一定有一种满足底层最小,即 **底层最小的方案一定可以构造出最高的层数**。 通俗点来说,就是干草堆底盘越小,塔身越瘦,就可以堆的越高。 此时,我们可以去考虑**动态规划**。 同贪心一样,我们将 $a$ 数组倒叙输入,求其**前缀和**。 我们可以定义两个数组来维护dp; $f[i]$ 表示从塔尖到塔底且选到第 $i$ 堆草时,能够得到的最大高度; $g[i]$ 表示高度为 $f[i]$ 时的草堆宽度。 得到转移方程为:$f[i]=f[j]+1$ ( $j$ 为满足 $j∈[0,i−1]$ 且 $g[j]≤sum[i]-sum[j]$ 的最后一个 $j$ ) 因为我们要求最后一层的宽度尽可以能的小,所以得到 j 后有 $g[i]=sum[i]-sum[j]$。 这样我们转移的时间复杂度为 $O({n})$ ,总时间复杂度为 $O({n^2})$。 但数据范围不能支持我们如此朴素的dp,所以我们可以去考虑**单调队列优化**。 因为我们 $g$ 的状态转移方程为 $g[j]≤sum[i]-sum[j]$ , 经过移项后得:$g[j]+sum[j]≤sum[i]$。 可以得出:若有 $k<j$ 且 $g[k]+s[k]⩾g[j]+s[j]$ ,则 $k$ 已经永远无法转移给他人,因为如果 $k$ 能够转移,那么 $j$ 也能够转移,而 $j$ 的位置要比 $k$ 更加靠后,这完全符合单调队列的性质。 所以我们可以维护以下这个值单调递增的队列: ```cpp int Calc(int x) { return sum[x] + g[x]; } ``` 我们每次转移时,从队列中找到最后一个满足条件的位置 $j$ ,以单调队列中 $j$ 以左的元素从队首弹出。 然后从队尾再弹出 $g[j]+s[j]⩾g[i]+s[i]$,然后将位置 $i$ 入队。 综上,总时间复杂度为 $O({n})$。 ### 代码: ```cpp #include <bits/stdc++.h> using namespace std; inline int read() { int x = 0, f = 1; char c = getchar(); while(c < '0' || c > '9') { if(c == '-') { f = -1; } c = getchar(); } while(c <= '9' && c >= '0') { x = (x << 1) + (x << 3) + (c ^ 48), c = getchar(); } return x * f; } const int Maxn = 101101; int n, a[Maxn], g[Maxn], q[Maxn], f[Maxn], sum[Maxn]; // f[i] 表示从塔尖到塔底且选到第i堆草时,能够得到的最大高度 // g[i] 表示高度为dp[i]时的草堆宽度 inline int Calc(register int x) { return sum[x] + g[x]; } // 干草堆越瘦,就能堆得越高 int main() { n = read(); for(register int i = n; i >= 1; --i) { // 倒序输入干草堆的宽度 a[i] = read(); } for(register int i = 1; i <= n; ++i) { // 前缀和 sum[i] = sum[i - 1] + a[i]; } int l = 1, r = 0; // 初始化单调队列 for(register int i = 1; i <= n; ++i) { // 维护满足sum[i] - sum[j] >= g[j]的最大的j(队首), 因为j越大, sum[i] - sum[j]越小 while(l <= r && sum[i] - sum[q[l]] >= g[q[l]]) { ++l; } //最后一个满足条件的位置 j-1 g[i] = sum[i] - sum[q[l - 1]]; f[i] = f[q[l - 1]] + 1; //状态转移 /*设当前候选决策点为j,那么须满足 g[j] + sum[j] <= sum[i]才能转移, 那么单调队列要维护g[x] + sum[x]的最小值, 因为这个值越小,sum[i]-sum[j]就越小,干草越窄 */ while(l <= r && Calc(i) < Calc(q[r])) { r--; } q[++r] = i; } printf("%d\n", f[n]); return 0; } ``` [LG5665 划分](https://www.luogu.com.cn/problem/P5665) ### 题意: 现在有长度为 $n(1≤n≤40,000,007)$ 的递增序列,要求划分使得**区间和平方**的和最小。 求对该数列的一个划分 $T = \{ k_1, k_2, \dots, k_p \}$,使得该划分满足 : $\sum\limits_{i=1}^{k_1} a_i\le \sum\limits_{i=k_1+1}^{k_2} a_i \le \dots \le \sum\limits_{i=k_p+1}^n a_i$, 且最小化 $ \left(\sum\limits_{i=1}^{k_1} a_i \right)^2+\left(\sum\limits_{i=k_1}^{k_2} a_i \right)^2+\cdots +\left(\sum\limits_{i=k_p+1}^n a_i \right)^2 $。 ### 分析: 根据题意,可以得出我们所有分的段是**递增**的,我们可以 $f[i][j]$ 为为划分到第 $i$ 个的前驱是 $j$ ,即状态 $f[i][j]$ 表示对前 $i$ 个数字分段,上一段的结尾为 $j$ 的时候段平方和的最小值。 即 $f[i][j]$ $=$ $f[j][k]$ $+$ $($ $sum[i]$ $−$ $sum[j]$ $)$ $^2$ 进行状态转移时,我们可以枚举出上一段的结尾 $j$,和再上一段的结尾 $k$ ,先判断是否满足转移条件,然后进行最小化转移,这样我们的时间复杂度就为为 $O({n^3})$。 但这样的时间复杂度是远远不够的,我们可以固定 $j$ ,可以发现在移动 $j$ 的过程中,$k$ 也在移动,满足一个单调性,然后我们维护一个 $f[i][j]$ 的最小值,此时我们的时间复杂度就为为 $O({n^2})$。 我们可以发现一个结论,在所有合法的情况中,都有 $f[i][j]$ $≤$ $f[i][j-1]$ 。 ##### (本人太菜了,具体也不知道是怎么发现并严格证明的,但可以看一下[matthew99巨佬的博客](https://matthew99.blog.uoj.ac/blog/5299)) 所以我们可以使用一个单调队列,维护一个最右边的前驱,再结合前缀和的单调性,左端点满足条件移动,右端点维护单调递增,得到转移点时可以直接得到答案。 所以综合时间复杂度为$O({n})$。 ### 代码: 以下给出非高精代码: ```cpp #include<bits/stdc++.h> using namespace std; inline long long read() { long long x = 0, f = 1; char c = getchar(); while(c < '0' || c > '9') { if(c == '-') { f = -1; } c = getchar(); } while(c <= '9' && c >= '0') { x = (x << 1) + (x << 3) + (c ^ 48), c = getchar(); } return x * f; } const int Maxn = 500005; int n, tpye; long long a[Maxn], sum[Maxn], q[Maxn], l[Maxn], w[Maxn]; unsigned long long f[Maxn]; inline long long Calc(register int x) { // 计算单调队列要维护的值 return sum[x] + w[x]; } int main() { n = read(), tpye = read(); if(!tpye) { for(register int i = 1; i <= n; ++i) { a[i] = read(); } } for(register int i = 1; i <= n; ++i) { sum[i] = sum[i - 1] + a[i]; } int l = 1, r = 0; // 初始化单调队列 for(register int i = 1; i <= n; ++i) { while(l <= r && sum[i] - sum[q[l]] >= w[q[l]]) { ++l; } w[i] = sum[i] - sum[q[l - 1]]; f[i] = f[q[l - 1]] + w[i] * w[i]; //状态转移 while(l <= r && Calc(i) <= Calc(q[r])) { // // 把可能的决策点插入调队列 r--; } q[++r] = i; } printf("%lld\n", f[n]); return 0; } ``` ------------ ## 总结: 单调队列的单调是指数组**下标**的单调及所维护的**权值**的单调。 一般情况下,碰到单调队列优化dp常有以下三种现象: 1. 状态转移方程一定是取**最值**。 2. $f[i] = Max/Min(f[j]+{A_i}+{B_i})$ 3. 决策点有个**单调的上界或下界** 单调队列优化dp是一种常见的dp优化方式,是掌握和运用**斜率优化**的基础,很多动态规划题目都会运用到,所以追求卓越同学一定要掌握。