对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎
LeetCode 1143. 最长公共子序列
1. 题目描述
给定两个字符串 text1 和 text2,返回这两个字符串的 最长公共子序列 (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. 问题分析
这是一个经典的二维动态规划问题。为什么需要动态规划?
- 重叠子问题 :在暴力枚举所有子序列并比较的过程中,大量比较计算会被重复进行。例如,比较
text1[0...i]和text2[0...j]的结果,可以被更小子问题的结果组合得到。 - 最优子结构 :整个问题 (
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 组件状态树,每一步的状态都依赖于前一步。
状态转移方程推导:
- 基础情况 :当
i == 0或j == 0时,即一个空字符串与任何字符串的 LCS 长度为 0。dp[0][j] = dp[i][0] = 0。 - 状态转移 :
- 如果当前字符相等 (
text1[i-1] == text2[j-1]):
那么这两个字符一定在公共子序列中。我们只需要在text1前i-1个字符和text2前j-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) 的数据。这是前端性能优化中常见的"空间换时间"或"时间换空间"思想的体现。
- 基础版本:O(m * n),用于存储整个
- 结论 :动态规划解法在时间上已达到理论下界(必须遍历所有字符对),是时间最优解。空间上可以使用滚动数组进行优化。
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];
}
核心思维 :定义一个有明确语义的 "状态" ,并找到如何从已知的、更小的状态 "转移" 到当前状态。