给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
一个字符串的 子序列 **是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
- 例如,
"ace"是"abcde"的子序列,但"aec"不是"abcde"的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
示例 1:
输入: text1 = "abcde", text2 = "ace"
输出: 3
解释: 最长公共子序列是 "ace" ,它的长度为 3 。
示例 2:
输入: text1 = "abc", text2 = "abc"
输出: 3
解释: 最长公共子序列是 "abc" ,它的长度为 3 。
示例 3:
输入: text1 = "abc", text2 = "def"
输出: 0
解释: 两个字符串没有公共子序列,返回 0 。
提示:
1 <= text1.length, text2.length <= 1000text1和text2仅由小写英文字符组成。
🏠 生活案例:寻找共同的“回忆”
想象你和你的好朋友都有一个存钱罐。
- 你每天往里存一块写着字母的积木,顺序是:
A -> B -> C -> D -> E。 - 你朋友每天也存,顺序是:
A -> C -> E。
现在你们想玩个游戏:不改变积木排队的先后顺序,看看你们俩的存钱罐里,最多能凑出多少个一模一样且顺序一致的积木?
在这个例子里,你们共同拥有的积木是 A、C 和 E,长度是 3。这就是“最长公共子序列”。
注意:子序列不要求连续。就像你朋友虽然跳过了
B,但A依然在C前面,这就算匹配成功!
💻 代码实现与生活化注释
这又是一个动态规划的经典案例。我们准备一张二维表格,横轴是你朋友的积木,纵轴是你的积木。
JavaScript
/**
* @param {string} text1
* @param {string} text2
* @return {number}
*/
var longestCommonSubsequence = function(text1, text2) {
let numOne = text1.length;
let numTwo = text2.length;
// 1. 准备一张“匹配记录表” dp[numOne + 1][numTwo + 1]
// dp[i][j] 的意思是:你的前 i 个积木和你朋友的前 j 个积木,能匹配出几个?
let dp = new Array(numOne + 1).fill(0).map(() => Array(numTwo + 1).fill(0));
// 2. 开始挨个比对
for (let i = 1; i <= numOne; i++) {
for (let j = 1; j <= numTwo; j++) {
// 如果现在的这两个积木刚好一样!
if (text1[i - 1] === text2[j - 1]) {
// 就像找到了一个共同回忆:
// 现在的匹配数 = “还没拿这两个积木前”的匹配数 + 1
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
// 如果不一样,那就看看之前的记录:
// 要么是你退一步看之前的,要么是你朋友退一步看之前的
// 我们选一个匹配数更多的结果保留下来
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// 3. 填完表后,右下角的数字就是你们最终最长的共同回忆长度
return dp[numOne][numTwo];
};
🧩 为什么代码里要 +1 还要取 max?
我们可以把填表的过程想象成在迷宫里寻宝:
-
当
text1[i-1] == text2[j-1]时:你发现了一个宝贝(相同的字母)!你必须从左上方(
dp[i-1][j-1])斜着走过来,并把宝物捡起来(+1)。这代表这个字母被你们共同锁定了。 -
当
text1[i-1] != text2[j-1]时:当前这两个字母没缘分。那你就得看看:是“我不拿这块积木时”的收获多,还是“你没拿这块积木时”的收获多?(取
Math.max)。
复杂度分析
- 时间复杂度:。 和 是两个字符串的长度。你需要遍历表格里的每一个格子。
- 空间复杂度:。需要这张二维表来存储过程。
小知识:这个算法在现实中非常有用!比如著名的 git diff 命令(查看代码哪里改动了)或者论文查重系统,底层逻辑都有 LCS 的影子。