单调队列优化DP
单调队列在 DP 中的基本应用,是对这样一类 DP 状态转移方程进行优化:\(dp[i] = \min \{ dp[j] + a[i] + b[j] \}, L(i) \le j \le R(i)\)。方程中的 \(\min\) 也可以是 \(\max\),方程的特点是其中关于 \(i\) 的项 \(a[i]\) 和关于 \(j\) 的项 \(b[j]\) 是独立的,而 \(j\) 被限制在窗口 \([L(i),R(i)]\) 内,常见的如给定一个窗口值 \(k\),即 \(i-k \le j \le i\)。这个 DP 状态转移方程的编程实现,如果简单地对 \(i\) 做外层循环,对 \(j\) 做内层循环,时间复杂度为 \(O(n^2)\),而如果用单调队列来优化,时间复杂度可以变为 \(O(n)\)。
其本质原因是外层 \(i\) 变化时,不同的 \(i\) 所对应的内层 \(j\) 的窗口有重叠。

如图,\(i=i_1\) 时,对应的 \(j_1\) 的滑动窗口范围是上方的阴影部分;\(i=i_2\) 时,对应的 \(j_2\) 处理的滑动窗口范围是下方的阴影部分;两部分有重叠。当 \(i\) 从 \(i_1\) 增加到 \(i_2\) 时,这些重叠部分被重复计算,如果减少这些重复,就得到了优化。如果把所有重叠的部分都优化掉,那么所有 \(j\) 加起来只从头到尾遍历了一次,此时 \(j\) 的遍历实际上就是 \(i\) 的遍历。
例题:P1725 琪露诺
解题思路
设 \(dp_i\) 为到达位置 \(i\) 时最大的冰冻指数,可以列出状态转移方程:\(dp_i = \max \{ dp_j \} + a_i\),其中 \(i-R \le j \le i-L\),显然 \(j\) 的选择范围是一个滑动窗口,用一个单调队列维护 \(dp\) 值的最值即可。
单调队列进出队怎么做?队首出界的是 \(\lt i-R\) 的,可以先把 \(i-L\) 放进队尾再计算也可以先计算再把 \(i-L+1\) 放进队尾。
注意:\(-10^3 \le a_i \le 10^3\),\(dp\) 数组除了 \(dp_0\) 以外需要初始化成 \(-\infty\),避免非法转移,最后答案为 \(dp_{n-R+1}\) 到 \(dp_{n}\) 中的最大值。
参考代码
#include <cstdio>
#include <deque>
using namespace std;
const int N = 2e5 + 5;
const int INF = 2e9;
int a[N], dp[N];
int main()
{
int n, l, r;
scanf("%d%d%d", &n, &l, &r);
for (int i = 0; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= n; i++) dp[i] = -INF;
deque<int> q;
for (int i = l; i <= n; i++) {
while (!q.empty() && q.front() < i - r) q.pop_front();
while (!q.empty() && dp[q.back()] < dp[i - l]) q.pop_back();
q.push_back(i - l);
// 当上一步的窗口中全是不可达状态时说明当前位置也不可达
if (dp[q.front()] == -INF) continue;
dp[i] = dp[q.front()] + a[i];
}
int ans = -INF;
for (int i = n - r + 1; i <= n; i++) ans = max(ans, dp[i]);
printf("%d\n", ans);
return 0;
}
例题:P3957 [NOIP2017 普及组] 跳房子
解题思路
首先注意到 \(g\) 小的时候的任何一种跳跃方案,都包含在 \(g\) 大的里面,因此满足二分答案的条件。
二分答案 \(g\),注意二分答案的范围是 \([0, \max (d, x_n)]\),初始弹跳距离 \(d\) 有可能比最后一个格子到起点的距离还要大。当 \(g\) 已知时,可以算出来每次跳跃的范围 \([L,R]\),再用与 P1725 琪露诺 一样的 DP 方式判断该答案是否可行(最大得分是否 \(\ge k\))。
设 \(dp[i]\) 表示到达第 \(i\) 个格子时的最高得分,注意有的格子可能不可到达,需要令其值为 \(-\infty\),而 \(dp[0]=0\),其状态转移方程类似于上一题“琪露诺”,\(dp_i = \max \{ dp_j \} + s_i\),其中 \(j\) 是可以跳到 \(i\) 的一段连续区间。
随着 \(i\) 转移,\(j\) 的合法区间的左右端点都是递增的,所以可以用单调队列维护 \(\max \{ dp_j \}\) 进行转移。
每次把与当前点距离 \(>R\) 的点出队,把与当前点距离 \(\ge L\) 的点入队(可能入很多点),可以用一个变量维护入队入到哪了。
时间复杂度为 \(O(n \log x)\)。
参考代码
#include <cstdio>
#include <deque>
#include <algorithm>
using namespace std;
typedef long long LL;
const int N = 500005;
const LL INF = 1e12;
int x[N], s[N], n, d, k;
LL dp[N];
bool check(int g) {
for (int i = 1; i <= n; i++) dp[i] = -INF;
deque<int> q;
int minstep = max(d - g, 1);
int j = 0;
for (int i = 1; i <= n; i++) {
while (!q.empty() && x[q.front()] < x[i] - d - g) q.pop_front();
while (j < i && x[j] <= x[i] - minstep) {
if (x[j] < x[i] - d - g) {
j++;
continue;
}
while (!q.empty() && dp[q.back()] < dp[j]) q.pop_back();
q.push_back(j);
j++;
}
if (q.empty() || dp[q.front()] == -INF) continue;
dp[i] = dp[q.front()] + s[i];
if (dp[i] >= k) return true;
}
return false;
}
int main()
{
scanf("%d%d%d", &n, &d, &k);
LL pos = 0;
for (int i = 1; i <= n; i++) {
scanf("%d%d", &x[i], &s[i]);
if (s[i] > 0) pos += s[i];
}
if (pos >= k) {
int l = 0, r = max(d, x[n]), ans;
while (l <= r) {
int mid = (l + r) / 2;
if (check(mid)) {
r = mid - 1; ans = mid;
} else {
l = mid + 1;
}
}
printf("%d\n", ans);
} else printf("-1\n");
return 0;
}
例题:P1776 宝物筛选(单调队列优化多重背包)
解题思路
假设物品的重量是 \(w\),价值是 \(v\),数量是 \(c\)。多重背包问题的状态转移方程是 \(dp_{i,j} = \max \{ dp_{i-1,j-k \times w_i} + k \times v_i \}\),其中 \(0 \le k \le c_i\)。如果直接转化成对应数量的 01 背包问题则时间复杂度为 \(O(W \sum c_i)\)。
将式子中的 \(j - k \times w_i\) 看成一个整体(换元思想),可以写成 \(dp_{i,j} = \max \{ dp_{i-1,k} + (j-k)/w_i \times v_i \}\),其中 \(j-k\) 是 \(w_i\) 的倍数,且 $ 0 \le (j-k)/w_i \le c_i$。
\(0 \le (j-k)/w_i \le c_i \implies 0 \le j-k \le c_i \times w_i \implies j - c_i \times w_i \le k \le j\)。
将转移方程拆成两个部分(跟转移点有关、跟当前点有关),得到 \(\max \{ dp_{i-1,k} - k / w_i \times v_i \} + j / w_i \times v_i\),转移点就是使 \(\max\) 内取到最大值时的 \(k\),根据上面对 \(k\) 的范围的限定,可以发现这是一个“滑动窗口”式的区间,因此可以用单调队列优化。当计算到 \(j\) 时,从前边出队的是 \(\lt j - c_i \times w_i\) 的,入队的是 \(j\),其单调性依据是 \(dp_{i-1,j} - j / w_i \times v_i\),取队首计算相应的值时要加上 \(j / w_i \times v_i\)。
为了保证 \(j-k\) 是 \(w_i\) 的倍数,把 \(j\) 按照 \(\bmod w_i\) 的结果分类,每一类内部完成这个转移(余数相同的,转移过程会形成一条链,余数不同的,转移过程是完全独立的)。

总时间复杂度为 \(O(nW)\)。
参考代码
#include <cstdio>
#include <algorithm>
#include <deque>
const int W = 40005;
int dp[2][W];
int main()
{
int n, maxw; scanf("%d%d", &n, &maxw);
for (int i = 1; i <= n; i++) {
int cur = i & 1, pre = 1 - cur;
int v, w, c; scanf("%d%d%d", &v, &w, &c);
int lim = std::min(1ll * maxw, 1ll * c * w);
for (int r = 0; r < w && r <= maxw; r++) { // 真正的j对w取余后的数
std::deque<int> dq;
for (int j = r; j <= maxw; j += w) {
while (!dq.empty() && dq.front() < j - lim) dq.pop_front();
while (!dq.empty()) {
if (dp[pre][j] - dp[pre][dq.back()] >= (j - dq.back()) / w * v) {
dq.pop_back();
} else break;
}
dq.push_back(j);
dp[cur][j] = dp[pre][dq.front()] + (j - dq.front()) / w * v;
}
}
}
printf("%d\n", dp[n & 1][maxw]);
return 0;
}
拓展:多重背包的方案数问题与可行性问题
方案数问题(没法用二进制优化):恰好占了 \(j\) 重量的方案有多少个?
- \(dp_{i,j} = dp_{i-1,j} + dp_{i-1,j-w_i} + dp_{i-1,j-2 \times w_i} + \dots\)
- \(dp_{i,j} = \sum dp_{i-1,k}\),其中 \(w_i \mid j-k\) 且 \(j - c_i \times w_i \le k \le j\)
- 还是把 \(j\) 按 \(\bmod w_i\) 分类,维护滑动窗口中 \(dp_{i-1,k}\) 的和(直接用一个变量维护即可)
可行性问题(bool 类型):哪些重量是能凑出来的?哪些是不能的?
- \(dp_{i,j} = \bigvee dp_{i-1,k}\),其中 \(w_i \mid j-k\) 且 \(j - c_i \times w_i \le k \le j\)
- 有多少个 \(dp_{i-1,k}\) 是
true - 用一个变量
cnt维护一下
例题:P3800 Power收集
给定一个 \(N\) 行 \(M\) 列的棋盘,有 \(K\) 个格子上的值为非零。要求在每一行选择一个格子,并且相邻行选的格子列标号差不超过 \(T\)。最大化选取的格子取值和。
数据范围:\(1 \le N,M,T,K \le 4000\)
分析:记 \(a_{i,j}\) 为格子的权值,设 \(dp_{i,j}\) 表示走到第 \(i\) 行,第 \(j\) 列的最大权值和,显然 \(dp_{i,j} = \max \limits_{1 \le k \le m, |k-j| \le T} \{ dp_{i-1,k}\} + a_{i,j}\)
这个转移实际上就是滑动窗口问题,即区间查询的左端点和右端点都是单调的。每一行 \(dp\) 值计算完成后,可以使用单调队列计算该行 \(dp\) 值中每个窗口的最大值供下一行 \(dp\) 值的计算使用。
#include <cstdio>
#include <deque>
#include <algorithm>
using namespace std;
const int N = 4005;
int a[N][N], maxv[N][N], dp[N][N];
int main()
{
int n, m, k, t; scanf("%d%d%d%d", &n, &m, &k, &t);
while (k--) {
int x, y, v; scanf("%d%d%d", &x, &y, &v); a[x][y] = v;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) dp[i][j] = maxv[i - 1][j] + a[i][j];
deque<int> dq;
int l = 1, r = 1;
for (int j = 1; j <= m; j++) {
while (!dq.empty() && dq.front() < j - t) dq.pop_front();
while (r <= j + t && r <= m) {
while (!dq.empty() && dp[i][dq.back()] <= dp[i][r]) dq.pop_back();
dq.push_back(r); r++;
}
maxv[i][j] = dp[i][dq.front()];
}
}
int ans = 0;
for (int i = 1; i <= m; i++) ans = max(ans, dp[n][i]);
printf("%d\n", ans);
return 0;
}
例题:P2627 [USACO11OPEN] Mowing the Lawn G
问题描述:有一个包括 \(n\) 个正整数的序列,第 \(i\) 个整数为 \(E_i\),给定一个整数 \(k\),找这样的子序列,子序列中的数在原序列连续不能超过 \(k\) 个。对子序列求和,问所有子序列中最大的和是多少?\(1 \le n \le 10^5, 0 \le E_i \le 10^9, 1 \le k \le n\)。
例如:\(n=5\),原序列为 \([7,2,3,4,5]\),\(k=2\),选择 \([7,2]\) 和 \([4,5]\),其最大和为 \(18\),其中每一段连续长度都不超过 \(k=2\)。
分析:设 \(dp[i]\) 为考虑到前 \(i\) 个整数的答案,状态转移方程为 \(dp[i] = \max \{ dp[j-1] + sum[i] - sum[j] \}\),其中 \(i-k \le j \le i\),\(sum[i]\) 为前缀和,即 \(E_1\) 加到 \(E_i\)。也就是说前面有一个位置 \(j\),该位置上的数不选,因此 \(j\) 之间部分的答案是 \(dp[j-1]\),而 \(j+1\) 到 \(i\) 这一段全选,这样的考虑对于 \(j\) 之前的部分和之后的部分都没有打破连续长度的限制,注意第 \(i\) 个数自己也可以不选,因此 \(j\) 的考虑范围是 \(i-k\) 到 \(i\)。
在计算 \(dp[i]\) 时 \(i\) 是一个定值,上述方程等价于 \(dp[i] = \max \{ dp[j-1]-sum[j] \} + sum[i]\),其中 \(i-k \le j \le i\),因此求 \(dp[i]\) 就是找到做个决策 \(j\) 使得 \(dp[j-1]-sum[j]\) 最大。这个决策范围可视作一个左端点和右端点都单调递增的滑动窗口,因此可以使用单调队列优化。
参考代码
#include <cstdio>
#include <algorithm>
#include <deque>
using namespace std;
typedef long long LL;
const int N = 100005;
LL dp[N], sum[N];
int e[N];
LL calc(int i) {
// 技巧:队列中只记录下标,需要比较实际的大小时再代入计算
return i == 0 ? 0 : dp[i - 1] - sum[i];
}
int main()
{
int n, k; scanf("%d%d", &n, &k);
for (int i = 1; i <= n; i++) {
scanf("%d", &e[i]); sum[i] = sum[i - 1] + e[i];
}
deque<int> dq; dq.push_back(0);
for (int i = 1; i <= n; i++) {
// dp[i] = max(dp[j-1]-sum[j])+sum[i] j in [i-k,i]
while (!dq.empty() && dq.front() < i - k) dq.pop_front();
while (!dq.empty() && calc(dq.back()) <= calc(i)) dq.pop_back();
dq.push_back(i);
dp[i] = calc(dq.front()) + sum[i];
}
printf("%lld\n", dp[n]);
return 0;
}
例题:CF1918D Blocking Elements
解题思路
考虑二分答案。因为尝试的分隔代价限定得越小,就越难实现,限定得越大则越有可能实现,满足单调性。
当尝试的分隔代价限定为 \(x\) 时,设 \(dp_i\) 表示前 \(i\) 个数,以第 \(i\) 个数作为分隔元素时,在保证每一段的元素和不超过 \(x\) 的情况下,所有的分隔元素之和的最小值,于是 \(dp_i = \min \{ dp_j \} + a_i\),其中 \(j\) 是每一个保证 \([j+1,i-1]\) 的区间和不超过 \(m\) 的位置。由于 \(a_j\) 保证非负,这样的 \(j\) 的取值范围一定是一个连续区间,也就是一个滑动窗口,这个窗口会随着 \(i\) 的右移而右移。因此,这个 \(dp\) 的计算过程可以用单调队列来优化。
时间复杂度为 \(O(n \log \sum a_i)\)。
参考代码
#include <cstdio>
#include <deque>
using namespace std;
typedef long long LL;
const int N = 100005;
int a[N], n;
LL dp[N], sum[N];
bool check(LL x) {
deque<int> dq; dq.push_back(0);
int idx = 0;
for (int i = 1; i <= n + 1; i++) {
while (idx <= i && sum[i - 1] - sum[idx] > x) idx++;
while (!dq.empty() && dq.front() < idx) dq.pop_front();
dp[i] = dp[dq.front()] + a[i];
while (!dq.empty() && dp[dq.back()] >= dp[i]) dq.pop_back();
dq.push_back(i);
}
return dp[n + 1] <= x;
}
int main()
{
int t; scanf("%d", &t);
while (t--) {
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]); sum[i] = sum[i - 1] + a[i];
}
a[n + 1] = 0; sum[n + 1] = sum[n];
LL ans, l = 1, r = sum[n];
while (l <= r) {
LL mid = (l + r) / 2;
if (check(mid)) {
r = mid - 1; ans = mid;
} else l = mid + 1;
}
printf("%lld\n", ans);
}
return 0;
}

浙公网安备 33010602011771号