从最长公共子序列问题理解动态规划算法(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 的子串,且要求它的长度最长,并确定这个长度.这个问题被我们称 ...
随机推荐
- 交换机三种端口模式Access、Hybrid和Trunk
以太网端口有 3种链路类型:access.trunk.hybird 什么是链路类型? vlan的链路类型可以分为接入链路和干道链路. 1.接入链路(access link)指的交换机到用户设备的链路, ...
- unittest框架中读取有特殊符号的配置文件内容的方法-configparser的RawConfigParser类应用
在搭建Unittest框架中,出现了一个问题,配置文件.ini中,出现了特殊字符如何处理? 通过 1.configparser的第三方库对应的ConfigParser类,无法完成对特殊字符的读取: # ...
- Java Stream 源码分析
前言 Java 8 的 Stream 使得代码更加简洁易懂,本篇文章深入分析 Java Stream 的工作原理,并探讨 Steam 的性能问题. Java 8 集合中的 Stream 相当于高级版的 ...
- 树莓派自动连接WiFi
使用sudo raspi-config配置好第一个wifi 然后只需要修改一个文件sudo nano /etc/wpa_supplicant/wpa_supplicant.conf 内容如下: ctr ...
- moviepy音视频剪辑:lum_contrast什么时候使用以及图像处理什么时候需要调整亮度与对比度
☞ ░ 前往老猿Python博文目录 ░ 一.亮度.对比度的概念 图像的亮度(luminosity )也即对明度的度量(参考<音视频处理基础知识扫盲:数字视频YUV像素表示法以及视频帧和编解码概 ...
- pycharm 2018.2.4过期-激活处理方式(Axure8.0版本到期)
参考文章:https://blog.csdn.net/HALEN001/article/details/81137092 第一种方法亲测可以 大致步骤: 1.2018.8.15更新最新破解补丁Jetb ...
- flask注册蓝图报错
记录下这个我找了两天的坑... take no arguments() 这两天一直学习flask的时候,我把注册的蓝图,写成注册的form表单的 举个栗子 class TetsView(view.Me ...
- this.$options.data()实战之重置data
刚刚看到这个方法学习了一下,然后想到正在开发的项目有一个需要重置data的操作,正好拿来使用一下,节省了好多代码,美滋滋...
- bootstrap 扩展参数
后台接受的参数形式 前端加载bootstrap时做的处理
- Raft概述
Raft 1. 概述 Raft是一种一致性(共识)算法,相比Paxos,Raft更容易理解和实现,它将分布式一致性问题分解成多个子问题,Leader选举(Leader election).日志复制(L ...