一些浅显的 dp 优化策略
前缀和优化
这个优化的方法还是显然的,就是当遇到形如 当前状态是由先前的连续状态转移, 则考虑将这种 连续 用前缀和维护,一般便会将这一部分的复杂度由 \(O(n)\) 降为 \(O(1)\)
对于我最近遇到的题来说是这样思考的,当然,更重要的还是优化前的转化,比如通过推式子形成可以前缀和处理的求和式
[ABC179D] Leaping Tak
考虑定义 \(f[i]\) 表示走到第 \(i\) 个点的方案数
朴素地想,如果设给定的这些不重合区间的数的集合为 \(D\),则有很显然的递推式:
但是这样的复杂度是 \(O(n^2)\) 的,瓶颈在于每次遍历集合 \(D\)
进一步,发现这样算没有用到 \(k\) 个区间 这个约束,同时 \(k\) 很小,考虑变形为:
这就好了, 发现 \(\sum\limits_{p = L[j]}^{R[j]}f[\max\{i - p, 0\}]\) 是一段连续的 \(f\) 值区间,
启发我们可以搞一个前缀和数组 \(s[i] = \sum\limits_{j=1}^{i}f[j]\),就像用前缀和数组求区间和一样,显然对于区间 \([L[j], R[j]]\) 的和就是 \(s[\max\{i - L[j], 0\}]-s[\max\{i - R[j] - 1\}]\)
注意 \(L[j] \leq R[j]\),所以 \(i - L[j]\) 会更大些在右边,我一开始就搞反了
这样就把里边那层求和式消掉了,复杂度降到 \(O(nk)\)
code
#include <bits/stdc++.h>
#define re register int
#define max(x, y) (x > y ? x : y)
using namespace std;
typedef long long LL;
const int N = 2e5 + 10, mod = 998244353;
int n, k, L[20], R[20];
LL f[N], s[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
cin >> n >> k;
for (re i = 1; i <= k; i ++) cin >> L[i] >> R[i];
s[1] = f[1] = 1;
for (re i = 2; i <= n; i ++)
for (re j = 1; j <= k; j ++)
{
f[i] = (f[i] + s[max(i - L[j], 0)] - s[max(i - R[j] - 1, 0)] + mod) % mod;
s[i] = (s[i - 1] + f[i]) % mod;
}
cout << f[n] % mod << '\n';
return 0;
}
练习:
[ABC222D] Between Two Arrays
[ABC183E] Queen on Grid
AT_dp_m Candies
UVA1650 / hdu4055 数字串 Number String
hdu6078 Wavel Sequence
单调队列优化
看别人博客新学的一个词,1d / 1d dp,单调队列就是对这种 dp 进行优化的
tip:所谓 xd / yd dp 指的是状态数有 \(n^x\) 种,每个状态的转移数有 \(n^y\) 种的 dp
显然就是针对线性 dp 的策略(当然也可能是多维的),不扯了 ......
其实就是通过维护一个单调队列 及时排除不可能成为最优转移的位置,将寻找决策点的复杂度均摊成 \(O(1)\),本质就是 滑动窗口
具体的,就是通过三个步骤:及时将队头出队、与当前值比较不断删去队尾更劣的值、加入当前值。
tip:在这一部分的实现过程中,我就遇到了一些很小的,但是至关重要的细节处理问题,比如:
- 队列指针 h,t 初值是 h = 1, t = 0 还是 h = t = 1。
要从含义出发,h < t 说明队列是空的,而当有的问题需要调用 0 这个位置的元素时,就需要提前入队一个 q[h] = 0 的元素,这个不好说,要具体分析- 决策与队尾入队的顺序。
这个就是看当前转移是否允许可以由自己转移咯
实现维护决策单调性,从而使队头元素总是当前的最优决策
而每个元素至多入队出队一次,每个元素的决策便是 \(O(1)\) 的,这也常常可以将 \(O(n^2)\) 的朴素 dp 优化为线性的 \(O(n)\)
P3572 [POI2014] PTA-Little Bird
首先考虑朴素 dp,
很容易想到定义一个当前位置的最小劳累值 \(f[i]\),当前状态只能从前边 \(k\) 个状态转移,轻易得到方程:
从第一个位置飞,所以初始化 \(f[1] = 0\)
这样就得到了一个 \(O(n^2)\) 的 50pts 代码
50pts code
#include <bits/stdc++.h>
#define re register int
using namespace std;
const int N = 1e6 + 10;
int n, q, k, d[N], f[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
cin >> n;
for (re i = 1; i <= n; i ++) cin >> d[i];
cin >> q;
while (q --)
{
cin >> k;
memset(f, 0x3f, sizeof(f));
f[1] = 0;
for (re i = 2; i <= n; i ++)
for (re j = k; j >= 1; j --)
{
if (i - j >= 1)
{
int w = (d[i] >= d[i - j] ? 1 : 0);
f[i] = min(f[i], f[i - j] + w);
}
}
// for (re i = 1; i <= n; i ++) cout << i << ' ' << f[i] << '\n';
cout << f[n] << '\n';
}
return 0;
}
而瓶颈就在于,每次对于当前位置 \(i\),我们都对前面 \(k\) 个位置重新扫一遍最优转移
但显然,有的位置是重复扫描过多次的,浪费了时间
我们希望每个位置都只扫一遍,同时维护当前状态的最优决策,考虑用单调队列维护,其实这就是一个滑动窗口的简单变式嘛,板
于是降到 \(O(n)\)
注意:
- 因为要从第一个位置转移(要不然从 f[0] 转移显然是不符合含义的),所以要在队列中放一个 1
- 当前位置不能由自己转移,所以决策要先于当前位置入队
- 这题维护的对象的比较规则中不只有一个元素,这是比较特殊的地方,因为这题有两个东西影响到最优性决策,f[] 和 d[]
我们可以简单分类讨论一下,现有队尾元素 \(f[q[t]],~d[q[t]]\),以及当前元素 \(f[i],~d[i]\)
当 \(f[q[t]] > f[i]\),总贡献 \(f[q[t]] - f[i]\) 以及可能的劳累值至少为 1 + (0 / 1)
当 \(f[q[t]] = f[i]\),贡献只能为 0 / 1
当 \(f[q[t]] < f[i]\),总贡献只能 ≤ 0
第三种情况可能成为最优决策保留,前两种决策可以看出第一种总是不会优于第二种,所以就不用再讨论 d[],当然第二种贡献为 0 也可能成为最优决策保留
code
#include <bits/stdc++.h>
#define re register int
using namespace std;
const int N = 1e6 + 10;
int n, T, k, d[N], f[N];
int q[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
cin >> n;
for (re i = 1; i <= n; i ++) cin >> d[i];
cin >> T;
while (T --)
{
cin >> k;
int h = 1, t = 1;
q[h] = 1;
for (re i = 2; i <= n; i ++)
{
if (h <= t && q[h] < i - k) h ++;
int w = (d[q[h]] <= d[i] ? 1 : 0);
f[i] = f[q[h]] + w;
while (h <= t && (f[q[t]] > f[i] || (f[q[t]] == f[i] && d[q[t]] <= d[i]))) t --;
q[++ t] = i;
}
cout << f[n] << '\n';
}
return 0;
}
练习:
CF1077F2 Pictures with Kittens (hard version)
数据结构优化
通常是用线段树、树状数组(甚至平衡树等,不过太难了对我 qwq)来维护,众所周知,这些数据结构可以很高效地维护区间和、最值等操作,
而 dp 转移 中,对决策集合也经常要涉及这样的操作,
所以,数据结构优化就是从转移的角度,将朴素地循环枚举决策的时间变成维护数据结构的时间,一般就可以实现复杂度由 \(O(n)\) 降到 \(O(\log n)\)
tip:所以,必须保证决策集合的下标是连续的,或是限制条件
LIS
惊喜?意外?我觉得例题还是不能放有太多弯弯绕绕的转化的,这题正好
求 LIS,朴素 dp 人尽皆知咯:
显然这样做是 \(O(n^2)\) 的,如果 \(n\) 达到 \(10^5\),朴素做法就 gg 了
考虑优化
主要是内层循环,要实时对 1 ~ i - 1 的决策集合取 max,同时要更新 i 决策的值
发现这两种操作,不就是求区间 min,单点修改吗?用线段树直接维护它
考虑转移方程共有两个限制,\(j < i\) 且 \(a_j < a_i\),前者好说,正序枚举保证,但是发现后者,如果直接按 0 ~ n 的下标维护,不好判断所得到的区间 max 所对应位置的 \(a_j\) 是否满足约束
那就直接 按值建线段树 -> 限制条件值域线段树(当然,这样做就经常需要结合离散化) 就好啦,对 \(a_i\), 考察 \([1, a_i - 1]\) 的决策最小值,总是满足约束
这样用线段树替换掉循环,复杂度优化到 \(O(n\log n)\)
code
#include <bits/stdc++.h>
#define re register int
#define lp p << 1
#define rp p << 1 | 1
using namespace std;
const int N = 5e3 + 10, M = 1e6 + 10;
struct Tree
{
int l, r, mx;
}t[M << 2];
int n, a[N], f[N], R;
inline void push_up(int p)
{
t[p].mx = max(t[lp].mx, t[rp].mx);
}
inline void build(int p, int l, int r)
{
t[p].l = l, t[p].r = r;
if (l == r) return;
int mid = (l + r) >> 1;
build(lp, l, mid);
build(rp, mid + 1, r);
}
inline void update(int p, int x, int k)
{
if (t[p].l == x && t[p].r == x)
{
t[p].mx = k;
return;
}
int mid = (t[p].l + t[p].r) >> 1;
if (x <= mid) update(lp, x, k);
if (x > mid) update(rp, x, k);
push_up(p);
}
inline int ask(int p, int l, int r)
{
if (l <= t[p].l && t[p].r <= r) return t[p].mx;
int res = 0;
int mid = (t[p].l + t[p].r) >> 1;
if (l <= mid) res = max(res, ask(lp, l, r));
if (r > mid) res = max(res, ask(rp, l, r));
return res;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
cin >> n;
for (re i = 1; i <= n; i ++)
{
cin >> a[i];
R = max(R, a[i]);
}
build(1, 0, R); // 这里不需初始化 update(1, a[i], 1),可以举出反例 2 1
for (re i = 1; i <= n; i ++)
{
f[i] = ask(1, 1, a[i] - 1) + 1;
update(1, a[i], f[i]);
}
int res = 0;
for (re i = 1; i <= n; i ++) res = max(res, f[i]);
cout << res << '\n';
return 0;
}
练习:
P2418 yyy loves OI IV(值域线段树优化)
[ARC159D] LIS 2(贪心结论,维护两个线段树)