引言
最长公共子序列(Longest Common Subsequence,LCS)是经典的字符串/序列比对问题,广泛应用于生物信息学(DNA序列比对)、版本控制(diff工具)、拼写纠错等领域。给定两个字符串,求它们最长的公共子序列的长度。本文用Java实现标准动态规划解法,并深入讲解滚动数组空间优化技巧,将空间复杂度从 O(m×n) 降至 O(min(m,n))。
问题定义
输入:两个字符串 text1 和 text2。
输出:它们的最长公共子序列的长度。
示例:
– text1 = “abcde”, text2 = “ace”
– 输出:3
– 解释:LCS 为 “ace”
动态规划解法(O(m×n) 空间)
核心思想
设 dp[i][j] 表示 text1[0..i-1] 和 text2[0..j-1] 的最长公共子序列长度。
状态转移:
– 如果 text1[i-1] == text2[j-1],则 dp[i][j] = dp[i-1][j-1] + 1
– 否则 dp[i][j] = max(dp[i-1][j], dp[i][j-1])
Java实现
public class LCS_StandardDP {
/**
* 标准二维DP解法,时间 O(m*n),空间 O(m*n)
*/
public static int longestCommonSubsequence(String text1, String text2) {
if (text1 == null || text2 == null) {
return 0;
}
int m = text1.length();
int n = text2.length();
// dp[i][j] 表示 text1[0..i-1] 和 text2[0..j-1] 的LCS长度
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
// 当前字符匹配,LCS长度+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];
}
/**
* 同时返回具体的LCS字符串
*/
public static String getLCS_String(String text1, String text2) {
int m = text1.length();
int n = text2.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 (text1.charAt(i - 1) == text2.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 (text1.charAt(i - 1) == text2.charAt(j - 1)) {
// 当前字符属于LCS
lcs.append(text1.charAt(i - 1));
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--; // 来自上方
} else {
j--; // 来自左方
}
}
return lcs.reverse().toString();
}
public static void main(String[] args) {
String s1 = "abcde";
String s2 = "ace";
System.out.println("text1: " + s1 + ", text2: " + s2);
System.out.println("LCS长度: " + longestCommonSubsequence(s1, s2));
System.out.println("LCS字符串: " + getLCS_String(s1, s2));
String s3 = "abc";
String s4 = "abc";
System.out.println("\ntext1: " + s3 + ", text2: " + s4);
System.out.println("LCS长度: " + longestCommonSubsequence(s3, s4));
}
}
滚动数组优化(O(min(m,n)) 空间)
核心思想
观察状态转移方程,dp[i][j] 只依赖于:
– dp[i-1][j-1](左上角)
– dp[i-1][j](上方)
– dp[i][j-1](左方)
因此我们只需要保留上一行和当前行,甚至只用一维数组就能完成计算。
优化技巧:使用一维数组 dp[j],从右向左更新,这样 dp[j-1] 是已经更新过的当前行值,dp[j] 是上一行的值。
Java实现
public class LCS_RollingArray {
/**
* 一维滚动数组优化,空间复杂度 O(min(m,n))
* 始终让较短的字符串作为列维度,进一步减少空间
*/
public static int longestCommonSubsequence_optimized(String text1, String text2) {
if (text1 == null || text2 == null) {
return 0;
}
// 确保 text1 是较长的字符串,text2 是较短的
// 这样 dp 数组的大小为 text2.length()+1
if (text1.length() < text2.length()) {
String temp = text1;
text1 = text2;
text2 = temp;
}
int m = text1.length();
int n = text2.length();
// 只使用一维数组,长度为较短字符串+1
int[] dp = new int[n + 1];
for (int i = 1; i <= m; i++) {
int prev = 0; // 保存 dp[i-1][j-1],即左上角的值
for (int j = 1; j <= n; j++) {
int temp = dp[j]; // 保存当前的 dp[j](即上一行的 dp[i-1][j]),下一轮它就是左上角
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[j] = prev + 1; // 使用左上角的值
} else {
dp[j] = Math.max(dp[j], dp[j - 1]); // max(上方, 左方)
}
prev = temp; // 更新左上角为当前保存的值
}
}
return dp[n];
}
/**
* 使用两个一维数组的版本(更易理解)
*/
public static int longestCommonSubsequence_twoRows(String text1, String text2) {
if (text1 == null || text2 == null) {
return 0;
}
int m = text1.length();
int n = text2.length();
int[] prev = new int[n + 1]; // 上一行
int[] curr = new int[n + 1]; // 当前行
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
curr[j] = prev[j - 1] + 1;
} else {
curr[j] = Math.max(prev[j], curr[j - 1]);
}
}
// 交换 prev 和 curr
int[] temp = prev;
prev = curr;
curr = temp;
}
return prev[n];
}
public static void main(String[] args) {
String s1 = "pmjghexybyrgzczy";
String s2 = "hafcdqbgncrcbihkd";
System.out.println("text1: " + s1);
System.out.println("text2: " + s2);
System.out.println("滚动数组LCS长度: " + longestCommonSubsequence_optimized(s1, s2));
System.out.println("双行数组LCS长度: " + longestCommonSubsequence_twoRows(s1, s2));
// 大规模测试
StringBuilder sb1 = new StringBuilder();
StringBuilder sb2 = new StringBuilder();
for (int i = 0; i < 5000; i++) {
sb1.append((char) ('a' + (i % 26)));
sb2.append((char) ('a' + ((i + 5) % 26)));
}
System.out.println("\n大规模测试(5000字符): " +
longestCommonSubsequence_optimized(sb1.toString(), sb2.toString()));
}
}
复杂度分析
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 标准二维DP | O(m×n) | O(m×n) | 需要重构LCS字符串,或教学演示 |
| 两行优化 | O(m×n) | O(min(m,n)) | 平衡理解与性能 |
| 单行优化 | O(m×n) | O(min(m,n)) | 空间极度受限,代码最精简 |
扩展应用
- 最长重复子数组:将LCS改为连续子串问题,状态转移稍有不同
- 编辑距离(Levenshtein Distance):在LCS基础上增加替换/插入/删除的代价
- DNA序列比对:生物信息学中BLAST算法的基础
- diff算法:Git diff、Myers diff 都基于最长公共子序列思想
- 最短公共超序列(SCS):m + n – LCS(text1, text2)
总结
最长公共子序列问题是二维动态规划的经典代表。标准解法直观易懂,而滚动数组优化则展示了动态规划中空间优化的核心思想:分析状态依赖关系,只保留必要的状态。这种”降维”技巧在背包问题、区间DP等场景中同样适用。掌握LCS的求解与优化,是迈向高级动态规划的重要一步。