DP套DP
DP套DP
顾名思义,外面是一个 \(DP\), 而这个 \(DP\) 的状态里还有一个 \(DP\) 。
引例(P10614):
给定长度为 \(k\) 的字符串 \(s\) ,对于 \(i\in [0,k]\) ,求有多少种长度为 \(n\) 字符串 \(t\) ,使得 \(s,t\) 的最长公共子序列(\(LCS\))的长度为 \(i\) 。
\(1\le k \le 15\) ,\(1\le n \le 1000\)
发现要满足的条件本生就需 \(DP\) 来算。
那么首先最简单的暴力就是把所有字符串枚举出来,然后 \(DP\) 来算。时间复杂度 \(O(z^n \times n \times k)\) ,\(z\) 是字符集大小。
接着考虑怎么用计数 \(DP\) 解决,大概思路就是设 \(dp[i][]\) 表示 \(t\) 的前 \(i\) 位____的贡献,关键就是考虑第二维怎么设计。
尝试将第二维表示为最长公共子序列的长度,但这样会发现根本没法转移。
原因是每加入一个数,最长公共子序列的长度都是需要重新 \(DP\) 计算的。
发现 \(k\) 非常小,那考虑直接把第二维的状态设为一整个 \(DP\) (实际代码中一般以编码的形式存),这样每加入一个数时,都可以继续推导内层的 \(DP\) 进行转移 。
然后先考虑怎么 \(dp\) 算两个字符串的最长公共子序列(即 \(LCS\) ),这个显然可以设 \(h[i][j]\) 表示 \(t\) 前 \(i\) 个字符,\(s\) 前 \(j\) 个字符的最长公共子序列,然后转移如下:
那么转移时怎么转移呢?
比如我们要从 \(dp[i][g]\) 去转移( \(g\) 是 \(dp\) 数组, 这里等同于 \(h[i][j]\))。
那么可以转移到 \(dp[i][f]\) ,其中 \(f[j]=max(g[j-1]+[t[i]=s[j]],f[j-1],g[j])\)
实际代码中由于 \(g[j-1]\) 与 \(g[j]\) 的差不会超过 \(1\) ,所以 \(g\) 的差分数组就只由 \(0,1\) 组成,所以可以直接用 \(2\) 进制来表示这个数组。
可以先预处理出 \(trans[state][c]\) 表示当前状态 \(state\),加入字符 \(c\),能转移到的状态。
查询时遍历每个状态 \(state\) ,统计该状态下的 \(LCS\) 长度 \(l\) ,然后将 \(dp[n][state]\) 的值累加到 \(ans[l]\) 中。
时间复杂度:\(O(n\times 2^k \times z \times k)\) ,当 \(k\le15\) 时可行。
代码:
int dp[2][(1<<15)+10];
int g[20],f[20];
char match[4]={'A','C','G','T'};
int get_trans(int state,char c){
//还原dp
g[0]=0;
for (int i=1;i<=k;i++) g[i]=g[i-1]+((state>>(k-i))&1);
//转移
f[0]=0;
for (int i=1;i<=k;i++) f[i]=max({g[i],f[i-1],g[i-1]+(c==s[i])});
//压缩
int new_state=0;
for (int i=1;i<=k;i++){
int cha=f[i]-f[i-1];
new_state+=cha*(1<<(k-i));
}
return new_state;
}
int trans[(1<<15)+10][4];
ll ans[50];
void check(ll state,ll v){
ll sum=0;
for (int i=1;i<=k;i++){
(sum+=((state>>(k-i))&1))%=mod;
}
(ans[sum]+=v)%=mod;
}
int main(){
cin>> T;
while(T--){//多测
cin >> s;
k=s.length();
s=' '+s;
cin >> n;
for (int state=0;state<=(1<<k)-1;state++){
for (int i=0;i<4;i++){
trans[state][i]=get_trans(state,match[i]);
}
}
int res=0;
memset(dp,0,sizeof dp);
dp[res][0]=1;//滚动优化
for (int i=0;i<n;i++){
memset(dp[res^1],0,sizeof dp[res^1]);
for (int state=0;state<=(1<<k)-1;state++){
for (int j=0;j<4;j++){
dp[res^1][trans[state][j]]+=dp[res][state];//外层转移
if (dp[res^1][trans[state][j]]>=mod){
dp[res^1][trans[state][j]]-=mod;
}//取模,只是为了更快,没别的目的
}
}
res^=1;
}
memset(ans,0,sizeof ans);
for (int state=0;state<=(1<<k)-1;state++){
check(state,dp[res][state]);
}
for (int i=0;i<=k;i++){
cout << ans[i] << endl;
}
}
return 0;
}
还有一道几乎完全一样的题:
P4590
这道题只是多了一个不能出现 \(NOI\) 的限制。
实际上就是外层 \(dp\) 转移时多一个维度和一些判断。
如:
for (int i=0;i<n;i++){
memset(dp[res^1],0,sizeof dp[res^1]);
for (int state=0;state<=(1<<k)-1;state++){
for (int p=0;p<3;p++){//表示当前与NOI匹配到了多少位
if (!dp[res][state][p]) continue;//剪枝优化
for (int j=0;j<3;j++){
int np;//表示新的匹配上的位数
if (j==p){
if (p==2) continue;
np=p+1;
}
else np=(j==0)?1:0;
dp[res^1][trans[state][j]][np]+=dp[res][state][p];//正常转移
if (dp[res^1][trans[state][j]][np]>=mod){
dp[res^1][trans[state][j]][np]-=mod;//依旧取模变减法,更快
}
}
}
}
res^=1;
}
例二:P15301
求满足最长上升子序列为 \(l\) 的 \(1-n\) 排列的个数(这道题有一些变体,即要求最长上升子序列之一是给定的序列)。\(n\le 15\)
首先有一个关键是如何去刻画求最长上升子序列的 \(dp\) 转移。
- 方法一:
暂时不考虑变体:
设 \(d[i]\) 表示长度为 \(i\) 的最长上升子序列的末尾的数的最小值。易发现 \(d\) 有序。
则新放进来一个数 \(x\) 时,若 \(d[j]\) 是第一个大于 \(x\) 的数,那么令 \(d[j]\) 等于 \(x\) 。
这样就更新了 \(d\) 。原理也比较简单:\(x\) 可以接在 \(d[j-1]\) 后面形成长度为 \(j\) 的上升子序列 。
由于 \(d\) 有序,用一个二进制数 \(state\) 来存每个数是否在 \(d\) 中出现过,这样 \(state\) 和 \(d\) 可以互相唯一确定 。
但是存下这个还不够,因为排列限定每个数只能用一次,所以还需要用一个状态 \(used\) 来记录每个数是否出现过。
由于 \(state \in used\) 所以说总状态数不超过 \(3^n\) 。
可以用三进制编码来存这两个状态,具体来说,如果第 \(x\) 位为:
\(1\): 表示 \(x\) 不属于 \(state\) ,也不属于 \(used\)
\(2\): 表示 \(x\) 属于 \(state\) ,但不属于 \(used\)
\(3\): 表示 \(x\) 属于 \(state\) 和 \(used\)
外层枚举 \(i\) 表示前 \(i\) 个数,然后枚举三进制状态 \(state\) ,内层再枚举当前这个数填 \(j\) 。
但这样时间复杂度是 \(O(n^23^n)\) ,当 \(n\) 为 \(15\) 时过不了。
但是发现每一层的状态只由上一层的状态转移过来,而且很多状态是不可达的。
所以类似 \(bfs\) 的,每次只考虑这一层中在队列的那些状态去转移,总共状态就是 \(3^n\) 的,总时间复杂度就是 \(O(n\,3^n)\)
这是可以过的。
现在考虑变体:
其实就是强制选的时候要给定序列里的元素要有序,那就直接结合 \(used\) 判断一下这个元素能不能加就行了。时间复杂度不变。
- 方法二:
方法一的时间复杂度并不优秀。
考虑将数值从小到大依次插入到序列中(这是处理排列计数的一种常用方法),
定义 \(f[i][j]\) 表示插入前 \(i\) 个数后,以前 \(j\) 个位置的元素结尾的最长上升子序列长度。
将 \(i+1\) 插入到位置 \(x\) 后,新序列的长度为 \(i+1\) ,我们需要计算新的 \(f[i+1][j]\) ,
那么:
若 \(j<x\): \(f[i+1][j]=f[i][j]\)
若 \(j=x\): \(f[i+1][j]=f[i][j-1]+1\) ,因为新加进来的数比之前都大,所以直接放在后面。
若 \(j>x\) : \(f[i+1][j]=max(f[i][j-1],f[i+1][x])\) 要么取之前的值(原来是第 \(j-1\) 个位置),要么取新产生的值。
当然同上题一样,对于每个 \(i\) ,\(f[i][j]\) 相邻两项的差都是 \(0,1\)
所以依旧差分,转化为二进制。
转移仍然和之前一样。
由于还要满足变体的限制,所以外层 \(dp\) 额外开一维表示当前序列里面已经有多少个给定序列中的数。
继续转移就行了,大多都一样。
如果还是和方法一样优化一下转移,那么时间复杂度就是:
\(O(n^22^n)\)
优秀多了。
DP套DP正确性说明:
内层DP把“一个方案的属性判定”压缩成一个确定的状态转移:
给定当前状态和当前字符,下一步状态唯一确定。
外层DP按构造顺序枚举字符,并同步维护内层状态:
dp[i][s] = 构造到第 i 位、内层状态为 s 的方案数
dp[i+1][trans(s, c)] += dp[i][s]
不重:每个方案是一串确定的字符,每一步的状态由当前状态和字符唯一确定,所以整个方案只对应一条确定的状态变化路径,只会在终点被统计一次。
不漏:每个合法方案都会走出一条完整路径并到达合法终态,必然被外层DP累加。
共享状态不破坏正确性:不同方案可以落到同一个内层状态,但它们是不同方案,会分别贡献计数;内层状态合并的是“对后续影响相同”的历史,不合并方案本身。
因此每个方案对应唯一一条状态路径,只被计数一次,DP套DP不重不漏。
ZJOI2019 麻将
一、题意
题目给定 \(n\) 种牌,每种牌有 \(4\) 张,共 \(4n\) 张。初始已知 \(13\) 张手牌,剩下的 \(4n - 13\) 张牌随机打乱后依次摸入。定义 \(T\) 为某组随机摸牌的顺序的最小的 \(i\),使得「初始 \(13\) 张 + 摸入的前 \(i\) 张」中存在一个子集能够胡牌。求 \(E(T)\)。
胡牌的规则有两种:
\(1\). 七对子:\(7\) 个不同的对子。
\(2\). 基本型:\(4\) 个面子 + \(1\) 个对子。面子指刻子(三张相同)或顺子(三张大小连续)。
二、期望的转化(尾和公式)
\(E(T)\) 的定义是:
直接求 \(E(T)\) 不方便,考虑变换一下:
\(P(T > i)\) 表示摸了 \(i\) 张牌之后仍然没有胡牌的概率。
证明:
把 \(k\) 拆成很多个 \(1\) 相加:
交换求和顺序:
内层是 \(P(T>i)\) ,所以:
因此只需要对每个 \(i\) 求出摸了 \(i\) 张还没有胡牌的方案数,再除以总方案数(从 \(4n-13\) 张里选 \(i\) 张的排列数),即可得到概率。而概率相加的和就是最终的答案。
所以核心任务变成:统计「不存在胡牌子集」的牌型数量。
三、关键观察:只关心张数,不关心编号
一副牌是否胡牌,只取决于每种牌出现了多少张,与牌的具体编号、摸牌顺序无关。因此可以把一副牌表示为一个长度为 \(n\) 的序列 \(a[1..n]\),其中 \(a[i] \in \{0,1,2,3,4\}\) 表示大小为 \(i\) 的牌有多少张。
后续所有 \(DP\) 都在这个「张数序列」上进行。即按照牌的大小从小到大处理每张牌要选多少张。
四、如何判定一副牌能否胡?
\(4.1\) 七对子的判定
统计有多少种牌的张数 \(\ge 2\),记为 \(cnt\)。如果 \(cnt \ge 7\),则存在七对子,直接判定为胡。这部分只需要一个简单的计数器。
\(4.2\) 基本型的判定:状态设计
按牌的大小 \(1, 2, \dots, n\) 从小到大处理。处理到大小 \(i\) 时,唯一需要记住的是跨越当前边界、尚未完成的顺子。
定义状态 \(f[i][j][k][0/1]\):
| 维度 | 含义 |
|---|---|
| \(i\) | 已经处理了大小为 \(1..i\) 的牌 |
| \(j\) | 预留了 \(j\) 组形如 \((i-1, i, ?)\) 的未完成顺子,等 \(i+1\) 来补全 |
| \(k\) | 预留了 \(k\) 组形如 \((i, ?, ?)\) 的未完成顺子,等 \(i+1, i+2\) 来补全 |
| \(flag(0/1)\) | 是否已经选出了对子 |
\(f\) 的值是:在当前情况下,最多能组成多少个完整面子。
\(4.3\) 为什么 \(j\), \(k\) 只需要考虑 \(0..2\)
\(3\) 个同类型的未完成顺子,总能等价地重新解释为 \(3\) 个刻子,面子数完全不变。
以 \(j = 3\) 为例:预留了 \(3\) 组 \((i-1, i, ?)\),用了 \(3\) 张 \(i-1\)、\(3\) 张 \(i\),承诺未来用 \(3\) 张 \(i+1\)。完成后得到 \(3\) 个面子。
换一种解释:把 \(3\) 张 \(i-1\) 组成刻子 \((i-1,i-1,i-1)\),\(3\) 张 \(i\) 组成刻子 \((i,i,i)\),未来 \(3\) 张 \(i+1\) 组成刻子 \((i+1,i+1,i+1)\)。用的牌完全一样,面子数也一样。
因此 \(j \ge 3\) 的状态是冗余的,可以无损压到 \(j \in \{0,1,2\}\)。对 \(k\) 同理。
另外,\(j\) 组顺子需要 \(j\) 张 \(i\),\(k\) 组顺子也需要 \(k\) 张 \(i\),而第 \(i\) 种牌最多只有 \(4\) 张,所以 \(j + k \le 4\)。
\(4.4\) 转移
处理到第 \(i+1\) 种牌,假设有 \(x\) 张。枚举:
- \(a\):用多少张 \(i+1\) 完成之前的 \(j\) 组 \((i-1, i, ?)\);
- \(b\):用多少张 \(i+1\) 完成之前的 \(k\) 组 \((i, ?, ?)\);
- \(c\):用多少张 \(i+1\) 选出对子(\(c \in \{0,2\}\));
剩余张数 \(x - a - b - c\) 中,每 \(3\) 张可以再组成一个刻子(一定最优),剩余 \(0,1,2\) 张作为新的 \(k\) 预留。
新状态:
- 新的 \(j' = b\)(本次补充的);
- 新的 \(k' = (x - a - b - c) \bmod 3\);
- \(flag' = flag \mid (c == 2)\);
- 面子数增加 \(a + (x-a-b-c)/3\)。
\(4.5\) 胡牌判定
如果存在某个状态满足 \(f[n][j][k][1] \ge 4\),即已经有对子且面子数达到 \(4\),则这副牌能胡。或者 \(cnt \ge 7\) 也能胡。
五、状态压缩:内层 \(DP\)
\(5.1\) 完整状态是什么
内层 \(DP\) 在处理到第 \(i\) 种牌时,真正有意义的信息不是某一个 \((j, k, flag)\),而是所有 \((j, k, flag)\) 组合对应的最大面子数,即一张表:
\(j, k \in \{0,1,2\}\),所以 \(P0\) 和 \(P1\) 各是一个 \(3\times 3\) 矩阵,\(cnt \in \{0..7\}\)。这张表就是内层 \(DP\) 的完整状态。
但我们会发现这张表的状态太多了大致是 \(6^{18}\times 8\) 无发完全存下, 但是我们学过了例二的方法一。
\(5.2\) 用 \(BFS\) 枚举所有可达的表
从初始状态出发:\(P0[0][0] = 0\),其余为 \(-1\)(表示不可达),\(P1\) 全为 \(-1\),\(cnt = 0\)。
对当前表,枚举下一种牌的数量 \(x \in \{0,1,2,3,4\}\),用 \(4.4\) 的转移方程计算新表。如果新表已经满足胡牌条件,压缩成一个特殊的终止节点。
用一个 \(map\) 对表去重:出现过就连到已有节点,没出现过就新建节点加入队列。
\(5.3\) 实际状态数
经过 \(BFS\) 后,非胡状态的数量大约在 \(2091\) 到 \(3956\) 之间,取决于实现细节。这个数量级完全可以在外层 \(DP\) 中承受。
六、外层 \(DP\)
\(6.1\) 状态定义
外层 \(DP\) 按牌的大小枚举,状态为:
其中 \(node\) 是 \(BFS\) (或者叫做自动机也行) 中确定的节点编号,\(k\) 是额外摸到的牌数(即不含初始牌)。
\(6.2\) 转移
从 \(i\) 转移到 \(i+1\) 时,枚举第 \(i+1\) 种牌最终有多少张 \(x\):\(org[i]\) 大小为 \(i\) 的牌在初始的 \(13\) 张牌中的出现次数。
\(x\) 从 \(org[i+1]\) 到 \(4\) 枚举
\(new\_node = trans[node][x]\)
\(dp[i+1][new\_node][k + x - org[i+1]] += dp[i][node][k] * C(4 - org[i+1], x - org[i+1])\)
其中 \(org[i+1]\) 是初始 \(13\) 张牌中大小为 \(i+1\) 的牌的数量。因为同种牌有 \(4\) 张带编号的物理牌,从剩余的 \(4 - org[i+1]\) 张中选 \(x - org[i+1]\) 张,乘上组合数。
\(6.3\) 统计答案
对所有自动机中非胡节点求和:
摸了 \(i\) 张还不胡的概率为:
最终答案:
七、复杂度分析
| 部分 | 复杂度 |
|---|---|
| \(BFS\) 建自动机 | \(O(\text{状态数} \times 5 \times \text{转移代价})\),可预处理 |
| 外层 \(DP\) | \(O(n \times \text{状态数} \times 5 \times (4n))\),状态数约 \(2091\) |
实际可做到 \(O(2091 \times n^2)\) 级别,其中 \(n \le 100\),可以通过。
八、总结
| 层次 | 作用 | 状态 |
|---|---|---|
| 内层 \(DP\) 的单个格子 | 记录局部结构 | \(f[j][k][flag]\) = 最大面子数 |
| 内层 \(DP\) 的完整状态 | 自动机的一个节点 | (\(P0\) 矩阵, \(P1\) 矩阵, \(cnt\)) |
| 外层 \(DP\) | 在自动机上计数 | \(dp[i][node][k]\) = 方案数 |
备注:不知道自动机是什么玩意也不重要,就当作 \(BFS\) 中的一个节点就行了。但如果你学过 \(SAM\) (后缀自动机) ,就应该大概知道这是一个什么东西。

浙公网安备 33010602011771号