单调队列优化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\) 的窗口有重叠。

image

如图,\(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\) 的结果分类,每一类内部完成这个转移(余数相同的,转移过程会形成一条链,余数不同的,转移过程是完全独立的)。

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\) 重量的方案有多少个?

  • \(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;
}
posted @ 2024-02-06 17:05  RonChen  阅读(448)  评论(0)    收藏  举报