浅谈序列对齐DP

浅谈序列对齐DP

[[LCS模型]] 解决的是这样一个标准问题:给定两个序列,在保持各自相对顺序的前提下,寻找一个尽可能长的公共子序列。

但在实际问题中,两个序列之间不一定只是“相同元素才能匹配”。字符可以被替换,也可以与空位对应,每一种对应方式还可能具有不同的代价或得分。

因此,我们可以从两个方向去展开这个问题:

  1. 一次对齐可以怎样变化:两个元素能否匹配,跳过元素的代价是多少,最终求最大得分还是最小代价;
  2. 基础状态还需要保存什么:输出具体方案、统计最优方案,或者限制选中的位置必须组成若干连续段。

零、从典题得到状态的定义

1. 为什么需要同时记录两个前缀

问题

AT_dp_f LCS - 洛谷
给定字符串 SS 和 TT,输出它们的一条最长公共子序列。

数据规模:∣S∣,∣T∣≤3000|S|,|T|\le 3000。

最直接的想法是枚举其中一个字符串的子序列,再判断它是否也是另一个字符串的子序列,但子序列数量是指数级的,显然不可行。

公共子序列中的每个字符都要同时对应到两个字符串中的某个位置。如果只记录字符串 SS 处理到了哪里,我们不知道字符串 TT 中已经使用到了哪里;反过来也一样。

因此,需要同时记录两个字符串的处理进度。定义 f[i][j]f[i][j] 表示字符串 SS 的前 ii 个字符与字符串 TT 的前 jj 个字符的最长公共子序列长度。

这里选择两个前缀作为状态,是因为后续的匹配,只与两个字符串当前分别处理到哪里有关,而不需要知道此前具体跳过了哪些字符。

2. 最后两个字符怎样处理

考虑两个前缀的最后位置 SiS_i 与 TjT_j,我们直接枚举最后一步。

  • 不选择 SiS_i,答案来自 f[i−1][j]f[i-1][j];
  • 不选择 TjT_j,答案来自 f[i][j−1]f[i][j-1];
  • 若 Si=TjS_i=T_j,可以让这两个字符匹配,答案来自 f[i−1][j−1]+1f[i-1][j-1]+1。

于是完整转移为:

f[i][j]=max⁡{f[i−1][j],f[i][j−1],f[i−1][j−1]+1(Si=Tj).f[i][j] = \max \begin{cases} f[i-1][j],\\ f[i][j-1],\\ f[i-1][j-1]+1 \qquad (S_i=T_j). \end{cases}

当 Si=TjS_i=T_j 时,选择这对相同字符一定不会比单独跳过其中一个更差,因此可以化简为:

f[i][j]={f[i−1][j−1]+1,Si=Tj,max⁡(f[i−1][j],f[i][j−1]),Si≠Tj.f[i][j] = \begin{cases} f[i-1][j-1]+1, & S_i=T_j,\\ \max(f[i-1][j],f[i][j-1]), & S_i\ne T_j. \end{cases}

边界为:f[i][0]=f[0][j]=0f[i][0]=f[0][j]=0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int f[maxn][maxn];

void solve( ) {
string s, t;
cin >> s >> t;
int n = s.size( ), m = t.size( );
s = " " + s, t = " " + t;

for( int i = 1; i <= n; ++ i ) {
for( int j = 1; j <= m; ++ j ) {
if( s[i] == t[j] ) f[i][j] = f[i-1][j-1] + 1;
else f[i][j] = max( f[i-1][j], f[i][j-1] );
}
}
cout << f[n][m] << '\n';
}

3. 如何输出一条具体方案

二维状态不仅保存了最优值,也保存了最优答案从哪里转移而来。

从 (n,m)(n,m) 反向回溯:

  • 若 Si=TjS_i=T_j,当前字符可以进入答案,同时令 i,ji,j 减一;
  • 否则走向答案值没有变小的上方或左方状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
string ans;
int i = n, j = m;

while( i && j ) {
if( s[i] == t[j] ) {
ans.push_back( s[i] );
i --, j --;
} else if( f[i-1][j] >= f[i][j-1] ) {
i --;
} else {
j --;
}
}

reverse( ans.begin( ), ans.end( ) );
cout << ans << '\n';

4. 从 LCS 中提取标准模型

LCS 的三个转移可以理解为:

1
2
3
1. f[i-1][j-1] : A <-> B | 匹配两个字符
2. f[i][j-1] : A <-> . | 跳过 Ai
3. f[i-1][j] : . <-> B | 跳过 Bj

因此,对于两个序列 A,BA, B,可以定义 f[i][j]f[i][j] 表示处理完 AA 的前 ii 个元素与 BB 的前 jj 个元素时,能够得到的最优答案。

若三种对应方式的贡献分别为:w(Ai,Bj),w(Ai,−),w(−,Bj)w(A_i, B_j), w(A_i, -), w(-, B_j),就得到了一种统一的转移骨架:

f[i][j]=opt{f[i−1][j−1]+w(Ai,Bj),f[i−1][j]+w(Ai,−),f[i][j−1]+w(−,Bj).f[i][j] = opt \begin{cases} f[i-1][j-1] + w(A_i,B_j),\\ f[i-1][j] + w(A_i,-),\\ f[i][j-1] + w(-,B_j). \end{cases}

其中 opt 为 max 或 min。若某种对应方式不合法,删除对应的转移。第一行和第一列由一个序列为空时的处理方式决定。

如果两个字符不同,也允许放在同一列,会得到什么问题?

一、一次对齐可以怎样变化

1. 编辑距离

问题

P2758 编辑距离
给定两个字符串 AA 和 BB。每次可以插入、删除或替换一个字符,求将 AA 变为 BB 的最少操作次数。

数据规模:∣A∣,∣B∣≤2000|A|,|B|\le 2000。

如果直接从 “当前字符串长什么样” 入手,会发现这并不好处理,因为插入和删除会不断改变字符串长度,后续字符的位置也会随之移动。

换一个角度,直接考虑最终两个字符串如何逐列对应。

  • 保留或替换:让 AiA_i 与 BjB_j 对应;
  • 删除 AiA_i:让 AiA_i 与空位对应;
  • 插入 BjB_j:让空位与 BjB_j 对应。

这样,三种编辑操作恰好对应 LCS 中的三种基础转移。

定义:f[i][j]f[i][j] 表示将 AA 的前 ii 个字符变成 BB 的前 jj 个字符所需的最少操作次数。

由此我们得到本题的转移方程:

f[i][j]=min⁡{f[i−1][j−1]+[Ai≠Bj],f[i−1][j]+1,f[i][j−1]+1.f[i][j] = \min \begin{cases} f[i-1][j-1]+[A_i\ne B_j],\\ f[i-1][j]+1,\\ f[i][j-1]+1. \end{cases}

当一个字符串为空时,只能连续删除或插入: f[i][0]=if[i][0] = i,f[0][j]=jf[0][j] = j。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ll f[maxn][maxn];

int calc( char a, char b ) {
if( a == b ) return 0;
else return 1;
}

void solve( ) {
string s, t;
cin >> s >> t;
int n = s.size( ), m = t.size( );
s = " " + s, t = " " + t;

for( int i = 1; i <= n; ++ i ) f[i][0] = i;
for( int j = 1; j <= m; ++ j ) f[0][j] = j;

for( int i = 1; i <= n; ++ i ) {
for( int j = 1; j <= m; ++ j ) {
f[i][j] = inf;
f[i][j] = min( f[i][j], f[i-1][j-1] + calc( s[i], t[j] ) );
f[i][j] = min( f[i][j], f[i-1][j] + calc( s[i], ' ' ) );
f[i][j] = min( f[i][j], f[i][j-1] + calc( ' ', t[j] ) );
}
}
cout << f[n][m] << '\n';
}

若是不允许替换呢?

若不允许替换,最终保留下来的字符必须同时出现在两个字符串中,并保持原有顺序,因此它们构成一个公共子序列。

为了减少操作次数,应尽可能多地保留字符,也就是保留一条最长公共子序列。设 L=LCS(A,B)L = LCS(A,B),则需要删除 n−Ln - L 个字符,再插入 m−Lm - L 个字符,答案为:ans=n+m−2Lans = n + m - 2L。

普通编辑距离中,每种操作的代价都是 11。如果不同字符之间的距离也不相同,状态还需要改变吗?

2. P1279 字串距离

问题

P1279 字串距离
给定两个仅包含小写字母的字符串 A,BA,B。可以在字符串的任意位置插入空格,得到两个等长的扩展串。两个非空格字符对应的代价为 ASCII 码差的绝对值,字符与空格对应的代价为 KK,求两个字符串的最小距离。

数据规模:∣A∣,∣B∣≤2000, 1≤K≤100|A|,|B|\le 2000,\ 1\le K\le 100。

本题已经给出了特殊情况下的距离,所以我们可以直接套用双前缀状态定义:f[i][j]f[i][j] 表示 AA 的前 ii 个字符与 BB 的前 jj 个字符进行对齐时的最小距离。

三种对应方式变为:

  1. AiA_i 与 BjB_j 对应,代价为 ∣Ai−Bj∣|A_i-B_j|;
  2. AiA_i 与空位对应,代价为 KK;
  3. 空位与 BjB_j 对应,代价为 KK。

因此得到转移:

f[i][j]=min⁡{f[i−1][j−1]+∣Ai−Bj∣,f[i−1][j]+K,f[i][j−1]+K.f[i][j] = \min \begin{cases} f[i-1][j-1]+|A_i-B_j|,\\ f[i-1][j]+K,\\ f[i][j-1]+K. \end{cases}

当另一个字符串为空时,所有字符都只能与空格对应,因此:f[i][0]=iKf[i][0]=iK,f[0][j]=jKf[0][j]=jK。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ll f[maxn][maxn];
int k;

int calc( char a, char b ) {
if( a == ' ' || b == ' ' ) return k;
return abs( a - b );
}

void solve( ) {
string s, t;
cin >> s >> t;
int n = s.size( ), m = t.size( );
s = " " + s, t = " " + t;

k = read( );

for( int i = 1; i <= n; ++ i ) f[i][0] = i * k;
for( int j = 1; j <= m; ++ j ) f[0][j] = j * k;

for( int i = 1; i <= n; ++ i ) {
for( int j = 1; j <= m; ++ j ) {
f[i][j] = inf;
f[i][j] = min( f[i][j], f[i-1][j-1] + calc( s[i], t[j] ) );
f[i][j] = min( f[i][j], f[i-1][j] + calc( s[i], ' ' ) );
f[i][j] = min( f[i][j], f[i][j-1] + calc( ' ', t[j] ) );
}
}
cout << f[n][m] << '\n';
}

但这样可能会产生一个错误的直觉:既然 AiA_{i} 和 BjB_{j} 都还没有处理,就直接让它们对应。

但可能将两个字符分别与空格对应会令总代价更小,举个例子:Ai=a,Bj=z,k=3A_{i} = a, B_{j} = z, k = 3,直接对应的代价为 2525,而分开对应只需要 66。

那还要加特判吗?其实不用,这题在转移过程中会从三种转移中取得处理这种代价更小的情况。

下面再看一道类似的题。

3. 相似基因

问题

P1140 相似基因
给定两个基因序列,以及字符 A C G T - 之间的评分表。可以在两个序列中插入空位,求最大相似度。
数据规模:n≤100n \le 100。

这题跟上面那题的对齐方式完全相同,定义:f[i][j]f[i][j] 表示第一个基因前 ii 个碱基与第二个基因前 jj 个碱基对齐时的最大得分。

若评分函数为 tab(x,y)tab(x,y),则:

f[i][j]=max⁡{f[i−1][j−1]+tab(Ai,Bj),f[i−1][j]+tab(Ai,−),f[i][j−1]+tab(−,Bj).f[i][j] = \max \begin{cases} f[i-1][j-1] + tab(A_i,B_j),\\ f[i-1][j] + tab(A_i,-),\\ f[i][j-1] + tab(-,B_j). \end{cases}

这题的转移并不难,坑点在于边界情况的处理,实际上,当另一条序列为空时,每个字符都必须与空格对应,并产生对应的得分,因此。

f[i][0]=f[i−1][0]+tab(Ai,−)f[0][j]=f[0][j−1]+tab(−,Bj)\begin{aligned} f[i][0] = f[i-1][0] + tab(A_i, -) \\ f[0][j] = f[0][j-1] + tab(-, B_j) \\ \end{aligned}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const int tab[5][5] = {
{5, -1, -2, -1, -3},
{-1, 5, -3, -2, -4},
{-2, -3, 5, -2, -2},
{-1, -2, -2, 5, -1},
{-3, -4, -2, -1, 0}
};

int id( char ch ) {
if( ch == 'A' ) return 0;
if( ch == 'C' ) return 1;
if( ch == 'G' ) return 2;
if( ch == 'T' ) return 3;
return 4;
}

int a[maxn], b[maxn];
int f[maxn][maxn];

void solve( ) {
int n, m;
string s1, s2;
cin >> n >> s1 >> m >> s2;

for( int i = 1; i <= n; ++ i ) a[i] = id( A[i-1] );
for( int j = 1; j <= m; ++ j ) b[j] = id( B[j-1] );

memset( f, 128, sizeof f );
f[0][0] = 0;

for( int i = 1; i <= n; ++ i ) f[i][0] = f[i-1][0] + tab[a[i]][4];
for( int j = 1; j <= m; ++ j ) f[0][j] = f[0][j-1] + tab[b[j]][4];

for( int i = 1; i <= n; ++ i ) {
for( int j = 1; j <= m; ++ j ) {
f[i][j] = max( f[i][j], f[i][j-1] + tab[b[j]][4] );
f[i][j] = max( f[i][j], f[i-1][j] + tab[a[i]][4] );
f[i][j] = max( f[i][j], f[i-1][j-1] + tab[a[i]][b[j]] );
}
}
cout << f[n][m] << '\n';
}

这三题都没有扩展双前缀的状态,从本质上没有区别,只是修改了 ww 成本函数的计算方式。

如果不只要求计算一个最优解,这个基础状态还有效吗?

二、在基础状态上增加信息

1. LCS 计数

问题

P2516 最长公共子序列
给定两个字符串,求 LCS 长度,以及不同最长公共子序列的数量。

数据规模约为 50005000,答案对 10810^8 取模。

只求 LCS 长度时,每个状态保存一个最优值即可。现在还要求不同最优方案的数量,因此增加:g[i][j]g[i][j] 表示两个前缀中不同的 LCS 的数量。

那么状态转移就从 LCS 的推导中同时完成计数:

1
2
3
4
5
6
7
8
9
10
if( A[i] == B[j] ) {
f[i][j] = f[i-1][j-1] + 1;
g[i][j] = g[i-1][j-1];
} else {
f[i][j] = max( f[i-1][j], f[i][j-1] );
if( f[i][j] == f[i-1][j] ) g[i][j] += g[i-1][j];
if( f[i][j] == f[i][j-1] ) g[i][j] += g[i][j-1];
// 容斥原理
if( f[i][j] == f[i-1][j-1] ) g[i][j] -= g[i-1][j-1];
}

边界状态为:g[i][0]=g[0][j]=1g[i][0] = g[0][j] = 1。因为任意字符串与空串只有一种公共子序列,即空串。

这是一种最优方案计数的问题,不局限于这个 LCS 模型,在 [[计数类DP]] 问题中,思路通用:先计算最优值,合并所有能到达最优值的前驱状态,再对重复方案进行容斥。

2. 连续段限制

问题

P2679 子串
从字符串 AA 中取出恰好 KK 个互不重叠的非空子串,按顺序拼接成字符串 BB,求方案数。

数据规模:n≤1000, m≤200, K≤mn\le 1000,\ m\le 200,\ K\le m。

这道题仍然要求保持两个字符串中的相对顺序,但 AA 中被选中的位置不能任意分散,而要组成恰好 KK 个连续子串。

最先想到的状态是:dp[k][i][j]dp[k][i][j] 表示从 AA 的前 ii 个字符中选出 kk 段,拼成 BB 的前 jj 个字符的方案数。

接着考虑转移,我们就会发现问题,处理 AiA_{i} 时,我们无法判断当前匹配是 接在上一段后面,还是在当前位置新开一段。

所以还需要记录最后一个位置是否被选择,定义:f[k][i][j]f[k][i][j] 表示从 AA 的前 ii 个字符中取出 kk 段,匹配到 BB 的前 jj 个字符,并且 AiA_i 必须被选中的方案数。

只有当 Ai=BjA_i=B_j 时,AiA_i 才能匹配 BjB_j。

  • A[i]A[i] 跟在上一段后:既然段数没变 ,那么就可以从 (k,i−1,j−1)(k, i - 1, j - 1) 转移过来。
  • A[i]A[i] 新起了一段:有点麻烦,能确定的是 (k−1,?,j−1)( k - 1, ?, j - 1 ),不好说是从哪个 ii 转移的。

由于状态已经是 O(nmk)\mathcal{O}(nmk) 数量级,转移必须控制在 O(1)\mathcal{O}(1) 的数量级,为了方便第二种情况的转移,我们再定义:g[k][i][j]=∑x=1if[k][x][j]g[k][i][j] = \sum_{x=1}^{i}f[k][x][j],表示最后一个被选位置不超过 ii 的总方案数。

tile
1
2
3
4
5
6
7
8
9
for( int k = 1; k <= kk; ++ k ) {
for( int i = 1; i <= n; ++ i ) {
for( int j = 1; j <= m; ++ j ) {
if( A[i] == B[j] ) f[k][i][j] = ( f[k][i-1][j-1] + g[k-1][i-1][j-1] ) % mod;
else f[k][i][j] = 0;
g[k][i][j] = ( f[k][i][j] + g[k][i-1][j] ) % mod;
}
}
}

边界状态为:g[0][i][0]=1g[0][i][0]=1。

朴素保存全部 f[k][i][j]f[k][i][j] 与 g[k][i][j]g[k][i][j] 需要较大空间,而当前段数 kk 只依赖 k−1k-1,因此可以滚动段数这一维。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int f[2][maxn][maxm];
int g[2][maxn][maxm];

void solve( ) {
int n = read( ), m = read( ), k = read( );
string A, B;
cin >> A >> B;
A = ' ' + A, B = ' ' + B;

for( int i = 0; i <= n; ++ i ) g[0][i][0] = 1;

int cur = 1;
for( int t = 1; t <= k; ++ t ) {
int pre = cur ^ 1;
memset( f[cur], 0, sizeof( f[cur] ) );
memset( g[cur], 0, sizeof( g[cur] ) );
for( int i = 1; i <= n; ++ i ) {
for( int j = 1; j <= m; ++ j ) {
if( A[i] == B[j] ) f[cur][i][j] = ( f[cur][i-1][j-1] + g[pre][i-1][j-1] ) % mod;
else f[cur][i][j] = 0;
g[cur][i][j] = ( f[cur][i][j] + g[cur][i-1][j] ) % mod;
}
}
cur ^= 1;
}
cur ^= 1;
cout << g[cur][n][m] << '\n';
}

连续段限制常见做法是扩展状态:当前已使用的段数。

三、抽象统一模型

双前缀状态是因为后续匹配同时依赖两个序列各自处理到的位置,不是因为出现了两个字符串就机械的开二维数组。

同样,增加状态也不是看到额外条件就机械地加一维,而是判断:这个信息是否会影响后续仍然允许怎样转移。

序列对齐 DP 的核心可以理解为:

双前缀状态 → 枚举最后一列的对应方式 → 初始化第一行第一列 → 修改局部贡献或扩展必要状态


浅谈序列对齐DP
http://miqiu.space/posts/91accdb5/
作者
Miqiu
发布于
2026年7月23日
许可协议