LeetCode 1143 最长公共子序列:python 3 题解


题目链接:1143. 最长公共子序列


1. 题目解读

什么是“子序列”?

在开始解题前,我们必须明确子序列 (Subsequence)子串 (Substring) 的区别:

  • 子串:必须是原字符串中连续的一段字符。例如 "abc" 的子串有 "a", "ab", "abc", "b" 等。
  • 子序列:不需要连续,但必须保持相对顺序。例如 "abcde" 的子序列可以是 "ace"(删掉了 b 和 d),但不能是 "aec"(因为 e 在 c 后面,顺序变了)。

题目目标

给定两个字符串 text1text2,找到它们共有的、长度最长的子序列,并返回这个长度。

示例分析:

  • 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 个字符 的最长公共子序列的长度。

  • 注意:这里 ij 代表长度(从 1 开始),而不是索引(从 0 开始)。
  • 这样定义的好处是可以方便地处理空字符串的情况(即 i=0j=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])时,有两种情况:

  1. 字符相同 (text1[i-1] == text2[j-1])

    • 既然这两个字符相同,它们一定可以构成公共子序列的一部分。
    • 那么,当前的最长长度 = 去掉这两个字符后的最长长度 + 1
    • 公式:dp[i][j] = dp[i-1][j-1] + 1
  2. 字符不同 (text1[i-1] != text2[j-1])

    • 这两个字符无法同时出现在公共子序列的末尾。
    • 我们需要尝试“舍弃”其中一个,看哪种情况结果更好:
      • 方案 A:舍弃 text1 的当前字符,看 text1i-1 个和 text2j 个的结果 (dp[i-1][j])。
      • 方案 B:舍弃 text2 的当前字符,看 text1i 个和 text2j-1 个的结果 (dp[i][j-1])。
    • 我们取两者的最大值。
    • 公式: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

推导过程:

  1. i=1 ('a'), j=1 ('a'): 字符相同。dp[1][1] = dp[0][0] + 1 = 1
  2. i=1 ('a'), j=2 ('c'): 字符不同。dp[1][2] = max(dp[0][2], dp[1][1]) = max(0, 1) = 1
  3. i=2 ('b'), j=1 ('a'): 字符不同。dp[2][1] = max(dp[1][1], dp[2][0]) = max(1, 0) = 1
  4. i=2 ('b'), j=2 ('c'): 字符不同。dp[2][2] = max(dp[1][2], dp[2][1]) = max(1, 1) = 1
  5. i=3 ('c'), j=1 ('a'): 字符不同。dp[3][1] = max(dp[2][1], dp[3][0]) = 1
  6. 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 秒内完成。
  • 空间复杂度: \(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. 总结

  1. 识别题型:求两个序列的“最长公共..."、“最小编辑..."等问题,通常是动态规划。
  2. 定义状态dp[i][j] 通常定义为两个序列前缀的最优解。
  3. 找转移方程:分析当前字符相等和不相等两种情况,如何从子问题推导。
  4. 处理边界:利用 i=0j=0 表示空序列,简化初始化。
  5. 返回值:通常是 dp 表格的最后一个元素。


posted @ 2026-03-03 11:14  MoonOut  阅读(133)  评论(0)    收藏  举报