单调队列优化DP

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

其本质原因是外层 i 变化时,不同的 i 所对应的内层 j 的窗口有重叠。

image

如图,i=i1 时,对应的 j1 的滑动窗口范围是上方的阴影部分;i=i2 时,对应的 j2 处理的滑动窗口范围是下方的阴影部分;两部分有重叠。当 ii1 增加到 i2 时,这些重叠部分被重复计算,如果减少这些重复,就得到了优化。如果把所有重叠的部分都优化掉,那么所有 j 加起来只从头到尾遍历了一次,此时 j 的遍历实际上就是 i 的遍历。

例题:P1725 琪露诺

解题思路

dpi 为到达位置 i 时最大的冰冻指数,可以列出状态转移方程:dpi=max{dpj}+ai,其中 iRjiL,显然 j 的选择范围是一个滑动窗口,用一个单调队列维护 dp 值的最值即可。

单调队列进出队怎么做?队首出界的是 <iR 的,可以先把 iL 放进队尾再计算也可以先计算再把 iL+1 放进队尾。

注意:103ai103dp 数组除了 dp0 以外需要初始化成 ,避免非法转移,最后答案为 dpnR+1dpn 中的最大值。

参考代码
#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,xn)],初始弹跳距离 d 有可能比最后一个格子到起点的距离还要大。当 g 已知时,可以算出来每次跳跃的范围 [L,R],再用与 P1725 琪露诺 一样的 DP 方式判断该答案是否可行(最大得分是否 k)。

dp[i] 表示到达第 i 个格子时的最高得分,注意有的格子可能不可到达,需要令其值为 ,而 dp[0]=0,其状态转移方程类似于上一题“琪露诺”,dpi=max{dpj}+si,其中 j 是可以跳到 i 的一段连续区间。

随着 i 转移,j 的合法区间的左右端点都是递增的,所以可以用单调队列维护 max{dpj} 进行转移。

每次把与当前点距离 >R 的点出队,把与当前点距离 L 的点入队(可能入很多点),可以用一个变量维护入队入到哪了。

时间复杂度为 O(nlogx)

参考代码
#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。多重背包问题的状态转移方程是 dpi,j=max{dpi1,jk×wi+k×vi},其中 0kci。如果直接转化成对应数量的 01 背包问题则时间复杂度为 O(Wci)

将式子中的 jk×wi 看成一个整体(换元思想),可以写成 dpi,j=max{dpi1,k+(jk)/wi×vi},其中 jkwi 的倍数,且 0(jk)/wici

0(jk)/wici0jkci×wijci×wikj

将转移方程拆成两个部分(跟转移点有关、跟当前点有关),得到 max{dpi1,kk/wi×vi}+j/wi×vi,转移点就是使 max 内取到最大值时的 k,根据上面对 k 的范围的限定,可以发现这是一个“滑动窗口”式的区间,因此可以用单调队列优化。当计算到 j 时,从前边出队的是 <jci×wi 的,入队的是 j,其单调性依据是 dpi1,jj/wi×vi,取队首计算相应的值时要加上 j/wi×vi

为了保证 jkwi 的倍数,把 j 按照 modwi 的结果分类,每一类内部完成这个转移(余数相同的,转移过程会形成一条链,余数不同的,转移过程是完全独立的)。

image

总时间复杂度为 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 重量的方案有多少个?

  • dpi,j=dpi1,j+dpi1,jwi+dpi1,j2×wi+
  • dpi,j=dpi1,k,其中 wijkjci×wikj
  • 还是把 jmodwi 分类,维护滑动窗口中 dpi1,k 的和(直接用一个变量维护即可)

可行性问题(bool 类型):哪些重量是能凑出来的?哪些是不能的?

  • dpi,j=dpi1,k,其中 wijkjci×wikj
  • 有多少个 dpi1,ktrue
  • 用一个变量 cnt 维护一下

例题:P3800 Power收集

给定一个 NM 列的棋盘,有 K 个格子上的值为非零。要求在每一行选择一个格子,并且相邻行选的格子列标号差不超过 T。最大化选取的格子取值和。
数据范围:1N,M,T,K4000

分析:记 ai,j 为格子的权值,设 dpi,j 表示走到第 i 行,第 j 列的最大权值和,显然 dpi,j=max1km,|kj|T{dpi1,k}+ai,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 个整数为 Ei,给定一个整数 k,找这样的子序列,子序列中的数在原序列连续不能超过 k 个。对子序列求和,问所有子序列中最大的和是多少?1n105,0Ei109,1kn
例如:n=5,原序列为 [7,2,3,4,5]k=2,选择 [7,2][4,5],其最大和为 18,其中每一段连续长度都不超过 k=2

分析:设 dp[i] 为考虑到前 i 个整数的答案,状态转移方程为 dp[i]=max{dp[j1]+sum[i]sum[j]},其中 ikjisum[i] 为前缀和,即 E1 加到 Ei。也就是说前面有一个位置 j,该位置上的数不选,因此 j 之间部分的答案是 dp[j1],而 j+1i 这一段全选,这样的考虑对于 j 之前的部分和之后的部分都没有打破连续长度的限制,注意第 i 个数自己也可以不选,因此 j 的考虑范围是 iki

在计算 dp[i]i 是一个定值,上述方程等价于 dp[i]=max{dp[j1]sum[j]}+sum[i],其中 ikji,因此求 dp[i] 就是找到做个决策 j 使得 dp[j1]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 时,设 dpi 表示前 i 个数,以第 i 个数作为分隔元素时,在保证每一段的元素和不超过 x 的情况下,所有的分隔元素之和的最小值,于是 dpi=min{dpj}+ai,其中 j 是每一个保证 [j+1,i1] 的区间和不超过 m 的位置。由于 aj 保证非负,这样的 j 的取值范围一定是一个连续区间,也就是一个滑动窗口,这个窗口会随着 i 的右移而右移。因此,这个 dp 的计算过程可以用单调队列来优化。

时间复杂度为 O(nlogai)

参考代码
#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;
}
posted @   RonChen  阅读(139)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
点击右上角即可分享
微信分享提示