LeetCode 1143 最长公共子序列:python 3 题解
题目链接:1143. 最长公共子序列
1. 题目解读
什么是“子序列”?
在开始解题前,我们必须明确子序列 (Subsequence) 和 子串 (Substring) 的区别:
- 子串:必须是原字符串中连续的一段字符。例如 "abc" 的子串有 "a", "ab", "abc", "b" 等。
- 子序列:不需要连续,但必须保持相对顺序。例如 "abcde" 的子序列可以是 "ace"(删掉了 b 和 d),但不能是 "aec"(因为 e 在 c 后面,顺序变了)。
题目目标
给定两个字符串 text1 和 text2,找到它们共有的、长度最长的子序列,并返回这个长度。
示例分析:
text1 = "abcde",text2 = "ace"- 公共的字符有 'a', 'c', 'e'。
- 它们在两个字符串中的顺序都是 a -> c -> e。
- 所以最长公共子序列是 "ace",长度为 3。
2. 核心思路:动态规划 (Dynamic Programming)
这道题是动态规划的经典入门题。为什么不能用贪心或简单的遍历?因为局部最优(比如遇到第一个相同的字符就匹配)不一定能导致全局最优(可能跳过这个字符能匹配更多后面的字符)。
我们需要一种方法来记录“已经比较过的部分”的结果,避免重复计算。
2.1 定义 DP 状态
我们定义一个二维数组 dp,其中 dp[i][j] 的含义是:
text1的前i个字符 和text2的前j个字符 的最长公共子序列的长度。
- 注意:这里
i和j代表长度(从 1 开始),而不是索引(从 0 开始)。 - 这样定义的好处是可以方便地处理空字符串的情况(即
i=0或j=0时,公共子序列长度为 0)。 - 因此,
dp数组的大小应该是(len(text1) + 1) x (len(text2) + 1)。
2.2 状态转移方程
我们需要思考,如何从已知的状态推导出 dp[i][j]?
当我们比较 text1 的第 i 个字符(即 text1[i-1])和 text2 的第 j 个字符(即 text2[j-1])时,有两种情况:
-
字符相同 (
text1[i-1] == text2[j-1]):- 既然这两个字符相同,它们一定可以构成公共子序列的一部分。
- 那么,当前的最长长度 = 去掉这两个字符后的最长长度 + 1。
- 公式:
dp[i][j] = dp[i-1][j-1] + 1
-
字符不同 (
text1[i-1] != text2[j-1]):- 这两个字符无法同时出现在公共子序列的末尾。
- 我们需要尝试“舍弃”其中一个,看哪种情况结果更好:
- 方案 A:舍弃
text1的当前字符,看text1前i-1个和text2前j个的结果 (dp[i-1][j])。 - 方案 B:舍弃
text2的当前字符,看text1前i个和text2前j-1个的结果 (dp[i][j-1])。
- 方案 A:舍弃
- 我们取两者的最大值。
- 公式:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
2.3 初始化
- 当
i = 0时,表示text1为空串,公共子序列长度为 0。 - 当
j = 0时,表示text2为空串,公共子序列长度为 0。 - 所以,
dp数组的第一行和第一列全部初始化为 0。
2.4 遍历顺序
- 我们需要从小到大遍历
i(从 1 到len(text1))。 - 在每一个
i内部,从小到大遍历j(从 1 到len(text2))。 - 这样保证计算
dp[i][j]时,dp[i-1][j-1],dp[i-1][j],dp[i][j-1]都已经计算好了。
3. 图解示例
假设 text1 = "abc", text2 = "ac"。
dp 表大小为 4 x 3 (包含 0 行 0 列)。
| "" (0) | "a" (1) | "c" (2) | |
|---|---|---|---|
| "" (0) | 0 | 0 | 0 |
| "a" (1) | 0 | 1 | 1 |
| "b" (2) | 0 | 1 | 1 |
| "c" (3) | 0 | 1 | 2 |
推导过程:
i=1 ('a'), j=1 ('a'): 字符相同。dp[1][1] = dp[0][0] + 1 = 1。i=1 ('a'), j=2 ('c'): 字符不同。dp[1][2] = max(dp[0][2], dp[1][1]) = max(0, 1) = 1。i=2 ('b'), j=1 ('a'): 字符不同。dp[2][1] = max(dp[1][1], dp[2][0]) = max(1, 0) = 1。i=2 ('b'), j=2 ('c'): 字符不同。dp[2][2] = max(dp[1][2], dp[2][1]) = max(1, 1) = 1。i=3 ('c'), j=1 ('a'): 字符不同。dp[3][1] = max(dp[2][1], dp[3][0]) = 1。i=3 ('c'), j=2 ('c'): 字符相同。dp[3][2] = dp[2][1] + 1 = 1 + 1 = 2。
最终结果 dp[3][2] = 2,对应子序列 "ac"。
4. 代码实现 (Python 3)
这里提供标准的二维动态规划解法,这是最通用、最容易理解的写法。
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
# 获取两个字符串的长度
m, n = len(text1), len(text2)
# 创建 DP 表格
# dp[i][j] 表示 text1 前 i 个字符和 text2 前 j 个字符的 LCS 长度
# 表格大小为 (m+1) x (n+1),多出来的一行一列用于表示空字符串的情况(初始化为 0)
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 开始填充 DP 表格
# i 从 1 开始,代表 text1 的第 i 个字符(实际索引是 i-1)
for i in range(1, m + 1):
# j 从 1 开始,代表 text2 的第 j 个字符(实际索引是 j-1)
for j in range(1, n + 1):
# 判断当前字符是否相等
# 注意:text1 的第 i 个字符对应索引 i-1
if text1[i - 1] == text2[j - 1]:
# 情况 1:字符相同
# 当前长度 = 左上角的值 + 1
dp[i][j] = dp[i - 1][j - 1] + 1
else:
# 情况 2:字符不同
# 当前长度 = 上方值 和 左方值 中的较大者
# 分别代表:忽略 text1 当前字符 或 忽略 text2 当前字符
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# 表格右下角的值即为两个完整字符串的最长公共子序列长度
return dp[m][n]
5. 复杂度分析
- 时间复杂度: \(O(M \times N)\)
- 其中 \(M\) 是
text1的长度,\(N\) 是text2的长度。 - 我们需要填充一个 \(M \times N\) 的表格,每个格子的计算时间是 \(O(1)\)。
- 题目中 \(M, N \le 1000\),所以运算量约为 \(10^6\),在 Python 中完全可以在 1 秒内完成。
- 其中 \(M\) 是
- 空间复杂度: \(O(M \times N)\)
- 我们需要一个二维数组来存储状态。
6. 其他解法与思路探讨
为了让你更全面地理解这道题,我们讨论一下除了上述标准解法外的其他思路。
6.1 暴力递归 (不推荐,会超时)
最直观的想法是写一个递归函数 solve(i, j):
- 如果
text1[i] == text2[j],返回1 + solve(i+1, j+1)。 - 如果不等,返回
max(solve(i+1, j), solve(i, j+1))。 - 缺点:存在大量重复计算。例如计算
solve(i, j)时可能会多次计算solve(i+1, j+1)。时间复杂度是指数级 \(O(2^{M+N})\),对于长度 1000 的字符串会直接超时 (TLE)。
6.2 带备忘录的递归 (Top-Down DP)
在暴力递归的基础上,加一个缓存(字典或数组),记录已经计算过的 solve(i, j) 的结果。
- 优点:逻辑与递归一致,容易由直觉转化而来。
- 缺点:递归调用有栈开销,Python 默认递归深度有限制,虽然这道题 1000 层通常没问题,但迭代法(上面的标准解法)更稳健。
6.3 空间优化版 DP (滚动数组)
观察状态转移方程:
dp[i][j] 只依赖于 dp[i-1][...] (上一行) 和 dp[i][...] (当前行)。
这意味着我们不需要保存整个二维表格,只需要保存上一行的数据就可以计算出当前行。
- 优化思路:将二维数组压缩为一维数组,或者只保留两行。
- 空间复杂度:可以优化到 \(O(\min(M, N))\)。
- 代码示例 (空间优化):
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
# 为了节省空间,让 text2 始终是较短的那个字符串
if len(text1) < len(text2):
text1, text2 = text2, text1
m, n = len(text1), len(text2)
# 只需要一维数组,大小为 n + 1
dp = [0] * (n + 1)
for i in range(1, m + 1):
# prev 用于保存 dp[i-1][j-1] 的值 (即左上角的值)
# 因为在一维数组更新过程中,dp[j-1] 会被更新成当前行的值,
# 我们需要一个变量暂存上一行 j-1 位置的值
prev = 0
for j in range(1, n + 1):
temp = dp[j] # 暂存当前的 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] = max(dp[j], dp[j - 1])
prev = temp # 更新 prev 为下一轮做准备
return dp[n]
注意:空间优化版代码逻辑稍复杂,面试中如果时间允许,建议先写出标准的二维 DP 版本,确保正确性,然后再尝试优化。
7. 总结
- 识别题型:求两个序列的“最长公共..."、“最小编辑..."等问题,通常是动态规划。
- 定义状态:
dp[i][j]通常定义为两个序列前缀的最优解。 - 找转移方程:分析当前字符相等和不相等两种情况,如何从子问题推导。
- 处理边界:利用
i=0或j=0表示空序列,简化初始化。 - 返回值:通常是
dp表格的最后一个元素。

浙公网安备 33010602011771号