最长公共子序列(Longest Common Subsequence, LCS)问题是动态规划中另一个经典问题,广泛用于比较两个序列的相似度。它的目标是找到两个序列之间最长的公共子序列(不是连续的),使得这个子序列同时出现在两个序列中。
1. 问题定义
给定两个序列 X
和 Y
,要找到它们的最长公共子序列,即一个序列 Z
,它同时是 X
和 Y
的子序列,且 Z
的长度最大。
例如:
- 对于序列
X = "ABCBDAB"
和Y = "BDCAB"
,它们的最长公共子序列是"BCAB"
或"BDAB"
,长度为 4。
2. 动态规划思路
LCS 问题可以通过动态规划求解。我们可以定义一个二维的 DP 表 dp[i][j]
,表示序列 X
的前 i
个字符与序列 Y
的前 j
个字符的最长公共子序列的长度。
状态转移方程:
- 当
X[i-1] == Y[j-1]
时,说明X[i]
和Y[j]
是公共子序列的一部分,此时dp[i][j] = dp[i-1][j-1] + 1
。 - 当
X[i-1] != Y[j-1]
时,最长公共子序列要么不包含X[i-1]
,要么不包含Y[j-1]
,因此dp[i][j] = max(dp[i-1][j], dp[i][j-1])
。
初始化:
- 当
i=0
或j=0
时,dp[i][j] = 0
,表示空序列与任何序列的公共子序列长度为 0。
3. 状态转移图解
以 X = "ABCBDAB"
和 Y = "BDCAB"
为例:
0 | B | D | C | A | B | |
---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 |
A | 0 | 0 | 0 | 0 | 1 | 1 |
B | 0 | 1 | 1 | 1 | 1 | 2 |
C | 0 | 1 | 1 | 2 | 2 | 2 |
B | 0 | 1 | 1 | 2 | 2 | 3 |
D | 0 | 1 | 2 | 2 | 2 | 3 |
A | 0 | 1 | 2 | 2 | 3 | 3 |
B | 0 | 1 | 2 | 2 | 3 | 4 |
其中,dp[7][5] = 4
,表示最长公共子序列的长度为 4。
4. 动态规划代码实现
下面是使用动态规划求解最长公共子序列的 Java 代码:
public class LongestCommonSubsequence {
public static void main(String[] args) {
String X = "ABCBDAB";
String Y = "BDCAB";
// 调用LCS函数
int lcsLength = lcs(X, Y);
System.out.println("The length of Longest Common Subsequence is: " + lcsLength);
}
// LCS动态规划实现
public static int lcs(String X, String Y) {
int m = X.length();
int n = Y.length();
// 创建dp二维数组,表示X的前i个字符与Y的前j个字符的LCS长度
int[][] dp = new int[m + 1][n + 1];
// 填充dp数组
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X.charAt(i - 1) == Y.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// 返回右下角的值,即最长公共子序列的长度
return dp[m][n];
}
}
5. 详细解读代码
5.1 输入和输出
在 main
方法中,定义了两个字符串 X
和 Y
,然后调用 lcs()
方法来计算它们的最长公共子序列的长度,并输出结果。
5.2 lcs()
函数详解
int m = X.length();
int n = Y.length();
// 创建dp二维数组,表示X的前i个字符与Y的前j个字符的LCS长度
int[][] dp = new int[m + 1][n + 1];
我们首先获取字符串 X
和 Y
的长度 m
和 n
,并创建一个二维数组 dp[][]
。数组的大小为 [m+1][n+1]
,其中 dp[i][j]
表示 X
的前 i
个字符与 Y
的前 j
个字符的最长公共子序列的长度。
5.3 动态规划填表过程
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X.charAt(i - 1) == Y.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
接着,使用两个嵌套循环遍历字符串 X
和 Y
的每一个字符:
- 当
X[i-1] == Y[j-1]
时:说明X[i]
和Y[j]
组成了最长公共子序列的一部分,因此dp[i][j] = dp[i-1][j-1] + 1
。 - 当
X[i-1] != Y[j-1]
时:最长公共子序列要么不包含X[i-1]
,要么不包含Y[j-1]
,因此dp[i][j] = max(dp[i-1][j], dp[i][j-1])
。
5.4 返回结果
return dp[m][n];
最后,dp[m][n]
表示 X
的前 m
个字符与 Y
的前 n
个字符的最长公共子序列的长度,因此返回该值。
6. 重建最长公共子序列
如果我们不仅想知道最长公共子序列的长度,还想知道最长公共子序列的具体内容,我们可以从 dp
数组反向推导出子序列:
public static String findLCS(String X, String Y) {
int m = X.length();
int n = Y.length();
int[][] dp = new int[m + 1][n + 1];
// 填充dp数组
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X.charAt(i - 1) == Y.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// 反向找出LCS
StringBuilder lcs = new StringBuilder();
int i = m, j = n;
while (i > 0 && j > 0) {
if (X.charAt(i - 1) == Y.charAt(j - 1)) {
lcs.append(X.charAt(i - 1));
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return lcs.reverse().toString(); // 由于是从后往前找到的,需反转结果
}
7. 复杂度分析
- 时间复杂度:
O(m * n)
,其中m
和n
分别是两个字符串的长度。我们需要填充一个大小为m * n
的二维数组。 - 空间复杂度:
O(m * n)
,由于使用了二维数组dp[][]
。
8. 总结
最长公共子序列问题通过动态规划求解,利用二维 DP 数组记录子问题的解,并通过状态转移方程逐步构建最终解。通过反向追溯 DP 数组,还可以重建具体的公共子序列内容。LCS 问题在文本比对、基因序列分析等领域有广泛的应用。