Loading [MathJax]/jax/input/TeX/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >详解最长公共子序列问题,秒杀三道动态规划题目

详解最长公共子序列问题,秒杀三道动态规划题目

作者头像
labuladong
发布于 2021-09-23 03:00:47
发布于 2021-09-23 03:00:47
83900
代码可运行
举报
运行总次数:0
代码可运行

学算法认准 labuladong 后台回复进群一起力扣😏

读完本文,可以去力扣解决如下题目:

1143.最长公共子序列(Medium

583. 两个字符串的删除操作(Medium

712.两个字符串的最小ASCII删除和(Medium

好久没写动态规划算法相关的文章了,今天来搞一把。

不知道大家做算法题有什么感觉,我总结出来做算法题的技巧就是,把大的问题细化到一个点,先研究在这个小的点上如何解决问题,然后再通过递归/迭代的方式扩展到整个问题

比如说我们前文 手把手带你刷二叉树第三期,解决二叉树的题目,我们就会把整个问题细化到某一个节点上,想象自己站在某个节点上,需要做什么,然后套二叉树递归框架就行了。

动态规划系列问题也是一样,尤其是子序列相关的问题。本文从「最长公共子序列问题」展开,总结三道子序列问题,解这道题仔细讲讲这种子序列问题的套路,你就能感受到这种思维方式了。

最长公共子序列

计算最长公共子序列(Longest Common Subsequence,简称 LCS)是一道经典的动态规划题目,大家应该都见过:

给你输入两个字符串s1s2,请你找出他们俩的最长公共子序列,返回这个子序列的长度。

力扣第 1143 题就是这道题,函数签名如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
int longestCommonSubsequence(String s1, String s2);

比如说输入s1 = "zabcde", s2 = "acez",它俩的最长公共子序列是lcs = "ace",长度为 3,所以算法返回 3。

如果没有做过这道题,一个最简单的暴力算法就是,把s1s2的所有子序列都穷举出来,然后看看有没有公共的,然后在所有公共子序列里面再寻找一个长度最大的。

显然,这种思路的复杂度非常高,你要穷举出所有子序列,这个复杂度就是指数级的,肯定不实际。

正确的思路是不要考虑整个字符串,而是细化到s1s2的每个字符。前文 子序列解题模板 中总结的一个规律:

对于两个字符串求子序列的问题,都是用两个指针ij分别在两个字符串上移动,大概率是动态规划思路

最长公共子序列的问题也可以遵循这个规律,我们可以先写一个dp函数:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j)

这个dp函数的定义是:dp(s1, i, s2, j)计算s1[i..]s2[j..]的最长公共子序列长度

根据这个定义,那么我们想要的答案就是dp(s1, 0, s2, 0),且 base case 就是i == len(s1)j == len(s2)时,因为这时候s1[i..]s2[j..]就相当于空串了,最长公共子序列的长度显然是 0:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
int longestCommonSubsequence(String s1, String s2) {
    return dp(s1, 0, s2, 0);
}

/* 主函数 */
int dp(String s1, int i, String s2, int j) {
    // base case
    if (i == s1.length() || j == s2.length()) {
        return 0;
    }
    // ...

接下来,咱不要看s1s2两个字符串,而是要具体到每一个字符,思考每个字符该做什么

我们只看s1[i]s2[j]如果s1[i] == s2[j],说明这个字符一定在lcs

这样,就找到了一个lcs中的字符,根据dp函数的定义,我们可以完善一下代码:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
    if (s1.charAt(i) == s2.charAt(j)) {
        // s1[i] 和 s2[j] 必然在 lcs 中,
        // 加上 s1[i+1..] 和 s2[j+1..] 中的 lcs 长度,就是答案
        return 1 + dp(s1, i + 1, s2, j + 1)
    } else {
        // ...
    }
}

刚才说的s1[i] == s2[j]的情况,但如果s1[i] != s2[j],应该怎么办呢?

s1[i] != s2[j]意味着,s1[i]s2[j]中至少有一个字符不在lcs

如上图,总共可能有三种情况,我怎么知道具体是那种情况呢?

其实我们也不知道,那就把这三种情况的答案都算出来,取其中结果最大的那个呗,因为题目让我们算「最长」公共子序列的长度嘛。

这三种情况的答案怎么算?回想一下我们的dp函数定义,不就是专门为了计算它们而设计的嘛!

代码可以再进一步:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
    if (s1.charAt(i) == s2.charAt(j)) {
        return 1 + dp(s1, i + 1, s2, j + 1)
    } else {
        // s1[i] 和 s2[j] 中至少有一个字符不在 lcs 中,
        // 穷举三种情况的结果,取其中的最大结果
        return max(
            // 情况一、s1[i] 不在 lcs 中
            dp(s1, i + 1, s2, j),
            // 情况二、s2[j] 不在 lcs 中
            dp(s1, i, s2, j + 1),
            // 情况三、都不在 lcs 中
            dp(s1, i + 1, s2, j + 1)
        );
    }
}

这里就已经非常接近我们的最终答案了,还有一个小的优化,情况三「s1[i]s2[j]都不在 lcs 中」其实可以直接忽略

因为我们在求最大值嘛,情况三在计算s1[i+1..]s2[j+1..]lcs长度,这个长度肯定是小于等于情况二s1[i..]s2[j+1..]中的lcs长度的,因为s1[i+1..]s1[i..]短嘛,那从这里面算出的lcs当然也不可能更长嘛。

同理,情况三的结果肯定也小于等于情况一。说白了,情况三被情况一和情况二包含了,所以我们可以直接忽略掉情况三,完整代码如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 备忘录,消除重叠子问题
int[][] memo;

/* 主函数 */
int longestCommonSubsequence(String s1, String s2) {
    int m = s1.length(), n = s2.length();
    // 备忘录值为 -1 代表未曾计算
    memo = new int[m][n];
    for (int[] row : memo) 
        Arrays.fill(row, -1);
    // 计算 s1[0..] 和 s2[0..] 的 lcs 长度
    return dp(s1, 0, s2, 0);
}

// 定义:计算 s1[i..] 和 s2[j..] 的最长公共子序列长度
int dp(String s1, int i, String s2, int j) {
    // base case
    if (i == s1.length() || j == s2.length()) {
        return 0;
    }
    // 如果之前计算过,则直接返回备忘录中的答案
    if (memo[i][j] != -1) {
        return memo[i][j];
    }
    // 根据 s1[i] 和 s2[j] 的情况做选择
    if (s1.charAt(i) == s2.charAt(j)) {
        // s1[i] 和 s2[j] 必然在 lcs 中
        memo[i][j] = 1 + dp(s1, i + 1, s2, j + 1);
    } else {
        // s1[i] 和 s2[j] 至少有一个不在 lcs 中
        memo[i][j] = Math.max(
            dp(s1, i + 1, s2, j),
            dp(s1, i, s2, j + 1)
        );
    }
    return memo[i][j];
}

以上思路完全就是按照我们之前的爆文 动态规划套路框架 来的,应该是很容易理解的。至于为什么要加memo备忘录,我们之前写过很多次,为了照顾新来的读者,这里再简单重复一下,首先抽象出我们核心dp函数的递归框架:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
int dp(int i, int j) {
    dp(i + 1, j + 1); // #1
    dp(i, j + 1);     // #2
    dp(i + 1, j);     // #3
}

你看,假设我想从dp(i, j)转移到dp(i+1, j+1),有不止一种方式,可以直接走#1,也可以走#2 -> #3,也可以走#3 -> #2

这就是重叠子问题,如果我们不用memo备忘录消除子问题,那么dp(i+1, j+1)就会被多次计算,这是没有必要的。

至此,最长公共子序列问题就完全解决了,用的是自顶向下带备忘录的动态规划思路,我们当然也可以使用自底向上的迭代的动态规划思路,和我们的递归思路一样,关键是如何定义dp数组,我这里也写一下自底向上的解法吧:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
int longestCommonSubsequence(String s1, String s2) {
    int m = s1.length(), n = s2.length();
    int[][] dp = new int[m + 1][n + 1];
    // 定义:s1[0..i-1] 和 s2[0..j-1] 的 lcs 长度为 dp[i][j]
    // 目标:s1[0..m-1] 和 s2[0..n-1] 的 lcs 长度,即 dp[m][n]
    // base case: dp[0][..] = dp[..][0] = 0

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            // 现在 i 和 j 从 1 开始,所以要减一
            if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
                // s1[i-1] 和 s2[j-1] 必然在 lcs 中
                dp[i][j] = 1 + dp[i - 1][j - 1];
            } else {
                // s1[i-1] 和 s2[j-1] 至少有一个不在 lcs 中
                dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
            }
        }
    }

    return dp[m][n];
}

自底向上的解法中dp数组定义的方式和我们的递归解法有一点差异,而且由于数组索引从 0 开始,有索引偏移,不过思路和我们的递归解法完全相同,如果你看懂了递归解法,这个解法应该不难理解。

另外,自底向上的解法可以通过我们前文讲过的 动态规划状态压缩技巧 来进行优化,把空间复杂度压缩为 O(N),这里由于篇幅所限,就不展开了。

下面,来看两道和最长公共子序列相似的两道题目。

字符串的删除操作

这是力扣第 583 题「两个字符串的删除操作」,看下题目:

函数签名如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
int minDistance(String s1, String s2);

题目让我们计算将两个字符串变得相同的最少删除次数,那我们可以思考一下,最后这两个字符串会被删成什么样子?

删除的结果不就是它俩的最长公共子序列嘛!

那么,要计算删除的次数,就可以通过最长公共子序列的长度推导出来:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
int minDistance(String s1, String s2) {
    int m = s1.length(), n = s2.length();
    // 复用前文计算 lcs 长度的函数
    int lcs = longestCommonSubsequence(s1, s2);
    return m - lcs + n - lcs;
}

这道题就解决了!

最小 ASCII 删除和

这是力扣第 712 题,看下题目:

这道题,和上一道题非常类似,这回不问我们删除的字符个数了,问我们删除的字符的 ASCII 码加起来是多少。

那就不能直接复用计算最长公共子序列的函数了,但是可以依照之前的思路,稍微修改 base case 和状态转移部分即可直接写出解法代码

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// 备忘录
int memo[][];
/* 主函数 */    
int minimumDeleteSum(String s1, String s2) {
    int m = s1.length(), n = s2.length();
    // 备忘录值为 -1 代表未曾计算
    memo = new int[m][n];
    for (int[] row : memo) 
        Arrays.fill(row, -1);

    return dp(s1, 0, s2, 0);
}

// 定义:将 s1[i..] 和 s2[j..] 删除成相同字符串,
// 最小的 ASCII 码之和为 dp(s1, i, s2, j)。
int dp(String s1, int i, String s2, int j) {
    int res = 0;
    // base case
    if (i == s1.length()) {
        // 如果 s1 到头了,那么 s2 剩下的都得删除
        for (; j < s2.length(); j++)
            res += s2.charAt(j);
        return res;
    }
    if (j == s2.length()) {
        // 如果 s2 到头了,那么 s1 剩下的都得删除
        for (; i < s1.length(); i++)
            res += s1.charAt(i);
        return res;
    }

    if (memo[i][j] != -1) {
        return memo[i][j];
    }

    if (s1.charAt(i) == s2.charAt(j)) {
        // s1[i] 和 s2[j] 都是在 lcs 中的,不用删除
        memo[i][j] = dp(s1, i + 1, s2, j + 1);
    } else {
        // s1[i] 和 s2[j] 至少有一个不在 lcs 中,删一个
        memo[i][j] = Math.min(
            s1.charAt(i) + dp(s1, i + 1, s2, j),
            s2.charAt(j) + dp(s1, i, s2, j + 1)
        );
    }
    return memo[i][j];
}

base case 有一定区别,计算lcs长度时,如果一个字符串为空,那么lcs长度必然是 0;但是这道题如果一个字符串为空,另一个字符串必然要被全部删除,所以需要计算另一个字符串所有字符的 ASCII 码之和。

关于状态转移,当s1[i]s2[j]相同时不需要删除,不同时需要删除,所以可以利用dp函数计算两种情况,得出最优的结果。其他的大同小异,就不具体展开了。

至此,三道子序列问题就解决完了,关键在于将问题细化到字符,根据每两个字符是否相同来判断他们是否在结果子序列中,从而避免了对所有子序列进行穷举。

这也算是在两个字符串中求子序列的常用思路吧,建议好好体会,多多联系~

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-10-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 labuladong 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
一步一步分析最长公共子序列问题
这两年一直有人跟我说选择大于努力,一开始不了解其中深意。最近开始感慨颇深,昨天有个老哥来我们公司,28岁就从字节退休了,问我们公司要不要投资。?好吧,别人家的28岁。在合适的时机能做出选择确实很重要啊
写代码的阿宗
2020/08/24
6790
一步一步分析最长公共子序列问题
【动态规划】两个数组的 dp 问题
dp[i][j] 表示 s1 的 0 ~ i 区间和 s2 的 0 ~ j 区间内所有子序列中,最长公共子序列的长度
2的n次方
2024/11/19
930
【动态规划】两个数组的 dp 问题
leetcode刷题(121)——1143. 最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
老马的编程之旅
2022/06/22
1750
DP专题 3 | LCS最长公共子序列 - POJ 1458
A subsequence of a given sequence is the given sequence with some elements (possible none) left out. Given a sequence X = < x1, x2, ..., xm > another sequence Z = < z1, z2, ..., zk > is a subsequence of X if there exists a strictly increasing sequence < i1, i2, ..., ik > of indices of X such that for all j = 1,2,...,k, xij = zj. For example, Z = < a, b, f, c > is a subsequence of X = < a, b, c, f, b, c > with index sequence < 1, 2, 4, 6 >. Given two sequences X and Y the problem is to find the length of the maximum-length common subsequence of X and Y.
ACM算法日常
2019/06/17
4720
DP专题 3 | LCS最长公共子序列 - POJ 1458
LintCode 最长公共子序列题目分析代码
典型的动态规划问题 dp[i][[j]:表示前i个和前j个字符最大LCS 当A[i] = B[i]的时候: 那么显然dp[i][j] = dp[i-1][j-1] + 1,因为dp[i-1][j-1]就是前最大的情况 当A[i] != B[i]: dp[i][j] = max(dp[i][j-1],dp[i-1][j]) 初始条件很简单,显然ii,j有一个为0,dp都是0
desperate633
2018/08/22
3470
经典面试题:最长公共子序列
最长公共子序列(Longest Common Subsequence,简称 LCS)是一道非常经典的面试题目,因为它的解法是典型的二维动态规划,大部分比较困难的字符串问题都和这个问题一个套路,比如说编辑距离。而且,这个算法稍加改造就可以用于解决其他问题,所以说 LCS 算法是值得掌握的。
五分钟学算法
2019/08/30
6100
经典面试题:最长公共子序列
最长公共子序列与最长公共子串
举个例子:s1="abcfde",s2="bcde"。那么s1与s2的最长公共子序列就是"bcde",注意不要求连续。该问题是典型的动态规划问题。
Cyril-KI
2022/09/16
1.1K0
LeetCode-1143-最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
benym
2022/07/14
2100
每日一刷《剑指offer》字符串篇之正则表达式匹配
在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串"aaa"与模式"a.a"和"ab_ac_a"匹配,但是与"aa.a"和"ab*a"均不匹配
终有救赎
2023/11/18
1640
每日一刷《剑指offer》字符串篇之正则表达式匹配
POJ 1458 Common Subsequence(最长公共子序列LCS)「建议收藏」
首先令dp[i][j]==x表示A串的前i个字符和B串的前j个字符的最长公共子序列长度为x.
全栈程序员站长
2022/07/10
3340
最长公共子序列(JAVA实现)
最长公共子序列动态规划解法: dp[i][j] -- 表示子串A[0...i](数组长度为n)和子串B[0...j](数组长度为m)的最长公共子序列
红目香薰
2022/11/29
5450
最长公共子序列(JAVA实现)
LeetCode *1143. 最长公共子序列(动态规划)
确定DP数组含义: dp[i][j]表示str1[0..i-1]与str2[0..j-1]的LCS(最长公共子序列)长度为dp[i][j]。
SakuraTears
2022/01/13
1770
LeetCode *1143. 最长公共子序列(动态规划)
C++ 动态规划经典案例解析之最长公共子序列(LCS)_窥探递归和动态规划的一致性
动态规划处理字符相关案例中,求最长公共子序列以及求最短编辑距离,算是经典中的经典案例。
一枚大果壳
2023/08/18
7060
C++ 动态规划经典案例解析之最长公共子序列(LCS)_窥探递归和动态规划的一致性
漫画:最长公共子序列
解释:一个字符串的子序列是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串,如下图示:
kunge
2020/10/22
1K0
动态规划 最长公共子序列 过程图解
首先需要科普一下,最长公共子序列(longest common sequence)和最长公共子串(longest common substring)不是一回事儿。什么是子序列呢?即一个给定的序列的子序列,就是将给定序列中零个或多个元素去掉之后得到的结果。什么是子串呢?给定串中任意个连续的字符组成的子序列称为该串的子串。给一个图再解释一下:
蛮三刀酱
2019/09/11
2.2K1
【面试高频题】难度 1.5/5,LCS 模板题
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 。
宫水三叶的刷题日记
2022/10/05
3160
【LeetCode热题100】【多维动态规划】最长公共子序列
我昨天面了天美L1的游戏客户端开发,面了我100分钟,问完实习、项目、计算机图形学和C++后给了我两道算法题做,一道是最长公共子序列,一道是LRU缓存,我知道是经典的题目,但是我都没敲过,最长公共子序列面试前一晚运气好随口问了一下GPT的解决思路,记得是二维的动态规划
叶茂林
2024/03/31
1430
最长公共子序列问题
问题描述: 求两个字符序列的公共最长子序列。 ---- 最长公共子串 在回到子序列问题之前,先来了解一下子串的问题。 例如,HISH和FISH两个字符序列的公共最长子串就是:ISH。很容易理解。 ---- 绘制网格 通过上一次背包问题的学习,给了我一些很重要的启示: 每种动态规划解决方案都设计网格。 动态规划可以帮助你在给定约束条件下找到最优解。 问题可分解为彼此独立且离散的子问题时,就可以使用动态规划法来解决。 那么,要解决这个问题的网格长什么样呢?要确定这一点,你首先得回答: 1.单元格中的值是什么?
我没有三颗心脏
2018/04/26
1.5K0
最长公共子序列问题
LeetCode 1143. 最长公共子序列(动态规划)
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
Michael阿明
2020/07/13
4850
<dp>求最长的公共子串
Given an unsorted array of integers, find the length of longest increasing subsequence.
大学里的混子
2018/11/18
1K0
相关推荐
一步一步分析最长公共子序列问题
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验