从最长公共子序列问题理解动态规划算法(DP)
一、动态规划(Dynamic Programming)
动态规划方法通常用于求解最优化问题。我们希望找到一个解使其取得最优值,而不是所有最优解,可能有多个解都达到最优值。
二、什么问题适合DP解法
如何判断一个问题是不是DP问题呢?适合DP求解的最优化问题通常具有以下两个特征:
最优子结构
如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构性质。
以0-1背包问题(给你一个可装载重量为
W的背包和N个物品,每个物品有重量和价值两个属性。其中第i个物品的重量为wt[i],价值为val[i],现在让你用这个背包装物品,最多能装的价值是多少?)为例说明什么是子问题:假设你已经选择了第i个物品,那么问题就变为”如何在可装载重量为W-wt[i]和剩下的N-1个物品的情况下求最多能装的价值“,这就是原问题的一个子问题。我们可以通过以下步骤来判断一个问题是否具有最优子结构性质:
1.先做出一个选择。做出这次选择会产生一个或多个待解的子问题。
2.假定第1步的选择是最优解的一个选择,确定第1步的选择会产生哪些子问题
3.判断以下结论是否成立:作为构成原问题的最优解的组成部分,每个子问题的解就是它本身的最优解。证明这个结论的最好办法是反证法,还是以上述的0-1背包问题为例:如果存在一个选择方案使得子问题”在可装载重量为
W-wt[i]和剩下的N-1个物品的情况下求最多能装的价值“的解大于原来的解,那么用这个选择方案替换原来的选择方案,就存在一个整体选择方案使得原问题”在可装载重量为W和N个物品的情况下求最多能装的价值“解大于最优解,这就与最优解的定义矛盾了,因此结论成立。重叠子问题
如果算法反复求解相同的子问题,就称最优化问题具有重叠子问题性质。
三、DP问题求解思路
下面以力扣的1143题最长公共子序列为例讲解DP问题的求解思路。

一个简单暴力的算法是穷举出两个字符串的所有子序列,但是这种方法复杂度太高,显然不可行。
如果用DP的思想,就要寻找递推关系式,只要递推关系式出来了,写出代码就是很简单的事了。
首先我们应该分析问题的最优子结构。最长公共子序列(Longest Common Subsequence, LCS)问题的最优子结构:设有两个字符串\(A,B\),其中\(A={a_1,a_2,...,a_m}\),有m个字符;\(B={b_1,b_2,...,b_n}\),有n个字符。\(C\)为字符串\(A\)和\(B\)的一个LCS,\(C={c_1,c_2,...,c_k}\)有k个字符。那么,很容易有以下结论:
- 如果\(a_m=b_n\),则必有\(a_m=b_n=c_k\),且\(Z_{k-1}\)(表示由Z的前k-1个字符组成的字符串)是\(A_{m-1}\)和\(B_{n-1}\)的一个LCS;
- 如果\(a_m\ne b_n\),\(a_m\ne c_k\),则表示\(Z\)是\(A_{m-1}\)和\(B\)的一个LCS;
- 如果\(a_m\ne b_n\),\(b_n\ne c_k\),则表示\(Z\)是\(A\)和\(B_{n-1}\)的一个LCS;
那么,原问题的求解过程就被划分为三种情况讨论,定义函数\(f(i,j)\)为由\(A\)的前\(i\)个字符组成的字符串和由\(B\)的前\(j\)个字符组成的字符串的LCS长度。基于这三种情况我们可以写出动态规划的递推式:
1+f(i-1,j-1),\quad &若a_i=b_j \\
max(f(i,j-1),f(i-1,j)),\quad &若a_i\ne b_j
\end{cases}
\]
- 当$ i=0或者j=0$时,意味着字符串为空串,这时返回0;
- 当\(a_i=b_j\),也就是最后一个字符相同时,那么这个字符一定在LCS里,LCS长度加1,所以返回值为\(1+LCS(A_{i-1},B_{j-1})\);
- 当\(a_i\ne b_j\),也就是最后一个字符不同时,那么这两个字符至少有一个字符不在LCS里,若\(a_i\)不在LCS,结果为\(LCS(A_{i-1},B_{j})\);若\(b_j\)不在LCS,结果为\(LCS(A_{i},B_{j-1})\);若\(a_i,b_j\)均不在LCS,结果为\(LCS(A_{i-1},B_{j-1})\);因为我们不知道是哪种情况,所以我们返回三种情况的最大值。又因为\(LCS(A_{i-1},B_{j-1})\)的结果一定是不大于\(LCS(A_{i-1},B_{j})\)的,毕竟\(B_{j-1}\)比\(B_{j}\)少了一个字符嘛,同样也是不大于\(LCS(A_{i},B_{j-1})\)的,所以就省去了两个字符均不在LCS的情况。
1. 一个递归解法
基于上述的DP递推式,我们写出LCS的一个递归解法:
def longestCommonSubsequence(text1: str, text2: str) -> int:
len1,len2 = len(text1),len(text2)
# 函数返回text1的前i个字符组成的字符串与text2的前j个字符组成的字符串的LCS长度
def dp_core(i, j):
if i == 0 or j == 0:
return 0
if text1[i-1] == text2[j-1]:
ret = 1 + dp_core(i-1,j-1)
else:
ret = max(dp_core(i, j-1), dp_core(i-1, j))
return ret
return dp_core(len1,len2)
2. 算法优化之消除重叠子问题
通过观察上述\(f(i,j)\)递推式,可以发现,我们在分别求解\(f(i,j-1),f(i-1,j)\)时,有可能都会求\(f(i-1,j-1)\)的值,也就是会重复求解子问题。DP问题里,发现了一个重叠子问题,就有非常多个子问题,消除子问题是提高DP算法效率的关键点。
使用备忘录的递归方法
怎么消除子问题呢,我们很容易想到的就是设置一个备忘录数组把计算过的值存起来。每次求解之前都先检查是否计算过,已计算的就直接返回存储的值。
def longestCommonSubsequence(text1: str, text2: str) -> int:
# 备忘录,储存已计算的值
memo = {}
len1,len2 = len(text1),len(text2)
# 初始状态
for i in range(len1+1):
memo[(i,0)] = 0
for j in range(len2+1):
memo[(0,j)] = 0
def dp_core(i, j):
if (i,j) in memo:
return memo[(i,j)]
if text1[i-1] == text2[j-1]:
memo[(i,j)] = 1 + dp_core(i-1,j-1)
else:
memo[(i,j)] = max(dp_core(i, j-1), dp_core(i-1, j))
return memo[(i,j)]
return dp_core(len1,len2)
自底向上法的非递归方法
递归的代码虽然思路清晰,可读性较高,但是递归函数会有额外的调用开销。递归的思想是自顶向下,但是最先返回计算值的子问题却是最下层的子问题,上层问题的解依赖于下层子问题的解。因此,理解了这个关系,我们可以抛弃递归,自底向上地计算子问题。
对于上边LCS的\(f(i,j)\)递推式来说,计算\(f(i,j)\)的值的时候,我们需要先求出\(f(i,j-1),f(i-1,j)\)或者\(f(i-1,j-1)\),其依赖于下层几个子问题的解。如果知道了这几个子问题的解,那么就可以推出\(f(i,j)\)的解。也就是说,我们可以先计算下层子问题的解。
基于自底向上的思想,我们就可以从\(i=0,j=0\)开始计算,一直向上计算到\(i=len(text1),j=len(text2)\)时,就是我们要求的最优解了。
# 自底向上版本
def longestCommonSubsequence(text1: str, text2: str) -> int:
# 记录最优解的值
memo = {}
len1,len2 = len(text1),len(text2)
# 初始状态
for i in range(len1+1):
memo[(i,0)] = 0
for j in range(len2+1):
memo[(0,j)] = 0
for i in range(1,len1+1):
for j in range(1,len2+1):
if text1[i-1] == text2[j-1]:
memo[(i,j)] = 1 + memo[(i-1,j-1)]
else:
memo[(i,j)] = max(memo[(i, j-1)], memo[(i-1, j)])
return memo[(len1,len2)]
通常情况下,如果每个子问题都需要求解一次,自底向上的动态规划算法会比带备忘录的自顶向下算法快,因为自底向上算法没有递归调用的开销。
对于有些DP问题,还可以使用状态压缩来优化备忘录所占用的空间,有兴趣的可以参看这篇文章,这里略去。
3. 重构最优解
有时候题目不仅让我们求出最优解的值,还需要重构出最优解。对于LCS问题而言,就是不仅要求出LCS的长度,还要求出这个LCS序列。那么,我们就需要另外开辟一个空间来记录我们求解最优解过程中所做的每一个选择。
在自底向上的非递归算法上加上记录选择的代码后为:
# 记录最优解的自底向上版本
def longestCommonSubsequence(text1: str, text2: str) -> int:
# 记录最优解的值
memo = {}
# 记录产生最优解时的选择
choices = {}
len1,len2 = len(text1),len(text2)
# 初始状态
for i in range(len1+1):
memo[(i,0)] = 0
for j in range(len2+1):
memo[(0,j)] = 0
for i in range(1,len1+1):
for j in range(1,len2+1):
if text1[i-1] == text2[j-1]:
memo[(i,j)] = 1 + memo[(i-1,j-1)]
choices[(i,j)] = 'ij--'
elif memo[(i, j-1)] >= memo[(i-1, j)]:
memo[(i, j)] = memo[(i, j-1)]
choices[(i,j)] = 'j--'
else:
memo[(i, j)] = memo[(i-1, j)]
choices[(i, j)] = 'i--'
return memo[(len1,len2)], choices
上述代码的choices字典就是记录求解每个子问题最优解时所做的选择,对于LCS问题来说,记录的就是每一步字符比较的结果。
我们可以用以下函数来重构并打印出最优解,即最长公共子序列。
'''
choices: 动态规划算法求解最优解时每一步的选择
text1: 原输入字符串
i: 表示字符串text1的前i个字符
j: 表示字符串text2的前j个字符
'''
def print_LCS(choices, text1, i, j):
if i == 0 or j == 0:
return
if choices[(i,j)] == 'ij--':
print_LCS(choices, text1, i - 1, j - 1)
print(text1[i-1]) # 因为字符串中第i个字符的索引为i-1
elif choices[(i,j)] == 'i--':
print_LCS(choices, text1, i - 1, j)
else: # choices[(i, j)] == 'j--'
print_LCS(choices, text1, i, j - 1)
示例代码
s1 = 'abcdefg'
s2 = 'acf'
max_length, choices = longestCommonSubsequence(s1,s2)
print(max_length)
print_LCS(choices, s1, len(s1), len(s2))
上述代码运行的结果为:
3
a
c
f
四、总结
DP问题的核心在于找出递推关系,也称状态转移方程。一般遵循这个思路:
确定基础状态,明确状态(原问题和子问题中会变化的量),做出选择(导致状态变化的量),明确备忘录应记录的量,写出递推关系。
在优化重叠子问题部分,我们分别说明了如何通过备忘录的递归方法和自底向上的非递归方法来优化递归树,实际上这两种方法本质上是一样的,只是自顶向下和自底向上的求解顺序不同。
以下给出力扣上的几个LCS相关题目:
从最长公共子序列问题理解动态规划算法(DP)的更多相关文章
- LCS(最长公共子序列)动规算法正确性证明
今天在看代码源文件求diff的原理的时候看到了LCS算法.这个算法应该不陌生,动规的经典算法.具体算法做啥了我就不说了,不知道的可以直接看<算法导论>动态规划那一章.既然看到了就想回忆下, ...
- 51nod--1006 最长公共子序列Lcs (动态规划)
题目: 给出两个字符串A B,求A与B的最长公共子序列(子序列不要求是连续的). 比如两个串为: abcicba abdkscab ab是两个串的子序列,abc也是,abca也是,其中abca是这两个 ...
- 【51NOD】1006 最长公共子序列Lcs(动态规划)
给出两个字符串A B,求A与B的最长公共子序列(子序列不要求是连续的). 比如两个串为: abcicba abdkscab ab是两个串的子序列,abc也是,abca也是,其中abca是这两个 ...
- 51nod 最长公共子序列问题(动态规划)(LCS)(递归)
最长公共子序列问题 输入 第1行:字符串A 第2行:字符串B (A,B的长度 <= 1000) 输出 输出最长的子序列,如果有多个,随意输出1个. 输入示例 abcicba abdkscab 输 ...
- 51nod1006 -最长公共子序列Lcs【动态规划】
给出两个字符串A B,求A与B的最长公共子序列(子序列不要求是连续的). 比如两个串为: abcicba abdkscab ab是两个串的子序列,abc也是,abca也是,其中abca是这两个字符串最 ...
- ACM/ICPC 之 最长公共子序列计数及其回溯算法(51Nod-1006(最长公共子序列))
这道题被51Nod定为基础题(这要求有点高啊),我感觉应该可以算作一级或者二级题目,主要原因不是动态规划的状态转移方程的问题,而是需要理解最后的回溯算法. 题目大意:找到两个字符串中最长的子序列,子序 ...
- POJ 1159 Palindrome-最长公共子序列问题+滚动数组(dp数组的重复利用)(结合奇偶性)
Description A palindrome is a symmetrical string, that is, a string read identically from left to ri ...
- 51nod_1006 最长公共子序列,输出路径【DP】
题意: 给出两个字符串A B,求A与B的最长公共子序列(子序列不要求是连续的). 比如两个串为: abcicba abdkscab ab是两个串的子序列,abc也是,abca也是,其中abca是这两个 ...
- 动态规划 - 最长公共子序列(LCS)
最长公共子序列也是动态规划中的一个经典问题. 有两个字符串 S1 和 S2,求一个最长公共子串,即求字符串 S3,它同时为 S1 和 S2 的子串,且要求它的长度最长,并确定这个长度.这个问题被我们称 ...
随机推荐
- High-Resolution Image Inpainting using Multi-Scale Neural Patch Synthesis
论文来源:CVPR 2017 摘要 之前方法的缺点:之前的方法是基于语义和上下文信息的,在填充较大holes的表现得很好,能够捕获更高级的图像特征,但是由于内存限制和难以训练网络的因素,只能处理分辨率 ...
- python应用(6):函数
在流程很简单的时候,或者流程不简单但我们不需要考虑开发维护成本的时候,平面地组织你的代码就够了,不用费脑子,不需要考虑层次或重用的东西.当事情变得越来越复杂时,当同行对代码质量要求越来越高时,有一些重 ...
- 在 CentOS 7 安装 RabbitMQ
一.安装 Erlang RabbitMQ 是使用 Erlang 开发的,所以需要首先安装 Erlang,本文安装其最新版本 添加 repo 文件: sudo vim /etc/yum.repos.d/ ...
- moviepy音视频开发:音频剪辑基类AudioClip
☞ ░ 前往老猿Python博文目录 ░ 一.背景知识介绍 1.1.声音三要素: 音调:人耳对声音高低的感觉称为音调(也叫音频).音调主要与声波的频率有关.声波的频率高,则音调也高. 音量:也就是响度 ...
- Python运算符的优先级是怎样的?
优先级数字越高表示优先级越高,有关运算符的详细介绍请参考<Python运算符大全>
- [SUCTF 2019]Game
buuoj杂项复现 下载了之后给了我们一张图片了网站的源代码 图片简单分析了之后没有什么内容,先看源代码的index.html 里面有base32编码,解码 ON2WG5DGPNUECSDBNBQV6 ...
- MySQL技术内幕InnoDB存储引擎(六)——锁
什么是数据库的锁? 锁是数据库系统区别于文件系统的一个关键特性.锁机制用于管理对共享资源的并发访问.让数据库事务满足隔离性的要求. InnoDB 中锁的作用 不仅用于对数据进行并发访问,还还包括了缓冲 ...
- shell 编程 -- 条件判断
1.按照文件类型进行判断(常用的)-b 判断该文件是否存在-d 判断是否存在,并且是否为目录(是目录就为真)-e 判断该文件是否存在(存在为真)-f 判断文件是否存在,并且是否为普通文件(是普通文件为 ...
- STL——容器(Set & multiset)之 仿函数(函数对象)functor 的用法
Set/multiset 中元素的存储数据总是会按照从大到小或者从小到大排列,这个是怎么实现的?这就要说 "仿函数" 这个概念了. 仿函数概念 1. 尽管函数指针被广泛用于实现函数 ...
- SpringBoot异步调用--@Async详解
1. 概述 在日常开发中,为了提高主线程的效率,往往需要采用异步调用处理,例如系统日志等.在实际业务场景中,可以使用消息中间件如RabbitMQ.RocketMQ.Kafka等来解决.假如对高可用 ...