引入
本文将介绍 DP 套 DP 的思想,并通过两道例题展示它如何应用到具体问题中。
思想
所谓「DP 套 DP」,实际上指的是在动态规划的过程中,把一个子问题的求解过程(通常是一个 DP)抽象成一个自动机(DFA),然后在这个自动机的基础上再设计一层新的 DP 的方法。
这种技巧主要应用于一类 序列计数、概率 或 期望 问题。一个典型的问题具有如下结构:
- 给定字符集 和它上面一个长度为 的「合法序列」的集合 。根据字符集不同,序列可以是二进制串、数字串、状态序列等。
- 对于每一个具体的序列 ,可以通过动态规划来判断它是否合法(即 ),计算它的权值或求出相关值。
- 最终,我们希望统计集合 中所有序列的数目、总权值、期望值等。
这时,枚举所有序列是不可行的。于是我们考虑将「判断一个序列是否合法」的过程(即内层 DP)抽象为一个 确定有限状态自动机(DFA)。一般地,对于固定的序列 ,内层 DP 的状态函数可以表示为 ,即已经处理完序列 的长度为 的前缀,且其他状态分量为 时某个量的取值。相应地,内层 DP 的状态转移方程为
也就是说,函数 由之前的函数 和当前的字符 唯一确定。如果将函数 看作是自动机的一个状态,那么,内层 DP 的状态转移方程就给出了自动机的一个转移。因此,内层 DP 对应的自动机 结构如下:
- 状态集合 就是所有可能的 和 对应的函数 的集合;
- 转移函数 就是内层 DP 的状态转移方程中的 ;
- 起始状态 通常是显然的,即内层 DP 的初始状态;
- 接受状态集合 就对应着所有的合法序列 。
函数 本身可能相当复杂,因此,在处理具体问题时,通常需要进行 状态压缩 或结合 DFA 最小化的技巧来压缩状态空间。这也是 DP 套 DP 相较于暴力 DP 能够显著降低时空复杂度的主要原因。
将内层 DP 抽象为 DFA 后,就可以在这个 DFA 上设计一个新的 DP 用于求解原问题,即外层 DP。为方便表述,以简单的计数问题为例。外层 DP 的状态函数定义为 ,即处理到长度为 的前缀时,到达 DFA 中状态 的前缀的数目。它的状态转移方程为
起始状态当然是 ,而最终要求的答案通常可以根据 简单计算得到。外层 DP 实际上是 DAG 上 DP 的特殊情形。
例题
接下来的两个例题会详细说明 DP 套 DP 的一般做法。
例一
给定一个字符集为
ACGT的字符串 ,且 。对于每个 ,求有多少个长度为 ,字符集ACGT的字符串 ,满足它与 的最长公共子序列长度为 。
题解
我们首先会想到一个 DP:设 表示长度为 的 中,和 的最长公共子序列的长度为 的方案数。但是这样无法转移,我们发现主要的问题是,我们不知道这个最长公共子序列对应的是哪些字符。
考虑朴素求最长公共子序列的过程。设 表示 的前 位和 的前 位,它们的最长公共子序列的长度,就有
我们发现,对于一个 ,只需要记录 这个一维数组每一位的值,就能准确维护当前 与 前 位最长公共子序列的状态。因为 长度只有 ,我们发现这个思想是可行的。
于是重新设状态 表示对于长度为 的 ,与 的 DP 数组(就是 )状态为 的方案数。这个 DP 看起来状态数很多,然而我们发现 ,就可以维护 的差分数组,状态数是 的。
现在思考怎么转移。容易发现,如果我们知道了 这个数组,也知道了 ,就能通过朴素 LCS 转移(即前文的 DP 方程)求出 。于是朴素的 LCS 就成为了帮助 转移的内层 DP。
因此,我们枚举 ,计算出 转移后的状态 ,再将 加上 就可以完成外层 DP 的状态转移。最后,我们记录 为 LCS 长度为 的答案,枚举每个状态 , 加上 即可。
参考代码
#include <cstring> #include <iostream> #include <string> using namespace std; const int MOD = 1e9 + 7; const int MAXN = 100; int T, a[MAXN + 1], h[MAXN + 1]; int g[MAXN + 1], m, n; int nxt[1 << 16] [4]; // 状态转移表:nx[mask][ch] 表示 mask 状态下追加 ch 后的新状态 int f[1001] [1 << 16]; // DP 数组:f[i][mask] 表示长度为 i,处于状态 mask 的方案数 int ans[MAXN + 1]; // 最终答案:ans[i] 表示长度为 m 时,包含 i 个匹配位置的方案数 void add(int &x, int y) { x = (x + y) % MOD; } int calc(int mask, int x) { // 给定当前 mask 状态和当前字符 // x(0~3),返回新状态 int res = 0; for (int i = 0; i < n; ++i) g[i + 1] = g[i] + ((mask >> i) & 1); for (int i = 1; i <= n; ++i) { if (a[i] == x) h[i] = g[i - 1] + 1; else h[i] = max(h[i - 1], g[i]); } for (int i = 1; i <= n; ++i) if (h[i] > h[i - 1]) res |= (1 << (i - 1)); return res; } int popcount(int x) { // 计算 mask 中有多少个 1(即匹配了多少位置) int res = 0; while (x) { res += x & 1; x >>= 1; } return res; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> T; while (T--) { string S; cin >> S >> m; n = S.length(); memset(f, 0, sizeof(f)); memset(ans, 0, sizeof(ans)); memset(a, 0, sizeof(a)); for (int i = 0; i < n; ++i) { if (S[i] == 'A') a[i + 1] = 0; if (S[i] == 'C') a[i + 1] = 1; if (S[i] == 'G') a[i + 1] = 2; if (S[i] == 'T') a[i + 1] = 3; } int lim = (1 << n); for (int i = 0; i < lim; ++i) for (int j = 0; j < 4; ++j) nxt[i][j] = calc(i, j); f[0][0] = 1; for (int i = 0; i < m; ++i) for (int j = 0; j < lim; ++j) if (f[i][j]) for (int k = 0; k < 4; ++k) add(f[i + 1][nxt[j][k]], f[i][j]); for (int i = 0; i < lim; ++i) add(ans[popcount(i)], f[m][i]); for (int i = 0; i <= n; ++i) cout << ans[i] << '\n'; } return 0; }
例二
假设麻将牌有 种大小的牌,每种大小有 张牌。定义面子为三张相邻大小的麻将牌 (顺子)或三种相同大小的麻将牌 (刻子),对子为两张相同大小的麻将牌 。定义一个麻将牌的序列是胡的,当且仅当它(看作多重集合)可以拆成四个面子和一个对子,或者七个不同的对子。给定 张麻将牌,问期望再摸多少张牌可以满足存在一个胡牌的子序列的条件。
题解
首先,对于一副牌,我们只需考虑每种牌的数量,而不必关心它们的顺序。因此,对于任何一副牌的任意前缀,我们都可以将它转化为一个长度为 ,每个位置上为 的序列。初始时,第 张牌的数量为 ,就相当于限制了序列中第 个数字 的取值为 内的整数。注意,转化后的序列没有考虑摸牌的顺序,但是题目中的麻将牌的序列是考虑顺序的。
设 表示可以胡牌的最小摸牌次数。直接计算期望 较为困难,可以考虑进行如下转化。设 表示摸了 张牌后 没有胡 的序列数。因为它对应的麻将牌序列中,这 张牌必然排在剩下的 张牌前方,但是这 张牌和剩下的 张牌的顺序是任意的,所以,只摸前 张牌无法胡牌的麻将牌序列的数目就是
因为麻将牌序列的总数为 ,所以,只摸前 张牌无法胡牌的概率为
利用尾求和公式,就可以得到所求期望
至此,问题转化为如何计算 。我们采用 DP 套 DP 的方法来解决这一问题。
首先,考虑内层 DP,也就是要用动态规划判断一个(转化后的)序列是否对应一副能胡的牌。七对子的情形较为容易,重点讨论第一种胡牌的形式。为此,设 表示处理完前 种牌,还剩 组 以及 张 ,且存在/不存在对子(即 )时最多的面子数。如果对于一个序列进行 DP,最后得到的 中包括一个大于等于 的数字,那么这个序列就是能胡的。
这个 DP 的状态转移较为复杂。我们分两步讨论。第一步,考虑 的转移。这相当于说,如果要向现在的牌型中,添加 张大小为 的牌,但是不组成新的对子时,面子数如何转移。显然,如果希望在添加 张大小为 的牌后,要得到 个顺子, 个 和 个单独的 ,那么,就应该从 中转移过来(这里的选择最大程度避免了浪费),并将剩余的牌 用于组成尽可能多的刻子。穷举所有的可能性,就得到如下转移方程:
第二步,再考虑需要组成对子的情形。加入 张大小为 的牌时,有如下三种转移:
- 将 加 张牌转移到 ;
- 将 加 张牌转移到 ;
- 若 ,将 加 张牌转移到 。
由此,就得到了从 添加 张牌得到 的全部转移。
解决了内层 DP 的状态转移,就可以构建 胡牌自动机。自动机的转移就是上述内层 DP 的转移,还需要考虑如何表示自动机的每个状态。每个状态都对应 的一种可能的取值。它有三个维度 。因为 和 对应的维度中保留的 和 都是用于将来组成顺子的,而三个相同顺子总是可以重组为三个刻子,所以,只需要考虑组成不超过 个相同顺子的需求就行,每种牌型也就只要保留不超过 个,即 。因此, 可以表示为一个 的数组。另外,为了维护七对子的胡牌牌型,还需要为每个状态添加一个计数器,用于表示当前最多可组成的对子数目。
数组 中每个元素的取值范围可能是 ,但是,因为面子数目大于等于 都是胡牌,所以,可以限制每个元素取值不超过 。由于胡牌序列再添加任何牌都是胡牌序列,所以,可以利用 DFA 最小化的思想,将全体胡牌状态压缩为一个状态。因此,对于非胡牌状态,实际上每个位置的取值只要考虑 就可以了。实现时, 用 表示。
尽管如此,所有可能的状态依然相当地多,共有 种。穷举它们并不现实。实际上,绝大多数这些可能性都不会真的出现在一个胡牌自动机中。为了避免考虑实际不存在的状态,可以利用 BFS 的思想,从初始状态开始,一步一步扩展状态,直到胡牌状态处停止。这样得到的自动机中有 个状态。
最后,考虑如何在胡牌自动机上 DP(即外层 DP)。设 表示处理到第 张牌,共摸了 张牌,走到了胡牌自动机上的 号状态的序列数。转移时,枚举摸牌数 ,其中 为初始 张牌中用掉的 的张数,将之前的序列数乘以 张牌中选 张牌的方案数 ,再累加到一起。形式化地,有:
其中,,表示向自动机的状态 中加入 张牌后的状态。外层 DP 结束后,就可以计算出摸了 张牌仍然没有胡牌的序列数目,即
代入前文所述表达式,就可以得到所要求的期望。
参考代码
#include <algorithm> #include <cstdio> #include <iostream> #include <map> #include <unordered_map> using namespace std; const int N = 100, M = 400, mod = 998244353; int n, m, a[N + 5], fact[M + 5], inv[M + 5]; inline int C(int x, int y) { return 1LL * fact[x] * inv[y] % mod * inv[x - y] % mod; } class HuAutomation { // 胡牌自动机 private: class Mat { private: int f[3][3]; public: Mat() { for (int i = 0; i <= 2; i++) for (int j = 0; j <= 2; j++) f[i][j] = -1; } int* operator[](const int& x) { return f[x]; } inline bool operator==(Mat x) const { for (int i = 0; i <= 2; i++) for (int j = 0; j <= 2; j++) if (f[i][j] != x[i][j]) return 0; return 1; } inline bool operator<(Mat x) const { for (int i = 0; i <= 2; i++) for (int j = 0; j <= 2; j++) if (f[i][j] != x[i][j]) return f[i][j] < x[i][j]; return 0; } inline bool Check() { for (int i = 0; i <= 2; i++) for (int j = 0; j <= 2; j++) if (f[i][j] > 3) return 1; return 0; } inline void Upd(Mat x, int t) { // 将已有矩阵 x 的值更新到当前矩阵中,模拟加入 t 张当前牌后的状态 for (int i = 0; i <= 2; i++) for (int j = 0; j <= 2; j++) if (x[i][j] != -1) for (int k = 0; k < 3 && i + j + k <= t; k++) f[j][k] = max(f[j][k], min(i + x[i][j] + (t - i - j - k) / 3, 4)); // i,j,k,(t-i-j-k) // 表示分别枚举用于拼面子、用于保留(i-1,i)、用于保留i和直接拼面子的牌数 // 更新时限制最多 4 个对子 } }; struct node { int t, state[5]; Mat F[2]; node() { for (int i = 0; i <= 4; i++) state[i] = 0; t = 0; for (int i = 0; i <= 1; i++) F[i] = Mat(); } inline bool Check() { return t == -1 || t >= 7 || F[1].Check(); } node Hu() { node x; x.t = -1; return x; } bool operator<(const node& x) const { if (t == x.t) { if (F[0] == x.F[0]) return F[1] < x.F[1]; return F[0] < x.F[0]; } return t < x.t; } node operator+(int x) { // 加上 x 张新牌 if (Check()) return Hu(); // 胡了直接返回 node res; res.F[0].Upd(F[0], x), res.F[1].Upd(F[1], x); // 更新 res.t = t; if (x > 1) res.F[1].Upd(F[0], x - 2), ++res.t; if (res.Check()) res = Hu(); // 判断是否胡了 return res; } } A[2100]; map<node, int> mp; inline int Get(node x) { if (mp.count(x)) return mp[x]; mp[x] = ++tot; A[tot] = x; return tot; } inline void Expand(int x) { // 构建状态转移图: // 对于当前状态 A[x],尝试加入 0~4 张同种牌,得到 5 个新状态 for (int i = 0; i <= 4; i++) A[x].state[i] = Get(A[x] + i); } inline node Hu() { node x; x.t = -1; return x; } inline node Emp() { node x; x.F[0][0][0] = 0; return x; } public: int tot, f[N + 5][M + 5][2100]; inline void Build() { A[1] = Emp(), A[2] = Hu(); // 状态1是初始合法状态,2 是胡牌状态 mp[A[1]] = 1, mp[A[2]] = 2; tot = 2; Expand(1); for (int i = 3; i <= tot; i++) Expand(i); // 枚举所有可达状态,构建状态图(因为 2 // 是胡牌状态所以不需要拓展) } void DP() { f[0][0][1] = 1; for (int i = 1; i <= n; i++) for (int j = m; j >= 0; j--) for (int k = 1; k <= tot; k++) if (f[i - 1][j][k]) for (int t = 0; t <= 4 - a[i]; t++) // 枚举这张牌能加多少个(不能超过4,总共已有a[i]个),做组合转移 (f[i][j + t][A[k].state[a[i] + t]] += 1LL * f[i - 1][j][k] * C(4 - a[i], t) % mod) %= mod; } } Hfu; inline long long qpow(long long x, int y) { long long res = 1; while (y) { if (y & 1) res = res * x % mod; x = x * x % mod; y >>= 1; } return res; } void init(int n) { fact[0] = 1; for (int i = 1; i <= n; i++) fact[i] = 1LL * fact[i - 1] * i % mod; inv[n] = qpow(fact[n], mod - 2); for (int i = n - 1; i >= 0; i--) inv[i] = 1LL * inv[i + 1] * (i + 1) % mod; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int ans = 0; Hfu.Build(); // 状态图构建 cin >> n; for (int i = 1; i <= 13; i++) { int x, y; cin >> x; cin >> y; ++a[x]; } m = (n << 2) - 13; init(m); Hfu.DP(); for (int i = 1; i <= m; i++) { (ans += 1LL * Hfu.f[n][i][1] * fact[i] % mod * fact[m - i] % mod) %= mod; for (int j = 3; j <= Hfu.tot; j++) // 因为 2 节点是胡牌节点所以不统计 (ans += 1LL * Hfu.f[n][i][j] * fact[i] % mod * fact[m - i] % mod) %= mod; // f[i][j][k]:表示处理到第 i 张牌,共摸了 j 张牌,走到了胡牌自动机上的 k // 号节点的方案数 每个方案乘以 i!(用于排列这 i 张) × (m - i)! // (剩下的牌排列),求和后取模 } cout << (1LL * ans * inv[m] + 1) % mod; // 最终答案乘上 inv[m] 是除以 m!,即去掉总排列数,最后 +1 是 dp 的定义是摸完 i // 张还没有胡 return 0; }