文章目录
前言
本专栏文章为《LeetCode 热题 100》的刷题题解,相关内容如有侵权,立即删除。
一、题目
1、原题链接
2、题目描述
二、个人思路整理
1、思路分析
核心思路:二维动态规划
- 状态定义
设dp[i][j]表示:text1的前i个字符(即下标范围0到i-1)与text2的前j个字符(即下标范围0到j-1)的最长公共子序列长度。- 多开一位空间(大小设为
(m + 1) * (n + 1)),让下标0代表空字符串,可以避免繁琐的边界判断。
- 多开一位空间(大小设为
- 状态转移方程
对于位置(i, j),考察当前末尾字符:- 如果
text1[i - 1] == text2[j - 1]:说明这两个字符可以作为公共子序列的新末尾,状态直接由两边都不包含该字符的前缀转移而来: d p i j = d p i − 1 j − 1 + 1 dpij = dpi - 1j - 1 + 1 dpij=dpi−1j−1+1 - 如果
text1[i - 1] != text2[j - 1]:说明这两个字符不能同时放在公共子序列末端。最长公共子序列要么来自text1前 i − 1 i-1 i−1 个字符与text2前 j j j 个字符,要么来自text1前 i i i 个字符与text2前 j − 1 j-1 j−1 个字符,取较大值即可: d p i j = max ( d p i − 1 j , d p i j − 1 ) dpij = \max(dpi - 1j, dpij - 1) dpij=max(dpi−1j,dpij−1)
- 如果
- 初始化与结果
- 初始化:当其中一个字符串为空串(即 i = 0 i = 0 i=0 或 j = 0 j = 0 j=0)时,LCS 长度必然为 0 0 0,整个数组默认初始化为 0 0 0 即可。
- 目标结果:
dp[m][n],其中 m m m 和 n n n 分别是两个字符串的长度。
cpp
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size();
int n = text2.size();
// dp[i][j] 表示 text1 的前 i 个字符(text[0..i-1])
// 与 text2 的前 j 个字符(text[0..j-1])的最长公共子序列长度。
// 维度开为 (m + 1) * (n + 1),方便处理空字符串(i=0 或 j=0)的边界情况。
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
// 当前字符相同:两个字符均可计入 LCS
// 长度为除去这两个字符后的最长公共子序列长度加 1
if (text1[i - 1] == text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
}
// 当前字符不匹配:至少有一个字符不在当前的最长公共子序列中
// 分别舍弃 text1[i-1] 或 text2[j-1],取两种情况的最大值
else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// 最终返回完整字符串 text1[0..m-1] 与 text2[0..n-1] 的LCS长度
return dp[m][n];
}
};
复杂度分析
- 时间复杂度: O ( m × n ) O(m \times n) O(m×n),双重循环遍历整个表格。
- 空间复杂度:标准写法为 O ( m × n ) O(m \times n) O(m×n)。由于计算
dp[i][j]仅依赖上一行dp[i-1][...]和当前行左侧的值,可以使用滚动数组优化到 O ( min ( m , n ) ) O(\min(m, n)) O(min(m,n))。
三、知识风暴
动态规划(Dynamic Programming) 是本题的核心算法思想。它通过将原问题拆解为若干重叠子问题,并利用「最优子结构」性质,用子问题的最优解递推得到全局最优解。对于「最长公共子序列」这类序列型动态规划问题,动态规划能以 O ( m × n ) O(m \times n) O(m×n) 的复杂度高效求解。
算法核心思想:
- 最优子结构 :
dp[i][j]表示text1的前 i i i 个字符与text2的前 j j j 个字符的最长公共子序列长度,可以由「当前末尾字符是否相等」这一条件,从dp[i - 1][j - 1]、dp[i - 1][j]、dp[i][j - 1]三个子状态递推得到。只要这些子问题已知,就能递推得到当前位置的最优解。 - 重叠子问题 :在递推过程中,同一个状态
dp[i][j]会被多个后续状态反复引用。例如计算dp[i + 1][j]与dp[i][j + 1]时,都会访问dp[i][j]的结果,因此用表格缓存结果可避免重复计算。 - 与贪心的区别 :贪心每一步只做当前最优选择、不回溯;而动态规划会枚举「末尾字符是否相等」两种分支,分别考虑「舍弃
text1[i-1]」与「舍弃text2[j-1]」两种情况并取最大值,从而保证结果的正确性。
常见对比:动态规划 vs 贪心
- 动态规划 :时间复杂度 O ( m × n ) O(m \times n) O(m×n),空间复杂度 O ( m × n ) O(m \times n) O(m×n)(可优化到 O ( min ( m , n ) ) O(\min(m, n)) O(min(m,n)))。适合需要同时考虑「匹配/不匹配」两种决策、且局部最优不能直接决定全局最优的场景,通用性更强。
- 贪心算法 :时间复杂度 O ( m + n ) O(m + n) O(m+n),空间复杂度 O ( 1 ) O(1) O(1)。适合每一步的局部最优能直接推导全局最优的场景,代码简洁高效,但本题中「最长公共子序列」无法用贪心直接证明(例如贪心只追求当前字符匹配,就可能错过更长的公共子序列)。
- 共同点:两者都依赖「最优子结构」性质。区别在于贪心只保留一个当前最优状态,而动态规划需要同时维护「匹配」与「不匹配」两种来源的状态。
动态规划的设计思想:
- 核心思想 :把大问题拆成小问题,先解决小问题,再用小问题的答案拼出大问题的答案。本题中,先确定空串边界( i = 0 i = 0 i=0 或 j = 0 j = 0 j=0 时 d p = 0 dp = 0 dp=0),再逐个字符递推出后续位置的状态。
- 与本题的联系 :最长公共子序列问题天然具有递推结构------每个状态
dp[i][j]的最优解都可以由「末尾字符相等时dp[i - 1][j - 1] + 1」与「末尾字符不等时max(dp[i - 1][j], dp[i][j - 1])」共同得到。因此无需回溯或搜索,只需按顺序填表即可。 - 注意事项 :动态规划的正确性依赖于「最优子结构」与「无后效性」。本题中
dp[i][j]只由左上、上方与左方三个状态决定,与未来的状态无关,因此递推顺序合法。
使用要点:
- 状态变量 :
dp[i][j]记录text1前 i i i 个字符与text2前 j j j 个字符的最长公共子序列长度( 0 ≤ i ≤ m 0 \le i \le m 0≤i≤m, 0 ≤ j ≤ n 0 \le j \le n 0≤j≤n),多开一位空间以统一处理空串边界。 - 初始化 :
dp[i][0] = 0、dp[0][j] = 0(任一字符串为空时 LCS 长度为 0 0 0),作为递推基准。 - 转移时机 :外层循环遍历
text1的每个字符 i i i,内层循环遍历text2的每个字符 j j j。当text1[i - 1] == text2[j - 1]时执行dp[i][j] = dp[i - 1][j - 1] + 1;否则执行dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])。 - 结果返回 :遍历结束后,返回
dp[m][n],表示两个完整字符串的最长公共子序列长度。
算法变体与扩展:
- 最长公共子串(LeetCode 718):与 LCS 不同,子串要求连续。当字符不匹配时状态直接归零,而非取两侧最大值,递推结构略有差异。
- 编辑距离(LeetCode 72):将「计数」改为「求最小编辑次数」,状态转移方程由取最大值改为取最小值,与本题的二维递推结构高度相似。
- 不同的子序列(LeetCode 115):在本题基础上引入「计数」约束,需要同时考虑「匹配」与「跳过」两种情形。
- 最长回文子序列(LeetCode 516):将两个字符串退化为同一个字符串的正反比较,通过二维区间 DP 刻画回文结构。
相关 LeetCode 例题:
- 718. 最长重复子数组(连续子串 + 计数)
- 72. 编辑距离(字符串 + 最小值)
- 115. 不同的子序列(匹配 + 计数)
- 516. 最长回文子序列(区间 + 回文)

