单调队列优化的DP问题
在学习相关知识之前,我们需要先了解到什么是单调队列?
思路为设置一个队列并保证该队列中的所有元素为递增/递减趋势;具体如何保证,可以看下面这幅图的解释(以递增为例):
代码 + 注释:
#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分析法的完整图像就不在这里画了,简单说一下状态数组的设置;表示已经做好了烽火台
的安排,并且点燃第
个烽火台的代价,属性为最小值。
由于题目规定最多不能超过连续个烽火台不被点燃,因此可以将其看作一个单调队列,队列长度就是
,以最后一个状态为分界点看前一个状态的最小值,下面这个图看起来比较清晰一些:
代码 + 注释:
#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.绿色通道
详细思路就不重复说了,变量设置以及数组的含义都是一样的,主要说两个点:1.二分 2.边界
1.二分枚举的是最长连续不写题目的数量,假设左右边界为和
,
为中间点,如果
满足题意,则说明长度属于
的都符合条件,否则需要在
中继续二分。
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.修剪草坪
和烽火狼烟唯一的区别就是本题是反过来的,求出最小代价之后用总和减去就是最大效率,注意数据范围过大,要开,否则最后一个数据过不去。
代码 + 注释:
#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;
}
当然了,这么糊弄过去没什么意思,下面讲解一下一种新方法:
的含义变为区间
中合法选择方案的最高效率;接着使用集合划分的角度去分析当前状态的转移:
其中相当于是一个函数,返回
。
代码展示:
#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.理想的正方形
本题思路很简单,先在每一行中选择长度为的区间并将区间中最大/小值存入两个数组中,这样就形成了两个
的二维数组,接着在列方向上再做一次同样的操作,就相当于得到了两个
的结果数组,在该数组中选择最小差值即可。
代码 + 注释:
#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;
}