我永远喜欢39号同学2018。

· · 题解

前言

如果有看不懂,或者题解中有错误,欢迎指出。

本篇题解讲得比较详细,可能比较啰嗦,如果想看更简单了请移步其它题解或跳读。

前置算法

请先学习 状态压缩 dp

建议学习动态规划(dp)求最长公共子序列。会 O(n^3) 的做法即可。(其实这题我也没做)

题目大意

n 个字符串的最长公共子序列。

数据范围:n\le10,每个字符在同一个字符串中最多出现 2 次。只会出现 52 个英文大小写字母。大小写敏感。

注:

  1. 为方便状态压缩,除特殊说明外,以下所有编号从 0 开始。
  2. 为避免下标、括号过多造成困扰,有些数组用中括号代替下标。a[x] 表示 a_x
  3. 所有 "之后" 都不包含当前。比如 3 之后 指 4,5,6,\ldots 而不包含 3
  4. 字母表大小指出现的字符种类数。比如对于本题,只会出现 52 种字符,则字母表大小为 52

经管理员反馈,暴力做法有些多余。所以放到这里,供有需要的看。

简化题目:每个字符在同一个字符串中最多出现 1 次。

只出现 1 次,让我们想到了用元素来表示下标

f[i] 表示所有字符串都删掉字符 i 之后的字符,剩下的字符串的最长公共子序列。。其中 i 可以为字母表内任意字母,此处为 'a'~'z' 和 'A'~'Z'。

f[i]=max\{f[j]\}+1j 在所有的字符串都出现在 i 位置的前面)

如果看了之前暴力的解法,这个状态转移方程也是同样的思路。故不多解释。

原题正解

每个字符在同一个字符串中最多出现 2 次。

还是采用同样的思路:用元素表示下标。

但是元素有重复了,怎么办?方法是记录第几次出现

f[i][j_0][j_1][j_2]\ldots[j_{n-1}] 表示对于 s_k,删掉第 j_k 次出现的 i 之后的所有字符。其中 i 取英文字母,j01

状态转移方程:f[i][j_0][j_1][j_2]\ldots[j_{n-1}]=max\{f[c][d_0][d_1][d_2]\ldots[d_{n-1}]\}+1 (d 要满足 s_k 中,字符 c 在第 d_k 次出现在 字符 ij_k 次 前面)

但是这怎么转移呢,难道枚举 d?这样是可以的,因为 n\le10。时间复杂度为 O(52\times 4^n) 但是不优,代码也比较难写。考虑继续优化。

继续优化

采用贪心的策略。对于 s_k,如果有 d_k=1 的转移,则 d_k=0 一定不比 d_k=1 优。

因为第 0 次出现一定比第 1 次前面。删除第 1 次后面的情况包含了删除 0 次之后的情况。因为在第 0 次与第 1 次之间的字符可以不选。

所以在转移的时候枚举完 c 就可以计算出 d。看 s_k 出现当前位置(字符 ij_k 次出现的位置)之前有多少个 c 字符。如果有 2 个则 d_k=1,有 1 个则 d_k=0,如果没有说明这个字符 c 不合法,无法进行状态转移。

时间复杂度:共 2^n 个状态,转移需要枚举第一维的字符,并需要 O(n) 求出 d 数组实现后面许多维的转移,所以是这两个乘起来,看代码就很快能分析复杂度。所以在本题中,复杂度(字母表大小 52 的常数不省略)为 O(52\times n\times 2^n)

如果将本题拓展一下,每个字符在同一个字符串中最多出现 ci 次。且字母表大小为 bet。此时 d 数组的取值就为 0~(ci-1) 那么复杂度为 O(bet\times n\times ci^n)

具体实现

$j=0或1$,这让我们想到了**状态压缩**。具体地,**用一个 $n$ 位二进制数表示 $n$ 个状态**。设这个数为 $g$,则 $g=2^0\times j_0+2^1\times j_1+2^2\times j_2+\ldots+2^{n-1}\times j_{n-1}$。可以发现,每一个 $g$ 表示了唯一的 $j$。 那么 $f[i][g]=max\{f[c][h]\}+1$。($h$ 可以通过 继续优化 板块中的方法先计算 $d$ 数组,然后计算得出,$c$ 为任意合法的字符) --- 输出方案:每个状态都**记录它是从哪个状态转移过来的**。 具体地,可以记录字符 $x(i,g)$ 和整数 $y(i,g)$ 表示 $f[x(i,g)][y(i,g)]$ 是最大值。($f[i][g]=max\{f[c][h]\}+1$ 中找出最大的 $f[c][h]$,然后,$x(i,g)=c,y(i,g)=h$) 具体见代码。 --- 由于 $i$ 是字符,转移不方便。所以将字符对应成数字。代码中 'a'~'z' 对应 0~25;'A'~'Z' 对应 26~51。 --- 统计答案与转移类似。如果一个字符在 $s_k$ 中出现了 $2$ 次,则 $d_k=1$,$1$ 次则 $d_k=0$,$0$ 次则不合法。 ## 代码 由于还是有很多无用状态,并且不方便按调用的先后顺序递推,于是用记忆化递归实现。 ```cpp #include<iostream> #include<cstdio> #include<algorithm> #include<cstring> using namespace std; int m[25],f[105][10005],tkr0[105][10005],tkr1[105][10005],id[2][25][105],n,ykb; //m:字符串长度。ykb:pow(2,n)。 //id[0][i][j]:第i个串中第0次出现类型j的字符的位置。id[1][i][j]:第1次。 //shu[i][j][k]:第i个串第j个位置前有几个类型k的字符。 //tkr0,tkr1分别表示题解中讲到的 x(i,g) 和 y(i,g)。 string s[25]; int fyr(int zifu,int ci,int step){ //zifu:最后一个公共字符。ci:出现几次的集合,就是题目中的g。这个就表示了 f[i][g]。step是调试的时候用的,请忽略。 if(f[zifu][ci]>=0){ return f[zifu][ci]; } int i,j,Max=0,maxid=-1,s_; //s_表示通过d数组计算出的g。实际上并不需要把d数组存下来,可以依次求其每一位,同时更新s_的值。具体见下。 for(i=0;i<52;i++){ //枚举转移的字符。 s_=0; for(j=0;j<n;j++){ //依次判断每个字符串。 if(id[0][j][i]>=id[(bool)(ci&(1<<j))][j][zifu]){ //如果之前没有出现过这个字符。 break; } if(id[1][j][i]<id[(bool)(ci&(1<<j))][j][zifu]){ //如果出现过2次。此时d_k=1,否则d_k=0。 s_|=(1<<j); //将第j位修改为1。由于初始时s_=0,所以未修改的位默认为0。 } } if(j==n&&fyr(i,s_,step+1)>=Max){ //前一个for循环正常结束时j应该等于n。如果j!=n则说明提前break了,说明有一个字符串之前没出现过此字符。 Max=f[i][s_]+1; tkr0[zifu][ci]=i; //存下从哪里转移过来的。 tkr1[zifu][ci]=s_; } } f[zifu][ci]=Max; return Max; } void out(int aaa,int bbb){ //输出方案部分。 if(tkr0[aaa][bbb]>=0){ //如果等于-1说明没有前一个状态了。就是以这个状态为开头(即此时f=1)。 out(tkr0[aaa][bbb],tkr1[aaa][bbb]); } if(aaa<26){ //用递归模拟栈输出aaa。 cout<<(char)(aaa+'a'); }else{ cout<<(char)(aaa-26+'A'); } } void Main(){ cin>>n; ykb=(1<<n); memset(id,39,sizeof(id)); memset(f,-1,sizeof(f)); memset(tkr0,-1,sizeof(tkr0)); int i,j,Max=0,maxid0=-1,maxid1=-1,s_; for(i=0;i<n;i++){ cin>>s[i]; m[i]=s[i].size(); for(j=0;j<m[i];j++){ if(islower(s[i][j])){ //将字符转化为数字。 s[i][j]=s[i][j]-'a'; }else{ s[i][j]=s[i][j]-'A'+26; } if(id[0][i][s[i][j]]>12345){ //如果>12345,说明是没有被存储过的,此时是第0次出现,否则是第1次出现。 id[0][i][s[i][j]]=j; }else{ id[1][i][s[i][j]]=j; } } } //统计答案部分。与转移类似,不详细解释。 for(i=0;i<52;i++){ s_=0; for(j=0;j<n;j++){ //依次判断每个字符串。 if(id[0][j][i]>12345){ //如果没有出现过这个字符。 break; } if(id[1][j][i]<12345){ //如果可以选第 1 个。 s_|=(1<<j); } } if(j==n&&fyr(i,s_,0)>=Max){ Max=f[i][s_]+1; maxid0=i; maxid1=s_; } } cout<<Max<<endl; if(maxid0!=-1){ //maxid0==-1说明没找到比0大的,即Max=0。 out(maxid0,maxid1); } cout<<endl; } int main(){ int t,i; cin>>t; for(i=0;i<t;i++){ Main(); } return 0; } ``` ## 题外话 分享一下(赛时/赛后)做这题的心路历程和代码中的一些细节。 40分钟做完 ABC,然后看 D,思考许久,还是感觉不可做。看 E,感觉可做,但就是一直想不出来。直到看完F,才发现 F 比 D,E 要简单许多。大概一眼就能想到大概的解法。但是对于今年提高组 24 分连三等奖都没拿到的我,在剩下不到一小时的时间内做完是不可能的。最后在 18:00 左右的时候才调出来。 所以下次比赛还是应该看完所有题目再挑最容易的做。(如果一眼想到正解可以马上开始写) 一开始,在求 $d$ 数组那一部分我没想到贪心转移的细节,然后就想用类似轮廓线的方法转移。后面发现贪心就可以了。 然后,一年多没 AC 过状态压缩,所以写出了一堆错误。这里只举三个典型一点的错误: ```id[(bool)(ci&(1<<j))][j][zifu]``` 这里我一开始没加 ```(bool)```,就是把位运算看成了逻辑运算,然后就数组越界了,调了很久。 然后,我认为只需要输出字符即可,所以记录方案时只记录了代码中的 tkr0,然后发现根本无法转移,所以卡了一会儿。 接着,统计答案部分我也写错了,就认为对于原问题 $d$ 数组所有元素都为 $1$(相当于 $g=2^n-1$),调用时直接写 ```if(j==n&&fyr(i,ykb-1,0)>=Max){``` 。后面才发现 **不一定所有字符都出现了 $2$ 次。所以统计答案时也要求 $d$ 数组。**