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)\)

优秀多了。

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