最长公共子序列

· · 算法·理论

定义

  1. 子序列:对于一个长度为 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)。

  2. 公共子序列:若长度为 n 序列 A 的一个子序列与长度为 m 的序列 B 的一个子序列相同(长度相同,且每一位都相同)则称该子序列为序列 A 与序列 B 的公共子序列,该子序列长度为该公共子序列长度。

  3. 最长公共子序列(\text{LCS}):在两个序列 AB 中,长度最大的公共子序列的长度就是 AB 的最长公共子序列的长度,长度为最长公共子序列长度的所有公共子序列都是 AB 的最长公共子序列。

基础题型

给定序列 AB,求他们最长公共子序列的长度。

例题: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}$ 问题思路比较难想,第一次见这类题目甚至不知道该怎么入手,所以需要多认识一些题型。