动态规划算法的基本要素
动态规划算法的基本要素
https://www.cnblogs.com/mfrank/p/10533701.html
动态规划的解题步骤:
-
确定状态i
-
状态转移方程(原问题和其子问题的递归式)
-
边界
-
计算顺序(一般从边界出发自底向上)
一般走一个数组
使用动态规划的前提:
- 最优子结构(Optimal substructure)
- 重叠子问题(Overlapping subproblems)
- 要满足状态的无后效性【比如计算dp计算出来了结果就不能修改了】
动态规划和分治的区别就是分治有重叠子问题,动态规划没有重叠子问题
最优子结构
定义1 (最优子结构).如果一个问题的最优解包含了它的子问题的最优解, 则称此问题具有最优子结构.
- 应用动态规划和贪心(Greedy)方法的条件
- 是否满足重叠子问题条件
重叠子问题
定义2 (重叠子问题).如果递归算法求解一个优化问题时, 反复求解相同的子问题, 则称该优化问题有重叠子问题.
动态规划与分治法的区别:
- 动态规划只计算每个子问题一次并保存, 避免反复计
算相同子问题多次 - 分治法每次都产生新问题并计算, 而不管是否该问题
已计算过
备忘录方法-memoization
- 对函数返回值进行缓存(一种计算机程序优化技术)
- 备忘录方法用表格保存已解决的子问题的答案, 在下
次需要解此问题时, 只要简单地查看该子问题的解答,
而不必重新计算 - 备忘录方法的递归方式是自顶向下, 而动态规划算法
则是自底向上
动态规划与备忘录方法
- 动态规划. 一个问题的所有子问题都至少要解一次
- 备忘录方法. 部分子问题可不必求解
题目1. 矩阵连乘问题
首先了解什么是矩阵连乘问题:通过使用合适的加括号的方式,使得最后矩阵连乘积的乘法次数最少
完整题目:
给定n个矩阵{A1,A2,…,An},其中Ai与Ai+1是可乘的,i=1,2 ,…,n-1。如何确定计算矩阵连乘积的计算次序,使得依此次序计算矩阵连乘积需要的数乘次数最少。
由于矩阵乘法满足结合律,所以计算矩阵的连乘可以有许多不同的计算次序。这种计算次序可以用加括号的方式来确定。
若一个矩阵连乘积的计算次序完全确定,也就是说该连乘积已完全加括号,则可以依此次序反复调用2个矩阵相乘的标准算法计算出矩阵连乘积
- 对于 p × q 矩阵 A 和 q × r 矩阵 B,A × B 需要多少次乘法计算?p × q × r 次乘法计算
- 例如, A 和 B 分别是 20 × 100, 100 × 10 矩阵, 则乘法总数为 20 × 100 × 10 = 20000
再来个例子~
通过上面的例子可以看出加括号的顺序对运算次数的影响之大,所以我们的任务就是为了找到一个最优的加括号的顺序,来使得最后的乘法次数最少
接下来开始入手,我们先将问题一般化
下面我们按照算法导论中的“动态规划四部曲”来一步一步的分析
-
分析最优解的结构特征
特征:如果A[i:j]是最优次序,那么它所包含的计算矩阵子链A[i:k]和A[k+1:j]的次序也是最优的
(满足最优子结构性质:最优解包含着其子问题的最优解)
- 建立递归关系
- 计算最优解的值(通常采用自底向上)
动态规划实现:
题目2. 最长公共子序列(LCS)
最长公共子序列 (Longest Common Subsequence, LCS) 的问题描述为:
给定两个字符串(或数字序列) A和B, 求二个字符串, 使得这个字符串是A和B的最长公共部分(子序列可以不连续)。
样例:

如样例所示, 字符串 "sadstory"与 "adminsorry" 的最长公共子序列为 "adsory", 长度 为6。
直接来看动态规划的做法(下文的 LCS 均指最长公共子序列)。
1. 确定状态
令 \(dp[i][j]\)表示字符串 A 的 i号位和字符串 B的j号位之前的 LCS 长度(下标从 1 开始), 如 \(dp[4][5]\)表示 ''sads" 与 "admin" 的 LCS 长度。
那么可以根据 A[i]和 B[j]的情况, 分为两 种决策:
-
若 A[i] == B[j], 则字符串 A与字符串 B 的 LCS 增加了 1 位,即有 \(dp[i][j] = dp[i- 1][j - 1] + 1\)。 例如, 样例中 \(dp[4][6]\)表示 "sads" 与 "admins" 的 LCS 长度, 比较 A[4]与 B[6],发现两者都是's', 因此 \(dp[4][6]\)就等千 \(dp[3][5]\)加1, 即为 3
-
若 A[i] != B[j], 则字符串 A的 i号位和字符串 B 的j号位之前的 LCS 无法延长, 因此 \(dp[i][j]\)将会继承 \(dp[i-1][j]\)与$ dp[i][j- 1]\(中的较大值, 即有\) dp[i][j]= max { dp[i - 1 ][j], dp[i][j -1]} $。
例如, 样例中$ dp[3][3]$表示 "sad" 与 "adm" 的 LCS 长度, 我们比较 A[3]与 B[3], 发现'd'不等千m', 这样 \(dp[3][3]\)无法再原先的基础上延长, 因此继承自 "sa" 与 "adm" 的 LCS, "sad" 与 "ad" 的 LCS 中的较大值, 即 "sad" 与 "ad" 的 LCS 长度 -2。
2. 由此可以得到状态转移方程:
3. 边界,
这样状态 \(dp[i][j]\)只与其之前的状态有关, 由边界出发就可以得到整个 dp 数组, 最终 \(dp[n][m]\)就是需要的答案, 时间复杂度为 \(O(nm)\) 。
题目3. 最长不下降子序列(LIS)
具体来说就是从一个数的的大小比较变成了一个区间的大小比较
yysy,想法真的骚!
最长不下降子序列 (Longest Increasing Sequence, LIS) 是这样一个问题:
在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。
例如, 现有序列 A={12,3,-1,-2,7,9} (下标从 1 开始), 它的最长不下降子序列是{1, 2, 3, 7, 9}, 长度为 5。另外, 还有一些子序列是不下降子序列, 比如{l,2, 3}、 {-2,7, 9}等,但都不是最长的。
1. 确定状态
令 dp[i]表示以 A[i]结尾的最长不下降子序列长度(和最大连续子序列和问题一样,以 A[i]结尾是强制的要求)。 这样对 A[i]来说就会有两种可能:
- 如果存在 A[i]之前的元素 A[j] (j < i), 使得 A[j]\(\leq\)A[i]且 dp[j] + 1\(>\) dp[i] (即把 A[i]跟在以 A[j]结尾的 LIS 后面时能比当前以 A[i]结尾的 LIS 长度更长), 那么就把A[i]跟在以 A[j]结尾的 LIS 后面, 形成一条更长的不下降子序列(令 dp[i]= dp[j] + 1)。
- 如果 A[i]之前的元素都比 A[i]大, 那么 A[i]就只好自已形成一条 LIS, 但是长度为1, 即这个子序列里面只有一个 A[i]。
2. 由此可以得到状态转移方程:
3. 边界,
题目4. 0/1背包问题
有 n 件物品, 每件物品的重量为 w[i], 价值为 c[i]。现有一个容量为 V 的背包, 问如何选取物品放入背包, 使得背包内物品的总价值最大。 其中每种物品都只有1件。
令$ dp[i][v]$表示前 i 件物品$1\leq i\leq n,0\leq v \leq V $ )恰好装入容量为 v 的背包中所能获得的最大 价值。 怎么求解 \(dp[i][v]\)呢?
考虑对第i件物品的选择策略, 有两种策略:
- 不放第i件物品,那么问题转化为前i- 1件物品恰好装入容量为v的背包中所能获得
的最大价值, 也即 \(dp[i- 1][v]\) - 放第 i 件物品, 那么问题转化为前 i- 1 件物品恰好装入容量为V - w[i]的背包中所能获得的最大价值, 也即 \(dp[i - 1 ][ v - w[i]] + c[i]\)
由于只有这两种策略, 且要求获得最大价值, 因此,
\[\large dp[i][v] = \begin{cases} dp[i- 1][ v ]&\text{,}1 \leq i \leq n, 0\leq v < w[i] 是指背包放不下所选物品的情况\\[2ex] \end{cases} \]
上面这个就是状态转移方程。 注意到$ dp[i][v]$只与之前的状态 \(dp[i - 1][ ]\)有关, 所以可以枚举 i 从 1 到 n, v从 0 到V, 通过边界$ dp[0][v] = 0 (O \leq v \leq V) \((即前 0 件物品放入任何容量v 的背包中都只能获得价值0)就可以把整个 dp 数组递推出来。而由于\) dp[i][v]$表示的是恰好 为 v 的情况, 所以需要枚举 \(dp[n][v] (O \leq v \leq V),\) 取其最大值才是最后的结果。
$ dp[i][v]$只与之前的状态 \(dp[i - 1][ ]\)有关
i:1->n
v:0->V
部分代码(伪代码):
for(int i=1,i<=n,i++){
for(int v_1 = 0, v_1<w[i], v_1++){
dp[i][v_1] = dp[i-1][v_1]
}
for(int v_2 = w[i],v_2<=V, v_2++){
dp[i][v_2]=max(dp[i-1][v_2],dp[i-1][v_2-w[i]] + c[i],
}
}
实例,

package main
import (
"fmt"
"math"
)
func main(){
n ,= 5
V ,= 8
// 物品从第1个开始计算
w ,= []int{0,3,5,1,2,2}
c ,= []int{0,4,5,2,1,3}
// dp的第0行代表前0个物品放入容量为v(v:0...V)的背包中所能得到的最优解,易知必为0
// dp的第0列表示当背包容量为0时前i(i:0...n)个物品放入背包所能得到的最优解
dp ,= [5+1][8+1]int{}
// 初始化边界,第0轮,因为前0个就算你有空间你也没东西拿所以一定为0
for i:=0,i<=V,i++{
dp[0][i] = 0
}
// 动态规划,代表只考虑前i个物品的情况(物品从第1个开始计算)
for i:=1,i<=n,i++{
// 第i个物品放不下(没有空间的情况下)的情况
for v1 ,= 0,v1<w[i],v1++{
dp[i][v1] = dp[i-1][v1]
}
//第i个物品放得下,可以选择放或者不放
for v2 ,= w[i],v2<=V,v2++{
dp[i][v2] = int(math.Max(float64(dp[i-1][v2]),float64(dp[i-1][v2-w[i]]+c[i])))
}
}
for i:=0,i<=n,i++{
fmt.Println(dp[i])
}
fmt.Println(dp[n][V])
}
输出结果:
[0 0 0 0 0 0 0 0 0]
[0 0 0 4 4 4 4 4 4]
[0 0 0 4 4 5 5 5 9]
[0 2 2 4 6 6 7 7 9]
[0 2 2 4 6 6 7 7 9]
[0 2 3 5 6 7 9 9 10]
10
动态规划是如何避免重复计算的问题在01背包问题中非常明显。在一开始暴力枚举每件物品放或者不放入背包时,其实忽略了一个特性:第i件物品放或者不放而产生的最大值是完全可以由前面i-1件物品的最大值来决定的,而暴力做法无视了这一点。
另外,01背包中的每个物品都可以看作一个阶段,这个阶段中的状态有\(dp[i][0]--> dp[i][V]\), 它们均由上一个阶段的状态得到。 事实上,对能够划分阶段的问题来说, 都可以尝试把阶段作为状态的一维, 这可以使我们更方便地得到满足无后效性的状态。 从中也可以得到这么一个技巧如果当前设计的状态不满足无后效性, 那么不妨把状态进行升维, 即增加一维或若干维来表示相应的信息, 这样可能就能满足无后效性了。
题目5. 完全背包问题
完全背包问题的叙述如下:
有 n 种物品, 每种物品的单件重量为w[i], 价值为 c[i]。现有 个容量为 V 的背包, 问如何选取物品放入背包,使得背包内物品的总价值最大。 其中每种物品都有无穷件。
可以看出,完全背包问题和 01 背包问题的唯 区别就在于:完全背包的物品数量每种有无穷件,选取物品时对同一种物品可以选1件、选2件...…只要不超过容量V即可,而01背包的物品数量每种只有1件。
同样令 \(dp[i][v]\) 表示前 i 件物品恰好放入容量为 v 的背包中能获得的最大价值。
和01背包一样,完全背包问题的每种物品都有两种策略,但是也有不同点。对第 i 件物品来说:
- 不放第 i 件物品,那么$ dp[i][v] = dp[i-1][v]$,这步跟01背包是一样的。
- 放第 i 件物品。这里的处理和01背包有所不同,因为01背包的每个物品只能选择一个,因此选择放第 i 件物品就意味着必须转移到$ dp[i-1][v-w[i]] \(这个状态;但是完全背包不同,完全背包如果选择放第 i 件物品之后并不是转移到\) dp[i-1][v- w[i]]\(,而是转移到\) dp[i][v-w[i]]$,这是因为每种物品可以放任意件 (注意有容量的限制,因此还是有限的),放了第 i 件物品后还可以继续放第 i 件物品,直到第二维的 $v-w[i] $无法保持大于等于0为止。
由上面的分析可以写出状态转移方程:
\[\large dp[i][v] = \begin{cases} dp[i- 1][ v ]&\text{,}1 \leq i \leq n, 0\leq v < w[i] 是指背包放不下所选物品的情况\\[2ex] \end{cases} \]
边界状态(同0/1背包问题,前0个无论背包大小,dp均为0)
边界: $dp[0][v] = 0 (0\leq v < w[i]) $
题目6. 最优二叉搜索树
二叉搜索树的定义
二叉搜索树(Binary Search Tree), 或者是一棵空树, 或者是具有下列性质的二叉树,
- 若它的左子树不空, 则左子树上所有结点的值均小于
它的根结点的值 - 若它的右子树不空, 则右子树上所有结点的值均大于
它的根结点的值 - 它的左、右子树也分别为二叉搜索树
递归式的推导
- 设a1, a2, . . . , an 是从小到大排列的互不相等的键(二叉搜索树的节点),p1, . . . , pn 是它们的查找概率
- \(T_j^i\)是由键ai, ai+1, . . . , aj 构成的二叉树,
- c[i, j] 是在这棵树中成功查找的最小的平均比较次数,
- 其中\(1 \leq i \leq j \leq n\)
最优子结构性质
从键ai, ai+1, . . . , aj 中选择一个根ak 构造一棵二叉树,它的根包含键\(a_k\),
(1) 它的左子树\(T_i^{k-1}\)中的键$a_i, a_{i+1}, . . . , a_{k-1} \(是最优排列
(2) 它的右子树\)T_{k+1}^j\(中的键\)a_{k+1}, . . . , a_j $也是最优排列.

看ppt(123页)可得到推导式:
两个特殊位置:(初始化条件,画表格用的)
- \(c[i][i-1]=0\)
- \(c[i][i]=p_i\)
ppt上有例题
r表示c[i,j]达到最小的时候的k值
题目7. 编辑距离
给你两个单词 word1
和 word2
,请你计算出将 word1
转换成 word2
所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
示例 1:
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
示例 2:
输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')
思路
-
动态规划
-
定义
dp[i][j]
dp[i][j]
代表word1
中前i
个字符,变换到word2
中前j
个字符,最短需要操作的次数
目的:word1\(\rightarrow\)word2
i:word1中的前i个字符
j:word2中的前j个字符
\(dp[i][j]\):将word1 中前 i 个字符,变换到 word2 中前 j 个字符需要的最短操作数
-
初始化条件:需要考虑
word1
或word2
一个字母都没有,即全增加/删除的情况,所以预留 \(dp[0][j] 和 dp[i][0]\)\(dp[0][j]=j\)
\(dp[i][0]=i\)
-
状态转移
-
增,\(dp[i][j] = dp[i][j - 1] + 1\)
先把word1的前i个变成word2的前j-1个,+1代表最后一个增加字符的操作
-
删,\(dp[i][j] = dp[i - 1][j] + 1\)
先把word1的前i-1个变成word2的前j个,+1代表删除word1中的第i个元素的操作
-
改,\(dp[i][j] = dp[i - 1][j - 1] + 1\)
先把word1的前i-1个变成word2的前j-1个,+1代表把word1的第i个元素修改为word2的第j个元素的操作
-
不变,\(dp[i][j]=dp[i-1][j-1]\)
如果刚好这两个字母相同 \(word1[i - 1] = word2[j - 1]\)
那么可以直接参考 \(dp[i - 1][j -1]\) ,操作不用加一
-
配合增删改这三种操作,需要对应的 dp 把操作次数加一,取三种的最小
算法的时间复杂度:O(mn)
m,n分别为i,j的长度
按顺序计算,当计算
dp[i][j]
时,dp[i - 1][j]
,dp[i][j - 1]
,dp[i - 1][j - 1]
均已经确定了