[学习笔记]动态规划的优化

本文深入探讨了动态规划问题中的空间和时间复杂度优化,介绍了滚动数组、状态复用等策略降低空间复杂度,并讨论了如何通过减去冗余状态、重新设计状态及优化转移来提升时间效率。动态规划优化不仅涉及状态和转移的精简,还涵盖了单调队列、矩阵乘法和分治算法在动态规划中的应用,为解决复杂问题提供了有效手段。
摘要由CSDN通过智能技术生成

这一部分自己学的比较少,所以内容比较少……后面学了会增加内容。(然而事实上学了之后由于时间少也没时间更新了)

对于动态规划问题,我们顺利地设计出状态和转移只能解决一部分问题,而很多问题需要我们进行时间和空间上的优化才能通过,所以我们要进行动态规划的优化。

空间复杂度的优化

这不是重点,因为现在空间不是很重要,大部分的题目的空间限制都是足够的,但是不排除有些 毒瘤 特殊的题目卡空间,这就需要我们优化空间复杂度。

介绍一下空间复用思想:在很多算法中,尤其是动态规划中,一块空间只会被利用很少的次数然后就毫无用处了,占着空间,这时我们就可以重复利用一块空间来降低空间复杂度。

空间复杂度的优化主要就是使用滚动数组。而我们有三种方式滚动:

1. 原数组上覆盖滚动

典型的一个例子是背包问题。下面是 Luogu P1616 疯狂的采药 一题的主体代码:

for(int i = 1; i <= n; i++)
	for(int j = c[i]; j <= m; j++)
		f[j] = max(f[j], f[j - c[i]] + w[i]);

状态实际上是二维的,但是我们通过在原数组上的覆盖把一维给滚掉了。

优点:代码简短;
缺点:循环顺序是个问题,且使用有局限性。

2. 互换式的滚动

NOIP普及组2005 采药 一题为例。01背包问题中,每一个阶段只依赖于上一个阶段(通俗地说, f [ i ] [ . . . ] f[i][...] f[i][...] 依赖于 f [ i − 1 ] [ . . . ] f[i-1][...] f[i1][...]),所以我们可以开两个数组 f f f t m p tmp tmp,假如我们要求阶段 x x x,此时 f f f 数组存储的是阶段 x − 1 x-1 x1,我们利用 f f f 求出阶段 x x x 的信息并存储在 t m p tmp tmp 中,然后再把 t m p tmp tmp 拷贝到 f f f

优点:无需考虑顺序;
缺点:代码较长,时间效率会偏低,有局限性,实现较为麻烦……

反正我不太喜欢这一种做法,我更喜欢第三种:

3. 原数组上复用的滚动

[学习笔记]状态压缩动态规划 中的例题2为例。

发现每一个 i i i 只会依赖于 i − 1 i-1 i1,所以我们在原代码上所有的第一维下标后面全部加上一个 %2 就可以优化了(无脑操作,是不是很简单),然后注意复用的时候清零,就完事了。

#include<iostream>
using namespace std;
const int mod = 1e8;
int m, n, cnt;
const int maxn = 14;
int field[maxn], a[1 << maxn];
int f[2][1 << maxn];  //这样我们就可以更改第一维的范围了

int main()
{
	cin >> m >> n;
	for(int i = 0; i < (1 << n); i++)
		if((i & (i << 1)) == 0)
			a[++cnt] = i; 
	for(int i = 1; i <= m; i++)
	{
		int x = 0;
		for(int j = 1; j <= n; j++)
		{
			int val;
			cin >> val;
			x = (x << 1) + (val ^ 1);
		}
		field[i] = x;
	}
	for(int i = 1; i <= cnt; i++)
		if((a[i] & field[1]) == 0)
			f[1 % 2][i] = 1;
    //统一%2,虽然1%2还是1,但是一次性改了
	for(int i = 2; i <= m; i++)
	{
		for(int j = 1; j <= cnt; j++)
			f[i % 2][j] = 0;  //注意清零
		for(int j = 1; j <= cnt; j++)
			for(int l = 1; l <= cnt; l++)
				if((a[j] & a[l]) == 0 && (a[j] & field[i]) == 0)
					f[i % 2][j] = (f[i % 2][j] + f[(i - 1) % 2][l]) % mod;
	}
	int ans = 0;
	for(int i = 1; i <= cnt; i++)
		ans = (ans + f[m % 2][i]) % mod;
	cout << ans << endl;
	return 0;
}

空间复杂度由 O ( n × 2 n ) O(n \times 2^n) O(n×2n) 优化成了 O ( 2 n ) O(2^n) O(2n)

类似地,如果依赖于前面两个就 %3,依赖于前面三个就 %4……以此类推。

优点:改动小,无脑简单,好改好删(万一一不小心改错了很容易改回来),无需考虑顺序;
缺点:需要注意复用时初始化,忘了这个就惨了……

接下来才是重点:

时间复杂度的优化

前面提到过,动态规划的时间复杂度一般是“状态 × \times × 每个状态的转移复杂度”,所以我们的优化基本就是两个大方向——状态和转移。

状态的优化

两种办法:第一是减去冗余状态,第二是重新设计状态。

减去冗余状态,即无用的状态不转移。当然这一优化的优化幅度一般比较小。NOIP2018普及组 摆渡车 一题中,通过冗余状态的减去可以将时间复杂度从 O ( t m ) O(tm) O(tm) 降至 O ( n m 2 + t ) O(nm^2 + t) O(nm2+t)(参考了《题解 P5017 【摆渡车】》)。

重新设计状态这个优化极为少用,两个原因:一是重新设计状态意味着一切推倒重来,二是基本上动态规划的状态进行设计后就已经定型了,很难重新设计。但是这个优化也不是没有,比如 LIS 问题就可以通过重新设计状态把时间复杂度从 O ( n 2 ) O(n^2) O(n2) 优化成 O ( n log ⁡ n ) O(n\log n) O(nlogn)

有的时候我们可以通过求对偶问题(即问题的对立面)来改变状态的设计以优化时间复杂度。

例1

USACO 2011 Open Gold Mowing the Lawn 修剪草坪

如果我们按照题目意思设计状态: d p i , j dp_{i,j} dpi,j 表示选择从第 i i i 只奶牛开始一直到向前 j j j 头奶牛中的所有奶牛,能获得的最大的效率值,那么时间和空间都不能接受。

但是我们考虑其对偶问题:不选一些奶牛,使得这些奶牛的效率值之和最小。那么我们用 d p i dp_i dpi 表示一定不选第 i i i 头奶牛时前 i i i 头奶牛中损失的最小效率值。空间可以接受,并且可以通过单调队列优化,将时间复杂度变为 O ( n ) O(n) O(n)

综上所述,状态的优化比较受限,但是我们在转移上可以做的文章很多。

转移的优化

转移的优化也分为两大类:减去无用转移(缩小转移的范围以优化时间复杂度)和利用算法和数据结构优化转移。

减去无用转移/缩小转移范围的典型例子也是 NOIP2018普及组 摆渡车 一题,还是可以参考 《题解 P5017 【摆渡车】》

利用算法和数据结构优化转移才是重头戏!

这一类优化有很多,比如单调队列优化动态规划,斜率优化动态规划,树状数组、线段树优化动态规划,四边形不等式优化……包括像 Trie字典树,RMQ 等算法和数据结构也都可以用于优化动态规划。

单调队列优化动态规划

推荐阅读:《朝花中学OI队的奋斗历程——浅谈单调队列》,可以帮助了解单调队列的基本操作、用处和实现。

在我看来,单调队列的使用最重要的就是三步:

  1. 将不满足要求的弹出单调队列
  2. 将满足要求的插入单调队列并维护队列
  3. 进行转移(用于动态规划)

以上三步根据具体的题目,顺序可能不定,但基本都有这3步。

其实关于单调队列有一句很有趣的话:

如果一个选手比你小还比你强,那么你就打不过他了。

解释一下:

  • 比你小:这个元素后进来
  • 比你强:它的值更小/更大
  • 打不过它:你该退役了(你该被弹出单调队列了)
单调队列的使用情境
  1. 单向滑动区间求最值;
  2. 用于优化动态规划,动态规划的转移来自一个区间。其中较为特殊的是单调队列优化多重背包;
  3. 也可以优化非动态规划问题。

推荐题目:

做完这些题目基本上就能摸清单调队列的套路了。

矩阵乘法优化动态规划

这一部分由于本人不是特别了解,所以有些地方书写可能不太严谨。

推荐阅读:《从零开始的矩阵乘法》 以了解前置芝士。另外需要学习矩阵快速幂。

这里只浅谈矩阵乘法优化线性齐次递推式的计算。以一个经典的问题——斐波那契数列为例。

众所周知,斐波那契数列 F i b i Fib_i Fibi 的定义是:
F i b i = { 1 i ≤ 2 F i b i − 1 + F i b i − 2 i > 2 Fib_i=\begin{cases} 1&i \le 2\\ Fib_{i-1}+Fib_{i-2}&i > 2 \end{cases} Fibi={1Fibi1+Fibi2i2i>2

我们可以通过这个式子递推,用 O ( n ) O(n) O(n) 的复杂度计算出斐波那契数列的第 n n n 项。然鹅我们来看一下这个问题:Luogu P1962 斐波那契数列

我们发现:
∣ 1 1 1 0 ∣ × ∣ F i b 2 F i b 1 ∣ = ∣ F i b 1 + F i b 2 F i b 2 ∣ \begin{vmatrix} 1 & 1 \\ 1 & 0 \end{vmatrix} \times \begin{vmatrix} Fib_2\\ Fib_1 \end{vmatrix} = \begin{vmatrix} Fib_1+Fib_2\\ Fib_2 \end{vmatrix} 1110×Fib2Fib1=Fib1+Fib2Fib2


∣ 1 1 1 0 ∣ × ∣ F i b 2 F i b 1 ∣ = ∣ F i b 3 F i b 2 ∣ \begin{vmatrix} 1 & 1 \\ 1 & 0 \end{vmatrix} \times \begin{vmatrix} Fib_2\\ Fib_1 \end{vmatrix} = \begin{vmatrix} Fib_3\\ Fib_2 \end{vmatrix} 1110×Fib2Fib1=Fib3Fib2

所以我们通过构造矩阵实现了式子的递推。

同理,如果我们在前面继续乘上一个 ∣ 1 1 1 0 ∣ \begin{vmatrix}1&1\\1&0\end{vmatrix} 1110,那么我们就能得到 ∣ F i b 4 F i b 3 ∣ \begin{vmatrix}Fib_4\\Fib_3\end{vmatrix} Fib4Fib3。所以我们有
∣ 1 1 1 0 ∣ n × ∣ F i b 2 F i b 1 ∣ = ∣ F i b n + 1 F i b n ∣ \begin{vmatrix} 1 & 1 \\ 1 & 0 \end{vmatrix}^n \times \begin{vmatrix} Fib_2\\ Fib_1 \end{vmatrix} = \begin{vmatrix} Fib_{n+1}\\ Fib_n \end{vmatrix} 1110n×Fib2Fib1=Fibn+1Fibn

这样就可以通过矩阵快速幂加快转移,时间复杂度 O ( 8 log ⁡ n ) O(8\log n) O(8logn) 8 8 8 的常数是计算矩阵快速幂产生的( 2 3 = 8 2^3=8 23=8)。

总结一下矩阵乘法优化线性齐次递推式的过程:

  1. 根据递推式构造矩阵
  2. 将式子的初始值放进一个矩阵里
  3. 计算矩阵快速幂得到答案

矩阵的构造比较简单,按照题目意思来就OK,熟能生巧。

推荐题目:Luogu P1939 【模板】矩阵加速(数列)

分治算法优化动态规划

这个优化比较冷门,所以写在这里。可以说是如果遇到了,学过就会没学过就废。

经典例题:CF868F Yet Another Minimization Problem
首先我们很容易在 O ( n 2 k ) O(n^2k) O(n2k) 的时间复杂度内解决这个问题.
接下来考虑优化,状态的个数肯定无法优化,为 n k nk nk。考虑优化转移,发现常规的单调队列、斜率优化等技巧都无法使用,因为“相同元素的对数”这个东西比较特殊。

接下来分治就要出场了。
f i , j f_{i,j} fi,j 的转移来自 f p , j − 1 f_{p,j-1} fp,j1,当 i i i 增加到 i ′ i' i j j j 不变时,设对应的决策点为 p ′ p' p(即 f i ′ , j f_{i',j} fi,j 的转移来自 f p ′ , j − 1 f_{p',j-1} fp,j1),那么显然 p ′ > p p'>p p>p,也就是说决策单调
证明的话可以用四边形不等式,理解的话感性理解就好。

接下来调整常规的动态规划转移顺序:

  • 对于一个阶段 j j j,先求 f m i d , j f_{mid, j} fmid,j m i d mid mid 为当前状态区间的中点;
  • 用朴素转移找到 m i d mid mid 的决策点 p p p
  • 1 ∼ m i d − 1 1\sim mid-1 1mid1 的决策点一定在 [ 1 , p ] [1,p] [1,p] 中; m i d + 1 ∼ n mid+1\sim n mid+1n 同理。
  • 递归分治;
  • 朴素转移的过程(找相同元素的对数)用类似莫队的方法,用两个指针扫并更新就可以了;
  • 总复杂度为 O ( k n log ⁡ n ) O(kn\log n) O(knlogn)

代码:

#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e5 + 10;
typedef long long ll;
int n, k, L, R;
ll sum;
int a[maxn];
ll cnt[maxn];
ll f[maxn][25];

void Move(int l, int r)  //更新相同元素的对数
{
	while(L < l) { sum -= cnt[a[L]] - 1; cnt[a[L++]]--;}
	while(L > l) { sum += cnt[a[L - 1]]; cnt[a[--L]]++;}
	while(R < r) { sum += cnt[a[R + 1]]; cnt[a[++R]]++;}
	while(R > r) { sum -= cnt[a[R]] - 1; cnt[a[R--]]--;}
	return;
}

void divide(int l, int r, int lp, int rp, int j)
//l, r:状态区间;lp, rp:决策区间;j: 阶段
{
	if(l > r) //注意边界细节
		return;
	int mid = (l + r) >> 1;
	int p = lp, lim = min(mid - 1, rp);
	for(int i = lp; i <= lim; ++i) //求f[mid][j]
	{
		Move(i + 1, mid);
		if(f[i][j - 1] + sum < f[mid][j])
		{
			p = i;
			f[mid][j] = f[i][j - 1] + sum;
		}
	}
	divide(l, mid - 1, lp, p, j);
	divide(mid + 1, r, p, rp, j);
	return;
}

int main()
{
	memset(f, 0x3f, sizeof(f));
	cin >> n >> k;
	for(int i = 1; i <= n; ++i)
		cin >> a[i];
	L = 1;
	R = 1;
	cnt[a[1]] = 1;
	sum = 0;
	for(int i = 1; i <= n; ++i)
	{
		Move(1, i);
		f[i][1] = sum;
	}
	for(int i = 2; i <= k; ++i)
		divide(1, n, 1, n, i);
	cout << f[n][k] << endl;
	return 0;
}
分治算法优化动态规划总结
  • 题型:多为划分型动态规划
  • 套路:划分段数 k k k 很小, n n n 1 0 5 10^5 105 量级左右
  • 使用的前提条件:决策单调
  • 实质:调整求解顺序,实现决策优化

推荐的练习题:BZOJ5125 小Q的书架

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值