C++ 动态规划经典案例解析之最长公共子序列(LCS)_窥探递归和动态规划的一致性

1. 前言

动态规划处理字符相关案例中,求最长公共子序列以及求最短编辑距离,算是经典中的经典案例。

讲解此类问题的算法在网上一抓应用一大把,即便如此,还是忍不住有写此文的想法。毕竟理解、看懂都不算是真正掌握,唯有瞧出其中玄机,能有自己独有的见解和不一样的感悟方算是把知识学到灵魂深入。

好了!闲话少说,进入正题。

2. 最长公共子序列(LCS)

2.1 问题描述

最长公共子序列,指找出 2 个或多个字符串中的最长公共子序列。

如字符串 s1=kabcs2=taijc,其最长公共子序列是ac

Tips: 子序列只要求其中字符保持和原字符串中一样的顺序,而不一定连续。

2.2 递归思想

这是一道求最值的题目,只要是求最值,必然会存在多个选择,原理很简单,如果没有多个选择,还有必要纠结谁是最大谁是最小吗?

Tips: 在你面前有苹果、桔子、香蕉……你只能选择一个,这时候方有纠结。如果面前只有苹果,还会纠结吗?

面对此问题,可以采用化整为零的思想,从宏观层面转移到微观层面,缩小问题的规模的递归思想。

如为字符串s1设置位置指针 i,为字符串s2设置位置指针j,则问题可以抽象为如下函数。函数的语义:ij作为起始位置时字符串s1,s2的最长公共子序列。

int lcs(string s1,int i,string s2,int j);
//如果 s1、s2为全局变量,函数可以是
int lcs(int i,int j);  

41.png

  • 初始时,i=0j=0意味求解完整的s1s2的最长公共子序列。此时规模最大,无法直接得到答案。如此,把问题延续到规模较小的子问题。

42.png

​ 上文说过,求最值一定存在多个选择的,原始问题中的k!=t,则可存在如下 3 种选择:

​ A、i不动,j+1。即把i指向作为起始位置的s1字符串和j+1作为起始位置的s2字符串继续比较。可算为一个子问题。

43.png

​ B、j不动,i+1。即把i+1指向作为起始位置的s1字符串和j作为起始位置的s2字符串继续比较。可算为另一个子问题。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Cr2f8B0w-1691975983175)(D:\红泥巴\我的课程体系\数据结构与算法\动态规划系列\images\44.png)]

​ C、ij同时移动到下一个位置。即把i+1指向作为起始位置的s1字符串和j+1作为起始位置的s2字符串继续比较。也算为一个子问题。

45.png

​ 也就是说,当原始问题中ij指向位置字符不相同时,存在 3 个选择。至于子问题如何求解,这个归功于递归思想。

Tips: 递归最大的好处就是只需要确定基础函数的功能,然后确定子问题,则子问题的内部如何求解站在宏观角度可以不管。反之它可以一步一步继续缩小问题规模,直到有答案为止。

​ 然后在3 种选择中,返回值最大的那一个作为当前的问题的结果。

int lcs(string s1,int i,string s2,int j){
    if(s1[i]!=s2[j]){
        //有 3 种选择
        int sel_1=lcs(s1,i,s2,j+1);
        int sel_2=lcs(s1,i+1,s2,j);
        int sel_3=lcs(s1,i+1,s2,j+1);
        return max(sel_1,sel_2,sel_3);
    } 
}
  • 如下图所示,当i和j所指向位置的值相同时,必然在当前子问题中就找到了一个公共字符,则最终结果就是后续子问题的结果基础上加 1 ,则为最长公共子序列为原来的值加 1

    Tips: 在海滩上捡贝壳时,当前拾到了一个,回家时最终能拾到的贝壳一定是当前拾到的这一个加上后续所拾到的贝壳。

45.png

​ 同时移动 ij,进入规模较小的子问题。如下图所示。

​ 此时可总结一下,使用递归求最长公共子序列,类似于玩消消乐,相同,则消掉,直接进入剩下的内容。不相同,选择会多些。

46.png

int lcs(string s1,int i,string s2,int j){
    if(s1[i]!=s2[j]){
        //有 3 种选择
        int sel_1=lcs(s1,i,s2,j+1);
        int sel_2=lcs(s1,i+1,s2,j);
        int sel_3=lcs(s1,i+1,s2,j+1);
         //三者之中选择最大返回值
    }else{
        //只有一个选择
        return lcs(s1,i+1,s2,j+1)+1;
    }
}
  • 递归边界。当i==s1.size() 或 j==s2.size()时,说明已经扫描到了子符串的最后。如下图所示,无论哪一个指针先到达字符串的末尾,则都不再存在任何公共子序列。

47.png

int lcs(string s1,int i,string s2,int j){
    if(i==s1.size() || j==s2.size())return 0;
    if(s1[i]!=s2[j]){
        //有 3 种选择
        int sel_1=lcs(s1,i,s2,j+1);
        int sel_2=lcs(s1,i+1,s2,j);
        int sel_3=lcs(s1,i+1,s2,j+1);
        //三者之中选择最大返回值
    }else{
        //只有一个选择
        return lcs(s1,i+1,s2,j+1)+1;
    }
}

上述是基于递归的角度分析问题,完整的代码如下:

#include <iostream>
using namespace std;
int lcs(string s1,int i,string s2,int j) {
	if(i==s1.size() || j==s2.size())return 0;
	if(s1[i]!=s2[j]) {
		//有 3 种选择
		int sel_1=lcs(s1,i,s2,j+1);
		int sel_2=lcs(s1,i+1,s2,j);
		int sel_3=lcs(s1,i+1,s2,j+1);
		int maxVal=max(sel_1,sel_2);
		maxVal=max(maxVal,sel_3);
		return maxVal;
	} else {
		//只有一个选择
		return lcs(s1,i+1,s2,j+1)+1;
	}
}
int main() {
	string s1,s2;
	cin>>s1>>s2;
	int res= lcs(s1,0,s2,0);
	cout<<res;
	return 0;
}

当字符串的长度较大时,基于递归的运算量会较大,问题在于递归算法中存在大量的重叠子问题。

2.3 重叠子问题

绘制递归树,可清晰看到重叠子问题的存在。

48.png

并且可以看到 sel_1sel_2分支包括sel_3分支,可以使用缓存方案解决递归中的重叠子问题,让重叠子问题只被计算一次。完整代码如下 :

#include <iostream>
#include <map>
using namespace std;
//缓存
map<pair<int,int>,int> cache;
int lcs(string s1,int i,string s2,int j) {
	if(i==s1.size() || j==s2.size())return 0;
	pair<int,int> p= {i,j};
	if (cache[p] ) {
		return cache[p];
	}
	if(s1[i]!=s2[j]) {
		//有 3 种选择
		int sel_1=lcs(s1,i,s2,j+1);
		int sel_2=lcs(s1,i+1,s2,j);
		cache[p]=max(sel_1,sel_2);;
	} else {
		//只有一个选择
		cache[p]=lcs(s1,i+1,s2,j+1)+1;
	}
	return 	cache[p];
}
int main() {
	string s1,s2;
	cin>>s1>>s2;
	int res= lcs(s1,0,s2,0);
	cout<<res;
	return 0;
}

递归实现性能不可观,代码层面也稍显繁琐。类似于这样求最值的问题,可以试着使用动态规划来实现。

2.4 动态规划

递归解决问题的思想是由上向下,所谓由上向下,指先搁置规模较大的问题,等规模较小的子问题解决后再回溯出大问题的解。通过上文贴的递归树可以清晰看到求解流程。

动态规划的思想是由下向上,是基于枚举思想。记录每一个子问题的解,最终推导出比之更大问题的解。当然,要求小问题具有独立性和最优性。

无论由上向下,还是由下向上,其本质都是知道子问题答案后,再求解出大问题的答案。动态规划算法是直接了当,递归是迂回求解。

现以求字符串的最长公共子序列为例,讲解动态规划的求解过程。

构建dp数组,用来记录所有子问题的解,类似于递归实现的缓存器。 于本问题而言,dp是一个二维数组,理论上讲,从A推导出B,再从B推导出C……问题域关心的是最后的推导结论C,之前使用过的历史推导结论其实是可以不用存储。有点类似于"忘恩负义",所以可以对于dp数组进行压缩。

  • 构建dp二维数组。先初始化数组的第一行和第一列的值为0。推导必须有一个源头,这里的 0就是源头。

    s1=""、s2="a……" 或当s1="a……"、s2=""或当s1=""、s2=""时可认为最长公共子序列的值为0

49.png

  • 如图,让i=1、j=1,比较 s1[i]和s2[j]位置的字符,显然kt是不相等的。递归是看后面(还没求解)有多少个子问题可以选择,动态规划是看前面(已经求解)有多个子问题会影响当前子问题。对于当前位置而言,对之有影响的位置有3个。如下图标记为黄色区域位置。

    1位置坐标为(i,j-1)。表示s1中有ks2中无t时最长公共子序列的值。

    2位置坐标为(i-1,j-1)。表示s1中无ks2中无t时最长公共子序列的值。

    3位置坐标为(i-1,j)。表示s1中无ks2中有t时最长公共子序列的值。

50.png

​ 可以舍弃位置3,然后在位置1和位置2中求最大值。

51.png

  • i=1不变,改成j的值。一路比较s1[i]s2[j]中值,因都不相等,根据前面的分析,很容易填写出dp值。

52.png

  • 移动i=2,重置j=1且移动j

    ij所在位置的字符不相等时的问题已经分析。

    如下图,当 i=2,j=2时,s[i]和s[j]的值相等,则影响此位置值的前置位置应该是哪个?

54.png

​ 相等,显然最长公共子序列会增加1,问题是在哪一个前置子问题的值上加 1

​ 其实,只需要在如下黄色区域位置的值上加上1,此位置表示当s1和s2中都没有a的时候。

56.png

  • 按如上分析原理,可以把整个dp表填写完成。

58.png

编码实现:

#include <iostream>
#include <map>
using namespace std;
int dp[100][100]= {0};
void lcs(string s1,string s2) {
	//初始化动态规划表
	for(int i=0; i<s2.size(); i++)
		dp[0][i]=0;
	for(int i=0; i<s1.size(); i++)
		dp[i][0]=0;

	for(int i=1; i<=s1.size(); i++) {
		for(int j=1; j<=s2.size(); j++)
			if(s1[i-1]==s2[j-1]) {
				//相等
				dp[i][j]=dp[i-1][j-1]+1;
			} else {
				dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
			}
	}
}
int main() {
	string s1,s2;
	cin>>s1>>s2;
	lcs(s1,s2);
	for(int i=0; i<=s1.size(); i++) {
		for(int j=0; j<=s2.size(); j++) {
			cout<<dp[i][j]<<"\t";
		}
		cout<<endl;
	}
	cout<<"最长公共子序列:"<<endl;
	int res=dp[s1.size()][s2.size()];
	cout<<res<<endl;
	return 0;
}

测试结果:

59.png

4. 总结

最长公共子序列很有代表性,分析基于递归和动态规划的实现过程,可以帮助我们理解此类问题,且解决此类问题。

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【LeetCode-简单】剑指 Offer 29. 顺时针打印矩阵(详解)

题目 输入一个矩阵&#xff0c;按照从外向里以顺时针的顺序依次打印出每一个数字。 示例 1&#xff1a; 输入&#xff1a;matrix [[1,2,3],[4,5,6],[7,8,9]] 输出&#xff1a;[1,2,3,6,9,8,7,4,5]示例 2&#xff1a; 输入&#xff1a;matrix [[1,2,3,4],[5,6,7,8],[9,10,1…

npm install 中 --save 和 --save-dev 是什么?

npm&#xff0c;全名 Node Package Manager&#xff0c;套件管理工具&#xff0c;package.json 会记下你在项目中安装的所有套件。 假设在项目中安装 lodash npm i --save lodash这样在 dependencies 中会出现&#xff1a; 如果修改了导入方式&#xff1a; npm i --save-dev …

20230814让惠普(HP)锐14 新AMD锐龙电脑不联网进WIN11进系统

20230814让惠普(HP)锐14 新AMD锐龙电脑不联网进WIN11进系统 2023/8/14 17:19 win11系统无法跳过联网 https://www.xpwin7.com/jiaocheng/28499.html Win11开机联网跳过不了怎么办&#xff1f;Win11开机联网跳过不了解决方法 Win11开机联网跳过不了怎么办&#xff1f;Win11开机…

【C++】开源:spdlog跨平台日志库配置使用

&#x1f60f;★,:.☆(&#xffe3;▽&#xffe3;)/$:.★ &#x1f60f; 这篇文章主要介绍spdlog日志库配置使用。 无专精则不能成&#xff0c;无涉猎则不能通。——梁启超 欢迎来到我的博客&#xff0c;一起学习&#xff0c;共同进步。 喜欢的朋友可以关注一下&#xff0c;下…

Qt 屏幕偶发性失灵

项目场景: 基于NXP i.mx7的Qt应用层项目开发,通过goodix使用触摸屏,走i2c协议。 问题描述 触摸屏使用过程中意外卡死,现场分为多种: i2c总线传输错误,直观表现为触摸屏无效,任何与触摸屏挂接在同一总线上的i2c设备,均受到干扰,并且在传输过程中内核报错以下代码: G…

react实现模拟弹框遮罩的自定义hook

需求描述 点击按钮用于检测鼠标是否命中按钮 代码实现 import React from react; import {useState, useEffect, useRef} from react;// 封装一个hook用来检测当前点击事件是否在某个元素之外 function useClickOutSide(ref,cb) {useEffect(()>{const handleClickOutside…

互联网发展历程:跨越远方,路由器的启示

互联网的蓬勃发展&#xff0c;一直在追求更广阔的连接&#xff0c;更遥远的距离。然而&#xff0c;在早期的网络中&#xff0c;人们面临着连接距离有限的问题。一项重要的技术应运而生&#xff0c;那就是“路由器”。 连接受限的问题&#xff1a;距离有限 早期的网络受限于直接…

vue+flask基于知识图谱的抑郁症问答系统

vueflask基于知识图谱的抑郁症问答系统 抑郁症已经成为当今社会刻不容缓需要解决的问题&#xff0c;抑郁症的危害主要有以下几种&#xff1a;1.可导致病人情绪低落&#xff1a;抑郁症的病人长期处于悲观的状态中&#xff0c;感觉不到快乐&#xff0c;总是高兴不起来。2.可导致工…

安卓13解决链接问题

作为Android用户&#xff0c;你可能已经注意到了一个问题——Android 13不再支持PPTP协议。但请别担心&#xff0c;作为一家专业的代理供应商&#xff0c;我们将与你分享解决方案&#xff0c;让你轻松解决L2TP问题&#xff0c;享受到高水平的连接体验。本文将为你提供实用的操作…

计算机竞赛 python+opencv+深度学习实现二维码识别

0 前言 &#x1f525; 优质竞赛项目系列&#xff0c;今天要分享的是 &#x1f6a9; pythonopencv深度学习实现二维码识别 &#x1f947;学长这里给一个题目综合评分(每项满分5分) 难度系数&#xff1a;3分工作量&#xff1a;3分创新点&#xff1a;3分 该项目较为新颖&…

安全头响应头(三)​X-Content-Type-Options

一 X-Content-Type-Options响应头 说明&#xff1a;先写个框架,后续补充 思考&#xff1a;请求类型是 "style" 和 "script" 是什么意思? script标签 style StyleSheet JavaScript MIME type 文件扩展和Content-Type的映射关系 场景&#xff1a; 一个…

Element Plus报错:ResizeObserver loop completed with undelivered notifications.

el-selected踩坑&#xff1a;el-selected 显示下拉框 mouseover 时报错&#xff01;&#xff01;&#xff01; 原来是属性 popper-append-to-body 被废除&#xff0c;改为 teleported。 element ui <el-select:popper-append-to-body"false"value-key"id&q…

Vscode 常用操作教程

一、语言换成中文 这是我们可以直接点击左边栏第四个图标搜索插件 chinese ,也可以直接ctrlshiftp快捷键也会出来如图所示图标&#xff0c;出来chinese 插件之后选择安装install,安装完成之后重新ctrlshiftp会出现如图所示页面 找到我的鼠标在的地方对应的中文&#xff0c;此时…

maven install

maven install maven 的 install 命令&#xff0c;当我们的一个 maven 模块想要依赖其他目录下的模块时&#xff0c;直接添加会找不到对应的模块&#xff0c;只需要找到需要引入的模块&#xff0c;执行 install 命令&#xff0c;就会将该模块放入本地仓库&#xff0c;就可以进…

大数据Flink(五十九):Flink on Yarn的三种部署方式介绍以及注意

文章目录 Flink on Yarn的三种部署方式介绍以及注意 一、Pre-Job 模式部署作业

【LeetCode75】第二十九题 删除链表的中间节点

目录 题目&#xff1a; 示例; 分析: 代码: 题目&#xff1a; 示例; 分析: 给我们一个链表&#xff0c;让我们把链表中间的节点删了。 那么最直观最基础的办法是遍历两边链表&#xff0c;第一遍拿到链表长度&#xff0c;第二次把链表中间节点删了。 这个暴力做法我没事过…

Sui网络的稳定性和高性能

Sui的最初的协议开发者设计了可扩展的网络&#xff0c;通过水平扩展的方式来保持可负担得起的gas费用。其他区块链与之相比&#xff0c;则使用稀缺性和交易成本来控制网络活动。 Sui主网上线前90天的数据指标证明了这一设计概念&#xff0c;在保持100&#xff05;正常运行的同…

无涯教程-Perl - setservent函数

描述 在第一次调用getservent之前,应先调用此函数。 STAYOPEN参数是可选的,在大多数系统上未使用。当getservent()检索服务数据库中下一行的信息时,然后setervent设置(或重置)枚举到主机条目集的开头。 语法 以下是此函数的简单语法- setservent STAYOPEN返回值 此函数不返…

Android 百度地图 bitmap 透明图片背景变黑色

现象&#xff1a; 本来透明背景的png图片渲染出来时黑色的了 原因&#xff1a; 为了节省内存资源对图片进行了压缩&#xff0c;使用到了 bitmap.compress(Bitmap.CompressFormat format, int quality, OutputStream stream)方法&#xff0c;具体设置为 bitmap.compress(Bit…

Linux系统中基于NGINX的代理缓存配置指南

作为一名专业的爬虫程序员&#xff0c;你一定知道代理缓存在加速网站响应速度方面的重要性。而使用NGINX作为代理缓存服务器&#xff0c;能够极大地提高性能和效率。本文将为你分享Linux系统中基于NGINX的代理缓存配置指南&#xff0c;提供实用的解决方案&#xff0c;助你解决在…