从最长公共子序列问题理解动态规划算法(DP)

时间:2023-03-08 21:51:40
从最长公共子序列问题理解动态规划算法(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个物品的情况下求最多能装的价值“的解大于原来的解,那么用这个选择方案替换原来的选择方案,就存在一个整体选择方案使得原问题”在可装载重量为WN个物品的情况下求最多能装的价值“解大于最优解,这就与最优解的定义矛盾了,因此结论成立。

  • 重叠子问题

    如果算法反复求解相同的子问题,就称最优化问题具有重叠子问题性质。

三、DP问题求解思路

下面以力扣的1143题最长公共子序列为例讲解DP问题的求解思路。

从最长公共子序列问题理解动态规划算法(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长度。基于这三种情况我们可以写出动态规划的递推式:

\[f(i,j)=\begin{cases}0,\quad &若 i=0\quad or\quad j=0 \\
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相关题目: