动态规划
内容写到什么程度才算完结呢?是写到足以对付互联网企业的算法机试吗?还是要达到信息学竞赛基础的级别?都不是,我只研究我感兴趣的内容。
1 | /* |
动态规划(dynamic programming,DP),教科书式的DP可以分为以下几个步骤:
- 明确问题
- 拆解子问题,定义状态
- 求解小规模的简单问题
- 构建状态转移方程
- 判断复杂度
完成前四个步骤后,相对于树结构而言,可以设计带有备忘录机制的自顶向下搜索的算法,这是一个记忆化搜索;也可以自底向上填入表格。programming被翻译为“规划”,但其实指的是一种表格法。大多数简单问题的表格都是一维或二维的,三维及以上属于高维动态规划,一般是较为困难的问题。
《算法设计与分析》王红梅著:DP将问题分解为若干个子问题,这些子问题应当不是相互独立的。DP在求解一些子问题时会将解保存起来,当在求解其他子问题时恰需要用到该子问题的解,DP会通过查表简单快捷地获取该子问题的解,而不会再一次进行重复的计算。
《Introduction to Algorithms》by Thomas H. Cormen, etc.(十分推荐阅读这本书):动态规划与分治法相似,都是通过组合子问题的解来求解原问题。不同的是,分治法将问题划分为互不相交的子问题、递归求解子问题,再将解组合起来求出原问题的解,而动态规划则被应用于子问题重叠的情况,即不同的子问题具有公共的子子问题(子问题的求解递归进行,将其划分为更小的子子问题),这种情况下如果考虑分治算法则会做许多不必要的工作,因为分治算法会反复求解那些公共的子子问题,而动态规划对每个子子问题只会求解一次,并将解保存在一张表中,从而避免了在每次求解一个子子问题时都要重新计算的问题。
动态规划常用来解决最优化问题。我们通常按如下4个步骤设计一个动态规划算法:
一、刻画一个最优解的结构特征
二、递归地定义最优解的值
三、计算一个最优解的值,这通常采取自底向上的计算方法
四、利用计算出的信息构造一个最优解
在这里,动态规划dynamic programming中的programming指一种表格法,并非编写程序。
……
最优子结构性质:问题的最优解由相关问题的最优解组合而成,而这些子问题可以独立求解。
……
用动态规划方法求解最优化问题的第一步就是刻画最优解的结构。如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最有子结构。因此,某个问题是否适合应用动态规划算法,其是否具有最优子结构的性质是一个很好的线索(当然,具有最优子结构性质也可能意味着问题适合应用贪心策略)。使用动态规划方法时,我们用子问题的最优解来构造原问题的最优解,所以我们必须确保考察了最优解中用到的所有子问题。
……
适合运用动态规划方法求解的最优化问题应具备的第二个性质是子问题空间必须足够的“小”,即问题的递归算法会反复地求解相同的子问题,而不是一直生成新的子问题。一般来讲,不同子问题的总数是输入的规模的多项式函数为好。如果递归算法反复求解相同的子问题,我们就称这个最优化问题具有重叠子问题的性质。与之相对的,适合用分治方法求解的问题通常在递归的每一步都生成全新的子问题。动态规划通常这样利用重叠子问题的性质:对每个子问题求解一次,将解存入一张表中,当再次需要这个子问题时直接查表,每次查表的代价为常量时间。
……
贪心算法与动态规划有许多相似之处,特别是能够应用贪心算法的问题也必须具有最优子结构的性质。贪心算法和动态规划最大的不同在于他并不是首先寻找子问题的最优解然后再在其中进行选择,而是首先做出一步“贪心”选择,即在当时(局部)看来是最优的选择,然后求解选出的子问题,从而不必费心求解所有可能相关的子问题,并重复这个过程。
对一个问题可以应用DP算法的必要条件是数据间的数据结构构成一个有向无环图(directed acyclic graph,DAG)。这是动态规划无后效性的原因。
根据状态转移方程设计的算法,时间复杂度为“状态数乘状态转移的开销”。
规定:分析中的
dp = np.zeros([n, m]).astype(int)
dp = [[0]*m for _ in range(n)]
浅浅说两句。产生想学动态规划的想法是导师告诉我他们的算法在动态规划耗时太多,所以让我看一看有没有什么可以改进的地方。但在此之前,我对动态规划的理解仅限于它可以用来解决离散的线性规划问题,24年年底我因病休学也无事可做,所以我就开始了动态规划算法的学习,算是“没事找事”。越学越觉得以前在动态规划世界的眼界之小,但同时方向也从贝塞尔方程的动态规划偏向了八股……因为动态规划也是许多互联网八股机试题很爱考的一种题型(所以说初衷真的不是我想跑路去互联网,,,)。动态规划也算是让我第一次明白了计算机科学的应用,我也似乎对算法很有兴趣(就是不知道这次又会坚持几天了),而此前我所做的编程则顶多算是简单数据分析。这篇文章实际上是我的一个动态规划(八股)的学习笔记,分析过程实际上就是我的思考过程。思考错误难以避免,所以有的问题我也重写过很多遍分析。因此,虽然这本质上是一篇学习笔记,但我觉得对初学计算机算法动态规划的人而言,还是有一定价值的,至少比较贴合初学者的思维。在我对动态规划有了新的认识后,我有时也会回过头对当初理解不当的地方进行修正。
背包模型
背包问题是组合问题上DP算法应用的最简单的数学模型之一,也是最经典的数学模型之一,我们就从这里开始吧。
01背包
01背包问题是最典型的DP在求解离散线性规划中的应用。我们有一个容量有限的背包knapsack,设其最大容量为重量
这是个典型的离散线性规划问题,可以考虑用动态规划求解,数学模型略。DP问题的关键在于找到状态转移方程。为分解问题得到一个个子问题,用
这样,
通过这样递推的方式,可以算出背包内物品组合的最大总价值
递推的过程,相当于在慢慢填满矩阵
举个例子,设一共有
物品1 | 物品2 | 物品3 | 物品4 | |
---|---|---|---|---|
重量 | 1 | 2 | 3 | 4 |
价值 | 2 | 4 | 4 | 5 |
如果分别考虑到
先考虑矩阵的第二行
同样地,根据状态转移方程,利用第二行可以算出第三行的值,即
以此类推,最后可以得到完整的矩阵
矩阵的末位对角元
将上述流程写成程序,就是下述代码:
1 | # 输入参数 |
该算法的时间复杂度与空间复杂度均为
1 | print(dpMatrix) |
输出:
[[0, 0, 0, 0, 0, 0], [0, 2, 2, 2, 2, 2], [0, 2, 4, 6, 6, 6], [0, 2, 4, 6, 6, 8], [0, 2, 4, 6, 6, 8]]
1 | print(dpMatrix[-1][-1]) |
输出:
8
为什么01背包问题不能通过计算每件物品的性价比,按性价比顺序先让性价比最高的物品进入背包呢?这是因为进入背包的物品的数量不可以是分数可以恰好把背包装满,只能是
完全背包
完全背包问题:在一次组合中,每个物品至多只能放入背包一次,也就是说每个物品要么放入背包、要么不放入背包,对应只有0和1两种状态。如果只要背包容量足够,每个物品都可以被无限次放入背包,这就是一个完全背包问题。这时,可以把物品编号视为物品的种类。例如,设正数
这种情况下,
尽管
1 | # 输入参数 |
该算法的时间复杂度与空间复杂度均为
1 | print(dpMatrix) |
输出:
[[0, 0, 0, 0, 0], [0, 15, 30, 45, 60], [0, 15, 30, 45, 60], [0, 15, 30, 45, 60]]
1 | print(dpMatrix[-1][-1 ]) |
输出:
60
另:
其实还有一个很重要的问题,为什么遍历物品在外层循环,遍历背包容量在内层循环?这个问题很多题解关于这里都是轻描淡写就略过了,大家都默认 遍历物品在外层,遍历背包容量在内层,好像本应该如此一样,那么为什么难道就不能遍历背包容量在外层,遍历物品层?01背包中二维dp数组的两个for遍历的先后循序是可以颠倒的,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍包容量。而在完全背包中,对于一维dp数组来说,其实两个for循环嵌套是无所谓的!因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。
滚动数组优化空间复杂度
空间优化一般有三种常见手段,分别是空间复用、信息扩展与信息压缩,他们的典型代表分别为滚动数组、迷宫寻路技巧与状态压缩DP。这里考虑滚动数组:注意到算法并不需要完整记录下
01背包问题算法利用滚动数组可优化为:
1 | # 输入参数 |
输出:
[0, 2, 2, 2, 2, 2] [0, 2, 4, 6, 6, 6] [0, 2, 4, 6, 6, 8] [0, 2, 4, 6, 6, 8] 8
完全背包问题算法利用滚动数组可优化为:
1 | # 输入参数 |
输出:
[0, 15, 30, 45, 60] [0, 15, 30, 45, 60] [0, 15, 30, 45, 60] 60
改进后的两种算法的空间复杂度均为
恰装满背包时的最大价值
先讨论01背包的情况。在01背包算法的基础上,只需要令DP_
初始化时除了第一项为DP_
代表着DP_[0]
为np.NINF
(换句话说,np.NINF
永远不会被max
函数return——除非两个参数均为np.NINF
,这意味着背包未被填满、值为np.NINF
的情况被视为无效数据)。根据状态转移方程,由于np.NINF
。
1 | import numpy as np |
输出:
[ 0. 2. -inf -inf -inf] [ 0. 2. 4. 6. -inf] [0. 2. 4. 6. 3.] 3.0
1 | W = 8 |
输出:
[ 0. -inf 2. -inf -inf -inf -inf -inf -inf] [ 0. -inf 2. 4. -inf 6. -inf -inf -inf] [ 0. -inf 2. 4. 1. 6. 3. 5. -inf] -1
完全背包的情况类似,只需要改变初始化条件:
1 | import numpy as np |
输出:
[ 0. -inf 2. -inf 4. -inf 6. -inf 8.] [ 0. -inf 2. 4. 4. 6. 8. 8. 10.] [ 0. -inf 2. 4. 4. 6. 8. 8. 10.] 10.0
背包杂例
暂略,有空了再整理
- 多重背包:每周一算法:背包问题(三)多重背包
- 分组背包:每周一算法:背包问题(四)分组背包,思路是转化为01背包问题
- 背包方案数:每周一算法:背包问题(五)求解方案数,关键在找到状态转移方程
以下问题均系01背包模型:
- 力扣 416:分割等和子集
- 力扣 1049:最后一块石头的重量 II
- 力扣 494:目标和
- 力扣 474:一和零
以下问题均系完全背包模型:
- 力扣 518:零钱兑换 II
- 力扣 377:组合总和 Ⅳ
- 力扣 70:爬楼梯
- 力扣 322:零钱兑换
- 力扣 279:完全平方数
- 力扣 139:单词拆分
最长匹配子列问题
这里只例举几个我最感兴趣的问题,并用最基本、最基础的动态规划方法解决问题。
最长公共子序列
# 这篇好像写得有点丑陋,因为写这篇的时候我对PD还停留在只是求解离散动态规划的优化算法阶段、理解十分浅薄,照书画瓢后发现我懂得太少,便学习了一段时间后才继续写的本文。想有空了把这个问题重写一遍,因为现在看来这个问题的很多重点逻辑与思想都没有写到,反而吧啦吧啦的废话写了很多……不过只有最长公共子序列问题写得烂,后文应该没什么大问题
如果不介意写得又丑又烂或者好奇有多烂,可以展开看看,有空了我会重写这个问题
最长公共子序列(the longest common subsequence,LCS)问题不像背包问题那么般显然,状态转移方程也没那么容易找到,所以按DP标准流程使用五步法分析。
明确问题:给定两个序列,寻找他们的最长公共子序列。应当注意区分,子串需要在原序列中按绝对的顺序连续,而子序列不需要,只需要保持元素间的相对顺序关系。这应当是一个二维动态规划问题。
拆解子问题,定义状态:要确定如何定义状态,先想到用矩阵的形式表示出来有哪些相同元素。以长度为
的字符串'seq_1 = kabtcdefj'
与长度为 的'seq_2 = jfacdgtestj'
为例,通过特例启发我们发现到问题背后蕴含的结构。按元素列成01表,其中 表示两元素不相同, 表示两元素相同,可以得到下表:01表 j f a c d g t e s t j k 0 0 0 0 0 0 0 0 0 0 0 a 0 0 1 0 0 0 0 0 0 0 0 b 0 0 0 0 0 0 0 0 0 0 0 t 0 0 0 0 0 0 1 0 0 1 0 c 0 0 0 1 0 0 0 0 0 0 0 d 0 0 0 0 1 0 0 0 0 0 0 e 0 0 0 0 0 0 0 0 0 0 0 f 0 1 0 0 0 0 0 0 0 0 0 j 1 0 0 0 0 0 0 0 0 0 1 记上表为矩阵
,接下来考虑如何分解问题。由于最长公共子序列同时是两个原序列的子序列,所以可取任一字符串的前缀序列,再将其与另一字符串的前缀序列进行比对,从前缀序列逐步讨论至完整的最长公共子序列,这样就形成了一个个子问题。务必注意由于子序列不要求连续的性质,这使得前缀序列新增的元素一旦使得某个公共子序列的长度加
,则所有的公共子序列的长度都会加 ,这意味着所有的公共子序列都将把这个新增的元素插入末位——长度信息蕴含了递增前缀序列间的最长公共子序列变化信息,并且由于最长公共子序列是唯一的,所以通过讨论公共子序列长度就可以唯一确定最长公共子序列。由此可以想到或许可以先研究最长公共子序列长度这个简单问题再升级至最长公共子序列,虽然长度信息不能直接提供最长子序列的具体值,但我们可以通过长度矩阵元素间的递增情况判断出最长公共子序列。所以一种解决办法就是从长度入手,定义长度矩阵 ,其中 代表seq_1[:i]
与seq_2[:j]
的最长公共子序列长度,稍后再根据 的结构定义具体的状态 。根据上述分析,
随着两个坐标的增加而递增。可以进行简单的排列组合计算出该特例问题的 ,同时为方便初始化,将一列零行向量与一列零列向量分别插入首行与首列作为第 行与第 列,其含义是 。最终计算出的 如下表:长度表 * j f a c d g t e s t j * 0 0 0 0 0 0 0 0 0 0 0 0 k 0 0 0 0 0 0 0 0 0 0 0 0 a 0 0 0 1 1 1 1 1 1 1 1 1 b 0 0 0 1 1 1 1 1 1 1 1 1 t 0 0 0 1 1 1 1 2 2 2 2 2 c 0 0 0 1 2 2 2 2 2 2 2 2 d 0 0 0 1 2 3 3 3 3 3 3 3 e 0 0 0 1 2 3 3 3 4 4 4 4 f 0 0 1 1 2 3 3 3 4 4 4 4 j 0 1 1 1 2 3 3 3 4 4 4 5 观察并通过对
实际意义的分析发现, 只与 、 和 有关。具体而言: 也可以说 通过这两个式子,可以让程序直接计算出 。如果问题只是求最长公共子序列长度,那么到这里就得到状态转移方程了,但我们需要的是最长公共子序列的值,因此接下来的问题才是重中之重:该怎样定义状态
?如何通过 计算出状态 ?又如何通过状态 求出最长公共子序列? 的计算过程与 中相邻元素间的递增关系可以帮助我们找到答案。由于 记录了两个前缀序列的最长公共子序列长度信息,所以 中相邻元素的递增关系就蕴含了两个前缀序列的最长公共子序列的长度变化信息。由于最长公共子序列是唯一的,我们想到可以用相邻元素间递增关系的行为来定义状态。根据 的递推关系式的取值条件,一共可以分为三种情况(这里将 视为两种情况,因为对应两种可能的取值): ,这时 ,定义这样的状态为 且 ,这时 ,定义这样的状态为 且 ,这时 ,定义这样的状态为
于是可以得到状态矩阵
, 表示 的转移状态表,即 相邻元素间递增关系行为的状态。注意这里状态的定义与背包问题不同,状态 至多取有限个值。 的定义式为: 接下来的问题是,如何通过蕴含了 转移状态的表 找出最长公共子序列?毕竟,如果不能帮助我们找到最长公共子序列,就意味着将这样的 定义为状态是没有意义的。我们的办法是向前逆序搜素。以
为起点,逐步向着 方向搜素最长公共子序列的元素,显然在以这种方式搜素的过程中,是先得到最长公共子序列的末位元素,再逐个向前得到前继元素,直到获得最长公共子序列的全部元素,序列的填充是逆序进行的。为什么想到向前逆序搜素而不采用更符合直觉的向后顺序搜索呢?这是因为向后顺序搜素会产生诸多麻烦。举个例子,对于序列'12345'
与'2013'
,显然他们的最长公共子序列为'13'
,但如果我们向后搜索,那么当搜素到两个一级前缀序列间、一个一级前缀序列和二级前缀序列间与两个二级前缀序列间时,得到的前缀序列们的最长公共子序列均为'2'
,但当我们逐渐搜素更高级的前缀序列直到搜素到两个原序列间时,会发现这时的最长公共子序列就变成了最终结果'13'
——尽管随着搜索的进行,前缀序列间的最长公共子序列长度是单调递增的,但实际上这些长度所对应的前缀序列间的最长公共子序列有可能不是继承的,这就是向后顺序搜索最主要的问题。这个问题如果处理不了,就很难通过记录 状态转移的 求出最长公共子序列,因为这时长度表 的相邻元素对应的前缀序列间的最长公共子序列间可以没有任何关系(除了长度不同)。而向前逆序搜素则规避了这个问题,因为从末位元素开始搜索,结果总是确定的——我们是从最长公共子序列的长度开始讨论转移状态,而长度最长的公共子序列又是唯一的,所以末位元素总可以被确定,进而通过转移状态表 才有可能确定最长公共子序列中的前继元素。向前逆序搜素,意味着不会重复计算子问题,达到了“剪枝”的效果,大大简化了流程。确定了搜素方向,那么该如何实施具体的搜索呢?
准备工作:设有位置参数
、 为当前搜索点的坐标,初始化 (即最长公共子序列的长度)、 、 。如果
,意味着 ,这时应将 (或 )置于答案序列的第 位,然后 自减 以表示下一个要放入答案序列元素的位置,再将搜索点坐标移至 ,对应操作k--
、i--
、j--
解释:因为
的等价条件 意味着 或 就是公共子序列的一个元素,由于采取了向前逆序搜素的方法,所以答案序列在 及其以后的元素都是确定的、不会改变的,故而应该直接令 或 进入答案序列;在这之后就该对seq_1[:i-1]
与seq_2[:j-1]
进行讨论了——因为已经确定了seq_1[i]
与seq_2[j]
是最长公共子序列的第 位元素,在向前逆序搜素的过程中就可以抛开seq_1[i]
与seq_2[j]
了,而只关心最长公共子序列可能的第 位元素。如果
,意味着 :seq_1[i]
与seq_2[j]
不会是最长公共子序列的第 位元素 :seq_1[j-1]
有可能是最长公共子序列的第 位元素,而seq_1[i-1]
不可能是最长公共子序列的第 位元素,所以应将搜索点坐标移至 ,对应操作j--
如果
,意味着 :seq_1[i]
与seq_2[j]
不会是最长公共子序列的第 位元素 :seq_1[i-1]
有可能是最长公共子序列的第 位元素,而seq_1[j-1]
不可能是最长公共子序列的第 位元素,所以应将搜索点坐标移至 ,对应操作i--
将上述流程循环进行直到
,所得的最终答案序列就是最长公共子序列。还可以得到最长公共子序列中各元素在两个原序列中的下标,只需要在
k--
前用两个长为 的数组分别记录下当前的 即可。求解小规模的简单问题:
构建状态转移方程:
判断复杂度:需要考虑具体的实现方式
现在可以开始编写程序了。在实际的实现中,为节省空间开销可以不计算01表seq_1[i]
是否等于seq_2[j]
。
1 | import numpy as np |
该算法的时间复杂度与空间复杂度均为
输出:
[[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.] [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.] [0. 0. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1.] [0. 0. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1.] [0. 0. 0. 1. 1. 1. 1. 2. 2. 2. 2. 2.] [0. 0. 0. 1. 2. 2. 2. 2. 2. 2. 2. 2.] [0. 0. 0. 1. 2. 3. 3. 3. 3. 3. 3. 3.] [0. 0. 0. 1. 2. 3. 3. 3. 4. 4. 4. 4.] [0. 0. 1. 1. 2. 3. 3. 3. 4. 4. 4. 4.] [0. 1. 1. 1. 2. 3. 3. 3. 4. 4. 4. 5.]] ------------------------------------- [[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.] [0. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.] [0. 1. 1. 0. 1. 1. 1. 1. 1. 1. 1. 1.] [0. 1. 1. 2. 1. 1. 1. 1. 1. 1. 1. 1.] [0. 1. 1. 2. 1. 1. 1. 0. 1. 1. 0. 1.] [0. 1. 1. 2. 0. 1. 1. 1. 1. 1. 1. 1.] [0. 1. 1. 2. 2. 0. 1. 1. 1. 1. 1. 1.] [0. 1. 1. 2. 2. 2. 1. 1. 0. 1. 1. 1.] [0. 1. 0. 1. 2. 2. 1. 1. 2. 1. 1. 1.] [0. 0. 1. 1. 2. 2. 1. 1. 2. 1. 1. 0.]] (['a', 'c', 'd', 'e', 'j'], array([1., 4., 5., 6., 8.]), array([ 2., 3., 4., 7., 10.]))
这里之所以写了真么多,是因为这是第一个子列问题,也是第一个子序列问题,许多技巧与规律并不容易独立思考出来。对于非复杂的子序列问题,常常有以下性质:
一般定义长度为状态,如果只需要最大长度,那么寻找
表中的最大值即可如果还希望求出最长长度对应的全部具体子序列,则可以通过下述两个等价方法搜寻:
- 从
表中最大长度开始,逆着状态转移方程搜寻,即寻找是哪一个长度转移到了最大长度,再寻找是哪一个长度转移到了上一个长度,循环直至找出最长子序列 - (过程中)记录或(结束后)寻找
表的状态转移(例如这里的 ),从 表中最大长度开始一步步向前搜索,直至找出最长子序列
这一点不仅适用于子列问题,在活动选择等问题中也大有用处
- 从
最长公共子串
是否可以用正则表达式实现?
最长公共子序列由于不要求连续的性质,是唯一的;而最长公共子串(the longest common substring,LCS)则可能存在多个。
动态规划是大厂的热门考点,其中最长公共子串与最长公共子序列这两道题出现得尤其频繁,这两道题其实有挺多变种,很适合考察侯选人对动态规划的掌握情况。
还是按照五步法来分析问题:
明确问题:给定两个序列
str_1
与str_2
(长度分别为 、 ),寻找他们的最长公共子串。公共子串的性质是公共子串必须同时是两个原序列的顺序连续切片,我们需要找到最长公共子串。这应当是一个二维动态规划问题。拆解子问题,定义状态:这里不能仿照最长公共子序列问题的做法,通过长度表
的转移状态,向前逆序搜素得到最长公共子序列。究其根本是最长公共子序列唯一而最长公共子串不一定唯一,仅凭各级前缀序列的最长公共子串长度信息至少不能得到全部的最长公共子串,所以应针对最长公共子串问题的特性定义新的状态,所谓具体问题具体分析。注意到子串在原序列中必然是连续的,所以想到可以定义状态
为同时以str_1[:i]
与str_2[:j]
作为末位元素的最长公共子串的长度,和最长公共子序列问题中的定义相似,这里 是 形矩阵,从第零行、第零列开始计数。这样定义状态的好处是利用了最长公共子串必然连续的性质分解问题——只要知道了最长公共子串的长度与最长公共子串中最后一个元素在原序列中的位置,那么在原序列的该位置处向前数最长公共子串长度个元素,就得到了最长公共子串。其中最后一个元素在原序列中的位置可以通过
相邻元素的增减情况获知,于是问题被分解为若干个子问题。于是不难写出
的状态转移:求解小规模的简单问题:
构建状态转移方程:
判断复杂度:需要考虑具体的实现方式
现在可以开始编写程序了。在实际的实现中,可以使用滚动数组的办法降低空间复杂度。
1 | # 输入参数 |
该算法的时间复杂度为
输出:
['abc', '123']
最长递增子序列
对于状态与状态转移方程不太明显的问题,还是按五步法来一步步分析。
明确问题:最长递增子序列问题(the longest increasing subsequence,LIS),是指给定的一组长为
数字序列seq
,按照从左向右顺序,由递增的数字组成的子序列(这些数字在原序列中不必连续出现)中,取长度最大的子序列为最长递增子序列。这应当是一个一维动态规划问题。拆解子问题,定义状态:这里对状态的定义可以参考最长公共子串问题中定义的状态,因为这两个问题都可以被视作对子列加上了更多限制的最长公共子序列问题。
由于这是一个一维动态规划问题,所以设状态为
(这里 ),类似于最长公共子串问题中的定义,定义 为以 为末位元素的最长递增子序列的长度,那么 一定与 有关,因为 对应的元素是 ,我们只要能找到 前 个元素中满足不大于 的最大元素(如果存在与之等大的元素,则取 值最大者),假设这个元素位于 的第 位,那么显然就有 ,因为在一个包含 且 是最大元素的递增子序列中, 能且只能位于末尾处,否则序列就不是递增序列了——这就利用到了递增子序列递增的性质。根据分析,可以写出 的计算公式: 算出 后,现在的问题是如何通过 找出seq
的最长递增子序列。举一个具体的例子,这里为方便起见考虑数字范围为 到 ,对于一个数字字符串'3091582079'
,元素与对应的下标为: 他的 数组为: 经观察可知,要寻找最长递增子序列,自然是从最大的 出发,因为这意味着对应的元素是长度最长的递增子序列的末位元素,因此可以通过最大的 先确定出最长递增子序列的最后一位,不妨记最大的 为 。接下来,逆着 的状态转移方程,找到 ,从每一个 算出的 就是以 为倒数第一位元素的最长递增子序列倒数第二位对应的全部可能的下标。再逆着 的状态转移方程对 进行同样的操作,循环往复,最终得到全部的最长递增子序列。可以看出,经分析,在通过 搜寻最长递增子序列时很自然地就进行了向前逆序搜素,而且各轮次计算出的 实际上呈现出树的数据结构。求解小规模的简单问题:
构建状态转移方程:
判断复杂度:需要考虑具体的实现方式
现在可以开始编写程序了。由于要找出全部的最长递增子序列的具体值最好用树的数据结构逐级运算,但这又需要先实现一颗多叉树,相对会麻烦一些,这与本文的主题——动态规划也没有太大关系,所以这里只实现返回最长递增子序列长度,并打印
1 | # 输入参数 |
该算法的时间复杂度为
输出:
[1, 1, 2, 2, 3, 4, 3, 2, 4, 5] 5
最长公共递增子序列
这个问题的解显然可以借鉴最长公共子序列问题与最长递增子序列问题的算法,是相似但同时了两个问题特征的问题。
明确问题:最长公共递增子序列(the longest common increasing subsequence,LICS),是指给定的两组数字序列
seq_1
、'seq_2'
,长度分别为 、 ,按照从左向右顺序,由递增的数字组成的二者的公共子序列(这些数字在原序列中不必连续出现)中,取长度最大的公共子序列为最长递增子序列。这应当是一个二维动态规划问题。拆解子问题,定义状态:类似处理,定义状态
为同时以 与 作为末位元素的最长公共递增子序列的长度,只需要对最长递增子序列的状态转移做部分修改就可以得到该问题的状态转移,其中的修改的目的是考虑到子序列是公共的而进行二维化改造,包括仅在 时才考虑 的值加一、在向前搜寻 满足条件的最大值时还要求 且 :求解小规模的简单问题:
构建状态转移方程:
判断复杂度:需要考虑具体的实现方式
现在可以开始编写程序了。同样地,简便起见这里只返回最长公共递增子序列的长度并打印
1 | # 输入参数 |
该算法的时间复杂度为
输出:
[[0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0, 2, 1], [0, 1, 0, 0, 0, 0, 0, 1, 3], [0, 0, 2, 1, 1, 1, 1, 1, 2], [0, 0, 1, 3, 2, 2, 2, 2, 2], [0, 0, 1, 2, 4, 3, 3, 3, 3], [0, 0, 1, 2, 3, 5, 4, 4, 4], [0, 0, 1, 2, 3, 4, 1, 5, 5], [0, 0, 1, 2, 3, 4, 5, 2, 5], [0, 1, 1, 2, 3, 4, 5, 5, 3]] 5
最长递增子串
结合最长公共子串算法中对子串性质的利用,以及最长递增子序列算法中对递增性质的利用,很容易解决这个问题。
明确问题:最长递增子串问题(the longest increasing substring,LIS),是指给定的一组长为
的数字序列seq
,按照从左向右顺序,由在'seq'
中连续的递增数字组成的子串中,取长度最大的子串为最长递增子串。这应当是一个一维动态规划问题,其中最长递增子串不一定唯一。拆解子问题,定义状态:利用到上文中提到的两个性质,不难想到可以定义
表示以 为末位数字的最长递增子串的长度。然后,只需要找到最大的 值,在这些 值对应 中的元素处向前数最大 值个元素,就得到了最长递增子串。 的递推关系为: 可以看到,这里的 递推关系与最长公共子串问题中的递推关系相比,区别仅仅在于条件中两个数字的序关系不同。求解小规模的简单问题:
构建状态转移方程:
判断复杂度:需要考虑具体的实现方式
现在可以开始编写程序了。可以用滚动数组的方法降低空间复杂度。
1 | # 输入参数 |
该算法的时间复杂度为
输出:
['01235', '12456']
最长公共递增子串
结合最长公共子串算法与最长递增子串算法,对最长递增子串算法进行二维化改造即可。
明确问题:最长递增子串问题(the longest common increasing substring,LICS),是指给定的两组数字序列
seq_1
、'seq_2'
,长度分别为 、 ,按照从左向右顺序,由在两个数字序列中均连续的递增数字所组成的子串中,取长度最大的公共子串为最长公共递增子串。这应当是一个二维动态规划问题,其中最长公共递增子串不一定唯一。拆解子问题,定义状态:定义
表示同时以 与 为末位数字的最长递增子串的长度,有 由于公共子串在任何一个原序列中都是连续的,所以算出 后对任意一个原序列按最长递增子串问题中最长递增子串的搜寻方法即可得到最长公共递增子串。求解小规模的简单问题:
构建状态转移方程:
判断复杂度:需要考虑具体的实现方式
现在可以开始编写程序了。可以用滚动数组的方法降低空间复杂度。
1 | # 输入参数 |
该算法的时间复杂度为
输出:
['01235', '24569']
最长重复子串之一
这个问题可以用Rabin-Karp算法解决,Rabin-Karp算法是非常高效的。除了Rabin-Karp算法,还可以使用利用KMP算法、二分查找等等,都可以解决该问题。这个问题不方便直接应用动态规划,因为难以分解出具有最优子结构性质的重叠子问题。
具体而言,最长重复子串(the longest repeating
substring,LRS)问题,是指给定一个长为seq
,寻找seq
所有至少出现两次的子串中长度最大的子串。这个问题不方便直接对seq
的元素定义状态并列出状态转移方程,但依然可以在设计算法时利用到动态规划方法。
设计算法解决最长重复子串问题,至少有两种思路:
KMP算法:KMP算法的关键就是求解
数组,针对 ,可以得到 ,这个式子恰好意味着重复子串,所以由此求最长重复子串问题就转化为求解 数组中最大值问题,而 数组的计算实际上就是用前缀表与动态规划完成的。这部分的分析与实现,留到后文“模式匹配KMP算法”一章中再作讨论。
后缀数组:取
seq
的各级后缀序列,再对其进行字符串间排序,其中排序可以考虑使用时间复杂度为 的快速排序算法,接着计算排序后两两相邻的后缀序列比较前缀公共子串的长度,找到所有相邻后缀序列间的前缀公共子串中长度最大的子串,该子串就是所求的最长重复子串。实际上,KMP算法中计算 数组也是基于前缀表的,所以可以说这个问题用前缀数组和后缀数组都可以解决。
这里先只利用后缀数组设计算法,尽管他与动态规划没有太大关系,但在后文中将会用基于动态规划的KMP算法再次解决这个问题。现在可以开始编写程序了。
1 | # 输入参数 |
该算法的平均时间复杂度为
输出:
mnbabc123abc450uio123519 nbabc123abc450uio123519 babc123abc450uio123519 abc123abc450uio123519 bc123abc450uio123519 c123abc450uio123519 123abc450uio123519 23abc450uio123519 3abc450uio123519 abc450uio123519 bc450uio123519 c450uio123519 450uio123519 50uio123519 0uio123519 uio123519 io123519 o123519 123519 23519 3519 519 19 9 0uio123519 123519 123abc450uio123519 19 23519 23abc450uio123519 3519 3abc450uio123519 450uio123519 50uio123519 519 9 abc123abc450uio123519 abc450uio123519 babc123abc450uio123519 bc123abc450uio123519 bc450uio123519 c123abc450uio123519 c450uio123519 io123519 mnbabc123abc450uio123519 nbabc123abc450uio123519 o123519 uio123519 ['123', 'abc']
最长无重复子串
其实这个问题,我认为用滑动窗口的方法是最合适且直观的,如果只需要算出长度,应用滑动窗口方法的Python码可以参考LCR 167. 招式拆解 I(滑动窗口 + 哈希表,清晰图解)(其中动态规划的思路也很简明清晰),C++的实现可以参考双指针滑动窗口、动态规划、暴力线性遍历,可以做到线性时间复杂度。
如果要应用动态规划方法且只需要计算长度,结合哈希表也可以做到绝对的线性时间复杂度
1 | class Solution: |
最长无重复子串也是一个对动态规划学习的很有启发性的问题。现在把这一问题当作动态规划问题,按五步法进行分析。
明确问题:最长无重复(字符)子串(the longest substrings without repeating characters,LSWRC)问题,是指给定一个长为
的字符串seq
,寻找seq
的所有不包含重复字符的子串中,长度最长的无重复字符子串。易见最长无重复字符子串不一定唯一。拆解子问题,定义状态:根据相似问题类似地定义
表示以 为末位字符的最长无重复字符子串的长度,对于 ,若 且 ,则记 。实际上,这样定义 就是为了表示从 向前数的第一个与 相同的字符的位置。容易知道,从 开始到 结束的子串是只包含两个字符 的子串,所以从 开始到 结束的子串一定不存在重复的字符 。现在可以拆解子问题了,从 出发可以得到 ,从 可以得到 得到了 ,可以推导出 : 得到了 ,可以推导出 :其中
指以 结尾的最长无重复子串可见,
是可以通过状态转移计算的,且只依赖于 与 。注意到, 与 之间也存在一些关系:前文已经分析过,从
开始到 结束的子串是一定不存在重复字符 的子串,而从 到 的子串又必然不存在重复字符(结合子串连续的性质与 的定义推理),所以如果 位于 之前,则意味着从 到 的子串均不存在重复字符,否则要么 在 之后,要么 就不意味着以 为末位字符的最长无重复字符子串的长度——后者是不可能的,前者则不满足假设,因此这样的情况下, ,此时的最长无重复子串为从 到 的子串。这些关系式蕴含了最优子结构性质,最优解 依赖于其子问题最优解 。不过,如果
位于 或其后,即 ,则说明尽管从 到 的子串不存在重复字符,但是 一定与从 到 的子串中的某个字符重复,这是容易理解的,这时 ——从 到 的子串不存在重复字符,当然从 到 的子串也不存在重复字符,但根据 的定义, 与 重复,所以以 为末位字符的最长无重复子串就是从 到 的子串。至于
不存在的情况,说明目前为止从 首位字符开始直到 都不存在与 重复的字符,自然地有 ,此时的最长无重复子串也是从 到 的子串。
现在距离算出
表只差最后一个问题:如何寻找 ?如果用for
循环遍历实现则需要回溯子串,那么算法的时间开销相比暴力搜索也没有优化太多太多(暴力搜索的时间复杂度为 ,而回溯子串的为 ),相对于滑动窗口方法的线性时间复杂度仍然差了一大截。我们可以用哈希表来解决这个问题,构造如下一个哈希映射:以字符对应的ASCII码作为地址映射到哈希表,值只取 或 ,首先将哈希表中的值全部初始化为 ,然后将当前最长无重复字符子串(从 到 的子串)的各个字符进行哈希映射,映射值取 ,接下来如果要判断 是否与当前最长无重复字符子串中的字符重复,只需要将 进行哈希映射,如果对应地址的哈希值为 ,则说明与当前最长无重复字符子串的字符均不重复;如果对应地址的哈希值为 ,则说明与当前最长无重复字符子串的字符均重复。对于第一种、不存在重复的情况,哈希表可以继续使用,将 对应地址的哈希值赋 即可;如果是第二种、存在重复字符的情况,则要么回到流程起始处重新构造新的更新最长无重复字符子串的哈希表,要么将从 到 的子串中各字符对应在哈希表中的值重新赋 。这会导致时间复杂度上升至 ,但如果希望得到具体的最长无重复子串,就不得不这样做,这是为了保证哈希表的值中至多存在一个 。若只需要得到最长无重复子串的长度,则可以去掉这一步,将时间复杂度降低至 。然后,现在我们的哈希表也只能判断是否有重复字符,而不能直接得到
,所以我们可以构造一个双层哈希表,只需要在将字符进行哈希映射并赋值作为第一元的同时向该地址处的第二元填入该字符在 中的位置参数,并在出现重复元素时将重复元素对应的位置参数更新为 即可(最初我并没有考虑到这一点)。这样,当 与当前最长无重复子串存在重复时, 就等于哈希表中第一元值为 位置处的第二元的值判断重复元素,是哈希表的妙用之一。所以,我们的解决方案是动态规划
哈希表。求解小规模的简单问题:
构建状态转移方程:
判断复杂度:需要考虑具体的实现方式
现在可以开始编写程序了。同样地,可以用滚动数组节省空间。
1 | # 输入参数 |
该算法的平均时间复杂度
输出:
['123456789', '896520413']
写这个算法的时候我碰到了两个错误,导致我在这个问题上花费了很多时间,这里记录一下我初次解这个问题时出现的错误:
- 第31行的
for char in seq[n-dp_last:k]:
,我最初写成了for char in seq[n-dp_last:k+1]:
,下标的使用不正确 - 起初漏写了第23行的代码,少考虑了情况,当时我以为只需要第28行代码就可以实现哈希表第二行的更新,实际上两行代码均缺一不可,少一个就会漏考虑情况:第23行代码的意义是当第
个字符与当前最长无重复子串中的字符重复时,更新第二行哈希表,将 位置上的字符 所对应的位置参数更新为 ;第28行代码的意义则是当存在 但 并不位于当前最长无重复子串中时,在将 插入当前最长无重复子串尾部作为新的最长无重复子串时,更新位置参数,可见二者的功能其实完全不同,因为第23行的代码修改的是哈希值原本就为 的位置参数,而第28行的代码修改的是哈希值为 的、需要赋值为 时的位置参数
不过如果只是求最长无重复子串的长度而非所有的最长无重复子串,代码会非常简单,这些错误也不会出现,因为错误对应的代码并不需要实现。
最长回文子序列
# 建议先阅读下一项问题:最长回文子串,再回头阅读本项问题
# 两个问题较为相似,本问题算法基于下一项问题,所以对相似之处不会做过多解释
# 如果理解有困难,先阅读下一项问题
这里是最长回文子序列问题(the longest palindromic
subsequence,LPS),和下一项问题最长回文子串一样,这里只计算最长回文子序列的长度,如果需要最长回文子序列的值,则记录seq
且长度为
易见,
以'bbbab'
为例,去掉基准
同时,注意到
1 | # 输入参数 |
该算法的时间复杂度与空间复杂度均为
输出:
[[0, 0, 0, 0, 0, 0], [1, 2, 3, 3, 4, 0], [0, 1, 2, 2, 3, 0], [0, 0, 1, 1, 3, 0], [0, 0, 0, 1, 1, 0], [0, 0, 0, 0, 1, 0]] 4
最长回文子串
其实最长回文子序列问题中对状态定义的思路源自于本问题。本问题比最长回文子序列简单,但状态的定义是类似的,而本问题的状态定义更容易想到,所以实际上应该先讨论本问题,再讨论最长回文子序列问题。记号的含义同最长回文子序列问题中的定义。
最长回文子串问题(the longest palindromic
substring,LPS),相较于最长回文子序列问题更为简单,一个直观的思路是利用双指针,使用中心扩展的方式寻找回文子串。当然,也可以使用大名鼎鼎的针对性算法:Manacher算法,时间复杂度为
在最长回文子序列问题中,我们想到用一个二维数组
如果还是感觉没有思绪,可以先分析回文子串的特征:回文子串也是一个子串,在原字符串seq
中是连续的,只用一个指针
明确了状态
1 | # 输入参数 |
该算法的时间复杂度与空间复杂度均为
输出:
[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 0, 0, 2, 2, 0, 0, 8, 0, 0, 2, 0, 4, 0], [0, 1, 0, 0, 0, 0, 6, 0, 2, 0, 0, 2, 0, 0], [0, 0, 1, 0, 0, 4, 0, 0, 0, 2, 0, 0, 0, 0], [0, 0, 0, 1, 2, 0, 0, 2, 0, 0, 2, 0, 2, 0], [0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 7, 0, 2, 0], [0, 0, 0, 0, 0, 1, 0, 0, 0, 5, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 1, 0, 3, 0, 0, 4, 0, 0], [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 4, 0], [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 3, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0]] 8
最长匹配子列杂例
暂略,有空了再整理
- 一些本文没有提到的序列问题:【算法专题】动态规划之子序列问题
模式匹配KMP算法
常用于字符串模式匹配的算法有很多,例如BM算法、Horspool算法、Sunday算法、KMP算法、KR算法与AC自动机算法,这里讨论基于动态规划的KMP算法——这是动态规划的一个较难的应用。
《数据结构》严蔚敏著:串的模式匹配通常指子串的定位操作,是各种串处理系统中最重要得到操作之一。
在模式匹配中,称定位操作的对象为主串
BF算法直观、易理解,且处理文本匹配这类问题时有着
匹配原理与流程
KMP算法于1977年由Donald E. Knuth,James H. Morris与Vaughan R. Pratt在论文《Fast Pattern Matching in Strings》中首次提出。KMP算法相较于BF算法的改进在于,在每一趟匹配过程中当出现返回结果匹配不成功(称为“失配”)的情况时,值,而是利用这趟匹配最终出现匹配不成功前已匹配成功的结果,在选择下一个进行匹配的主串中字符时 ,选择尽可能靠后的字符,这样就可以跳过不必进行的匹配。而确定具体该选择主串中哪个字符作为下一个进行匹配的字符的过程,这背后蕴含的实际上是一个动态规划的过程。
在初学时我也因为教科书上一堆抽象的数学公式而完全不能理解(即使我是半个数学系毕业的……),现在我希望从需求出发自然地引出前缀表、
让我们先来模拟一个流程,说明为什么最初的算法效率低下,而KMP算法又需要从哪里对最初的算法进行改进,并且改进后指针
举一个具体的例子,设主串'ABBBAAAAAAABBBA'
、模式串'AAAABBB'
。一趟匹配定义为从上一轮匹配发生失配后的初始化到本轮匹配发生失配的过程,假设在某趟匹配开始时,i++
、j++
。这里就不重复匹配成功情况下的过程了,我们观察到当i++
操作,而i++
操作整体向右移了一位。
'A'
,而'A'
,所以其实
那么KMP算法会怎么做呢?KMP算法中的指针i++
、j++
,直到发生失配。直到这一步,KMP算法与BF算法还没有太大差别。
发生失配后,i++
时模式中有必要从某处开始匹配的位置——
知道了要解决的问题,就可以来一步步分析了。记主串
从
既然只需要研究状态ABBABAB
,暂时不必考虑
所以令ABBABAB
的最长相同前后缀,我们先来看看前缀与后缀的定义。
对于一个字符串str
,以'BABAABA'
为例,他的前缀(prefix)是不包括末位字符的所有以其首位字符为起始的子串所组成的集合,他的后缀(postfix)是不包括首位字符的所有以其末位字符为终点的子串所组成的集合。对于'BABAABA'
而言,他的前缀与后缀为:
'BA'
是相同的前后缀,自然也是最长的相同前后缀,其长度为
通过以上定义,可以知道:前文中我们所观察到的“'ABBAB'
的一个相同前后缀,而且其恰好是最长的相同前后缀:
数组
接下来讨论在更普遍问题中
当
总结:当i++
,
- 为什么令
而不是 ?之所以令 而不是 ,是因为我们利用的是失配前的那些匹配成功的信息。对于很多模式匹配问题,主串与状态的字符集中都不止两种字符,这种情况下如果发生失配,则我们不可能仅从失配时的 推断出 与 的具体值与他们同 中其他字符的关系,除非回到主串 与模式串 中进行查找与比对。所以我们在寻找如何最大初始化 的过程中,其实对信息的利用主要都是从上一位开始向首位进行的。
对于模式ABBABAB
的例子,上文中只分析了'A'
,构造一种i++
、
究其根本,发生这种错误的根本原因是因为我们应该在匹配前进行某种操作,保证有
先暂时回到
到这里,在已知
1 | # 输入参数 |
前缀表的动态规划
现在让我们讨论怎么快速地计算
按这类问题动态规划的“套路”,定义状态
当
,意味着 可见 代表的最长相同前后缀长度一定恰比 大一,所以有 。当
,记 所对应的 式等号左边的前缀为 、等号右边的后缀为 ,则 与 在 中的排列为:注意,当
足够长, 是有可能位于 左边的现分析
对应的最长相同前后缀的情况:对于前缀
,如果存在 ,其中 且 ,则一定有 可见 与 中的较小值对应的 一定是较大值对应 的前缀,并且可以确认这一前缀是几级前缀,除非 ,则两个 相等,互为子串(因为前缀不包括最后一个元素,所以这时不能说是前缀)。前提同上,但后缀
与 间就不存在这样的关系。 如果不利用 找出两个后缀具体的值并通过模式匹配对字符逐个比对,这时根本无法找出二者更进一步的对应关系,他们也有可能完全不同,也有可能部分相同,还有可能完全相同,而且即使部分相同,也找不出所有相同字符在 与 中的位置。
所以不能通过具体的前后缀寻找
,因为 的实现中只记录了 的长度为 的子串的最长相同前后缀的长度,但并没有具体找出并记录这些最长相同前后缀的具体值,根据上文中对 与 相应最长相同前后缀关系的分析,我们无法近通过长度找到 与 对应最长相同前后缀的关系 我们无法通过已知的 找出所有具体的 与 ,再根据他们找出 对应的最长相同前后缀 与 ,从而计算出 。既然最直观的想法——计算出所有具体的
与 并根据他们与 、 的关系找到 、 的办法行不通,则只能考虑从 本身入手。仔细分析后发现,实际上我们也并不需要所有具体的 与 ,我们首先只需要 的长度为 且满足 的子串并找到该子串的最长相同前后缀长度——这里解释一下为什么需要该子串满足这两点要求,这是计算 最核心的一点。究其根本,是因为 对应子串的前后缀要成为最长相同前后缀,首先必须满足前缀的前 个字符与对应的后缀前 个字符必须是全部匹配成功(前 个对应位置的字符应全部相同,保证除最后一个字符外二者是相同公共前后缀)的、同时后缀的前 个字符组成的子串必须是 的后缀(该要求源自于在 的大前提下可以推导出 一定不大于 ,而 不能作为 的最长前缀只是因为 ,所以这样限制就可以保证要找的后缀有可能且是仅有可能的成为 最大前缀的字符串)。记 是某个小于 的非负整数、表示除开最后一位字符外的相同前后缀长度,则只需要找出满足下式的 ,于是 就是符合前述限制的相同前后缀的长度: 在具体的计算过程中,注意到 正是满足条件的最大的 —— 的长度为 且满足 的子串的最长相同前后缀长度,记为 ,也就是 的前 个字符所组成的子串的最长相同前后缀之长度,- 当
,先赋 ,然后判断是否有 成立,若等式成立则赋值 ,若等式不成立则寻找满足 式的第二大的 ,记为 - 类似对
的分析,这时 实际上正是 的长度为 且满足 的子串的最长相同前后缀长度,根据结果 再次判断是否有 成立,如果成立则赋值 ,等式不成立则寻找满足 式的第三大的 ,记为 - 令
,判断是否有 成立,若成立则赋值 ,不成立则寻找满足 式的第四大的 ,记为 - … …
- 直到在
时第一次有 成立、令 ,这时无需再判断是否有 成立,或直到判断到最小的 ,记为 ,仍没有 成立,说明不存在公共前后缀,视最长公共前后缀长度为 ,这时赋 。
以上就是状态转移的具体描述了。为什么说
、 、 等,而不说 、 、 等等呢?原因和第二、三、四点中对 的分析类似,因为在匹配不成功时导致匹配不成功的这对字符我们没有也没有必要讨论他们具体值,因为在实际问题中字符集可能很大,我们只要知道他俩不相等即可(所以将他俩排除,“不相等”难以被利用),最关键的信息来自于这对字符之前那些相等的字符们,故只考虑匹配不成功字符以前的字符,他们的长度为 减一。综上所述,现在可以按分析编写计算
数组的程序了。 其实可以被放入循环中进行判断,也就是说其实可以将所有的判断均放入循环之中,这包括了第一步对 的判断,令 即可。1
2
3
4
5
6
7
8
9
10
11
12
13T = 'abaabcac'
m = len(T)
next = [0 for _ in range(m)]
for j in range(1, m): # j+1为T正在匹配字符的位置,T[j]为最长后缀尾部
y = next[j-1] # 初始化y为next[j-1],next[j-1]+1是next[j]可能的最大值;T[y]为最长前缀尾部
while y > 0 and T[y] != T[j]: # 若满足条件则next[j]向左滑,y被更新为所有y中第n大的y
y = next[y-1] # 左滑的过程同时也是状态转移的过程,有next[next[next[...next[y-1]]]]
if T[y] == T[j]:
y += 1 # 除了y <= 0,结果均加1,因为另外两种情况都有T[y] == T[j],需要加1才能得到正确结果
next[j] = y
print(next)输出:
[0, 0, 1, 1, 2, 0, 1, 0]
补充KMP算法可视化:KMP 学一遍忘一遍?ACM 金牌选手用可视化直击本质,理解了内核后想忘记都难!
基本的实现
在实际的实现中,输入参数除了需要匹配的字符串
1 | # 输入参数 |
输出:
8
其中计算
常数优化与nextval改进
最长重复子串之二
组合优化Others
活动选择
活动选择问题(activity-selection problem)是一个十分经典的贪心算法代表性问题。然而,几乎每一个贪心算法都对应一个更繁琐的动态规划算法,所以这里将用动态规划的方式解决这个问题。
活动选择问题是指,假设一共有
例如,对下面
证明过程实际上就构造出了状态转移,设
1 | # 输入参数 |
该算法的时间复杂度为
输出: np.max(dp)=2
,并且存在两个值为
若还需要给出具体的活动安排,则可以根据结果搜寻开始时间在
编辑距离
最短编辑距离(edit distance / minimum edit
distance)是一个非常之经典的动态规划问题。如果只是初见,可能很难想到这个问题可以通过动态规划的方法解决。最短编辑距离是这样描述的:提供三种针对单词的操作,分别为在单词的某处插入一个字母、删除一个字母与替换一个字母,求通过这三种操作将单词word_1
转换为word_2
的最少操作次数。
以单词word_1 = 'csgo'
与单词word_2 = 'coser'
为例,先讨论为什么这可以是一个动态规划问题。通过前面这么多问题的分析,大概也能总结出动态规划的目的:找出状态与状态转移,并通过一些初始条件(基问题)与状态转移,求出整个word_1
与word_2
的顺序子串,观察他们的规律:
word_1
的前word_2
的前word_1
、紫罗兰色字符为子问题的word_2
,这样就可以问题分解为若干子问题。按逐行从左至右的顺序分别设表格单元对应在
- 当两单词末位字符不同时,若对
'csgo'
进行一次删除末位字符操作,则 ,对应 - 当两单词末位字符不同时,若对
'csgo'
在末位插入一个字符,由于我们插入的字符一定是匹配'coser'
的,这里应增加'r'
,否则需要删除再重新插入,于是在word_1
末位插入字符等价于不必考虑'word_2'
的末位字符,因此 ,对应 - 当两单词末位字符不同时,若对
'csgo'
替换一次末位字符,类似上一种情况,这时可以同时不考虑word_1
与word_2
,所以 ,对应 - 在两单词的末位字符相同(尽管这一例子不是这种情况),意味着可以直接“无视”两单词的末位字符,这时不必对末位字符进行任何插入、替换与删除操作,这种情况类似于上一情况(或者说上一情况下进行替换操作后得到了这一情况),但并不需要对操作数加
——因为没有进行操作,对应
于是得到了word_1
的前word_2
的前
1 | # 输入参数 |
该算法的时间复杂度与空间复杂度均为
输出:
13
这个算法针对句子等也是可用的,不止能用来比对单词,因为他是按下标进行匹配的。
通配符匹配
通配符匹配(wildcard matching / wildcard pattern
matching)也是一个模式匹配问题,输入包含一个长为'?'
与'*'
匹配规则的通配符匹配。为简化实现,属于通配符集的字符仅可能在
'?'
可以匹配任何单个字符'*'
可以匹配任何字符序列,包括空字符序列''
如果true
,否则返回false
。
为了寻找思路,我们先来看一个例子。设S = 'youonlyliveonce'
、T = 'y*n*n?e'
,对两个字符串逐字符制表,对于匹配成功的记为true
;否则返回false
。在本例中,由若干true
。
因而,若是希望用动态规划解决这个问题,就需要找出有可能到达表右下角的合法路径在表格之间转移的规则。对于'?'
与英文字母的匹配是容易想到的,记当前匹配字符为
- 如果
中第 个字符为通配符'?'
,这时无论如何也会匹配成功一位,所以路径向右下方延展一格,即 ,然后讨论第 个字符; - 如果
中第 个字符为小写英语字母,则路径当且仅当 时向右下角延展一格,即 ,并继续讨论第 个字符,否则路径中断,不再延展。
不满足以上规则的路径是非法的。难点在于,若'*'
、'*'
至少可以匹配空字符,所以路径先向下延展一格,此时路径点来到了'*'
应该匹配几个字符。
这时我们发现好像找不到状态转移方程,进而也无法通过动态规划确定'*'
应该匹配几个字符——至少我只想得到用回溯算法求解。
我们找不到状态转移的根本原因是如果将true
,否则记为false
。这样,每一个
在这样的状态定义下,其他情况的状态转移不言自明,只解释当'*'
到底匹配了几个字符,因为眼下只关注'?...'
或''
。若将'?...'
,这时'*'
至少匹配了一个字符,则''
,则
在这样的定义下,只考虑内容为true
的表格,true
简记为true
,就说明
不难分析出,每一行
- 初始化
表的第一列与第一行为false
,除了 记为true
- 如果
为文本字符,则遍历 的字符将 与 进行匹配,若匹配成功则延续 的状态(如果还想不明白,请回到我们对 的定义),若整行均为false
,说明匹配均失败,程序直接返回最终结果false
;并且,匹配可以从第四种情况中定义的下标 开始以减少不必要的匹配 - 如果
为通配符'?'
,则按上一种情况中一定匹配成功的结果处理,可以通过将 整体右移一位快速得到 而无需遍历(队列) - 如果
为通配符'*'
,则向下延续上一行 中匹配成功的状态true
,并在本行对匹配成功的状态true
向右延续,具体操作为先找到 中第一个为true
的对象,记其下标为 ,则对行 下标大于等于 的对象均赋值true
对照代码,一目了然:
1 | # 输入参数 |
该算法的时间复杂度为
输出:
true
执行时间轻松击败力扣上96.60%的人,内存开销击败90.34%的人。虽然这个问题在力扣上属于hard题目,个人认为只要能想到如何定义状态其实就不是难题。
现在来看一个变种问题,正则表达式匹配问题,但额外要求恰完全匹配。借着这个问题,现在套用刚才的思路来完整地分析一遍流程。
本问题的完整描述为,正则完全匹配是指输入包含一个长为'.'
与'*'
的正则表达式匹配。为简化实现,'.'
与'*'
仅可能在'.'
与'*'
的匹配规则为:
'.'
可以匹配任何单个字符'*'
可以匹配零个或多个前面的那个元素
如果true
,否则返回false
。
可以看出,'.'
的作用与上一问题中'?'
的作用完全相同,我们完全可以仿照上一问题的解决思路,只需要关注如何处理本问题中的'*'
即可。照搬通配符匹配问题中我们对状态的定义:true
,否则记为false
。
这里还是举一个具体的例子,例如S = 'coooolleepic'
、T = 'go**.l.*pic*'
,则不难得到其'*'
时当前行的true
不再一直向右延展到底,而是向右延展、直到'?'
实际上匹配的是何字符,视
单调递增- 路径不存在 T 字形分叉
'*'
不被允许位于 的首位- 不能再将
'.*'
与'*.'
均视为'*'
,对于'.*'
,要明确'.'
具体匹配了什么字符,这样才能知道其后的'*'
可能匹配什么重复字符 - 若
,则行 中每个表格对应的匹配字符可能是不同的,例如 分别匹配了'o'
、'o'
、'o'
、与'l'
,但无论搜索进行至哪一行,对于某一确定的行而言,已匹配的字符都是固定的,不会随着搜索的进行而改变,所以需要额外使用一个向量用以存储当 时行 中值为true
处所匹配的字符,用以更新
实际上重新讨论'*'
时的情况即可,'*'
,则可以列出状态转移:
当
:若
,若
为其他字符,这时'*'
要么匹配空字符、要么匹配 ,故若
,这时'*'
要么匹配空字符、要么匹配'.'
所匹配的元素,利用 ,将这一子问题转化为另一子问题“ 且 为其他字符”,故
当
:将 整体右移一格作为 ,同时记录当
为其他字符:
代码如下:
1 | class Solution: |
复杂度同上。
正则表达式匹配
正则表达式匹配(regular expression
matching)是指输入包含一个长为'.'
与'*'
的正则表达式匹配。为简化实现,'.'
与'*'
仅可能在'.'
与'*'
的匹配规则为:
'.'
可以匹配任何单个字符- 对于任意元素
'?'
,'?*'
可以匹配零个或多个'?'
('*'
不会单独出现在 的首位,必须搭配前继元素以发挥作用)
如果true
,否则返回false
。注意,这里的'*'
实际上可以对其前一位元素不予考虑,而上一个问题中的'*'
至多只能对本处的'*'
不予考虑,这是二者的根本差别。例如,当s = 'abc'
、p = 'c*abc'
时,在本问题中应当返回true
,因为'c*abc'
可以被视为'abc'
;而在上一个问题中则应返回false
,因为'c*abc'
至多应被视为'cabc'
,首位永远为'c'
。
正则表达式匹配最好最标准的实现还是通过生成编译原理中的有限状态机,可以参考正则表达式匹配;由动态规划的实现,也有一篇写得很好的文章『 动态规划 』字符串 DP 详解:状态转移推导 + 滚动优化 + 提前结束,可以补充阅读,提供了与本文不一样的视角。
该问题与上一个问题类似,
若
为文本字符,则若
为'.'
,则将 右移一位作为若
为'*'
,则需要讨论若
为'*'
,则视'**'
为'*'
,若
为'.'
,则视'.*'
为任意长字符串(但长度不能为零),所以若
为文本字符'x'
,则视'x*'
为'x'
重复任意次的字符串,因此令当
'x*'
匹配''
,相当于对子字符规律的最后 个元素不予考虑,取 ,则 ,蕴含有当
'x*'
匹配'x'
,当
'x*'
匹配'xx'
,… …
当
'*'
匹配 个'x'
,其中
满足 ,也就是说当 遍历到下一个 时, 置 ,重新开始这一匹配流程
总结下来,当
为'*'
且 为'x'
时状态转移可简化为
这个问题用动态规划处理的难点在寻找'x*'
时的状态转移。但只要我们逐步分析,问题也能够迎刃而解。
需要注意由于本问题的算法中涉及到false
,而应当考虑p
中的'*'
:按行数从小到大遍历,当'*'
时令false
。
根据以上分析,同时注意边界条件,代码如下:
1 | # 输入参数 |
该算法的时间复杂度为
输出:
true
最后,这个算法其实写复杂了些——即便他能正常工作,而且速度与效率也令人满意。原因还是由于涉及到__init_dp_rem__
函数)。实际上,完全可以压缩到只使用两个一维数组:将行列转置,即以s
对应行、p
对应列,然后再以行遍历为大循环、列遍历为小循环。这样不仅代码简洁,连初始化也变得十分容易。
容易分析,造成我们代码繁复的根本原因是:由于状态转移涉及到
这告诉我们正确分析了问题后,不能直接一股脑就照着实现代码,得看看怎么写才方便……这个代码写得我太恶心了。
扰乱字符串
前言:这是一个高维动态规划问题。十分推荐先看一看这个可视化视频:ACM 金牌选手教你记忆化搜索。力扣 No.87 扰乱字符串,真·动画教编程,适合语言初学者或编程新人。
这个视频讲明白了扰乱字符串中的递归与动态规划两种实现方式的思路。
# 该问题的
扰乱字符串(scramble
string)是指,我们可以用下述算法扰乱字符串s
得到字符串t
:
- 如果字符串的长度为
,算法停止 - 如果字符串的长度大于
,则执行下述步骤:- 在一个随机下标处将字符串分割成两个非空的子字符串。即,如果已知字符串
s
,则可以将其分成两个子字符串x
和y
,且满足s = x + y
。 - 随机决定是要“交换两个子字符串”还是要“保持这两个子字符串的顺序不变”。即,在执行这一步骤之后,
s
可能是s = x + y
或者s = y + x
。 - 在
x
和y
这两个子字符串上继续从步骤1开始递归执行此算法。
- 在一个随机下标处将字符串分割成两个非空的子字符串。即,如果已知字符串
现给定两个长度均为s1
和 s2
,判断 s2
是否是
s1
的扰乱字符串。如果是,返回 true
;否则,返回 false
。该问题的描述引用自87.
扰乱字符串。
按照一贯的做法,似乎我们应该定义状态false
(
现在通过一个具体的例子分析问题,注意到这实际上这是一个二叉树的交换问题,而且s1
与s2
地位等价(对称)。以字符串'fairy'
为例,我们先随机进行二分:
'fa'
,交换其两个子节点'a'
与'f'
,得到扰乱字符串'afiry'
:
'fa'
与'iry'
,得到讨乱字符串'iryaf'
:
'fairy'
、'afiry'
与'iryaf'
就互为扰乱字符串。但'fairy'
与'ifyar'
却不是,因为不可能经过任何扰乱由'iryaf'
得到'ifyar'
。
思路:因为一个字符串可能是另一字符串的扰动字符串发前提是两字符串长度相同,故可以用三个指针对s1
与s2
取相同长度的子串,如令第一个指针与第二个指针分别指向开头与结尾、作用于s1
截取子串,令第三个指针则指向s2
子串的结尾,再根据s1
截取子串的长度确定s2
所截取的子串。也可以理解为对两个字符串分别用两个指针截取子串,只不过利用子串长度相同的性质,实际上只需要三个指针。可定义状态
要是实在想不到这样定义状态,也可以从最简单、“暴力”的递归与回溯着手考虑,利用备忘录机制进行搜索,也不会花太多时间。对于Python,递归时只需要加上@cache
装饰器就好了。若还是一定要用动态规划的方法,可以从这样递归和回溯的思路中寻找灵感——对大多数问题而言,记忆化搜索与动态规划在思想上是等价的、两个算法也是可以相互转换的,除了运行效率上可能有所区别。递归的简洁实现可以参考递归,也是非常好的方法,这里就不实现了,主要讲怎么运用动态规划。
定义了状态后,现在就需要找到状态转移——其实状态转移已经蕴含在问题描述中了,s = x + y
正是子问题的分解。我们知道,字符串s1
可以被分割为两个子串s1 = x1 + y1
,相应地,s2
也可以被分割为与s1
两个子串相对应的两个子串x2 + y2
,那么s1
是否为s2
的扰乱字符串,就等价于是否存在一组特定的子串x1'
、y1'
、x2'
与y2'
,使得x1'
为x2'
的扰乱字符串且y1'
为y2'
的扰乱字符串——而后者是前者的子问题的解,于是我们便分解出了子问题,并且最优解依赖于子问题的最优解。但应当注意,由于x
、y
是可以交换的,所以对于一对确定的x1
、y1
,实际上存在两对相应的x2
、y2
,分别记为x21
、y21
与x22
、y22
。为方便理解,下作图演示:
x21
与y21
以外,万万不可忽略描述中提到的x + y
被允许交换为y + x
,因此还必须考虑到x22
与y22
,否则会漏掉条件:
x1
与y1
就不必再区分x11
、y11
与x12
、y12
了,因为s1
与s2
地位相同,有轮换对称性,所以考虑一边的交换就可以了,否则会产生重复计算。只要x1
与x21
互为扰乱字符串且y1
与y21
互为扰乱字符串时,令x2' = x21
、y2'=y21
;或者有x1
与x22
互为扰乱字符串且y1
与y22
互为扰乱字符串时,令x2' = x22
、y2'=y22
,这两种情况都可以视为我们找到了s1
与s2
互为扰乱字符串的证据。
于是算法可以按如下步骤设计:
当
、子串长度为 ,只需要判断相应字符 是否相等,故当
、子串长度为 大于 ,则根据分析,有 其中, 表示长为 的x1
与x21
是否互为扰乱字符串, 表示长为 的y1
与y21
是否互为扰乱字符串; 表示长为 的x1
与x22
是否互为扰乱字符串, 表示长为 的y1
与y22
是否互为扰乱字符串。
可见定义s1
与s2
地位相同所带来的对称性——只要最外层循环为
通过这一状态转移方程可以看出,算法的复杂度为状态数
1 | # 输入参数 |
该算法的空间复杂度为
输出:
true
同样用Python实现,这个问题用带有备忘录机制的递归写法会比动态规划快上几倍,尽管时间复杂度是相同的。
我怀疑可能是Python的for
循环性能太弱导致的,毕竟不得不嵌套了四层for
循环。
跳跃游戏Ⅰ
55.
跳跃游戏 - 力扣(LeetCode):给你一个长为nums
,你最初位于数组的第一个下标,数组中的每个元素代表你在该位置可以跳跃的最大长度(长度被要求非负)。判断你是否能够到达最后一个下标,如果可以,返回
true
;否则,返回 false
。
不难想到定义状态true
;否则赋false
。
换言之,可以这样定义状态转移方程:
1 | class Solution: |
该算法的时间复杂度为
空间复杂度改进:如果print
打印一下true
;严谨的证明可以考虑数学归纳法,易证。因此,我们记录这样的
时间复杂度改进:沿着空间复杂度改进的思路,我们也没有必要在逐个遍历k = max(n+nums[n], k)
,则新的状态转移方程为
当n+nums[n]
与k
相等时提前返回false
,当k
大于等于N-1
时可以提前返回true
,否则待循环结束返回true
。这里有一些贪心策略的成分,在这样的问题中,我们就很难定义算法是一个贪心算法还是“赤裸”的动态规划;这也从侧面印证,动态规划与贪心算法并不是完全二元对立的。
1 | # 输入参数 |
该算法的时间复杂度为
输出:
false
跳跃游戏Ⅱ与优化方案
# 该问题同时也是典型的可以使用单调队列进行算法优化的问题
# 在后文“单调队列与单调栈”中会再次以这个问题为模板讲解新的优化思路
45.
跳跃游戏 II -
力扣(LeetCode):在跳跃游戏Ⅰ的基础上,求得到达终点(即nums[N-1]
)所需的最少跳跃次数。
该问题与跳跃游戏Ⅰ有明显的不同,不可以直接套用跳跃游戏Ⅰ的思路。可以定义
1 | class Solution: |
该算法的时间复杂度为
在力扣上,执行用时花费了11619 ms
。
现在我们换一种状态的定义方式,定义
(前提:一定能通过某种路线跳跃至终点)
第一点与第二点是显而易见的,这里只证明第三点。若
因此,当计算
1 | # 输入参数 |
该算法的时间复杂度与空间复杂度均为
输出:
2
在力扣上,执行用时花费了0 ms
。即使以1 ms
计算,
将速度提升了一万倍
跳跃游戏Ⅲ与跳跃游戏Ⅳ就不可以用动态规划解决了,因为跳跃时允许向前跳跃、也允许向后跳跃,数据结构呈现的关系无法构成一个有向无环图。这类图论搜索问题,可以考虑BFS或DFS。
选择数字
# 该问题同时也是典型的可以使用单调队列进行算法优化的问题
# 在后文“单调队列与单调栈”中会再次以这个问题为模板讲解新的优化思路
选择数字问题:给定
明显地,这个问题可以用动态规划解决。定义
为达到上述目的,我们可以先用
现在可以开始编写程序了,尽管按当前分析的细节实现的代码仍然有许多可以优化的地方,但最根本的逻辑大致如此。
1 | # 输入参数 |
算法的最坏时间复杂度接近
输出:
21
时间复杂度优化
https://space.bilibili.com/8888480/search/video?keyword=%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E4%BC%98%E5%8C%96%E4%B8%93%E9%A2%98
决策单调性
上文所给出的算法,在时间开销上显然不能让人满意,这是由于我们在计算
定义:对于类似
对于满足决策单调性的动态规划,我们可以采取一系列方法以降低其时间复杂度。
经典模型 1:模型一对于
改进方法:二分查找
所有状态只能起始状态转移,称均为起始状态的状态序列为111...11222...22
,故可以通过二分查找寻找从哪一点开始自状态
经典模型
2:在模型一的基础上,修改状态转移递推式为
改进方法:分治算法
对于模型二,如果在计算
经典模型 3:对于满足
经典模型 4:
改进方法 如果
跳跃游戏Ⅰ实际上可以套用这里的模型进行优化。由于跳跃游戏Ⅰ问题较为简单——没有进行优化的必要性,这里就不做演示了。
单调队列与单调栈
以跳跃游戏Ⅱ为例,说明如何通过单调队列的方法优化时间复杂度。利用单调队列的方法,可以同时应用滚动数组,有可能进一步降低空间复杂度。
高级数据结构
图上的动态规划
Dijkstra算法
地下城游戏
TSP问题
多段图的最短路径
状态压缩动态规划
状态压缩之国王游戏
插头动态规划
哈密尔顿回路总数
国王游戏
动态规划算法优化
状态转移方程的优化
动态规划在其他领域的应用
近似串匹配
临时----回溯算法
只列举我最感兴趣的几个问题。
临时----全排列
临时----背包问题 (回溯)
有别于DP的回溯算法(backtracking algorithm)也可以解决背包问题,而且能够给出最优措施的具体方案,但可能会花费更多的空间与时间。DP的思路是分解问题,回溯的思路是遍历。
01背包问题的回溯算法:
临时----路径
9集,见收藏,稍后学习
临时----八皇后
临时----数独
test