「数据结构与算法」动态规划学习笔记:线性动态规划
用动态规划解决问题的过程有以下几个关键点:
- 状态定义
- 状态的转移
- 初始化
- 边界条件
状态定义
就是定义子问题,如何表示目标规模的问题和更小规模的问题。例如常见的方法:定义状态 dp[n],表示规模为 n 的问题的解,dp[n - 1] 就表示规模为 n - 1 的子问题的解。在实战中 dp[n] 的具体含义需要首先整理清楚再往下做。
状态转移
就是子问题之间的关系,例如定义好状态 dp[n],此时子问题是 dp[n-1] 等,并且大规模的问题的解依赖小规模问题的解,此时需要知道怎样通过小规模问题的解推出大规模问题的解。这一步就是列状态转移方程的过程。一般的状态转移方程可以写成如下形式
dp[n] = f(dp[i]) 其中 i < n
按照状态定义和状态转移的常见形式,可以对动态规划进行分类。
其中线性动态规划的主要特点是状态的推导是按照问题规模 i 从小到大依次推过去的,较大规模的问题的解依赖较小规模的问题的解。
这里问题规模为 i 的含义是考虑前 i 个元素 [0..i] 时问题的解。
线性动态规划简介
线性动态规划的主要特点是状态的推导是按照问题规模 i 从小到大依次推过去的,较大规模的问题的解依赖较小规模的问题的解。
这里问题规模为 i 的含义是考虑前 i 个元素 [0..i] 时问题的解。
状态定义:
dp[n] := [0..n] 上问题的解
状态转移:
dp[n] = f(dp[n-1], ..., dp[0])
从以上状态定义和状态转移可以看出,大规模问题的状态只与较小规模的问题有关,而问题规模完全用一个变量 i 表示,i 的大小表示了问题规模的大小,因此从小到大推 i 直至推到 n,就得到了大规模问题的解,这就是线性动态规划的过程。
线性动态规划是动态规划中最基本的一类。问题的形式、dp 状态和方程的设计、以及与其它算法的结合上面变化很多。按照 dp 方程中各个维度的含义,可以大致总结出几个主流的问题类型:
- 单串
- 双串
- 矩阵
- 无串
单串
单串 dp[i] 线性动态规划最简单的一类问题,输入是一个串。
线性动态规划中单串 dp[i] 的问题,状态的推导方向以及推导公式如下:
依赖比 i 小的 O(1) 个子问题:dp[n] 只与常数个小规模子问题有关
状态的推导过程:
dp[i] = f(dp[i - 1], dp[i - 2], ...)
时间复杂度 O(n),空间复杂度 O(n) 可以优化为 O(1),例如 LeetCode 70, 801, 790, 746 都属于这类。
如上图所示,虽然蓝紫色部分的 dp[i-1], dp[i-2], ..., dp[0] 均已经计算过,但计算橙色的当前状态时,仅用到 dp[i-1],这属于比 i 小的 O(1) 个子问题。
例如,当 f(dp[i-1], ...) = dp[i-1] + nums[i] 时,当前状态 dp[i] 仅与 dp[i-1] 有关。这个例子是一种数据结构前缀和的状态计算方式。
例题:
一个数组有很多个子数组,求哪个子数组的和最大。可以按照子数组的最后一个元素来分子问题,确定子问题后设计状态
dp[i] := [0..i] 中,以 nums[i] 结尾的最大子数组和
状态的推导是按照 i 从 0 到 n - 1 按顺序推的,推到 dp[i],时,dp[i - 1], ..., dp[0] 已经计算完。因为子数组是连续的,所以子问题 dp[i] 其实只与子问题 dp[i - 1] 有关。如果 [0..i-1] 上以 nums[i-1] 结尾的最大子数组和(缓存在 dp[i-1] )为非负数,则以 nums[i] 结尾的最大子数组和,就在 dp[i-1] 的基础上加上 nums[i] 就是 dp[i] 的结果否则以 i 结尾的子数组就不要 i-1 及之前的数,因为选了的话子数组的和只会更小。
因此,状态转移方程如下:
dp[i] = nums[i] + max(dp[i - 1], 0)
依赖比 i 小的 O(n) 个子问题:dp[n] 与此前的更小规模的所有子问题 dp[n - 1], dp[n - 2], ..., dp[1] 都可能有关系
状态推导过程如下:
dp[i] = f(dp[i - 1], dp[i - 2], ..., dp[0])
如上图所示,计算橙色的当前状态 dp[i] 时,蓝紫色部分为此前计算过的状态 dp[i-1], ..., dp[0] 均有可能被用到,在计算 dp[i] 时需要将它们遍历一遍完成计算。
其中 f 常见的有 max/min,可能还会对 i-1,i-2,...,0 有一些筛选条件,但推导 dp[n] 时依然是 O(n) 级的子问题数量。
双串
有两个输入从串,长度分别为 m, n,此时子问题需要用 i, j 两个变量表示,分别代表第一个串和第二个串考虑的位置 dp[i][j]:=第一串考虑[0..i],第二串考虑[0..j]时,原问题的解
较大规模的子问题只与常数个较小规模的子问题有关,其中较小规模可能是 i 更小,或者是 j 更小,也可以是 i,j 同时变小。
其中一种最常见的状态转移形式:推导 dp[i][j] 时,dp[i][j] 仅与 dp[i-1][j], dp[i][j-1], dp[i-1][j-1]
线性动态规划中双串 dp[i][j]
的问题,状态的推导方向以及推导公式如下
如图所示,绿色部分的 dp[i-1 ~ 0][j-1 ~ 0]
均已经计算过,但计算橙色的当前状态时,仅用到 dp[i-1][j], dp[i][j-1], dp[i-1][j-1]
,即比 i, j 小的 O(1) 个子问题。
这种形式的线性 DP 的代码常见写法
for i = 1..m
for j = 1..n
dp[i][j] = f(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
时间复杂度 O(mn),空间复杂度 O(mn)
以上是 O(1) 转移的情况,即计算 dp[i][j]
时,虽然绿色部分的子问题均已经计算完,但只需要用到 dp[i-1][j], dp[i][j-1], dp[i-1][j-1]
。也可能出现更高复杂度的转移,类似单串中依赖比 i 小的 O(n) 个子问题的情况。
经典问题:最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
思路
单个数组或者字符串要用动态规划时,可以把动态规划 dp[i]
定义为 nums[0:i]
中想要求的结果;当两个数组或者字符串要用动态规划时,可以把动态规划定义成两维的 dp[i][j]
,其含义是在 A[0:i]
与 B[0:j]
之间匹配得到的想要的结果。
状态定义
可以定义 dp[i][j]
表示 text1[0:i-1]
和 text2[0:j-1]
的最长公共子序列。 (注: text1[0:i-1]
表示的是 text1 的 第 0 个元素到第 i - 1 个元素,两端都包含)
之所以 dp[i][j]
的定义不是 text1[0:i]
和 text2[0:j]
,是为了方便当 i = 0 或者 j = 0 的时候, dp[i][j]
表示的为空字符串和另外一个字符串的匹配,这样 dp[i][j]
可以初始化为 0。
状态转移
- 当
text1[i - 1] == text2[j - 1]
时,说明两个子字符串的最后一位相等,所以最长公共子序列又增加了 1,所以dp[i][j] = dp[i - 1][j - 1] + 1
;举个例子,比如对于 ac 和 bc 而言,他们的最长公共子序列的长度等于 a 和 b 的最长公共子序列长度 0 + 1 = 1。 - 当
text1[i - 1] != text2[j - 1]
时,说明两个子字符串的最后一位不相等,那么此时的状态dp[i][j]
应该是dp[i - 1][j]
和dp[i][j - 1]
的最大值。举个例子,比如对于 ace 和 bc 而言,他们的最长公共子序列的长度等于 ① ace 和 b 的最长公共子序列长度0 与 ② ac 和 bc 的最长公共子序列长度1 的最大值,即 1。
综上状态转移方程为:
dp[i][j] =
1. dp[i-1][j-1] + 1 (text1[i] == text2[j])
2. max(dp[i][j-1], dp[i-1][j]) (text1[i] != text2[j])
状态初始化
初始化就是要看当 i = 0 与 j = 0 时, dp[i][j]
应该取值为多少。
当 i = 0 时, dp[0][j]
表示的是 text1text1 中取空字符串 跟 text2 的最长公共子序列,结果肯定为 0。
当 j = 0 时, dp[i][0]
表示的是 text2text2 中取空字符串 跟 text1 的最长公共子序列,结果肯定为 0。
综上,当 i = 0 或者 j = 0 时, dp[i][j]
初始化为 0。
经典问题:最长重复子数组
给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。
思路
区分两个概念:
- 子序列可以是不连续的
- 子数组(子字符串)需要是连续的
状态定义
定义 dp[i][j]
为以 nums1[i - 1]
和 nums2[j - 1]
为末尾项的公共子数组的长度。
状态转移
- 当
nums1[i - 1] == nums2[j - 1]
时,以nums1[i - 1]
和nums2[j - 1]
为末尾项的公共子数组dp[i][j]
的长度保底为1,要考虑它俩的前缀数组dp[i - 1][j - 1]
能为它俩提供多大的公共长度。 - 当
nums1[i - 1] != nums2[j - 1]
时,以nums1[i - 1]
和nums2[j - 1]
为末尾项的公共子数组dp[i][j]
的长度为0。
综上状态转移方程为:
dp[i][j] =
1. dp[i-1][j-1] + 1 (nums1[i] == nums2[j])
2. 0 (nums1[i] != nums2[j])
经典问题:编辑距离
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
思路
我们可以对任意一个单词进行三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
题目给定了两个单词,设为 A 和 B,这样我们就能够六种操作方法(插入A、插入B、删除A、删除B、替换A、替换B)。
但可以发现:
- 对单词 A 删除一个字符和对单词 B 插入一个字符是等价的。例如当单词 A 为 doge,单词 B 为 dog 时,既可以删除单词 A 的最后一个字符 e,得到相同的 dog,也可以在单词 B 末尾添加一个字符 e,得到相同的 doge
- 对单词 B 删除一个字符和对单词 A 插入一个字符也是等价的
- 对单词 A 替换一个字符和对单词 B 替换一个字符是等价的。例如当单词 A 为 bat,单词 B 为 cat 时,修改单词 A 的第一个字母 b -> c,和修改单词 B 的第一个字母 c -> b 是等价的。
这样以来,本质不同的操作实际上只有三种:
- 在单词 A 中插入一个字符
- 在单词 B 中插入一个字符
- 修改单词 A 的一个字符
状态定义
定义 dp[i][j]
为word1
中前 i
个字符,变换到 word2
中前 j
个字符,最短需要操作的次数。
需要考虑 word1
或 word2
一个字母都没有,即全增加or全删除的情况,所以预留 dp[0][j]
和 dp[i][0]
。
状态转移
D[i][j-1]
为word1
的前i
个字符和word2
的前j - 1
个字符编辑距离的子问题。即对于word2
的第 j 个字符,我们在word1
的末尾添加了一个相同的字符,那么D[i][j]
最小可以为D[i][j-1] + 1
D[i-1][j]
为word1
的前i - 1
个字符和word2
的前j
个字符编辑距离的子问题。即对于word1
的第i
个字符,我们在word2
的末尾添加了一个相同的字符,那么D[i][j]
最小可以为D[i-1][j] + 1
D[i-1][j-1]
为word1
前i - 1
个字符和word2
的前j - 1
个字符编辑距离的子问题。即对于word2
的第j
个字符,我们修改word1
的第i
个字符使它们相同,那么D[i][j]
最小可以为D[i-1][j-1] + 1
。特别地,如果word1
的第i
个字符和word2
的第j
个字符原本就相同,那么我们实际上不需要进行修改操作。在这种情况下,D[i][j]
最小可以为D[i-1][j-1]
综上状态转移方程为:
dp[i][j] =
1. min(dp[i][j−1] + 1, dp[i−1][j] + 1, dp[i−1][j−1]) (word1[i - 1] == word2[j - 1])
2. min(dpi][j−1], dp[i−1][j], dp[i−1][j−1]) + 1 (word1[i - 1] != word2[j - 1])
总结
动态规划中最重要的三个概念:最优子结构,重复子问题,无后效性。
- 最优子结构:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构。
- 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
- 重复子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。