一、题目描述
给定两个字符串 text1 和 text2,返回它们最长公共子序列的长度。如果不存在公共子序列,则返回 0。
子序列是从原字符串中删除零个或多个字符后,在不改变剩余字符相对顺序的情况下得到的新字符串。
例如:
"ace" 是 "abcde" 的子序列
因为可以删除字符 b 和 d,剩余字符的顺序仍然是 a → c → e。
但:
"aec" 不是 "abcde" 的子序列
虽然三个字符都存在,但它们在原字符串中的相对顺序不一致。
题目示例:
输入:text1 = "abcde",text2 = "ace"
输出:3
解释:最长公共子序列是 "ace"。
输入:text1 = "abc",text2 = "abc"
输出:3
输入:text1 = "abc",text2 = "def"
输出:0
二、子序列与子串的区别
"最长公共子序列"最容易误解的地方,是把子序列当成子串。
-
子串要求字符在原字符串中连续;
-
子序列不要求连续,但必须保持相对顺序。
对于字符串:
"abcde"
"bcd" 既是子串,也是子序列;"ace" 是子序列,但不是子串。
本题允许跳过字符,因此不能只在两个字符串中寻找连续相同的部分。
三、为什么使用动态规划?
假设正在比较两个字符串末尾的字符,可以分为两种情况。
1. 末尾字符相同
如果当前两个字符相同,那么它们可以共同加入公共子序列。问题就转化成:
求两个字符串去掉当前字符后的最长公共子序列,再加上当前这个相同字符。
2. 末尾字符不同
如果当前两个字符不同,它们不可能同时作为同一公共子序列的最后一个字符。此时至少要舍弃其中一个:
-
舍弃
text1当前字符; -
舍弃
text2当前字符。
分别计算这两种情况,再保留较大的结果。
大问题可以由更短字符串的答案推导,而且子问题会被重复使用,因此适合使用动态规划。
四、定义动态规划状态
定义:
dp[i][j]:text1 前 i 个字符与 text2 前 j 个字符的
最长公共子序列长度
注意,这里的 i 和 j 表示字符数量,不是正在访问的字符下标。
例如:
dp[3][2]
表示 text1 前 3 个字符与 text2 前 2 个字符的最长公共子序列长度。
如果:
text1 = "abcde"
text2 = "ace"
那么 dp[3][2] 比较的就是:
"abc" 和 "ac"
最终答案为:
dp[len1][len2]
五、为什么 DP 数组要多一行一列?
代码创建:
int[][] dp = new int[len1 + 1][len2 + 1];
多出的第 0 行和第 0 列用于表示空字符串。
-
dp[0][j]:空字符串与text2前j个字符比较; -
dp[i][0]:text1前i个字符与空字符串比较。
空字符串与任何字符串的最长公共子序列长度都为 0,而 Java 创建 int 数组后默认值就是 0,因此不需要额外初始化。
这种设计还有一个好处:计算第一个字符时,可以安全访问 dp[0][0],不需要为数组负下标编写特殊判断。
六、推导状态转移公式
代码使用 i、j 遍历字符串下标:
for (int i = 0; i < len1; i++) {
for (int j = 0; j < len2; j++) {
// 比较 text1.charAt(i) 与 text2.charAt(j)
}
}
由于 dp 的下标表示字符数量,所以字符 text1.charAt(i) 和 text2.charAt(j) 对应的当前状态是:
dp[i + 1][j + 1]
1. 当前字符相等
如果:
text1.charAt(i) == text2.charAt(j)
当前字符可以加入公共子序列。去掉这两个字符后,对应的前置状态是 dp[i][j],因此:
dp[i + 1][j + 1] = dp[i][j] + 1;
例如比较 "abc" 和 "ac" 的最后一个字符时,两个字符都是 c。只需要先求 "ab" 和 "a" 的答案,再加 1。
2. 当前字符不相等
如果:
text1.charAt(i) != text2.charAt(j)
需要比较两种选择:
dp[i][j + 1]:舍弃 text1 当前字符
dp[i + 1][j]:舍弃 text2 当前字符
为了获得最长结果,取两者最大值:
dp[i + 1][j + 1] = Math.max(
dp[i + 1][j],
dp[i][j + 1]
);
完整状态转移公式为:
如果 text1[i] == text2[j]:
dp[i+1][j+1] = dp[i][j] + 1
否则:
dp[i+1][j+1] = max(dp[i+1][j], dp[i][j+1])
核心思想可以概括为:
当前字符相同就同时选取;当前字符不同就分别舍弃其中一个,并保留更优结果。
七、示例推演:"abcde" 与 "ace"
text1 放在行方向,text2 放在列方向。第 0 行和第 0 列表示空字符串。
最终 DP 表如下:
dp |
空 | a | c | e |
|---|---|---|---|---|
| 空 | 0 | 0 | 0 | 0 |
| a | 0 | 1 | 1 | 1 |
| b | 0 | 1 | 1 | 1 |
| c | 0 | 1 | 2 | 2 |
| d | 0 | 1 | 2 | 2 |
| e | 0 | 1 | 2 | 3 |
下面观察几个关键位置。
1. 比较 a 与 a
两个字符相等:
dp[1][1] = dp[0][0] + 1 = 1
最长公共子序列是 "a"。
2. 比较 b 与 c
两个字符不相等:
dp[2][2] = max(dp[2][1], dp[1][2])
= max(1, 1)
= 1
3. 比较 c 与 c
两个字符相等:
dp[3][2] = dp[2][1] + 1
= 1 + 1
= 2
此时公共子序列可以是 "ac"。
4. 比较 e 与 e
两个字符相等:
dp[5][3] = dp[4][2] + 1
= 2 + 1
= 3
最终:
dp[5][3] = 3
最长公共子序列为 "ace",长度为 3。
八、Java 完整代码
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int len1 = text1.length();
int len2 = text2.length();
// dp[i][j] 表示 text1 前 i 个字符和 text2 前 j 个字符的
// 最长公共子序列长度
int[][] dp = new int[len1 + 1][len2 + 1];
for (int i = 0; i < len1; i++) {
for (int j = 0; j < len2; j++) {
if (text1.charAt(i) == text2.charAt(j)) {
// 当前字符相同,同时加入公共子序列
dp[i + 1][j + 1] = dp[i][j] + 1;
} else {
// 当前字符不同,分别舍弃其中一个字符
dp[i + 1][j + 1] = Math.max(
dp[i + 1][j],
dp[i][j + 1]
);
}
}
}
return dp[len1][len2];
}
}
代码整体可以分为四步:
-
获取两个字符串的长度;
-
创建多一行、多一列的二维 DP 数组;
-
从左上到右下比较每一对字符并填写状态;
-
返回右下角的
dp[len1][len2]。
九、复杂度分析
设 text1 长度为 m,text2 长度为 n。
时间复杂度
两层循环会比较每一对字符,因此时间复杂度为:
O(m × n)
空间复杂度
使用了一个 (m+1) × (n+1) 的二维数组,因此空间复杂度为:
O(m × n)