一些浅显的 dp 优化策略

前缀和优化

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

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


[ABC179D] Leaping Tak

link

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

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

\[f[i] = \sum\limits_{j\in D}f[\max\{i - j, 0\}] \]

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

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

\[f[i] = \sum\limits_{j = 1}^{k}\sum\limits_{p = L[j]}^{R[j]}f[\max\{i - p, 0\}] \]

这就好了, 发现 \(\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 的策略(当然也可能是多维的),不扯了 ......

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

其实就是通过维护一个单调队列 及时排除不可能成为最优转移的位置,将寻找决策点的复杂度均摊成 \(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

link

首先考虑朴素 dp,

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

\[f[i] = \min\limits_{j = 1}^{k}\{f[\max\{i - j, 1\}] + w)\}~, w = \begin{cases}1 & d_i \geq d_{i - j}\\ 0 & d_i < d_{i - j}\end{cases} \]

从第一个位置飞,所以初始化 \(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 人尽皆知咯:

\[f[i] = \max\limits_{j < i, a_j < a_i}\{f[j]\} + 1~~~~~\forall i\in[1, n],f[i] = 1 \]

显然这样做是 \(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(贪心结论,维护两个线段树)

posted @ 2024-09-03 13:18  Zhang_Wenjie  阅读(88)  评论(0)    收藏  举报