单调队列优化DP学习笔记

Tarantula

2021-08-20 10:07:16

Personal

先来看[题](https://www.luogu.com.cn/problem/P2034) 考虑DP。设$dp_{i,0/1}$表示第$i$个数选或不选时,前$i$个数的最大结果。显然有状态转移方程: $$dp_{i,0}=\max\{dp_{i-1,0},dp_{i-1,1}\}$$ $$dp_{i,1}=\max\limits_{i-k\le j\le i-1}\{dp_{j,0}+(sum_i-sum_j)\}$$ 其中$sum$表示前缀和。 但是朴素计算的复杂度是$O(n^2)$的,因此必须优化。 将状态转移方程变形: $$dp_{i,1}=\max\limits_{i-k\le j\le i-1}\{dp_{j,0}-sum_j\}+sum_i$$ 我们发现,当$i$增加$1$时,$j$的取值的上界和下界均会增加$1$。这个形式就长得很像滑动窗口了。 因此,我们可以用一个单调队列,维护$dp_{j,0}-sum_j$的最大值。这样一来,总时间复杂度就是维护单调队列的复杂度,即$O(n)$。 ```cpp #include<bits/stdc++.h> #define int long long using namespace std; int n, k; int a[100005], sum[100005]; int dp[100005][2], q[100005]; int l, r; int calc(int x) {return dp[x][0] - sum[x];} signed main() { cin >> n >> k; for (int i = 1; i <= n; i++) cin >> a[i], sum[i] = sum[i - 1] + a[i]; l = r = 1; q[1] = 0; for (int i = 1; i <= n; i++) { dp[i][0] = max(dp[i - 1][0], dp[i - 1][1]); while (l <= r && i - q[l] > k) l++;//排除队头“过期”的决策 dp[i][1] = calc(q[l]) + sum[i];//直接取出队头计算 while (l <= r && calc(i) >= calc(q[r])) r--;//维护单调性 q[++r] = i; } cout << max(dp[n][0], dp[n][1]) << endl; return 0; } ``` --- 再来看[这个](https://vjudge.net/problem/POJ-1821) 设$dp_{i,j}$表示前$i$个人刷前$j$块木板所能得到的最大价值。那么显然: $$dp_{i,j}=\max\limits_{j-l_i\le k\le s_i-1}\{dp_{i-1,k}+p_i\times(j-k)\}$$ 变形得 $$dp_{i,j}=p_i\times j+\max\limits_{j-l_i\le k\le s_i-1}\{dp_{i-1,k}-p_i\times k\}$$ 我们把外层循环的$i$看做定值,那么随着$j$的增大,$k$的上界不变,下界增大。因此,我们也可以按照上题的方法,维护一个$dp_{i-1,k}-p_i\times k$单调递减的队列,从而将时间复杂度优化至$O(nm)$。 ```cpp #include<iostream> #include<algorithm> using namespace std; int n, m; struct node { int p, l, s; bool operator < (const node &a) const {return s < a.s;} } a[100005];//首先要把所有人按照s排序 int dp[105][100005], q[100005]; int l, r; int calc(int i, int k) {return dp[i - 1][k] - a[i].p * k;} int main() { cin >> n >> m; for (int i = 1; i <= m; i++) cin >> a[i].l >> a[i].p >> a[i].s; sort(a + 1, a + 1 + m); for (int i = 1; i <= m; i++) { l = 1, r = 0;//每次新建一个队列 for (int k = max(0, a[i].s - a[i].l); k <= a[i].s - 1; k++) { while (l <= r && calc(i, q[r]) <= calc(i, k)) r--; q[++r] = k; }//将所有可能的决策入队 for (int j = 1; j <= n; j++) { dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);//第i个人可以什么都不刷,或第j块木板可以不刷 if (j >= a[i].s) { while (l <= r && q[l] < j - a[i].l) l++; if (l <= r) dp[i][j] = max(dp[i][j], calc(i, q[l]) + a[i].p * j); } } } cout << dp[m][n] << endl; return 0; } ``` --- [一道经典题](https://www.luogu.com.cn/problem/P3957) 很有意思的好题。考虑二分答案,问题转化为当跳跃距离为$[d-x,d+x]$时能取得多少分数。这个东西就可以DP了。 设$dp_i$表示前$i$个格子能取得的最大分数,那么显然: $$dp_i=\max\limits_{x_i-d-x\le x_j\le x_i-d+x}\{dp_j\}+s_i$$ 注意到$x_i$是单调递增的,所以这个东西显然也是可以单调队列优化的。时间复杂度$O(n)$。 ```cpp #include<bits/stdc++.h> using namespace std; int n, d, k; int a[500005]; int s[500005]; int dp[500005]; int q[500005], l, r; bool check(int x) { dp[0] = 0; for (int i = 1; i <= n; i++) dp[i] = -1e9; int p = 0; l = 1, r = 0; for (int i = 1; i <= n; i++) { while (a[i] - a[p] >= max(1, d - x) && p < i) {//把状态i的所有可能决策入队 if (dp[p] == -1e9) {p++; continue;}//p不可到达,则直接退出 while (l <= r && dp[q[r]] <= dp[p]) r--; q[++r] = p++;//将p入队 } while (l <= r && a[i] - a[q[l]] > d + x) l++;//排除队头“过期”决策 if (l <= r) dp[i] = dp[q[l]] + s[i]; if (dp[i] >= k) return 1; } return 0; } void work() { int l = -1, r = 5000001; while (l + 1 < r) { int mid = l + r >> 1; if (check(mid)) r = mid; else l = mid; }//二分 if (r != 5000001) cout << r << endl; else cout << -1 << endl; } int main() { cin >> n >> d >> k; for (int i = 1; i <= n; i++) cin >> a[i] >> s[i]; work(); return 0; } ``` --- Q:何时可以考虑单调队列优化DP? A:状态转移方程遵循如下形式: $$dp_i=\max\{dp_j+val(i,j)\}$$ 其中$val$是一个**仅与$i,j$中的一项有关**的多项式。如果含$i,j$的乘积项的话,那就考虑斜率优化吧。 --- 几道练习: - [P4954 [USACO09OPEN]Tower of Hay G](https://www.luogu.com.cn/problem/P4954) 比较有意思的变形 - [P3089 [USACO13NOV]Pogo-Cow S](https://www.luogu.com.cn/problem/P3089) 很奇特的题目 - [P1776 宝物筛选](https://www.luogu.com.cn/problem/P1776) 单调队列优化多重背包的板子 - [P2569 [SCOI2010]股票交易](https://www.luogu.com.cn/problem/P2569) 神题,细节比较多