最长公共子序列
nikangle
·
2023-10-09 14:45:48
·
算法·理论
定义
子序列:对于一个长度为 n 的序列 A 的长度为 k 的下标序列 v_1,v_2, \dots ,v_k(1 \le v_1 < v_2 < \dots < v_k \le n) ,称称序列 A_{v_1},A_{v_2}, \dots ,A_{v_k} 为序列 A 的子序列。序列 A 共有 2^n 个子序列(k 可以等于 0 )。
公共子序列:若长度为 n 序列 A 的一个子序列与长度为 m 的序列 B 的一个子序列相同(长度相同,且每一位都相同)则称该子序列为序列 A 与序列 B 的公共子序列,该子序列长度为该公共子序列长度。
最长公共子序列(\text{LCS} ):在两个序列 A 和 B 中,长度最大的公共子序列的长度就是 A 与 B 的最长公共子序列的长度,长度为最长公共子序列长度的所有公共子序列都是 A 和 B 的最长公共子序列。
基础题型
给定序列 A 和 B ,求他们最长公共子序列的长度。
例题:P1439
朴素求法
用 dp_{i,j} 表示序列 A 的前 i 位与序列 B 的前 j 位的最长公共子序列的长度。
考虑转移:
当 A_i = B_j 时,最长公共子序列长度可以增加,即 dp_{i,j} = dp_{i-1,j-1} + 1 。
否则,长度无法更新,dp_{i,j} = \max(dp_{i-1,j},dp_{i,j-1}) 。
此做法复杂度为 \mathcal O(n^2) ,空间复杂度为 \mathcal O(n^2) 或用滚动数组优化为 \mathcal O(n) 。能得到 \text{50} 分。
Code
for(int i=0;i<=n;i++){
dp[i][0]=dp[0][i]=0;
}
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(a[i]==b[j]){
dp[i][j]=dp[i-1][j-1]+1;
}
else{
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
}
}
}
cout<<dp[n][n];
优化(仅对于此题可行)
题目中说序列 A 与序列 B 都是 1 \sim n 的排列,可以记录每一个数在 A 序列中出现的位置 numa_i 和每一个数在 B 序列中出现的位置 numb_i (无重复),然后对于每一个 A_i ,只要找出序列 B 的前 numb_{A_i}-1 位与序列 A 的前 i-1 位的最长公共子序列,再加一,就是序列 A 的前 i 位与序列 B 的前 numb_{A_i} 位的最长公共子序列长度。
用 dp_i 表示在 B 序列的前 i 位和 A 序列的前 numa_{B_i} 的最长公共子序列长度。
从前到后遍历序列 A ,每一步求出 dp_{numb_{A_i}} ,即求出 \max(dp_1,dp_2, \dots ,dp_{numb_{A_i}-1}) ,因为 A 序列是顺序遍历的,所以顺序不会错乱,答案正确。
可使用数据结构维护,下给出树状数组维护的模板。
时间复杂度 \mathcal O(n \log n) ,空间复杂度 \mathcal O(n) ,能够 \text{AC} 。
Code
int lowbit(int x){
return x&(-x);
}
int getmax(int x){
int ans=0;
for(;x>0;x-=lowbit(x)){
ans=max(ans,cal[x]);
}
}
void modify(int x,int d){
for(;x<=n;x+=lowbit(x)){
cal[x]=max(cal[x],d);
}
}
int main(){
for(int i=1;i<=n;i++){
numa[a[i]]=i;//虽然用不到
numb[b[i]]=i;
}
for(int i=1;i<=n;i++){
dp[numb[a[i]]]=getmax(numb[a[i]]-1)+1;
modify(numb[a[i]],dp[numb[a[i]]]);
}
}
进阶题型 1
求最长公共子序列的方案数。
例题:洛谷 P2516
可以在 DP 的过程中维护方案数:记录 sum_{i,j} 表示序列 A 的前 i 位与序列 B 的前 j 位构成最长公共子序列有 sum_{i,j} 种方案。
时间复杂度 $\mathcal O(nm)$,空间复杂度 $\mathcal O(nm)$,可使用滚动数组优化为 $\mathcal O(m)$。
### Code
```cpp
for(int i=0;i<=n;i++){
dp[i][0]=0;
sum[i][0]=1;
}
for(int i=0;i<=m;i++){
dp[0][i]=0;
sum[0][i]=1;
}
//滚动时不能这么初始化!
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
if(a[i]==b[j]){
dp[i][j]=dp[i-1][j-1]+1;
sum[i][j]=sum[i-1][j-1];
if(dp[i-1][j]==dp[i][j]){
sum[i][j]=(sum[i][j]+sum[i-1][j])%mod;
}
if(dp[i][j-1]==dp[i][j]){
sum[i][j]=(sum[i][j]+sum[i][j-1])%mod;
}
}
else{
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
sum[i][j]=0;
if(dp[i-1][j]==dp[i][j]){
sum[i][j]=(sum[i][j]+sum[i-1][j])%mod;
}
if(dp[i][j-1]==dp[i][j]){
sum[i][j]=(sum[i][j]+sum[i][j-1])%mod;
}
if(dp[i-1][j-1]==dp[i][j]){
sum[i][j]=(sum[i][j]-sum[i-1][j-1]+mod)%mod;//方案重复应减去
}
}
}
}
cout<<dp[n][m];
```
# 进阶题型 2
输出一个最长公共子序列的方案。
例题:[AtCoder dp f](https://atcoder.jp/contests/dp/tasks/dp_f)
在 $\text{DP}$ 过程中记录更新路径 $l_{i,j}$ 即可。
时间复杂度 $\mathcal O(nm)$,空间复杂度 $\mathcal O(nm)$(不可优化)。
### Code
```cpp
void Print(int i,int j){
if(i==0||j==0){
return;
}
if(l[i][j]==0){
Print(i-1,j-1);
printf("%c",a[i]);
}
else if(l[i][j]==-1){
Print(i-1,j);
}
else{
Print(i,j-1);
}
}
int main(){
for(int i=0;i<=n;i++){
dp[i][0]=dp[0][i]=0;
}
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
if(a[i]==b[j]){
dp[i][j]=dp[i-1][j-1]+1;
l[i][j]=0;
}
else{
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
if(dp[i-1][j]==dp[i][j]){
l[i][j]=-1;
}
else{
l[i][j]=1;
}
}
}
}
Print(n,m);
}
```
## 再次进阶
求字典序最小的最长公共子序列方案。
首先从后往前维护出序列 $A$ 的 $A_i,A_{i+1}, \dots ,A_n$ 与序列 $B$ 的 $B_j,B_{j+1}, \dots ,B_m$ 的最长公共子序列 $dp_{i,j}$,维护方法在**基础题型 $\to$ 做法一:朴素求法**上稍作改动即可。
然后,枚举最长公共子序列的每一位。
若当前枚举到第 $i$ 位,先遍历每个数在第 $i-1$ 次枚举选择的位置(特别的,当 $i=1$ 时遍历整个序列)后的第一次出现的位置 $numa_j$ 和 $numb_j$(每次重新处理,不存在将其设为 $-1$),再按字典序从小到大枚举每个值 $j$,如果 $j$ 可以选入最长公共子序列,即 $dp_{numa_j,numb_j}=$ 最长公共子序列长度 $-i+1$,则输出 $j$,更新选择的位置,并进行下一次枚举。
离散化后,时间复杂度 $\mathcal O(nm)$,空间复杂度 $\mathcal O(nm)$。
### Code
```cpp
for(int i=1;i<=n+1;i++){
dp[i][m+1]=0;
}
for(int i=1;i<=m+1;i++){
dp[n+1][i]=0;
}
for(int i=n;i>=1;i--){
for(int j=m;j>=1;j--){
if(a[i]==b[j]){
dp[i][j]=dp[i+1][j+1]+1;
}
else{
dp[i][j]=max(dp[i+1][j],dp[i][j+1]);
}
}
}
lasta=lastb=0;
for(int i=1;i<=dp[1][1];i++){
memset(numa,-1,sizeof(numa));
memset(numb,-1,sizeof(numb));
for(int j=lasta+1;j<=n;j++){
if(numa[a[j]]==-1){
numa[a[j]]=j;
}
}
for(int j=lastb+1;j<=m;j++){
if(numb[b[j]]==-1){
numb[b[j]]=j;
}
}
for(int j=1;j<=n;j++){
if(dp[numa[j]][numb[j]]==dp[1][1]-i+1){
printf("%d ",j);
lasta=numa[j];
lastb=numb[j];
break;
}
}
}
```
# 进阶题型 3
为什么要叫进阶题型呢,要求的都不是最长公共子序列~~
## ~~又是~~定义
1. 公共超序列:若有一序列使得序列 $A$ 与序列 $B$ 都是该序列的子序列,则称该序列为序列 $A$ 和 $B$ 的公共超序列。
2. 最短公共超序列:在两个序列 $A$ 和 $B$ 中,长度最小的公共超序列的长度就是 $A$ 与 $B$ 的最短公共超序列的长度,长度为最短公共超序列长度的所有公共超序列都是 $A$ 和 $B$ 的最短公共超序列。
思路基本跟**基础题型**一致:
用 $dp_{i,j}$ 表示序列 $A$ 的前 $i$ 位与序列 $B$ 的前 $j$ 位的最短公共超序列的长度。
考虑转移:
当 $A_i = B_j$ 时,最短公共超序列可以复用一位,即 $dp_{i,j} = dp_{i-1,j-1} + 1$。
否则,无法复用,$dp_{i,j} = min(dp_{i-1,j},dp_{i,j-1})+1$。
计数也跟**进阶题型** $\mathbf{1}$ 一样,$sum_{i,j}$ 是所有能更新到 $dp_{i,j}$ 的方案中 $sum$ 值的和。
至于方案,这里只对字典序最小做详解:
首先倒序处理 $dp_{i,j}$,与**进阶题型** $\mathbf{2}$ 相同。
然后进行双指针,定义 $i$ 和 $j$ 表示现在讨论到了序列 $A$ 的第 $i$ 位和序列 $B$ 的第 $j$ 位。
当 $A_i = B_j$ 的时候,直接选择将 $A_i$ 和 $B_j$ 作为最短公共超序列的这一位,输出,并 $i++,j++$。
当 $dp_{i,j}$ 既可以从 $dp_{i+1,j}$ 更新,也可以从 $dp_{i,j+1}$ 更新,则输出字典序较小的那一个,并将下标 $++$。
否则,从哪里更新,就输出哪个,并将对应的下标 $++$。
最后,将没遍历完的序列接在后面。
时间复杂度 $\mathcal O(nm)$,空间复杂度 $\mathcal O(nm)$。
### Code
```cpp
for(i=1;i<=n+1;i++){
dp[i][m+1]=n-i+1;
}
for(i=1;i<=m+1;i++){
dp[n+1][i]=m-i+1;
}
//初始化不一样
for(i=n;i>=1;i--){
for(j=m;j>=1;j--){
if(a[i]==b[j]){
dp[i][j]=dp[i+1][j+1]+1;
}
else{
dp[i][j]=min(dp[i+1][j],dp[i][j+1])+1;
}
}
}
i=j=1;
while(i<=n&&j<=m){
if(a[i]==b[j]){
printf("%d ",a[i]);
i++,j++;
}
else{
if(dp[i][j]==dp[i+1][j]+1&&dp[i][j]==dp[i][j+1]+1){
if(a[i]<b[j]){
printf("%d ",a[i]);
i++;
}
else{
printf("%d ",b[j]);
j++;
}
}
else if(dp[i][j]==dp[i+1][j]+1){
printf("%c",a[i]);
i++;
}
else{
printf("%d ",b[j]);
j++;
}
}
}
while(i<=n){
printf("%d ",a[i]);
i++;
}
while(j<=m){
printf("%d ",b[j]);
j++;
}
```
# 总结
总体来说,$\text{LCS}$ 问题思路比较难想,第一次见这类题目甚至不知道该怎么入手,所以需要多认识一些题型。