vb treeview 展开子节点_详解最长公共子序列问题,秒杀三道动态规划题目

学算法认准 labuladong

后台回复进群一起力扣?

051d45acbf47159998dd1b19b8428b93.png

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

1143.最长公共子序列(Medium)

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

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

051d45acbf47159998dd1b19b8428b93.png

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

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

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

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

最长公共子序列

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

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

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

int longestCommonSubsequence(String s1, String s2);

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

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

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

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

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

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

// 定义:计算 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:

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两个字符串,而是要具体到每一个字符,思考每个字符该做什么

5be5489422a87fac8d7f6b73c2836bf9.png

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

947ddc1e784bb73548e77c0768eb771a.png

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

// 定义:计算 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

09ac2b178476ec114434544e3b2c6a3d.png

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

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

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

代码可以再进一步:

// 定义:计算 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当然也不可能更长嘛。

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

// 备忘录,消除重叠子问题
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函数的递归框架:

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数组,我这里也写一下自底向上的解法吧:

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 题「两个字符串的删除操作」,看下题目:

ab654bc5f8329d23610cb61301552803.png

函数签名如下:

int minDistance(String s1, String s2);

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

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

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

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 题,看下题目:

461d3ac2f24766e86adf0d5f0a8a98f1.png

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

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

// 备忘录
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             res += s2.charAt(j);
        return res;
    }
    if (j == s2.length()) {
        // 如果 s2 到头了,那么 s1 剩下的都得删除
        for (; 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函数计算两种情况,得出最优的结果。其他的大同小异,就不具体展开了。

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

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

往期推荐 ?

东哥手把手带你套框架刷通二叉树|第一期

阶乘相关的算法题,东哥又整活儿了

东哥手写正则通配符算法,结构清晰,包教包会!

关于算法笔试,东哥又整出套路了?

原创 | 东哥教你几招常用的位运算技巧

_____________

学好算法靠套路,认准 labuladong,知乎、B站账号同名。

《labuladong的算法小抄》即将出版,公众号后台回复关键词「pdf」下载,回复「进群」可加入刷题群。

edb231a7e3e7351e57f5147a4b34d4b7.png

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/266210.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

linux查看数据积压,查看kafka消息队列的积压情况

创建topickafka-topics --create --zookeeper master:2181/kafka2 --replication-factor 2 --partitions 3 --topic mydemo5列出topickafka-topics --list --zookeeper master:2181/kafka2描述topickafka-topics --describe --zookeeper master:2181/kafka2 --topic mydemo5生产…

python 三引号_Python 基础(一):入门必备知识

目录1 标识符2 关键字3 引号4 编码5 输入输出6 缩进7 多行8 注释9 数据类型10 运算符10.1 常用运算符10.2 运算符优先级1 标识符标识符是编程时使用的名字&#xff0c;用于给变量、函数、语句块等命名&#xff0c;Python 中标识符由字母、数字、下划线组成&#xff0c;不能以数…

排序算法:冒泡和快排 摘自网络

冒泡排序&#xff1a; 首先我们自己来设计一下“冒泡排序”&#xff0c;这种排序很现实的例子就是&#xff1a; 我抓一把沙仍进水里&#xff0c;那么沙子会立马沉入水底&#xff0c; 沙子上的灰尘会因为惯性暂时沉入水底&#xff0c;但是又会立马像气泡一样浮出水面&#xff0c…

镭波笔记本安装linux,镭波笔记本windows7旗舰版系统下载与安装教程

镭波笔记本windows7旗舰版系统下载地址以及安装教程有很多盆友询问&#xff0c;今天&#xff0c;我就将镭波电脑下载安装win7旗舰版系统的详细步骤分享给你们,一起来了解一下镭波电脑是如何安装windows7旗舰版。镭波笔记本Windows7旗舰版系统下载&#xff1a;64位Windows7旗舰版…

linux运维和3dmax哪个简单,牛逼运维常用的工具系列-2

劳动最光荣nmonnmon是linux性能监视和分析数据的工具&#xff0c;它的安装很简单&#xff0c;下载解压后&#xff0c;添加可执行权限&#xff0c;即可运行下载解压后&#xff0c;通过文件名可以发现&#xff0c;是多个发行版本的&#xff0c;根据自己的发行版本&#xff0c;然后…

语义分割和实例分割_语义分割入门的一点总结

点击上方“CVer”&#xff0c;选择加"星标"或“置顶”重磅干货&#xff0c;第一时间送达作者&#xff1a;Yanpeng Sunhttps://zhuanlan.zhihu.com/p/74318967本文已由作者授权&#xff0c;未经允许&#xff0c;不得二次转载语义分割目的&#xff1a;给定一张图像&…

linux 音频驱动的流程,Intel平台下Linux音频驱动流程分析

【软件框架】在对要做的事情一无所知的时候&#xff0c;从全局看看系统的拓扑图对我们认识新事物有很大的帮助。Audio 部分的驱动程序框架如下图所示&#xff1a;这幅图明显地分为 3 级。上方蓝色系的 ALSA Kernel 整体属于Linux Kernel&#xff0c;是原生Linux 操作系统的一部…

Windows Server 2008 R2Cisco2960 配置Radius服务 实现802.1x认证 实战

实战配置Windows Server 2008 R2 Radius服务 与Cisco 2960 实现 802.1x认证实验拓扑1.Radius服务器 安装 dc 域名 wjl.com &#xff0c;和ca 安装步骤不再详解2.安装完ca之后&#xff0c;打开MMC 添加计算机证书&#xff0c;查看个人-证书里面有没有ca颁发给计算机的证书&…

linux文件编程(3)—— main函数传参、myCp(配置成环境变量)、修改配置文件、将整数和结构体数组写到文件

参考&#xff1a;linux文件编程&#xff08;3&#xff09;—— 文件编程的简单应用&#xff1a;myCp、修改配置文件 作者&#xff1a;丶PURSUING 发布时间&#xff1a; 2021-04-09 23:45:05 网址&#xff1a;https://blog.csdn.net/weixin_44742824/article/details/115209404 …

linux 修改文件名_Linux常用命令

Linux下一切皆文件查看型ls 查看当前文件夹内容 选项 -a 查看隐藏文件 -l 查看文件详细信息pwd 查看当前所在路径su 切换用户cat /etc/passwd 查看当前系统的用户cat 文件 查看文件内容选项 -n 加上编号 -E 每行末尾加上$ifconfig 查看网卡名&#xff0c;IP地址等网络信息route…

c语言mfc弹出窗口函数,CMFCDesktopAlertWnd实现桌面弹出消息框

1.创建一个CMFCDesktopAlertWnd指针CMFCDesktopAlertWnd* pPopup new CMFCDesktopAlertWnd;2.设置参数pPopup->SetAnimationType((CMFCPopupMenu::ANIMATION_TYPE) 2);pPopup->SetAnimationSpeed(100);pPopup->SetTransparency((BYTE)128);pPopup->SetSmallCaptio…

linux文件编程(2)——系统文件描述符、动静态文件、块设备介绍

参考&#xff1a;linux文件编程&#xff08;2&#xff09;——文件操作原理简述之文件描述符、动静态文件、块设备 作者&#xff1a;丶PURSUING 发布时间&#xff1a; 2021-04-09 11:14:12 网址&#xff1a;https://blog.csdn.net/weixin_44742824/article/details/115209312 目…

java中volatile的使用方式

2019独角兽企业重金招聘Python工程师标准>>> 转载地址&#xff1a; http://www.cnblogs.com/aigongsi/archive/2012/04/01/2429166.html 转载于:https://my.oschina.net/wangfree/blog/122664

linux文件编程(1)—— open、write、read、lseek、阻塞问题(ps文件操作/文件描述符/重定向原理/缓冲区/标准错误)

参考&#xff1a;linux文件编程&#xff08;1&#xff09;—— 常用API之open、write、read、lseek 作者&#xff1a;丶PURSUING 发布时间&#xff1a; 2021-04-08 22:19:28 网址&#xff1a;https://blog.csdn.net/weixin_44742824/article/details/115209134 【Linux】文件操…

linux文件编程(4)—— 用ANSIC标准C库函数进行文件编程:fopen、fread、fwrite、fseek

参考&#xff1a;linux文件编程&#xff08;5&#xff09;—— 用ANSIC标准中的C库函数进行文件编程 作者&#xff1a;丶PURSUING 发布时间&#xff1a; 2021-04-11 11:58:25 网址&#xff1a;https://blog.csdn.net/weixin_44742824/article/details/115209680 部分参照&#…

swig封装 c语言函数到python库,python swig 调用C/C++接口

转载&#xff1a;https://www.cnblogs.com/dda9/p/8612068.html当你觉得python慢的时候&#xff0c;当你的c/c代码难以用在python上的时候&#xff0c;你可能会注意这篇文章。swig是一个可以把c/c代码封装为python库的工具。(本文封装为python3的库)文章结构整体看封装只使用py…

Java学习---面试基础知识点总结

Java中sleep和wait的区别① 这两个方法来自不同的类分别是&#xff0c;sleep来自Thread类&#xff0c;和wait来自Object类。sleep是Thread的静态类方法&#xff0c;谁调用的谁去睡觉&#xff0c;即使在a线程里调用b的sleep方法&#xff0c;实际上还是a去睡觉&#xff0c;要让b线…

使用NPOI和委托做EXCEL导出

首先&#xff0c;在用NPOI导出时&#xff0c;学习了邀月这篇文章NPOI根据Excel模板生成原生的Excel文件实例&#xff0c;在这里先行谢过了。 本篇文章在邀月的基本上&#xff0c;做了一些小的改动&#xff0c;加上委托的机制。因为在做导出时&#xff0c;加载模板&#xff0c;下…

android 放大镜功能,简单实现Android放大镜效果

利用之前学过的图形图像绘画技术和图片添加特效技术&#xff0c;我们来实现一个Android放大镜的简单应用。最终效果如图具体实现:用来显示自定义的绘图类的布局文件res/layout/main.xml:xmlns:tools"http://schemas.android.com/tools"android:layout_width"fil…

android 音乐播放器的状态栏通知,Android仿虾米音乐播放器之通知栏notification解析...

通知栏notification是Android中一个很重要的组件&#xff0c;可以在顶部状态栏中存在&#xff0c;用户也可以通过此来操作应用&#xff0c;在Android中只有3.0以上的版本才加入了notification的按钮点击功能。先看一下仿虾米写出来的通知的效果这是一个自定义的notification&am…