【算法笔记】动态规划(DP)_动态规划 dp
动态规划(DP)
- 前言
- 矩阵DP
-
- 背包问题
-
- 01背包
- 完全背包
- 多重背包
- 打家劫舍
- 树型DP
前言
核心思想:状态转移、记忆化搜索、递推、递归
通过一个简单的例子引入
斐波那契数列
问题描述:根据斐波那契数列的规律求第n个斐波那契数
根据题目,首先小明回忆其规律, F(n)=F(n−1)+F(n−2) F(n) = F(n - 1) + F(n - 2) F(n)=F(n−1)+F(n−2)
小明觉得自己还可以更“精简”这个规律
既然 F(n)=F(n−1)+F(n−2),F(n−1)=F(n−2)+F(n−3) F(n) = F(n - 1) + F(n - 2),F(n - 1) = F(n - 2) + F(n - 3) F(n)=F(n−1)+F(n−2),F(n−1)=F(n−2)+F(n−3)
那么 F(n)=2F(n−2)+F(n−3) F(n) = 2F(n - 2) + F(n - 3) F(n)=2F(n−2)+F(n−3)……
可是越发计算,规律却更复杂了。
原来斐波那契先生总结出的就是最优子结构
很幸运,站在巨人肩膀上的小明不用进行大量穷举总结得出规律
而是直接知道了状态转移方程
于是小明准备简单手推一个结果进行验证,如 F(10) F(10) F(10)
自底向上的递推
- F ( 3 ) = F ( 2 ) + F ( 1 ) F(3) = F(2) + F(1) F(3)=F(2)+F(1)
- F ( 4 ) = F ( 3 ) + F ( 2 ) F(4) = F(3) + F(2) F(4)=F(3)+F(2)
- F ( 5 ) = F ( 4 ) + F ( 3 ) F(5) = F(4) + F(3) F(5)=F(4)+F(3)
- ……
- F ( 10 ) = F ( 9 ) + F ( 8 ) F(10) = F(9) + F(8) F(10)=F(9)+F(8)
- F ( 5 ) = F ( 4 ) + F ( 3 ) F(5) = F(4) + F(3) F(5)=F(4)+F(3)
- F ( 4 ) = F ( 3 ) + F ( 2 ) F(4) = F(3) + F(2) F(4)=F(3)+F(2)
小明开心得发现,原来很轻松就可以递推得到 F(10) F(10) F(10)
但是小明太兴奋以至于忘记了 F(8) F(8) F(8)的值是多少,于是他去尝试回忆自己还记得哪些值
自顶而下的递归
- F ( 8 ) = F ( 7 ) + F ( 6 ) F(8) = F(7) + F(6) F(8)=F(7)+F(6)
- F ( 7 ) = F ( 6 ) + F ( 5 ) F(7) = F(6) + F(5) F(7)=F(6)+F(5)
- F ( 6 ) = F ( 5 ) + F ( 4 ) F(6) = F(5) + F(4) F(6)=F(5)+F(4)
- F ( 5 ) = F ( 4 ) + F ( 3 ) F(5) = F(4) + F(3) F(5)=F(4)+F(3)
- ……
以上不断求子问题的过程,其实就是一个递归的过程
但不同于小明的我们也发现一个非常严重的问题——存在大量重复的计算,即子问题重叠
自然而然的,我们希望有一个“备忘录”对已经计算过的数据进行记录,下次使用时直接取之
这种思想就是记忆化搜索
归纳总结:
对于动态规划的问题解决应该分以下几个步骤完成
- 进行问题分析,确定“备忘录dp”要记录的内容具体是什么,即 d p [ ] dp[] dp[]表达什么
- 通过各种穷举尝试写出状态转移方程
- 发现边界情况
矩阵DP
背包问题
首要问题是从具体问题中抽象出:
- 什么是背包容量
- 什么是物品重量
- 什么是物品价值
最朴素的背包问题(01):
问题描述:假如你是一个小偷,背着一个可装4磅东西的背包。现在有以下三种物品可供你盗窃如何才能盗取最大的收益
二维:
dp[i][j] dp[i][j] dp[i][j] 中 i i i 代表对于每一件物品进行遍历, j j j 代表对于背包的每一格容量进行遍历, dp[i][j] dp[i][j] dp[i][j] 为当前物品和容量下,装入背包的最大价值。
其中二维的意义为物品维度和容量维度
确定完dp的意义之后,开始从上到下,从左到右穷举寻找状态转移方程
(第0行可理解为,背包空时的状态;第0列可理解为,背包容量为0时,什么都装不下)
注意因为多加了一行0状态的,所以当前行的物品应该是 i − 1 i-1 i−1
第一件物品是音响,3000美元,4磅。
从有效的 [1][1] [1][1] [1][1]开始穷举递推,刚出门就遇到问题,1磅的背包装不下4磅的物品!!!
于是得出第一个条件: j > = W [ i − 1 ] j >= W[i -1] j>=W[i−1] 时才能开始讨论对于当前物品,取还是不取。装不下的话,免谈!!!
当穷举递推到 [1][4] [1][4] [1][4],容量 4≥4 4\\geq4 4≥4可以取音响。于是检查了一下背包,当前背包是空的啊,那肯定取。
其中就隐含了另一个问题,怎么取才能获得当前容量下的最大价值?
这便是第二个条件: d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , V [ i − 1 ] ) dp[i][j]=max(dp[i-1][j],V[i-1]) dp[i][j]=max(dp[i−1][j],V[i−1]),比较当前背包里的物品和当前要取的物品哪个价值更大!!!
根据以上两个条件,一直穷举递推到 [3][4] [3][4] [3][4]。照例,比较当前背包里的物品和当前要取的物品价值,发现 3000>1500 3000 > 1500 3000>1500
但仔细一想,如果取第二件物品笔记本电脑和第三件物品吉他的话, 2000+1500=3500>3000 2000 + 1500 = 3500 > 3000 2000+1500=3500>3000
原来背包没规定只能装一件物品啊!!!
于是,想到对于第三件物品其实只需要1磅的容量,那另外多出来的空间可以装别的物品啊!!!
多出来的空间为 j−w[i−1] j-w[i-1] j−w[i−1],其最大价值为 dp[i−1][j−w[i−1]] dp[i-1][j-w[i-1]] dp[i−1][j−w[i−1]]
不用怀疑,因为我们每次递推得出的都是当前容量下的最大价值,这递归回去的仍然也是
那么可以对第二个条件进行修改: d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − W [ i − 1 ] ] + V [ i − 1 ] ) dp[i][j]=max(dp[i-1][j],dp[i-1][j-W[i-1]]+V[i-1]) dp[i][j]=max(dp[i−1][j],dp[i−1][j−W[i−1]]+V[i−1])
由递推和递归,得出完整的状态转移方程
dp[i][j]= { d p [ i − 1 ] [ j ] j = W [ i − 1 ] dp[i][j]=\\begin{cases}dp[i-1][j] \\quad j=W[i-1]\\end{cases} dp[i][j]={dp[i−1][j]j<W[i−1]max(dp[i−1][j],dp[i−1][j−W[i−1]]+V[i−1])j>=W[i−1]
dp里的i-1是指上一行状态,w,v中的i-1是指当前行物品,这里要注意
一维:
观察二维的状态转移方程是否发现——不论哪种情况下, dp[i][] dp[i][] dp[i][]总是与 dp[i−1][] dp[i-1][] dp[i−1][]相关
即当前行以上一行为基础,且不与其他行相关
一个大胆的想法随即孕育而出,将二维压缩为一维,将物品维度塌缩
得出新的状态转移方程
dp[j]= { d p [ j ] j = W [ i − 1 ] dp[j]=\\begin{cases}dp[j]\\quad j=W[i-1]\\end{cases} dp[j]={dp[j]j<W[i−1]dp[j−W[i−1]]+V[i−1]j>=W[i−1]
继续观察二维的状态转移方程——不论哪种情况下, dp[][j] dp[][j] dp[][j]总是与 dp[][k],k<=j dp[][k], k<=j dp[][k],k<=j相关
说明每一列以之前列为基础,且不与之后列相关
如果继续二维的从左到右的递推方法,必然出现当前物品取多次的情况,于是我们选择不会影响前列的从右到左递推
何为当前物品取多次?
以取第三件物品吉他 (1500,1) (1500,1) (1500,1)为例:
到容量2时, dp[2]=dp[2−1]+1500=3000 dp[2] = dp[2-1]+1500 = 3000 dp[2]=dp[2−1]+1500=3000,凭空多取了一件吉他
01背包
0和1的意义:对于每一物品,在每一容量下,是否放入
01背包最值问题:
// 二维// row是物品数量 col是背包容量// 最大值for (int i = 1; i <= row; i++) { for (int j = 1; j <= col; j++) {dp[i][j] = dp[i - 1][j];if (j >= W[i - 1])dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - W[i - 1]] + V[i - 1]);}}// 最小值时,将 dp 除边界情况外初始化为0x3f3f3f3f
// 一维for (int i = 1; i <= row; i++) {for (int j = col; j >= W[i - 1]; j--) {dp[j] = max(dp[j], dp[j - W[i - 1]] + V[i - 1]);}}
力扣 1049. 最后一块石头的重量 II
本题要点:
- 将每两块石头一起粉碎的具体过程,抽象为两堆石头一起粉碎。则两堆石头重量的差值即为剩余重量。
- 如何构建这两堆石头才能使其差值最小?是不是越接近于 所有石头重量之和的一半 a v g = s u m / 2 avg=sum/2 avg=sum/2 更好。
- w 1 → a v g , w 2 → a v g , w 1 − w 2 → 0 w1 \\to avg,w2 \\to avg,w1-w2\\to 0 w1→avg,w2→avg,w1−w2→0
- 由此得出,背包的最大容量为 t a r g e t = a v g target=avg target=avg,而每块石头的价值所在就是其重量
- 求出 t a r g e t target target 下能够装入的最大价值,则剩余价值为 s u m − 2 ∗ t a r g e t sum-2*target sum−2∗target
最大容量转化为 s u m / 2 sum/2 sum/2是比较多的做法
力扣 474. 一和零
本题要点:
- 容量有两种,分别用于存放0,1
- 如何处理多容量问题?通常情况下,将dp定义为两维,物品维度和容量维度
- 因此做法为直接增加一个容量维度,将其拓展为三维 d p [ i ] [ j ] [ k ] dp[i][j][k] dp[i][j][k]
- 而重量为每个字符串中,0和1的个数。每个字符串价值为1
能否转化为分组背包问题?
01背包存在问题
// 二维for (int i = 1; i <= row; i++) {for (int j = 1; j <= col; j++) {dp[i][j] = dp[i - 1][j];if (j > W[i - 1])dp[i][j] = dp[i - 1][j] || dp[i - 1][j - W[i - 1]];}}
// 一维for (int i = 1; i <= row; i++) {for (int j = col; j >= W[i - 1]; j--) {dp[j] = dp[j] || dp[j - W[i - 1]];}}
力扣 416. 分割等和子集
本题要点:
- 求两个子集元素和相同,那么容量 t a r g e t = s u m / 2 target=sum/2 target=sum/2
- 物品的重量和价值均为数字的大小
洛谷 P8742 [蓝桥杯 2021 省 AB] 砝码称重
01背包组合问题:
// 二维for (int i = 1; i <= row; i++) {for (int j = 1; j <= col; j++) {dp[i][j] = dp[i - 1][j];if (j >= W[i])dp[i][j] += dp[i - 1][j - W[i - 1]];}}
// 一维for (int i = 1; i <= row; i++) {for (int j = col; j >= W[i - 1]; j--) {dp[j] += dp[j - W[i - 1]];}}
力扣 494. 目标和
本题要点:
- s u m = ∑ i = 0n u m s [ i ] sum=\\sum _{i=0}nums[i] sum=∑i=0nums[i]
- 给 n u m num num 添加正号或负号,会产生正数或负数,设正数和为 x x x,负数和为 y y y
- { x + y = t a r g e t x − y = s u m ⇒ x = t a r g e t + s u m 2 \\begin{cases}x+y=target\\\\x-y=sum \\end{cases} \\Rightarrow x= \\frac{target+sum}{2} {x+y=targetx−y=sum⇒x=2target+sum
- 所以问题转换为,在每个 n u m num num前考虑是否添加正号(01问题),最后有多少种方法算得 x x x(背包容量)
- 因为每个 n u m > = 0 num>=0 num>=0,所以 s u m > = 0 sum>=0 sum>=0,而 t a r g e t target target是由部分 n u m num num添负号得到,可知 s u m > = t a r g e t sum>=target sum>=target
- 又因为正数和 x > = 0 x>=0 x>=0,所以 t a r g e t + s u m target+sum target+sum为非负偶数
- 边界情况: d p [ 0 ] [ 0 ] = 1 dp[0][0]=1 dp[0][0]=1。解释为,由0个数相加得到0是成立的,且只有一种情况
以上方法,将原本的添加正号或符号,变成了只考虑如何添加正号,成功构成了二维的背包。
是否能以多维背包的思想求解?但并没有给定正号和负号的容量大小,正号和负号的多少是互相影响的
其实还有一种比较“老实”的方法,就是将负数转化为正数也编入容量当中,即 j=sum+∣a∣ j=sum+|a| j=sum+∣a∣
eg. sum=5 sum=5 sum=5,则 −3 -3 −3 转化为正数就是 5+∣−3∣=8 5+|-3|=8 5+∣−3∣=8
完全背包
完全的意义: 每个物品可以不限次数的取多次
完全背包作为01背包的拓展性问题,理解的要点在于
如何实现物品取多次
回想一下,01背包时二维压缩一维的过程,为什么要从右向左?
因为,之前列会影响当前列,而与之后列无关
(已经证实从左向右可以让小偷凭空多偷出一把吉他)
这正是完全背包需要的,用之前列保存是否取了当前物品的状态,并不断叠加。
(01背包中使用 dp[i-1] 是为了读取 是否取了上一件物品的状态)
顺序相关情况分析:
在正式开始介绍完全背包各类问题之前,必须要介绍一种特殊情况——物品取的顺序会影响结果
(这种情况多见于完全背包的组合问题,还未对01背包中的顺序相关问题进行分析)
结果来说,是将内外循环调换,即对于dp一列一列的遍历。
不再是一行一行遍历
接下来以一道简单题进行引入:
力扣 70. 爬楼梯
问题描述:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
第3个台阶可以由第2个台阶或第1个台阶爬到。
第4个台阶可以由第3个台阶或第2个台阶爬到。
……
可以推导出本题其实是斐波那契数列规律的一个应用
F(n)=F(n−1)+F(n−2) F(n)=F(n-1)+F(n-2) F(n)=F(n−1)+F(n−2)
即第n个台阶可以由第n-1个台阶或第n-2个台阶爬到。
// 以斐波那契数列规律推导int q = 0, p = 1, s;for(int i = 1; i <= n; i++) {s = p + q;p = q;q = s;}return s; // s为爬到第n个台阶的方法数目
或者能不能用背包的思想对本题进行求解。(虽然二者都是动态规划)
可以将每个台阶看作背包的每个容量,而上台阶走一步或两步作为物品的挑选
但是,走一步或两步这个动作是可以多次进行的。说明这是一个完全背包问题
在这个基础上,我们对此题进行推广
将问题修改为:每次你可以爬m个台阶。你有多少种不同的方法可以爬到楼顶呢?
(假设1<= m <= 3)
按照之前背包的遍历方式,应该对每种上台阶的方法(1步、2步、3步)遍历
分别求走到第每个台阶的方式有多少
状态转移方程为: dp[i]+=dp[i−step] i>=step dp[i]+=dp[i-step]\\quad i>=step dp[i]+=dp[i−step]i>=step
// 走到第3个台阶n = 3;vector<int> dp(3 + 1);dp[0] = 1;vector<int> steps = {1, 2, 3};for (int step : steps) {for (int i = step; i <= n; i++)dp[i] += dp[i - step];cout << \"dp[3] = \" << dp[3];
输出: d p [ 3 ] = 3 dp[3]=3 dp[3]=3
是否感觉到不对劲,那手推一遍
走到台阶3的方式1+1+11+22+13
明明有4种,那问题出在哪呢?
原来,在这个走法下,如果按上台阶的方法遍历,走过1,就只能走2,再走3。严格遵循了顺序
2+1 2+1 2+1在这种走法下是无法出现的
说明物品取的顺序会影响最终结果
正确的遍历方式:
应该通过对每个台阶进行遍历,相加走到其前一个台阶,前两个台阶……前m个台阶的方法之和
最终走到第n个台阶有多少种方法
for (int i = 1; i <= 3; i++) for (int step : steps)if (i >= step)dp[i] += dp[i - step];
即对顺序影响结果的问题,应该按照列遍历
完全背包最值问题:
// 二维for (int i = 1; i <= row; i++) {for (int j = 1; j <= col; j++) {dp[i][j] = dp[i - 1][j];if (j > W[i - 1])dp[i][j] = max(dp[i][j - W[i - 1]] + V[i - 1], dp[i][j]); // 这里有区别,是不断叠加当前物品}}// 最小值时,将 dp 除边界情况外初始化为0x3f3f3f3f, 注意可能要判断是否越界
// 一维for (int i = 1; i <= row; i++) {for (int j = W[i - 1]; j <= col; j++) { // 反而是要从左向右dp[j] = max(dp[j], dp[j - W[i - 1]] + V[i - 1]);}}
力扣 322. 零钱兑换
力扣 279. 完全平方数
完全背包存在问题:
// 二维for (int i = 1; i <= row; i++) {for (int j = 1; j <= col; j++) {dp[i][j] = dp[i - 1][j];if (j > W[i - 1])dp[i][j] = dp[i][j] || dp[i][j - W[i - 1]];}}
// 一维for (int i = 1; i <= row; i++) {for (int j = W[i - 1]; j <= col; j++) {dp[j] = dp[j] || dp[j - W[i - 1]];}}
力扣 518. 零钱兑换 II
力扣 139. 单词拆分
本题要点:
- 要考虑顺序
- 用集合作为字典存放单词,用 s u b s t r ( j , i − i ) substr(j,i-i) substr(j,i−i)判断是否是一个出现在集合中的单词
完全背包组合问题:
// 二维for (int i = 1; i <= row; i++) {for (int j = 1; j <= col; j++) {dp[i][j] = dp[i - 1][j];if (j >= W[i])dp[i][j] += dp[i][j - W[i - 1]];}}
// 一维for (int i = 1; i <= row; i++) {for (int j = W[i - 1]; j <= col; j++) {dp[j] += dp[j - W[i - 1]];}}
力扣 377. 组合总和 Ⅳ
本题要点:
- 要考虑顺序
多重背包
暂时搁置