算法提高课 -- 9

单调队列优化的DP问题

在学习相关知识之前,我们需要先了解到什么是单调队列?

154.滑动窗口

思路为设置一个队列并保证该队列中的所有元素为递增/递减趋势;具体如何保证,可以看下面这幅图的解释(以递增为例):

代码 + 注释:

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 1e6 + 10;
int n, m;
int w[N], q[N]; // q[i] 表示单调队列中下标为i的值,存储的是w[]中的下标
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 0; i < n; i++) scanf("%d", &w[i]);
    int hh = 0, tt = -1; // hh 表示队头 tt 表示队尾
    // 单调队列的构造和运用过程
    for (int i = 0; i < n; i++) {
        if (i - q[hh] + 1 > m) hh++; // 如果区间长度大于题目要求长度,则队头前进一位
        while (hh <= tt && w[q[tt]] > w[i]) tt--; // 大于w[i]的点可能不止一个
        q[++tt] = i; // 存储的是下标!!
        if (i >= m - 1) printf("%d ", w[q[hh]]); // 注意能够得到答案的下标起点
    }
    puts("");
    hh = 0, tt = -1;
    for (int i = 0; i < n; i++) {
        if (i - q[hh] + 1 > m) hh++;
        while (hh <= tt && w[q[tt]] < w[i]) tt--;
        q[++tt] = i;
        if (i >= m - 1) printf("%d ", w[q[hh]]);
    }
    return 0;
}

既然已经搞定了单调队列的概念,那么我们就可以进行更深一步的探讨了,下面是一道应用类型的题目:Acwing 135.最大子序和

思路:这道题和上一题的区别在于上一题是长度问题,而本题则是求的问题,本题的思路分析和上题基本一致,有关本题正确性的证明,大家可以看看这篇题解

代码 + 注释:

#include <iostream>
#include <algorithm>
#include <cstring>
#include <climits>
using namespace std;
const int N = 300010;
int n, m, res = INT_MIN;
int w[N], s[N], q[N];
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &w[i]);
        s[i] = s[i - 1] + w[i];
    }
    int hh = 0, tt = 0; // 表示初始阶段有1个元素,若初始阶段无元素则hh = 0, tt = -1
    q[0] = 0; // 注意,我们这里求[l, r]区间和所采用的两个端点为l - 1, r两个点,因此需要往前多存一个数,初始阶段多存的数字为0
    for (int i = 1; i <= n; i++) {
        while (hh <= tt && i - q[hh] > m) hh++; // 注意这里的长度也要 + 1,也是因为我们往前多存了一个数字
        res = max(res, s[i] - s[q[hh]]);
        while (hh <= tt && s[i] <= s[q[tt]]) tt--; // 这段代码得到的是第一个小于的s[i]的数
        q[++tt] = i;
    }
    printf("%d\n", res);
    return 0;
}

下面也是一道单调队列的应用题:Acwing 1088.旅行问题

思路:本题单调队列的应用和上题差不多,针对这个题详细讲下顺时针和逆时针两者的区别和做法:

代码 + 注释:

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
typedef long long LL;
const int N = 1e6 + 10;
int n;
int o[N], d[N], q[N];
LL s[N];
bool ans[N];
int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d%d", &o[i], &d[i]);
    // 顺时针
    for (int i = 1; i <= n; i++) s[i] = s[i + n] = o[i] - d[i];
    for (int i = 1; i <= n * 2; i++) s[i] += s[i - 1];
    int hh = 0, tt = -1;
    for (int i = 2 * n; i; i--) {
        if (hh <= tt && q[hh] - i + 1 > n) hh++;
        while (hh <= tt && s[q[tt]] >= s[i]) tt--;
        q[++tt] = i;
        if (i <= n) {
            if (s[q[hh]] - s[i - 1] >= 0) ans[i] = true;
        }
    }
    // 逆时针
    // y总的那个不是很好理解,后缀和的思路更清晰一些
    d[0] = d[n];
    for (int i = n; i; i--) s[i] = s[i + n] = o[i] - d[i - 1];
    for (int i = 2 * n; i; i--) s[i] += s[i + 1];
    hh = 0, tt = -1;
    for (int i = 1; i <= 2 * n; i++) {
        if (hh <= tt && i - q[hh] + 1 > n) hh++;
        while (hh <= tt && s[q[tt]] >= s[i]) tt--;
        q[++tt] = i;
        if (i > n) {
            if (s[q[hh]] - s[i + 1] >= 0) ans[i - n] = true;
        }
    }
    for (int i = 1; i <= n; i++) {
        if (ans[i]) puts("TAK");
        else puts("NIE");
    }
    return 0;
}

上面的几道题其实和DP关系不大,下面这道题才是真正将DP和单调队列优化相结合:Acwing 1089.烽火传递

解题思路:闫式DP分析法的完整图像就不在这里画了,简单说一下状态数组的设置;f[i]表示已经做好了烽火台[1, i]的安排,并且点燃第i个烽火台的代价,属性为最小值。

由于题目规定最多不能超过连续m个烽火台不被点燃,因此可以将其看作一个单调队列,队列长度就是m,以最后一个状态为分界点看前一个状态的最小值,下面这个图看起来比较清晰一些:

代码 + 注释:

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 200010;
int n, m;
int w[N], f[N], q[N]; // f[i] 表示[1, i]的烽火台已经点好并且点了第i个烽火台所需要付出的最小代价
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%d", &w[i]);
    int hh = 0, tt = 0; // 表示最初有一个元素在其中,就是q[0] = 0
    for (int i = 1; i <= n; i++) {
        if (i - q[hh] > m) hh++;
        f[i] = f[q[hh]] + w[i];
        while (hh <= tt && f[q[tt]] >= f[i]) tt--;
        q[++tt] = i;
    }
    int res = 0x3f3f3f3f;
    for (int i = n - m + 1; i <= n; i++) res = min(res, f[i]);
    printf("%d\n", res);
    return 0;
}

下面这道题和上面这道差不多,区别就是多了一个二分求值:Acwing 1090.绿色通道

详细思路就不重复说了,变量设置以及f数组的含义都是一样的,主要说两个点:1.二分 2.边界

1.二分枚举的是最长连续不写题目的数量,假设左右边界为lr(l + r) / 2 = mid为中间点,如果mid满足题意,则说明长度属于 [l, mid]的都符合条件,否则需要在 (mid, r]中继续二分。

2.本题在构建单调队列的过程中,与上一题有很大差别,可以参照下面这张图来理解:

代码 + 注释:

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 50010;
int n, m;
int w[N], f[N], q[N];
bool check(int mid) {
    // 由于二分会多次调用,因此每次使用都需要重置
    memset(f, 0, sizeof f);
    memset(q, 0, sizeof q);
    int hh = 0, tt = 0;
    for (int i = 1; i <= n; i++) {
        if (hh <= tt && i - q[hh] - 1 > mid) hh++;
        f[i] = f[q[hh]] + w[i];
        while (hh <= tt && f[q[tt]] >= f[i]) tt--;
        q[++tt] = i;
    }
    int res = 0x3f3f3f3f;
    for (int i = n - mid; i <= n; i++) res = min(res, f[i]);
    return res <= m; // 判断是否符合题意
}
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%d", &w[i]);
    int l = 0, r = n;
    while (l < r) {
        // 二分:求最大的最小值模板
        int mid = l + r >> 1;
        if (check(mid)) r = mid; // 如果这个长度老师可以容忍,那么就短一些
        else l = mid + 1; // 否则就扩大
    }
    printf("%d\n", l);
    return 0;
}

下面这个题也可以和烽火狼烟这道题联系起来:Acwing 1087.修剪草坪

和烽火狼烟唯一的区别就是本题是反过来的,求出最小代价之后用总和减去就是最大效率,注意数据范围过大,要开long \ long,否则最后一个数据过不去。

代码 + 注释:

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
int n, m;
int w[N], q[N];
LL f[N], sum;
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%d", &w[i]), sum += w[i];
    int hh = 0, tt = 0; // 存在初始元素0
    for (int i = 1; i <= n; i++) {
        if (i - q[hh] - 1 > m) hh++;
        f[i] = f[q[hh]] + w[i];
        while (hh <= tt && f[q[tt]] >= f[i]) tt--;
        q[++tt] = i;
    }
    LL res = f[n - m + 1];
    for (int i = n - m; i <= n; i++) res = min(res, f[i]);
    printf("%lld\n", sum - res);
    return 0;
}

当然了,这么糊弄过去没什么意思,下面讲解一下一种新方法:

f[i]的含义变为区间[1, i]中合法选择方案的最高效率;接着使用集合划分的角度去分析当前状态的转移:

其中g[ \ ]相当于是一个函数,返回g[i] = f[i] - s[i]

代码展示:

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
int n, m;
int q[N];
LL s[N], f[N];
LL g(int i) {
    if (!i) return 0;
    return f[i - 1] - s[i];
}
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%lld", &s[i]), s[i] += s[i - 1];
    int hh = 0, tt = 0;
    for (int i = 1; i <= n; i++) {
        if (i - q[hh] > m) hh++;
        f[i] = max(f[i - 1], g(q[hh]) + s[i]);
        while (hh <= tt && g(q[tt]) <= g(i)) tt--;
        q[++tt] = i;
    }
    printf("%lld\n", f[n]);
    return 0;
}

上面的这些单调队列问题都是一维的,下面这道题则是将二维数组与优先队列联系起来:Acwing 1091.理想的正方形

本题思路很简单,先在每一行中选择长度为k的区间并将区间中最大/小值存入两个数组中,这样就形成了两个n * (m - k + 1)的二维数组,接着在列方向上再做一次同样的操作,就相当于得到了两个(n - k + 1) * (m - k + 1)的结果数组,在该数组中选择最小差值即可。

代码 + 注释:

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 1010;
int n, m, k;
int w[N][N];
int row_max[N][N], row_min[N][N]; // 行区间最大/小值存储数组
int q[N];
void get_min(int a[], int b[], int tot) {
    int hh = 0, tt = -1;
    for (int i = 1; i <= tot; i++) {
        if (hh <= tt && i - q[hh] >= k) hh++;
        while (hh <= tt && a[q[tt]] >= a[i]) tt--;
        q[++tt] = i;
        b[i] = a[q[hh]];
    }
}
void get_max(int a[], int b[], int tot) {
    int hh = 0, tt = -1;
    for (int i = 1; i <= tot; i++) {
        if (hh <= tt && i - q[hh] >= k) hh++;
        while (hh <= tt && a[q[tt]] <= a[i]) tt--;
        q[++tt] = i;
        b[i] = a[q[hh]];
    }
}
int main() {
    scanf("%d%d%d", &n, &m, &k);
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            scanf("%d", &w[i][j]);
        }
    }
    for (int i = 1; i <= n; i++) {
        get_min(w[i], row_min[i], m); // 传入该行的数据进行单调队列计算出最小值
        get_max(w[i], row_max[i], m);
    }
    int a[N], b[N], c[N]; // a[] 表示临时数组存储二维数组中某一列的数据
    int res = 1e9;
    for (int i = k; i <= m; i++) {
        for (int j = 1; j <= n; j++) a[j] = row_min[j][i]; // 将刚刚计算出的行最小值放在数组中,列方向再用单调队列做一遍
        get_min(a, b, n);
        for (int j = 1; j <= n; j++) a[j] = row_max[j][i];
        get_max(a, c, n);
        for (int j = k; j <= n; j++) res = min(res, c[j] - b[j]); // 每列再比一次就得到了答案
    }
    printf("%d\n", res);
    return 0;
}

  • 11
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值