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\) 个字符的最长公共子序列,然后转移如下:

\[h[i][j]=max(h[i-1][j-1]+[t[i]=s[j]],h[i][j-1],h[i-1][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)=\sum_{k=0}^{4n-13}k\times P(T=k) \]

直接求 \(E(T)\) 不方便,考虑变换一下:

\[E(T) = \sum_{i=0}^{4n-13} P(T > i) \]

\(P(T > i)\) 表示摸了 \(i\) 张牌之后仍然没有胡牌的概率。

证明:

把 \(k\) 拆成很多个 \(1\) 相加:

\[E(T)=\sum_{k=0}^{4n-13} \sum_{i=0}^{k-1} P(T=k) \]

交换求和顺序:

\[E(T)=\sum_{i=0}^{4n-13} \sum_{k=i+1}^{4n-13} P(T=k) \]

内层是 \(P(T>i)\) ,所以:

\[E(T) = \sum_{i=0}^{4n-13} 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)\) 组合对应的最大面子数,即一张表:

\[P0[j][k] = \text{没有对子时,预留 } j \text{ 和 } k \text{ 的最大面子数} \]

\[P1[j][k] = \text{已经有对子时,预留 } j \text{ 和 } k \text{ 的最大面子数} \]

\[cnt = \text{张数 } \ge 2 \text{ 的牌的种类数} \]

\(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\) 按牌的大小枚举,状态为:

\[dp[i][node][k] = \text{处理到第 } i \text{ 种牌,内层状态为 node,总共摸了 } k \text{ 张牌的方案数} \]

其中 \(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\) 统计答案

对所有自动机中非胡节点求和:

\[\text{不胡方案数}[i] = \sum_{\text{node 非胡}} dp[n][node][i] \]

摸了 \(i\) 张还不胡的概率为:

\[P(T > i) = \frac{\text{不胡方案数}[i]\cdot i!}{\binom{4n-13}{i} \cdot i!}=\frac{\text{不胡方案数}[i]}{\binom{4n-13}{i}} \]

最终答案:

\[E(T) = \sum_{i=0}^{4n-13} P(T > 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\) (后缀自动机) ,就应该大概知道这是一个什么东西。

posted @ 2026-09-06 16:53  wlhao  阅读(20)  评论(0)    收藏  举报