LeetCode 1143:最长公共子序列——Java 二维动态规划详解

一、题目描述

给定两个字符串 text1text2,返回它们最长公共子序列的长度。如果不存在公共子序列,则返回 0

子序列是从原字符串中删除零个或多个字符后,在不改变剩余字符相对顺序的情况下得到的新字符串。

例如:

复制代码
"ace" 是 "abcde" 的子序列

因为可以删除字符 bd,剩余字符的顺序仍然是 a → c → e

但:

复制代码
"aec" 不是 "abcde" 的子序列

虽然三个字符都存在,但它们在原字符串中的相对顺序不一致。

题目示例:

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

输入:text1 = "abc",text2 = "abc"
输出:3

输入:text1 = "abc",text2 = "def"
输出:0

二、子序列与子串的区别

"最长公共子序列"最容易误解的地方,是把子序列当成子串。

  • 子串要求字符在原字符串中连续;

  • 子序列不要求连续,但必须保持相对顺序。

对于字符串:

复制代码
"abcde"

"bcd" 既是子串,也是子序列;"ace" 是子序列,但不是子串。

本题允许跳过字符,因此不能只在两个字符串中寻找连续相同的部分。


三、为什么使用动态规划?

假设正在比较两个字符串末尾的字符,可以分为两种情况。

1. 末尾字符相同

如果当前两个字符相同,那么它们可以共同加入公共子序列。问题就转化成:

求两个字符串去掉当前字符后的最长公共子序列,再加上当前这个相同字符。

2. 末尾字符不同

如果当前两个字符不同,它们不可能同时作为同一公共子序列的最后一个字符。此时至少要舍弃其中一个:

  • 舍弃 text1 当前字符;

  • 舍弃 text2 当前字符。

分别计算这两种情况,再保留较大的结果。

大问题可以由更短字符串的答案推导,而且子问题会被重复使用,因此适合使用动态规划。


四、定义动态规划状态

定义:

复制代码
dp[i][j]:text1 前 i 个字符与 text2 前 j 个字符的
          最长公共子序列长度

注意,这里的 ij 表示字符数量,不是正在访问的字符下标。

例如:

复制代码
dp[3][2]

表示 text13 个字符与 text22 个字符的最长公共子序列长度。

如果:

复制代码
text1 = "abcde"
text2 = "ace"

那么 dp[3][2] 比较的就是:

复制代码
"abc" 和 "ac"

最终答案为:

复制代码
dp[len1][len2]

五、为什么 DP 数组要多一行一列?

代码创建:

复制代码
int[][] dp = new int[len1 + 1][len2 + 1];

多出的第 0 行和第 0 列用于表示空字符串。

  • dp[0][j]:空字符串与 text2j 个字符比较;

  • dp[i][0]text1i 个字符与空字符串比较。

空字符串与任何字符串的最长公共子序列长度都为 0,而 Java 创建 int 数组后默认值就是 0,因此不需要额外初始化。

这种设计还有一个好处:计算第一个字符时,可以安全访问 dp[0][0],不需要为数组负下标编写特殊判断。


六、推导状态转移公式

代码使用 ij 遍历字符串下标:

复制代码
for (int i = 0; i < len1; i++) {
    for (int j = 0; j < len2; j++) {
        // 比较 text1.charAt(i) 与 text2.charAt(j)
    }
}

由于 dp 的下标表示字符数量,所以字符 text1.charAt(i)text2.charAt(j) 对应的当前状态是:

复制代码
dp[i + 1][j + 1]

1. 当前字符相等

如果:

复制代码
text1.charAt(i) == text2.charAt(j)

当前字符可以加入公共子序列。去掉这两个字符后,对应的前置状态是 dp[i][j],因此:

复制代码
dp[i + 1][j + 1] = dp[i][j] + 1;

例如比较 "abc""ac" 的最后一个字符时,两个字符都是 c。只需要先求 "ab""a" 的答案,再加 1

2. 当前字符不相等

如果:

复制代码
text1.charAt(i) != text2.charAt(j)

需要比较两种选择:

复制代码
dp[i][j + 1]:舍弃 text1 当前字符
dp[i + 1][j]:舍弃 text2 当前字符

为了获得最长结果,取两者最大值:

复制代码
dp[i + 1][j + 1] = Math.max(
        dp[i + 1][j],
        dp[i][j + 1]
);

完整状态转移公式为:

复制代码
如果 text1[i] == text2[j]:
    dp[i+1][j+1] = dp[i][j] + 1

否则:
    dp[i+1][j+1] = max(dp[i+1][j], dp[i][j+1])

核心思想可以概括为:

当前字符相同就同时选取;当前字符不同就分别舍弃其中一个,并保留更优结果。


七、示例推演:"abcde""ace"

text1 放在行方向,text2 放在列方向。第 0 行和第 0 列表示空字符串。

最终 DP 表如下:

dp a c e
0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2
d 0 1 2 2
e 0 1 2 3

下面观察几个关键位置。

1. 比较 aa

两个字符相等:

复制代码
dp[1][1] = dp[0][0] + 1 = 1

最长公共子序列是 "a"

2. 比较 bc

两个字符不相等:

复制代码
dp[2][2] = max(dp[2][1], dp[1][2])
         = max(1, 1)
         = 1

3. 比较 cc

两个字符相等:

复制代码
dp[3][2] = dp[2][1] + 1
         = 1 + 1
         = 2

此时公共子序列可以是 "ac"

4. 比较 ee

两个字符相等:

复制代码
dp[5][3] = dp[4][2] + 1
         = 2 + 1
         = 3

最终:

复制代码
dp[5][3] = 3

最长公共子序列为 "ace",长度为 3


八、Java 完整代码

复制代码
class Solution {

    public int longestCommonSubsequence(String text1, String text2) {
        int len1 = text1.length();
        int len2 = text2.length();

        // dp[i][j] 表示 text1 前 i 个字符和 text2 前 j 个字符的
        // 最长公共子序列长度
        int[][] dp = new int[len1 + 1][len2 + 1];

        for (int i = 0; i < len1; i++) {
            for (int j = 0; j < len2; j++) {
                if (text1.charAt(i) == text2.charAt(j)) {
                    // 当前字符相同,同时加入公共子序列
                    dp[i + 1][j + 1] = dp[i][j] + 1;
                } else {
                    // 当前字符不同,分别舍弃其中一个字符
                    dp[i + 1][j + 1] = Math.max(
                            dp[i + 1][j],
                            dp[i][j + 1]
                    );
                }
            }
        }

        return dp[len1][len2];
    }
}

代码整体可以分为四步:

  1. 获取两个字符串的长度;

  2. 创建多一行、多一列的二维 DP 数组;

  3. 从左上到右下比较每一对字符并填写状态;

  4. 返回右下角的 dp[len1][len2]

九、复杂度分析

text1 长度为 mtext2 长度为 n

时间复杂度

两层循环会比较每一对字符,因此时间复杂度为:

复制代码
O(m × n)

空间复杂度

使用了一个 (m+1) × (n+1) 的二维数组,因此空间复杂度为:

复制代码
O(m × n)
相关推荐
sunshine22 girl11 小时前
Java学习一 环境配置2 Idea的入门使用和maven配置,项目启动脚本配置
java·maven
有点。12 小时前
*C++哈夫曼树与哈夫曼编码
java·c++·servlet
CRMEB系统商城12 小时前
CRMEB标准版系统(Java)v3.1正式发布
java·spring·微信小程序·php·教育电商
写后端的胖头鱼12 小时前
【高频面试题】Java 基础类型 + 包装类 + String 互相转换
java·开发语言
weixin_4604435613 小时前
企业考试系统从.NET迁移到Java+Linux:数据库迁移、接口兼容与灰度切换实践
java·煤矿培训考试系统·煤矿在线考试系统·煤矿安全培训·煤矿考试系统·一人一档
Navigator_Z13 小时前
LeetCode //C - 1240. Tiling a Rectangle with the Fewest Squares
c语言·算法·leetcode
稻米哟13 小时前
力扣100——双指针
算法·leetcode
香菜TTT13 小时前
Redis的哨兵机制
java·数据库·redis
withoutfear13 小时前
IntelliJ IDEA卡顿内存分配不足和索引被频繁触发问题解决办法
java·ide·intellij-idea
大圣编蚕13 小时前
Java StringWriter 详解:从入门到实战
java·开发语言·python