这一部分自己学的比较少,所以内容比较少……后面学了会增加内容。(然而事实上学了之后由于时间少也没时间更新了)
对于动态规划问题,我们顺利地设计出状态和转移只能解决一部分问题,而很多问题需要我们进行时间和空间上的优化才能通过,所以我们要进行动态规划的优化。
空间复杂度的优化
这不是重点,因为现在空间不是很重要,大部分的题目的空间限制都是足够的,但是不排除有些 毒瘤 特殊的题目卡空间,这就需要我们优化空间复杂度。
介绍一下空间复用思想:在很多算法中,尤其是动态规划中,一块空间只会被利用很少的次数然后就毫无用处了,占着空间,这时我们就可以重复利用一块空间来降低空间复杂度。
空间复杂度的优化主要就是使用滚动数组。而我们有三种方式滚动:
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[i−1][...]),所以我们可以开两个数组 f f f 和 t m p tmp tmp,假如我们要求阶段 x x x,此时 f f f 数组存储的是阶段 x − 1 x-1 x−1,我们利用 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
i−1,所以我们在原代码上所有的第一维下标后面全部加上一个 %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队的奋斗历程——浅谈单调队列》,可以帮助了解单调队列的基本操作、用处和实现。
在我看来,单调队列的使用最重要的就是三步:
- 将不满足要求的弹出单调队列
- 将满足要求的插入单调队列并维护队列
- 进行转移(用于动态规划)
以上三步根据具体的题目,顺序可能不定,但基本都有这3步。
其实关于单调队列有一句很有趣的话:
如果一个选手比你小还比你强,那么你就打不过他了。
解释一下:
- 比你小:这个元素后进来
- 比你强:它的值更小/更大
- 打不过它:你该退役了(你该被弹出单调队列了)
单调队列的使用情境
- 单向滑动区间求最值;
- 用于优化动态规划,动态规划的转移来自一个区间。其中较为特殊的是单调队列优化多重背包;
- 也可以优化非动态规划问题。
推荐题目:
- POJ2823 滑动窗口 /【模板】单调队列
- Luogu P1440 求m区间内的最小值
- USACO 2011 Open Gold Mowing the Lawn 修剪草坪
- POI2005 A Journey to Mars 旅行问题
- NOIP2017普及组 跳房子
- HAOI2007 理想的正方形
做完这些题目基本上就能摸清单调队列的套路了。
矩阵乘法优化动态规划
这一部分由于本人不是特别了解,所以有些地方书写可能不太严谨。
推荐阅读:《从零开始的矩阵乘法》 以了解前置芝士。另外需要学习矩阵快速幂。
这里只浅谈矩阵乘法优化线性齐次递推式的计算。以一个经典的问题——斐波那契数列为例。
众所周知,斐波那契数列
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={1Fibi−1+Fibi−2i≤2i>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}
∣∣∣∣1110∣∣∣∣n×∣∣∣∣Fib2Fib1∣∣∣∣=∣∣∣∣Fibn+1Fibn∣∣∣∣
这样就可以通过矩阵快速幂加快转移,时间复杂度 O ( 8 log n ) O(8\log n) O(8logn)。 8 8 8 的常数是计算矩阵快速幂产生的( 2 3 = 8 2^3=8 23=8)。
总结一下矩阵乘法优化线性齐次递推式的过程:
- 根据递推式构造矩阵
- 将式子的初始值放进一个矩阵里
- 计算矩阵快速幂得到答案
矩阵的构造比较简单,按照题目意思来就OK,熟能生巧。
分治算法优化动态规划
这个优化比较冷门,所以写在这里。可以说是如果遇到了,学过就会没学过就废。
经典例题: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,j−1,当
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′,j−1),那么显然
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 1∼mid−1 的决策点一定在 [ 1 , p ] [1,p] [1,p] 中; m i d + 1 ∼ n mid+1\sim n mid+1∼n 同理。
- 递归分治;
- 朴素转移的过程(找相同元素的对数)用类似莫队的方法,用两个指针扫并更新就可以了;
- 总复杂度为 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的书架