最长公共子序列:程序员的“找不同”游戏,居然藏着动态规划的大智慧!

76 阅读6分钟

引言

你有没有玩过那种“找不同”的小游戏?两幅看似一模一样的图片,却在某个角落悄悄藏了几个细节差异。而今天我们要讲的这个算法题——最长公共子序列(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?

  1. 最优子结构:当前最长公共子序列,取决于前面字符的匹配情况。
  2. 重叠子问题:多个路径都会走到同一个 (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"? 等等……)

冷静!不如画张表👇

""ace
""0000
a0111
b0111
c0122
d0122
e0123

看!最后那个 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万能公式:

  1. 定义状态dp[i][j] 到底代表啥?
  2. 初始化边界:空串、单字符怎么处理?
  3. 推导转移方程:分情况讨论,逻辑闭环。
  4. 编码实现 + 优化:先跑通,再瘦身。
  5. 举例验证:拿个小例子手动走一遍,防止翻车。

结语:DP不是魔法,是思维的艺术

最长公共子序列看似简单,却是通往高级算法的入口。它教会我们的不只是如何“找相同”,更是如何拆解问题、积累经验、做出最优选择

正如人生,不必事事相连,只要方向一致,也能走出属于自己的最长公共轨迹。

所以,下次当你看到两个毫无关联的字符串时,不要绝望。
也许它们的LCS只是——一个‘我’字而已
但没关系,至少你还存在。

✨愿你在算法路上,总能找到那个与你最长匹配的人(或代码)。