一些浅显的 dp 优化策略

前缀和优化

这个优化的方法还是显然的,就是当遇到形如 当前状态是由先前的连续状态转移, 则考虑将这种 连续 用前缀和维护,一般便会将这一部分的复杂度由 O(n) 降为 O(1)

对于我最近遇到的题来说是这样思考的,当然,更重要的还是优化前的转化,比如通过推式子形成可以前缀和处理的求和式


[ABC179D] Leaping Tak

link

考虑定义 f[i] 表示走到第 i 个点的方案数

朴素地想,如果设给定的这些不重合区间的数的集合为 D,则有很显然的递推式:

f[i]=jDf[max{ij,0}]

但是这样的复杂度是 O(n2) 的,瓶颈在于每次遍历集合 D

进一步,发现这样算没有用到 k 个区间 这个约束,同时 k 很小,考虑变形为:

f[i]=j=1kp=L[j]R[j]f[max{ip,0}]

这就好了, 发现 p=L[j]R[j]f[max{ip,0}] 是一段连续的 f 值区间,

启发我们可以搞一个前缀和数组 s[i]=j=1if[j],就像用前缀和数组求区间和一样,显然对于区间 [L[j],R[j]] 的和就是 s[max{iL[j],0}]s[max{iR[j]1}]

注意 L[j]R[j],所以 iL[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 指的是状态数有 nx 种,每个状态的转移数有 ny 种的 dp

显然就是针对线性 dp 的策略(当然也可能是多维的),不扯了 ......

前置:P1886 滑动窗口 /【模板】单调队列

其实就是通过维护一个单调队列 及时排除不可能成为最优转移的位置,将寻找决策点的复杂度均摊成 O(1),本质就是 滑动窗口

具体的,就是通过三个步骤:及时将队头出队、与当前值比较不断删去队尾更劣的值、加入当前值。

tip:在这一部分的实现过程中,我就遇到了一些很小的,但是至关重要的细节处理问题,比如:

  • 队列指针 h,t 初值是 h = 1, t = 0 还是 h = t = 1。
    要从含义出发,h < t 说明队列是空的,而当有的问题需要调用 0 这个位置的元素时,就需要提前入队一个 q[h] = 0 的元素,这个不好说,要具体分析
  • 决策与队尾入队的顺序。
    这个就是看当前转移是否允许可以由自己转移咯

实现维护决策单调性,从而使队头元素总是当前的最优决策

而每个元素至多入队出队一次,每个元素的决策便是 O(1) 的,这也常常可以将 O(n2) 的朴素 dp 优化为线性的 O(n)


P3572 [POI2014] PTA-Little Bird

link

首先考虑朴素 dp,

很容易想到定义一个当前位置的最小劳累值 f[i],当前状态只能从前边 k 个状态转移,轻易得到方程:

f[i]=minj=1k{f[max{ij,1}]+w)} ,w={1didij0di<dij

从第一个位置飞,所以初始化 f[1]=0

这样就得到了一个 O(n2) 的 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(logn)

tip:所以,必须保证决策集合的下标是连续的,或是限制条件


LIS

惊喜?意外?我觉得例题还是不能放有太多弯弯绕绕的转化的,这题正好

求 LIS,朴素 dp 人尽皆知咯:

f[i]=maxj<i,aj<ai{f[j]}+1     i[1,n],f[i]=1

显然这样做是 O(n2) 的,如果 n 达到 105,朴素做法就 gg 了

考虑优化

主要是内层循环,要实时对 1 ~ i - 1 的决策集合取 max,同时要更新 i 决策的值

发现这两种操作,不就是求区间 min,单点修改吗?用线段树直接维护它

考虑转移方程共有两个限制,j<iaj<ai,前者好说,正序枚举保证,但是发现后者,如果直接按 0 ~ n 的下标维护,不好判断所得到的区间 max 所对应位置的 aj 是否满足约束

那就直接 按值建线段树 -> 限制条件值域线段树(当然,这样做就经常需要结合离散化) 就好啦,对 ai, 考察 [1,ai1] 的决策最小值,总是满足约束

这样用线段树替换掉循环,复杂度优化到 O(nlogn)

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(贪心结论,维护两个线段树)

posted @   Zhang_Wenjie  阅读(43)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示