【每日算法】LeetCode 1143. 最长公共子序列

对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎

LeetCode 1143. 最长公共子序列

1. 题目描述

给定两个字符串 text1text2,返回这两个字符串的 最长公共子序列 (Longest Common Subsequence, LCS) 的长度 。如果不存在公共子序列,返回 0

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

例如,"ace""abcde" 的子序列,但 "aec" 不是。

示例 1:

复制代码
输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace",它的长度为 3。

示例 2:

复制代码
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc",它的长度为 3。

示例 3:

复制代码
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0。

2. 问题分析

这是一个经典的二维动态规划问题。为什么需要动态规划?

  1. 重叠子问题 :在暴力枚举所有子序列并比较的过程中,大量比较计算会被重复进行。例如,比较 text1[0...i]text2[0...j] 的结果,可以被更小子问题的结果组合得到。
  2. 最优子结构 :整个问题 (text1[0...m]text2[0...n] 的 LCS) 的最优解,可以通过其子问题 (text1[0...m-1]text2[0...n-1] 等) 的最优解推导出来。

作为前端开发者,可以将其类比于计算两个复杂 JSON 对象DOM 树结构 的"相似度"或"差异"的核心逻辑(尽管真实的 Diff 算法更复杂)。理解 LCS 是理解 React/Vue 中虚拟 DOM Diffing 等高级概念的重要基础。

3. 解题思路

3.1 核心思路:动态规划

我们定义一个二维数组 dp[i][j],其含义为:text1 中前 i 个字符 (即 text1[0...i-1]) 和 text2 中前 j 个字符 (即 text2[0...j-1]) 的最长公共子序列的长度。

为什么这么定义? 这创造了一个"状态",这个状态是逐步构建最终答案的基石,就像在构建一个复杂的 React 组件状态树,每一步的状态都依赖于前一步。

状态转移方程推导:

  1. 基础情况 :当 i == 0j == 0 时,即一个空字符串与任何字符串的 LCS 长度为 0。dp[0][j] = dp[i][0] = 0
  2. 状态转移
    • 如果当前字符相等 (text1[i-1] == text2[j-1]):
      那么这两个字符一定在公共子序列中。我们只需要在 text1i-1 个字符和 text2j-1 个字符的 LCS 长度基础上加 1 即可。
      公式:dp[i][j] = dp[i-1][j-1] + 1
    • 如果当前字符不相等 (text1[i-1] != text2[j-1]):
      那么 text1[i-1]text2[j-1] 不可能同时出现在当前的 LCS 中。LCS 的长度要么来自 text1 少一个字符的情况 (dp[i-1][j]),要么来自 text2 少一个字符的情况 (dp[i][j-1])。我们取两者的最大值,以确保获取的是"最长"的。
      公式:dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1])

复杂度与最优解:

  • 时间复杂度:O(m * n),其中 m 和 n 分别是两个字符串的长度。我们需要填充一个 (m+1) x (n+1) 的二维表格。
  • 空间复杂度
    • 基础版本:O(m * n),用于存储整个 dp 表。
    • 优化版本(滚动数组) :O(min(m, n)),这是空间上的最优解 。因为我们计算 dp[i][j] 时,只依赖于上一行 (i-1) 和当前行 (i) 的数据。这是前端性能优化中常见的"空间换时间"或"时间换空间"思想的体现。
  • 结论 :动态规划解法在时间上已达到理论下界(必须遍历所有字符对),是时间最优解。空间上可以使用滚动数组进行优化。

4. 代码实现

4.1 标准动态规划(清晰易懂版)

javascript 复制代码
/**
 * @param {string} text1
 * @param {string} text2
 * @return {number}
 */
var longestCommonSubsequence = function(text1, text2) {
    const m = text1.length, n = text2.length;
    
    // 1. 定义DP数组并初始化 (多一行一列,方便处理边界条件)
    // dp[i][j] 表示 text1[0..i-1] 和 text2[0..j-1] 的 LCS 长度
    const dp = new Array(m + 1);
    for (let i = 0; i <= m; i++) {
        dp[i] = new Array(n + 1).fill(0);
    }
    // 初始化部分已经通过fill(0)完成:dp[0][j] = dp[i][0] = 0

    // 2. 状态转移:填充DP表
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            // 注意:字符串下标从0开始,所以是 text1[i-1] 和 text2[j-1]
            if (text1[i - 1] === text2[j - 1]) {
                // 字符相等,LCS长度加1
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                // 字符不相等,取"舍掉text1当前字符"和"舍掉text2当前字符"两种情况的最大值
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }

    // 3. 最终结果:整个字符串的LCS长度
    return dp[m][n];
};

// 示例步骤分解 (text1="abcde", text2="ace")
// 初始化 6x4 的DP表 (全部为0)
// i=1, j=1: text1[0]='a', text2[0]='a' -> 相等 -> dp[1][1] = dp[0][0]+1 = 1
// i=1, j=2: text1[0]='a', text2[1]='c' -> 不等 -> dp[1][2] = max(dp[0][2]=0, dp[1][1]=1) = 1
// i=1, j=3: text1[0]='a', text2[2]='e' -> 不等 -> dp[1][3] = max(dp[0][3]=0, dp[1][2]=1) = 1
// i=2, j=1: text1[1]='b', text2[0]='a' -> 不等 -> dp[2][1] = max(dp[1][1]=1, dp[2][0]=0) = 1
// i=2, j=2: text1[1]='b', text2[1]='c' -> 不等 -> dp[2][2] = max(dp[1][2]=1, dp[2][1]=1) = 1
// i=2, j=3: text1[1]='b', text2[2]='e' -> 不等 -> dp[2][3] = max(dp[1][3]=1, dp[2][2]=1) = 1
// i=3, j=1: text1[2]='c', text2[0]='a' -> 不等 -> dp[3][1] = max(dp[2][1]=1, dp[3][0]=0) = 1
// i=3, j=2: text1[2]='c', text2[1]='c' -> 相等 -> dp[3][2] = dp[2][1]+1 = 2
// ... 以此类推,最终 dp[5][3] = 3

4.2 空间优化动态规划(滚动数组)

javascript 复制代码
/**
 * 空间优化版本 - 使用滚动数组
 * 原理:dp[i][j] 只依赖于 dp[i-1][j-1], dp[i-1][j], dp[i][j-1]
 *       即:当前行(i)的数据,只依赖于上一行(i-1)和当前行已计算的部分。
 *       我们可以只用两行数组(或一行+几个变量)来交替保存状态。
 */
var longestCommonSubsequence = function(text1, text2) {
    // 保证 text2 是较短的那个,以最小化空间消耗
    if (text1.length < text2.length) {
        [text1, text2] = [text2, text1];
    }
    const m = text1.length, n = text2.length;
    
    // 1. 只定义两行DP数组:prev 代表上一行 (i-1), curr 代表当前行 (i)
    let prev = new Array(n + 1).fill(0);
    let curr = new Array(n + 1).fill(0);
    
    // 2. 状态转移
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (text1[i - 1] === text2[j - 1]) {
                // 相等时,依赖左上角的值 (prev[j-1])
                curr[j] = prev[j - 1] + 1;
            } else {
                // 不等时,依赖左边(curr[j-1])和上边(prev[j])的值
                curr[j] = Math.max(prev[j], curr[j - 1]);
            }
        }
        // 3. 关键步骤:当前行计算完毕,变为下一轮的"上一行"
        // 这里通过交换引用实现,避免创建新数组
        [prev, curr] = [curr, prev];
        // 注意:curr 被换成了旧的 prev,需要"清空"吗?不需要,因为下一次循环会覆盖所有值。
        // 但严谨起见,可以将 curr 第一个元素置0 (curr[0] = 0),但覆盖会处理。
    }
    
    // 4. 最终结果保存在交换后的 prev 中 (因为最后一次循环后,curr变成了i=m-1的行,prev才是i=m的行)
    // 但由于我们最后交换了一次,所以结果在 prev 的最后一个元素
    return prev[n];
};

// 进一步优化:使用单个数组 + 一个变量
var longestCommonSubsequenceSingleArray = function(text1, text2) {
    const m = text1.length, n = text2.length;
    // dp[j] 在计算第i行时,表示原二维dp[i][j]的值
    const dp = new Array(n + 1).fill(0);
    
    for (let i = 1; i <= m; i++) {
        let prev = 0; // 这个变量保存 dp[i-1][j-1],即"左上角"的值
        for (let j = 1; j <= n; j++) {
            // 在覆盖 dp[j] 之前,先把它存下来,作为下一轮 j+1 的"左上角"值
            const temp = dp[j];
            if (text1[i - 1] === text2[j - 1]) {
                // 当前 dp[j] 还是上一行的值,即 dp[i-1][j]
                // prev 是 dp[i-1][j-1]
                dp[j] = prev + 1;
            } else {
                // dp[j] (即将被覆盖) 是 dp[i-1][j]
                // dp[j-1] (刚计算完) 是 dp[i][j-1]
                dp[j] = Math.max(dp[j], dp[j - 1]);
            }
            // 为下一轮更新"左上角"的值
            prev = temp;
        }
    }
    return dp[n];
};

5. 复杂度与优缺点对比

实现方案 时间复杂度 空间复杂度 优点 缺点
标准动态规划 O(m * n) O(m * n) 思路最清晰,易于理解和调试;DP表完整,可回溯构造出具体的LCS字符串。 空间占用大,当字符串很长时可能成为瓶颈。
滚动数组优化 O(m * n) O(min(m, n)) 大幅节省内存,是处理大规模数据的实用选择;保留了核心逻辑。 代码稍复杂;DP表信息被覆盖,无法直接回溯构造LCS。
单数组优化 O(m * n) O(min(m, n)) 空间利用率极致;常数因子更小。 代码逻辑最绕,可读性差;同样无法回溯。

选择建议

  • 面试/理解:首选标准动态规划,清晰地展示你的思路。
  • 生产环境/竞赛:使用滚动数组或单数组优化,尤其是对于前端,内存节省意味着更好的性能表现。
  • 需要输出具体序列:必须使用标准动态规划,并记录路径信息。

6. 总结

6.1 通用解题模板(二维字符串DP)

此类"两个字符串/序列"的动态规划问题,通常遵循以下模式:

javascript 复制代码
function stringDP(text1, text2) {
    const m = text1.length, n = text2.length;
    // 1. 定义状态 (通常为 dp[i][j],表示 text1[0..i-1] 和 text2[0..j-1] 的关系)
    const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));
    
    // 2. 初始化边界 (通常第一行和第一列为0或某种初始值)
    // for (...) ...
    
    // 3. 状态转移 (双层循环)
    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] + ?;
            } else {
                // 字符不匹配时的转移,通常是几种选择的最优值
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1], ...);
            }
        }
    }
    
    // 4. 返回最终结果
    return dp[m][n];
}

核心思维 :定义一个有明确语义的 "状态" ,并找到如何从已知的、更小的状态 "转移" 到当前状态。

相关推荐
老华带你飞2 小时前
农产品销售管理|基于java + vue农产品销售管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
小徐_23332 小时前
2025 前端开源三年,npm 发包卡我半天
前端·npm·github
GIS之路3 小时前
GIS 数据转换:使用 GDAL 将 Shp 转换为 GeoJSON 数据
前端
JIngJaneIL3 小时前
基于springboot + vue房屋租赁管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
天天扭码3 小时前
以浏览器多进程的角度解构页面渲染的整个流程
前端·面试·浏览器
长安er3 小时前
LeetCode 20/155/394/739/84/42/单调栈核心原理与经典题型全解析
数据结构·算法·leetcode·动态规划·
MarkHD3 小时前
智能体在车联网中的应用:第28天 深度强化学习实战:从原理到实现——掌握近端策略优化(PPO)算法
算法
你们瞎搞3 小时前
Cesium加载20GB航测影像.tif
前端·cesium·gdal·地图切片
能源系统预测和优化研究3 小时前
【原创代码改进】考虑共享储能接入的工业园区多类型负荷需求响应经济运行研究
大数据·算法