最长公共子序列
最长公共子序列(Longest Common Subsequence, LCS)问题是一个经典的动态规划问题,在字符串处理、生物信息学等领域有广泛应用。
问题定义
【定义】给定两个序列X和Y,找出X和Y的最长公共子序列。子序列是从原序列中删除零个或多个元素后得到的序列,保持剩余元素的相对顺序不变。
例如:
- 序列X = "ABCBDAB"
- 序列Y = "BDCABA"
- X和Y的最长公共子序列是"BCBA",长度为4
动态规划解法
状态定义
设dp[i][j]表示序列X的前i个字符与序列Y的前j个字符的最长公共子序列长度。
状态转移方程
对于每一对位置(i,j),有以下情况:
如果X[i] = Y[j],则dp[i][j] = dp[i-1][j-1] + 1 (当前字符相同,最长公共子序列可以加上这个字符)
如果X[i] ≠ Y[j],则dp[i][j] = max(dp[i-1][j], dp[i][j-1]) (当前字符不同,最长公共子序列取决于去掉X[i]或Y[j]后的最长公共子序列)
初始条件
- dp[0][j] = 0(X为空序列时,与Y的任何子序列的LCS长度为0)
- dp[i][0] = 0(Y为空序列时,与X的任何子序列的LCS长度为0)
代码实现
// 计算两个字符串的最长公共子序列长度
int longestCommonSubsequence(string X, string Y) {
int m = X.length();
int n = Y.length();
// dp[i][j]表示X[0...i-1]和Y[0...j-1]的LCS长度
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1]) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
}
return dp[m][n];
}
输出最长公共子序列
除了计算LCS的长度外,有时还需要输出具体的LCS内容。可以通过回溯DP表格来实现:
// 计算并输出最长公共子序列
string getLCS(string X, string Y) {
int m = X.length();
int n = Y.length();
// 计算dp数组
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1]) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
}
// 回溯构造LCS
string lcs;
int i = m, j = n;
while (i > 0 && j > 0) {
if (X[i-1] == Y[j-1]) {
// 当前字符是LCS的一部分
lcs = X[i-1] + lcs;
i--; j--;
} else if (dp[i-1][j] > dp[i][j-1]) {
// LCS来自于去掉X[i]
i--;
} else {
// LCS来自于去掉Y[j]
j--;
}
}
return lcs;
}
空间优化
观察状态转移方程,dp[i][j]只依赖于dp[i-1][j-1], dp[i-1][j]和dp[i][j-1],因此可以使用滚动数组优化空间复杂度:
// 使用滚动数组优化空间的LCS算法
int longestCommonSubsequence_optimized(string X, string Y) {
int m = X.length();
int n = Y.length();
// 只使用两行dp数组
vector<vector<int>> dp(2, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
// curr代表当前行,prev代表上一行
int curr = i % 2;
int prev = 1 - curr;
for (int j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1]) {
dp[curr][j] = dp[prev][j-1] + 1;
} else {
dp[curr][j] = max(dp[prev][j], dp[curr][j-1]);
}
}
}
return dp[m % 2][n];
}
更进一步,我们可以只使用一维数组(需要特别注意更新顺序):
// 使用一维数组优化空间的LCS算法
int longestCommonSubsequence_1D(string X, string Y) {
int m = X.length();
int n = Y.length();
vector<int> dp(n + 1, 0);
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[i-1][j]
if (X[i-1] == Y[j-1]) {
dp[j] = prev + 1;
} else {
dp[j] = max(dp[j], dp[j-1]);
}
prev = temp; // 为下一轮更新prev
}
}
return dp[n];
}
时间和空间复杂度
- 时间复杂度:O(m*n),其中m和n分别是两个序列的长度。
- 空间复杂度:原始算法为O(m*n),使用滚动数组优化后为O(min(m,n)),一维数组优化为O(n)。
LCS的变种问题
最长公共子串
【问题定义】与LCS不同,最长公共子串要求子串中的字符在原字符串中是连续的。
【状态定义】dp[i][j]表示以X[i-1]和Y[j-1]结尾的最长公共子串长度。
【状态转移方程】
- 如果X[i-1] == Y[j-1],则dp[i][j] = dp[i-1][j-1] + 1
- 否则dp[i][j] = 0
// 最长公共子串
int longestCommonSubstring(string X, string Y) {
int m = X.length();
int n = Y.length();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
int maxLength = 0;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1]) {
dp[i][j] = dp[i-1][j-1] + 1;
maxLength = max(maxLength, dp[i][j]);
} else {
dp[i][j] = 0; // 不连续就重置为0
}
}
}
return maxLength;
}
最长回文子序列
【问题定义】找出字符串中最长的回文子序列。
【解法】可以转化为LCS问题:计算字符串S与其反转S'的LCS即为答案。
// 最长回文子序列
int longestPalindromeSubsequence(string s) {
string r = s;
reverse(r.begin(), r.end());
return longestCommonSubsequence(s, r);
}
或者直接使用DP求解:
// 直接DP求最长回文子序列
int longestPalindromeSubsequence_direct(string s) {
int n = s.length();
vector<vector<int>> dp(n, vector<int>(n, 0));
// 单个字符是回文
for (int i = 0; i < n; i++) {
dp[i][i] = 1;
}
// 逐步扩展长度
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
if (s[i] == s[j]) {
dp[i][j] = dp[i+1][j-1] + 2;
} else {
dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
}
}
}
return dp[0][n-1];
}
编辑距离
【问题定义】编辑距离是将一个字符串转换成另一个字符串所需的最少操作次数,操作包括插入、删除和替换。
【状态定义】dp[i][j]表示将字符串X的前i个字符转换成字符串Y的前j个字符所需的最少操作次数。
// 编辑距离
int editDistance(string X, string Y) {
int m = X.length();
int n = Y.length();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
// 初始化
for (int i = 0; i <= m; i++) {
dp[i][0] = i; // 删除X的i个字符
}
for (int j = 0; j <= n; j++) {
dp[0][j] = j; // 插入Y的j个字符
}
// 填充dp表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (X[i-1] == Y[j-1]) {
dp[i][j] = dp[i-1][j-1]; // 无需操作
} else {
dp[i][j] = 1 + min({dp[i-1][j], // 删除
dp[i][j-1], // 插入
dp[i-1][j-1]}); // 替换
}
}
}
return dp[m][n];
}
LCS的应用
- DNA序列分析:比较DNA序列的相似性
- 文件比较:如Git中的diff功能
- 拼写检查:找出与错误拼写最相似的词
- 自然语言处理:文本相似度计算
- 图像识别:比较特征序列
LCS高级优化
最长递增子序列变形
当一个序列是1到n的排列时,可以将LCS问题转化为最长递增子序列(LIS)问题,可以优化到O(n log n)。
二分优化
基于贪心策略,可以使用二分查找来优化LCS的计算过程,适用于某些特殊情况。
稀疏DP
当两个序列很长但相同字符较少时,可以使用稀疏动态规划来优化空间和时间复杂度。
实战技巧
- 区分LCS和最长公共子串:子序列可以不连续,子串必须连续
- 处理多个序列:可以两两比较或使用更复杂的多维DP
- 空间优化:根据需要选择适当的空间优化方法
- 特殊情况:处理空序列、相同序列等边界情况
- 输出方案:回溯时处理多个可能的LCS时,可根据需要输出字典序最小的LCS
练习题目推荐
- POJ 1458: Common Subsequence (基础LCS题)
- POJ 1080: Human Gene Functions (带权LCS)
- POJ 1159: Palindrome (最长回文子序列)
- POJ 3176: Cow Bowling (类似LCS的DP问题)
- LeetCode 583: Delete Operation for Two Strings (LCS应用题)
- LeetCode 1143: Longest Common Subsequence (标准LCS问题)
总结
最长公共子序列问题是动态规划的经典应用,掌握LCS的求解思路对于理解和解决其他序列比较问题至关重要。通过本章的学习,你应该能够:
- 理解LCS问题的核心思想
- 使用动态规划解决LCS及其变种问题
- 应用空间优化技巧降低算法空间复杂度
- 举一反三,解决与LCS相关的衍生问题