Daniel_yuan's Blog

芙卡米天下第一可爱 n(*≧▽≦*)n

0%

DP of DP

做过不少 DP of DP 题,但是考试还是想不到,很气,写个总结记录下。

对于一个题目,如果它要求的是在某个限制下计数,并且在看一个状态有没有满足限制的时候也需要 DP,这时候往往就要用到 DP of DP。

一般的流程是这样的,我们先考虑对于一个已知状态,怎么判定其是否满足性质。这时候我们一般都会有一个 DP,且这个 DP 是用增量法填表的,即类似于 \(f_{i,...}\) 表示前 \(i\) 个位置(或其它)怎么怎么样。然后因为我们的根本需求是计数,而计数我们也用到增量法,所以可以把 \(f_{i,...}\)\(i\) 放到外层 DP 处,然后把剩下的 \(f_{...}\) 给排成一列压缩起来,变成一个状态。这样在外层我们就得到了 \(F_{i,S}\),表示考虑前 \(i\) 个位置(或其它),内层 DP 的状态是 \(S\) 的方案数。在转移 \(i+1\) 的时候,我们把 \(S\) 还原成内层 DP 数组 \(f_{...}\),然后对 \(f_{...}\)\(i+1\) 跑内层 DP 得到一个新状态\(f^{'}_{...}\) 并重新压缩成 \(T\),这样就可以从 \(F_{i,S}\) 转移到 \(F_{i+1,T}\)

考虑看一道例题:

给一个小写字母字符串 \(S\)\(|S|\leq15\),问有多少个长度为 \(n\)\((n\leq 100)\) 的小写字母字符串 \(T\),使得 \(S\)\(T\) 的最长上升子序列的长度为 \(k\)。对于 \(k=0,1,2...|S|\) 都要求答案。

经典题,按照上面的流程来做。

首先对于一个已知字符串 \(T\),考虑求它的最长上升子序列。设 \(f_{i,j}\) 表示考虑 \(T\) 的前 \(i\) 个字符,\(S\) 的前 \(j\) 个字符的最长上升子序列最长是多少。这个 DP 符合增量法,所以把 \(f_{1,2...j}\) 压缩成一个状态 \(S\)。在外层,设 \(F_{i,S}\) 表示考虑 \(T\) 的前 \(i\) 个字符,\(f_{i,\{0,1...|S|\}}\) 算出来的值状压起来为 \(S\) 的方案数。转移的时候,枚举下一个字符填什么,然后把 \(S\) 还原成 \(f\),然后计算出新的 \(f'\),再状压回 \(T\),就可以从 \(F_{i,S}\) 转移到 \(F_{i+1,T}\) 了。

这样我们就完成了普通的 DP of DP,但是这样复杂度有问题,因为 \(f\) 的取值范围是 \([0,15]\) 压起来特别大,但是我们发现 \(f_i-f_{i-1}\in[0,1]\),所以可以把 \(f_i-f_{i-1}\) 压起来,这样 \(F_{i,S}\)\(S\) 的取值只有 \(2^{15}\) 了。

代码如下,这里只实现了字符集为 \(\{N,O,I\}\) 。原题是 [TJOI2018] 游园会

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#include <bits/stdc++.h>
#define debug(...) fprintf(stderr, __VA_ARGS__)
#define RI register int
typedef long long LL;

#define FILEIO(name) freopen(name".in", "r", stdin), freopen(name".out", "w", stdout);

using namespace std;

int const mod = 1e9 + 7;
char s[20];
int f[20], g[20];
int Trans[1 << 15][3];
int dp[2][3][1 << 15];
int bitcnt[1 << 15];
int Ans[20];

inline void Add(int &x, int y) { x += y - mod, x += (x >> 31) & mod; }

int main() {

#ifdef LOCAL
FILEIO("a");
#endif

int n, k; scanf("%d %d", &n, &k);
scanf("%s", s + 1);
for (RI S = 0, NS; S < (1 << k); ++S) {
for (RI i = 1; i <= k; ++i)
f[i] = f[i - 1] + ((S >> (i - 1)) & 1);

for (RI i = 1; i <= k; ++i)
g[i] = max(g[i - 1], max(f[i], f[i - 1] + (s[i] == 'N')));
NS = 0;
for (RI i = 1; i <= k; ++i)
NS |= (g[i] - g[i - 1]) << (i - 1);
Trans[S][0] = NS;

for (RI i = 1; i <= k; ++i)
g[i] = max(g[i - 1], max(f[i], f[i - 1] + (s[i] == 'O')));
NS = 0;
for (RI i = 1; i <= k; ++i)
NS |= (g[i] - g[i - 1]) << (i - 1);
Trans[S][1] = NS;

for (RI i = 1; i <= k; ++i)
g[i] = max(g[i - 1], max(f[i], f[i - 1] + (s[i] == 'I')));
NS = 0;
for (RI i = 1; i <= k; ++i)
NS |= (g[i] - g[i - 1]) << (i - 1);
Trans[S][2] = NS;
}

int cur = 1, nxt = 0;
dp[0][0][0] = 1;
for (RI i = 0; i < n; ++i) {
swap(cur, nxt);
memset(dp[nxt], 0, sizeof(dp[nxt]));
for (RI S = 0; S < (1 << k); ++S) {
Add(dp[nxt][1][Trans[S][0]], dp[cur][0][S]);
Add(dp[nxt][1][Trans[S][0]], dp[cur][1][S]);
Add(dp[nxt][1][Trans[S][0]], dp[cur][2][S]);
Add(dp[nxt][0][Trans[S][1]], dp[cur][0][S]);
Add(dp[nxt][2][Trans[S][1]], dp[cur][1][S]);
Add(dp[nxt][0][Trans[S][1]], dp[cur][2][S]);
Add(dp[nxt][0][Trans[S][2]], dp[cur][0][S]);
Add(dp[nxt][0][Trans[S][2]], dp[cur][1][S]);
}
}
for (RI S = 1; S < (1 << k); ++S)
bitcnt[S] = bitcnt[S ^ (S & (-S))] + 1;
for (RI i = 0; i < 3; ++i)
for (RI S = 0; S < (1 << k); ++S)
Add(Ans[bitcnt[S]], dp[nxt][i][S]);
for (RI i = 0; i <= k; ++i)
printf("%d\n", Ans[i]);

return 0;
}

// created by Daniel yuan
/*
________
/ \
/ / \ \
/ / \ \
\ /
\ ______ /
\________/
*/

但是我们肯定不仅仅满足这个普通的 DP of DP,我们需要更便捷的东西——自动机。

也就是说我们干脆就直接把 \(S\) 看成是一个状态的集合,然后把所有的状态的集合建出一个转移的自动机。一个自动机上面的节点代表着一个状态,而转移边就代表着这个状态加入一个东西后可以转移到哪里。这样我们的 DP 就变成了一个普通的自动机上 DP。

典型例子是 [ZJOI2019] 麻将,可以结合我的这篇博客把这个题切了。

(不知为何写着写着就变成一个普及 DP of DP 的博客了……)