引言
你有没有玩过那种“找不同”的小游戏?两幅看似一模一样的图片,却在某个角落悄悄藏了几个细节差异。而今天我们要讲的这个算法题——最长公共子序列(LCS),简直就是程序员版的“找相同”游戏:不是让你找不同,而是从两个字符串里,找出它们最像的那一段共同回忆。
听起来浪漫吗?其实写代码的时候只想哭 😭。但别急!今天我们不卷LeetCode排行榜,也不装高冷算法大神,就用一杯奶茶的时间,带你轻松拿下这道动态规划(DP)入门神题——既搞懂原理,又能笑着写出代码!
一、问题初体验:啥是“最长公共子序列”?
先来个灵魂拷问:
给定两个字符串
text1 = "abcde"和text2 = "ace",它们的最长公共子序列是什么?
别慌,这不是语文阅读理解题。我们只需要找到在两个串中都出现,并且字符顺序一致的一段序列。
比如:
"a"出现在两个串中;"c"也都在;"e"也在; 而且它们的顺序都是 a → c → e。
所以答案就是 "ace",长度为 3 ✅
⚠️注意!这里说的是子序列,不是子串!
👉 子串必须连续(如"abc"),而子序列可以“隔空传情”,只要顺序对就行(如"a_c_e"中间跳几个字也没关系)。
举个更扎心的例子:
text1 = "我爱学习"text2 = "我也想躺平"
它们的LCS是?…… "我" 😢
看来感情越深,公共部分越短。
二、为什么用动态规划?因为它能“记住过去”
面对这种“比对+匹配+最优解”的问题,暴力枚举所有子序列?时间复杂度直接爆炸到指数级,电脑都会骂你:“你是来复仇的吧?”
而动态规划(Dynamic Programming)就像一个有记忆的聪明人:它会把每一步的小结果存下来,避免重复计算,还能用小问题的答案拼出大问题的解。
✅ LCS为何适合DP?
- 最优子结构:当前最长公共子序列,取决于前面字符的匹配情况。
- 重叠子问题:多个路径都会走到同一个
(i,j)状态,不用每次都重新算。
换句话说:DP不做无用功,只走高效路。
三、DP四步走:从零构建你的算法思维
别怕二维数组,我们一步步来,像搭积木一样建出整个逻辑。
第一步:定义状态 —— 我是谁?我在哪?
我们定义一个二维数组 dp[i][j] 表示:
text1的前 i 个字符 和text2的前 j 个字符 的最长公共子序列长度。
例如:
dp[2][3] 就是 "ab" 和 "ace" 的LCS长度 → 是2(因为"a"和"c" or "a"和"e"? 不,其实是"a"和"c"? 错!是"a"和"c"? 等等……)
冷静!不如画张表👇
| "" | 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 |
看!最后那个 dp[6][4] = 3,正是我们要的结果!
第二步:边界条件 —— 空字符串没有爱情
如果其中一个字符串为空,那公共子序列当然为0。
所以第一行和第一列全设为0。
第三步:状态转移方程 —— 核心逻辑上线!
这才是真正的“决策时刻”。我们每次看两个字符是否相等:
if (text1[i-1] == text2[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队伍,长度+1;
- 如果不同 → 那就问问自己:“少了你我能活吗?”
取去掉text1[i-1]或 去掉text2[j-1]后的最大值。
这就像是在说:“你不在我心里,但我还在你世界里挣扎。”
四、代码实现:让理论落地成诗
function longestCommonSubsequence(text1, text2) {
const m = text1.length;
const n = text2.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (text1[i - 1] === text2[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];
}
是不是很清爽?这就是DP的魅力:逻辑清晰,代码简洁,背后全是智慧。
五、进阶操作:空间压缩,做个体面的程序员
上面用了 O(mn) 的空间,但我们发现:每次更新只依赖上一行的数据。
于是我们可以把二维压成一维,节省内存,提升逼格 🕶️
function longestCommonSubsequence(text1, text2) {
// 让短的那个当内循环,省点空间
if (text1.length > text2.length) [text1, text2] = [text2, text1];
const n = text2.length;
const dp = new Array(n + 1).fill(0);
for (let c of text1) {
let prev = 0;
for (let j = 1; j <= n; j++) {
const temp = dp[j];
if (c === text2[j - 1]) {
dp[j] = prev + 1;
} else {
dp[j] = Math.max(dp[j], dp[j - 1]);
}
prev = temp;
}
}
return dp[n];
}
你看,不仅省内存,还显得你“懂得变通”,面试官看了都想给你加薪 💰
六、对比加深:LCS vs 最长公共子数组
很多人混淆这两个概念,其实关键就是一个字:连不连续!
| 类型 | 是否连续 | 字符不同怎么办 |
|---|---|---|
| LCS(子序列) | ❌ 不需要连续 | 继承之前最大值 |
| 子数组(子串) | ✅ 必须连续 | 直接归零,断了就断了 |
就像友情 vs 恋情:友情可以偶尔失联,但恋情一断基本凉凉。
七、通用框架总结:一套模板打天下
无论你是刷题新手还是老鸟,记住这套DP万能公式:
- 定义状态:
dp[i][j]到底代表啥? - 初始化边界:空串、单字符怎么处理?
- 推导转移方程:分情况讨论,逻辑闭环。
- 编码实现 + 优化:先跑通,再瘦身。
- 举例验证:拿个小例子手动走一遍,防止翻车。
结语:DP不是魔法,是思维的艺术
最长公共子序列看似简单,却是通往高级算法的入口。它教会我们的不只是如何“找相同”,更是如何拆解问题、积累经验、做出最优选择。
正如人生,不必事事相连,只要方向一致,也能走出属于自己的最长公共轨迹。
所以,下次当你看到两个毫无关联的字符串时,不要绝望。
也许它们的LCS只是——一个‘我’字而已。
但没关系,至少你还存在。
✨愿你在算法路上,总能找到那个与你最长匹配的人(或代码)。