一、动态规划(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的思想,就要寻找递推关系式,只要递推关系式出来了,写出代码就是很简单的事了。

首先我们应该分析问题的最优子结构。最长公共子序列(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相关题目:

从最长公共子序列问题理解动态规划算法(DP)的更多相关文章

  1. LCS(最长公共子序列)动规算法正确性证明

    今天在看代码源文件求diff的原理的时候看到了LCS算法.这个算法应该不陌生,动规的经典算法.具体算法做啥了我就不说了,不知道的可以直接看<算法导论>动态规划那一章.既然看到了就想回忆下, ...

  2. 51nod--1006 最长公共子序列Lcs (动态规划)

    题目: 给出两个字符串A B,求A与B的最长公共子序列(子序列不要求是连续的). 比如两个串为: abcicba abdkscab ab是两个串的子序列,abc也是,abca也是,其中abca是这两个 ...

  3. 【51NOD】1006 最长公共子序列Lcs(动态规划)

    给出两个字符串A B,求A与B的最长公共子序列(子序列不要求是连续的). 比如两个串为:   abcicba abdkscab   ab是两个串的子序列,abc也是,abca也是,其中abca是这两个 ...

  4. 51nod 最长公共子序列问题(动态规划)(LCS)(递归)

    最长公共子序列问题 输入 第1行:字符串A 第2行:字符串B (A,B的长度 <= 1000) 输出 输出最长的子序列,如果有多个,随意输出1个. 输入示例 abcicba abdkscab 输 ...

  5. 51nod1006 -最长公共子序列Lcs【动态规划】

    给出两个字符串A B,求A与B的最长公共子序列(子序列不要求是连续的). 比如两个串为: abcicba abdkscab ab是两个串的子序列,abc也是,abca也是,其中abca是这两个字符串最 ...

  6. ACM/ICPC 之 最长公共子序列计数及其回溯算法(51Nod-1006(最长公共子序列))

    这道题被51Nod定为基础题(这要求有点高啊),我感觉应该可以算作一级或者二级题目,主要原因不是动态规划的状态转移方程的问题,而是需要理解最后的回溯算法. 题目大意:找到两个字符串中最长的子序列,子序 ...

  7. POJ 1159 Palindrome-最长公共子序列问题+滚动数组(dp数组的重复利用)(结合奇偶性)

    Description A palindrome is a symmetrical string, that is, a string read identically from left to ri ...

  8. 51nod_1006 最长公共子序列,输出路径【DP】

    题意: 给出两个字符串A B,求A与B的最长公共子序列(子序列不要求是连续的). 比如两个串为: abcicba abdkscab ab是两个串的子序列,abc也是,abca也是,其中abca是这两个 ...

  9. 动态规划 - 最长公共子序列(LCS)

    最长公共子序列也是动态规划中的一个经典问题. 有两个字符串 S1 和 S2,求一个最长公共子串,即求字符串 S3,它同时为 S1 和 S2 的子串,且要求它的长度最长,并确定这个长度.这个问题被我们称 ...

随机推荐

  1. 一枚程序猿的MacBook M1详细体验报告

    前言 2020年11月11日双十一上午,苹果发布了M1芯片的新款Mac,其最大的变化就是将处理器从Intel换成了苹果自研的ARM芯片M1. 上一次苹果更换Mac芯片要追溯到2006年,14年前,苹果 ...

  2. MyBatis-01:环境搭建

    MyBatis-01:环境搭建 1.搭建实验数据库 CREATE DATABASE `mybatis`; USE `mybatis`; DROP TABLE IF EXISTS `user`; CRE ...

  3. 02day

    1.python注释(不执行) #:单行注释 ''' '''或者""" """:多行注释 2.python2中文解决方法 #coding=u ...

  4. 记一次MacPro风扇一直转的问题排查

    1.查看CPU占用最高的进程 借助活动监视器,查看CPU占用最高的进程,可以观察到是Chrome浏览器 2.打开Chrome的任务管理器 2.1.查看CPU占用最高的chrome进程 3.分析和结束进 ...

  5. 查询满足条件的最新数据(逐步优化,mysql、达梦数据库)

    1.条件:报警信息表sensor_warning 2.需求: 查询当前车厢的.不同设备的.所有处理未完成的.不同报警原因的.时间最新的数据集合,最后按设备id或报警时间排序 3.原始sql,不满足实际 ...

  6. 半夜删你代码队 Day7冲刺

    一.每日站立式会议 1.站立式会议 成员 昨日完成工作 今日计划工作 遇到的困难 陈惠霖 好友界面初步 完善好友界面 无 侯晓龙 帮助他人建立数据库 用户信息界面 无 周楚池 完善管理员界面 用户界面 ...

  7. 一种使用 Redis 深度驱动的,为构建轻量级分布式应用程序(Microservices)的工程方案

    Hydra 是一个轻量级的 NodeJS 库,用于构建分布式计算应用程序,比如微服务.我们对轻量级的定义是:轻处理外部复杂性和基础设施依赖 -- 而不是有限的轻处理. Hydra 声称对基础设施的依赖 ...

  8. CF500G / T148321 走廊巡逻

    题目链接 这题是 Codeforces Goodbye 2014 的最后一题 CF500G,只是去掉了 \(u \not= x, v \not = v\) 的条件. 官方题解感觉有很多东西说的迷迷瞪瞪 ...

  9. python叠加矩形框图层

    两种方式以及效果: 方式一,使用PIL.Image.blend方式: from PIL import Image, ImageDraw im = Image.open('d:/tmp/58.249.0 ...

  10. A Simple Framework for Contrastive Learning of Visual Representations 阅读笔记

      Motivation 作者们构建了一种用于视觉表示的对比学习简单框架 SimCLR,它不仅优于此前的所有工作,也优于最新的对比自监督学习算法, 而且结构更加简单:这个结构既不需要专门的架构,也不需 ...