动态规划
【自顶向下】从一个规模较大的问题
f(20)
,向下逐渐分解规模,直到f(1)
、f(2)
触底,再逐层返回答案,这就是【自顶向下】【自顶向上】直接从最低下,最简单,问题规模最小的
f(1)
和f(2)
开始往上推,直到推到想要的答案f(20)
,这就是动态规划的思路。
求解动态规划的核⼼问题是穷举。
但是这类存在重叠子问题,直接暴力搜索效率较低。故需要一个备忘录或者DP Table,避免不必要的计算。
其一定具备最优子结构,才能通过子问题的最值得到原问题的最值。
由于问题千变万化,穷举所有可行解不不容易,故需要列出正确的状态转移方法。
重叠子问题,最优子结构,状态转移方程是动态规划的三要素。
写出状态转移方程一般是最难的,一个辅助框架:
明确 状态 -> 定义 dp数组/函数的含义 -> 明确 选择 -> 明确 base case。
摘自算法小抄
基本例子
斐波那契数列
1、暴力递归
1 | def fib(N:int): |
假设$N=20$,
可以发现,这里存在大量的重复计算,既重叠子问题。
递归算法的时间复杂度:子问题个数 * 解决一个子问题需要的时间(函数中的操作)。
2、带备忘录的递归
既然耗时原因是超时,那就造一个备忘录,每次算一个子问题的值后,先将其存到备忘录中,再返回;每次遇到一个子问题先查询备忘录,看看有没有记录,再去做。
1 | def fib_with_not(N:int): |
现在画出递归树,你就知道 备忘录 到底做了什么:
实际上, 带「备忘录」 的递归算法, 把⼀棵存在巨量冗余的递归树通过「剪枝」 , 改造成了⼀幅不存在冗余的递归图, 极⼤减少了⼦问题(即递归图中节点) 的个数。
自顶向上与自顶向下
【自顶向下】从一个规模较大的问题 f(20)
,向下逐渐分解规模,直到 f(1)
、f(2)
触底,再逐层返回答案,这就是【自顶向下】
【自顶向上】直接从最低下,最简单,问题规模最小的f(1)
和f(2)
开始往上推,直到推到想要的答案f(20)
,这就是动态规划的思路。
3、dp数组的迭代解法
将备忘录独立出来,搞成dp_table
,在其上完成【自底向上】。
1 | def fib3(N:int): |
这⾥, 引出「状态转移⽅程」 这个名词, 实际上就是描述问题结构的数学形式:
「状态转移⽅程」 的重要性, 它是解决问题的核⼼。 很容易发现, 其实状态转移⽅程直接代表着暴⼒解法。
凑零钱问题
给你 k
种⾯值的硬币, ⾯值分别为 c1, c2 ... ck
, 每种硬币的数量⽆限, 再给⼀个总⾦额 amount
, 问你最少需要⼏枚硬币凑出这个⾦额, 如果不可能凑出, 算法返回 -1
。函数签名如下:
1 | # coins 代表可选面值,amount是目标金额 |
⽐如说 k = 3
, ⾯值分别为 1, 2, 5
, 总⾦额 amount = 11
。 那么最少需要 3
枚硬币凑出, 即 11 = 5 + 5 + 1
。
1、暴力递归
什么是最优子结构?
假如问题是考出最高的总成绩,那么你只需要每门课考到最好,每门课之间互不影响。
但是如果加一个条件,你的语文和数学成绩会相互制约,此消彼长。这样你考的总分就不是最高的了。因为子问题并不独立,无法同时最优,所以最优子结构被破坏。
回到这一题,如你想求amount=11
的最少硬币数,你可以先求amount=10
的最少硬币数(子问题),你只需要把子问题的答案加一就是原问题的答案。
先确定【状态】,既原问题和子问题之间变化的量,由于硬币数量无限,故唯一的状态就是目标金额amount
。
确定【dp函数】的定义,当前目标金额是n
,至少需要dp(n)
个硬币凑出该金额。
然后确定【选择】并择优,既对于每个状态,可以做出什么选择改变当前状态。
体到这个问题, ⽆论当前的⽬标⾦额是多少, 选择就是从⾯额列表coins 中选择⼀个硬币, 然后⽬标⾦额就会减少。
代码框架:
1 | def coinChange(coins:list, amount:int): |
最后明确【base case】,显然目标金额为0时,所需硬币为0;当目标金额小于0时,无解,返回-1;
1 | def coinChange(coins:list, amount:int): |
以上代码的数学形式就是状态转移方程:
2、带备忘录的递归
1 | def coinChange2(self, coins: list, amount: int) -> int: |
3、dp数组的迭代解法
定义dp
数组:
dp[i]=x
表示,当目标金额为i
时,至少需要x
枚硬币。
1 | class Solution: |
NC19 子数组的最大累加和问题
分析
定义dp
数组的含义:
**以nums[i]
为结尾的「最大子数组和」为dp[i]
**。
这种定义之下,想得到整个nums
数组的「最大子数组和」,不能直接返回dp[n-1]
,而需要遍历整个dp
数组:
1 | int res = Integer.MIN_VALUE; |
依然使用数学归纳法来找状态转移关系:假设我们已经算出了dp[i-1]
,如何推导出dp[i]
呢?
可以做到,dp[i]
有两种「选择」,要么与前面的相邻子数组连接,形成一个和更大的子数组;要么不与前面的子数组连接,自成一派,自己作为一个子数组。
如何选择?既然要求「最大子数组和」,当然选择结果更大的那个:
1 | // 要么自成一派,要么和前面的子数组合并 |
解法
1 | class Solution: |
NC7 买卖股票的最好时机
暴力解法
1 | class Solution: |
一次遍历
1 | def maxProfit(self,prices ): |
NC34 求路径
分析
机器人有两个约束条件:
- 机器人每次只能往右或者往下走
- 机器人不能越界
定义DP数组
二维dp数组,dp[i] [j] 表示从起点到我当前位置(i行j列)有多少条可行的路径,因为我们从约束条件中可以知道,机器人每次只能往右或者往下走,则我们当前点必定是从上面或者左边走过来的(除边界外)。
解法
1 | class Solution: |
NC126 换钱的最少货币数
描述
给定数组arr,arr中所有的值都为正整数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个aim,代表要找的钱数,求组成aim的最少货币数。
如果无解,请返回-1.
解法
定义dp
数组:dp[i]=x
表示,当目标金额为i
时,至少需要x
枚硬币。
回到这一题,如你想求
amount=11
的最少硬币数,你可以先求amount=10
的最少硬币数(子问题),你只需要把子问题的答案加一就是原问题的答案。
加1指的是,用当前面额的硬币可以凑出来。
1 | class Solution: |
NC145 0-1背包
描述
已知一个背包最多能容纳物体的体积为
现有 个物品,第 个物品的体积为$ v_i$ , 重量为 $w_i$。
求当前背包最多能装多大重量的物品?
解法
这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。
第一步要明确两点,「状态」和「选择」。
状态有两个,就是「背包的容量」和「可选择的物品」。
选择就是「装进背包」或者「不装进背包」。
明白了状态和选择,动态规划问题基本上就解决了,只要往这个框架套就完事儿了:
1
2
3
4 for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 择优(选择1,选择2...)
第二步要明确dp
数组的定义。
dp
数组是什么?其实就是描述问题局面的一个数组。换句话说,我们刚才明确问题有什么「状态」,现在需要用dp
数组把状态表示出来。
首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维dp
数组,一维表示可选择的物品,一维表示背包的容量。
dp[i][w]
的定义如下:对于前i
个物品,当前背包的容量为w
,这种情况下可以装的最大价值是dp[i][w]
。
比如说,如果 dp[3][5]
= 6,其含义为:对于给定的一系列物品中,若只对前 3 个物品进行选择,当背包容量为 5 时,最多可以装下的价值为 6。
根据这个定义,我们想求的最终答案就是dp[N][W]
。
base case 就是dp[0][..] = dp[..][0] = 0
,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0。
细化上面的框架:
1 | int dp[N+1][W+1] |
第三步,根据「选择」,思考状态转移的逻辑。
简单说就是,上面伪码中「把物品i
装进背包」和「不把物品i
装进背包」怎么用代码体现出来呢?
这一步要结合对dp
数组的定义和我们的算法逻辑来分析:
先重申一下刚才我们的dp
数组的定义:
dp[i][w]
表示:对于前i
个物品,当前背包的容量为w
时,这种情况下可以装下的最大价值是dp[i][w]
。
如果你没有把这第i
个物品装入背包,那么很显然,最大价值dp[i][w]
应该等于dp[i-1][w]
。你不装嘛,那就继承之前的结果。
如果你把这第i
个物品装入了背包,那么dp[i][w]
应该等于dp[i-1][w-wt[i-1]] + val[i-1]
。
首先,由于i
是从 1 开始的,所以对val
和wt
的取值是i-1
。
从1开始,对应的物品是0。
而dp[i-1][w-wt[i-1]]
也很好理解:你如果想装第i
个物品,你怎么计算这时候的最大价值?
w是当前背包的容量,wt[i-1]是第i个物品的重量,val[i-1]是第i个物品的价值
换句话说,在装第i
个物品的前提下,背包能装的最大价值是多少?
显然,你应该寻求剩余重量w-wt[i-1]
限制下能装的最大价值,加上第i
个物品的价值val[i-1]
,这就是装第i
个物品的前提下,背包可以装的最大价值。
综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码:
1 | for i in [1..N]: |
完整代码:
1 | class Solution: |
C++版:
1 | int knapsack(int W, int N, vector<int>& wt, vector<int>& val) { |
NC148 几步可以从头跳到尾
描述
动态规划
来自牛客题解区
使用 dp[i]
表示第 i
个元素到最后一个元素所需要的步数(下标从1开始),起点是 n-1
到 n
需要步数为1,依次自定向下计算:
- 对于第 $i$个元素,如果 $i + A[i-1] \geq n$ (数组下标从0开始),则可以一步到位;
- 否则我们遍历 $i$ 之后的元素,找到最小到$n$的步数,既 $dp[i] = min(dp[i+j],d[i])$,(j为往后遍历的数量),并使之加一
1 | class Solution: |
动态规划2
从前向后找跳板
1 | def Jump2(self,n,A): |
动态规划3
1 | def Jump3(self,n,A): |
贪心算法
摘自牛客评论区
1 | def greedy(self,n,A): |
NC155 牛牛的数列
摘自牛客评论区
牛牛现在有一个 n 个数组成的数列 nums , 牛牛现在想取一个连续的子序列,并且这个子序列还必须得满足: 最多只把一个数改变成一个正整数, 就可以使得这个连续的子序列是一个严格上升的子序列, 牛牛想知道这个连续子序列最长的长度是多少。
分析
tail[i]
表示以i结尾的最长连续递增子序列的长度,
head[i]
表示以i开头的最长连续递增子序列的长度
状态转移方程,
对于tail[i]
,初始状态tails[0]=1
,如果 nums[i]
大于nums[i-1]
,则tail[i] = tail[i-1]+1
,从左往右遍历
对于head[i]
,初始状态head[n-1]=1
,如果nums[i]
小于nums[i+1]
,则head[i] = head[i+1] +1
,从右往左遍历
解法
1 | class Solution: |
NC127 最长公共字串
描述
给定两个字符串str1和str2,输出两个字符串的最长公共子串
题目保证str1和str2的最长公共子串存在且唯一。
解法:暴力解法
1 | class Solution: |
解法:动态规划
参考评论区:
【数据结构和算法】动态规划解最长公共子串_牛客博客 (nowcoder.net)
注意这题求的是最长公共子串,不是最长公共子序列,子序列可以是不连续的,但子串一定是连续的。
定义 dp[i][j]
表示字符串 str1
中的第 i
个字符和 str2
中 第 j
个字符为最后一个元素所构成的最长公共字串。
如果要求 dp[i][j]
,也就是 str1
的第 i
个字符和str2
的第j
个字符为最后一个元素所构成的最长公共字串,我们首先需要判断这两个字符是否相等。
如果不相等,那么他们就不能构成公共字串,也就是
dp[i][j]=0
如果相等,我们还需要计算前面相等字符的个数,其实就是
dp[i-1][j-1]
,所有dp[i][j]=dp[i-1][j-1]+1
其重点是,以i,j分别是str1,str2的结束字符串,当前的结尾字符记录,会在前一个字符记录上进行迭代,同时在迭代过程中记录字串长度,并记住结束位置
1 | class Solution: |
解法二:优化
1 | maxLastIndex = 0 # |
NC91 最长递增子序列
描述
给定数组arr,设长度为n,输出arr的最长递增子序列。(如果有多个答案,请输出其中 按数值(注:区别于按单个字符的ASCII码值)进行比较的 字典序最小的那个)
示例:
1 | 输入:[2,1,5,3,6,4,8,9,7] |
解法:动态规划
来自评论区
dp[i]=x
意义为 以i
为结尾的序列,最大递增子长度是多少。
比较顺序如下:
i | j_border | i->j |
---|---|---|
1 | 1 | 0 |
2 | 2 | 0,1 |
3 | 3 | 0,1,2 |
… | … | … |
6 | 6 | 0,1,2,3,4,5 |
6号位置上的元素会依次与0-5号位置上的元素进行比较,如果6号位置上的元素大于0号位置上的元素,则dp[6]=dp[0]+1
。
1 | dp[i] = dp[j] + 1, if arr[i] > arr[j], j = 0,..., i-1 |
1 | class Solution: |
优化版:二分法动态规划
TY78 最大上升子序列
描述
链接:https://www.nowcoder.com/questionTerminal/dcb97b18715141599b64dbdb8cdea3bd?answerType=1&f=discussion
来源:牛客网
一个数的序列bi,当b1 < b2 < … < bS的时候,我们称这个序列是上升的。对于给定的一个序列(a1, a2, …,aN),我们可以得到一些上升的子序列(ai1, ai2, …, aiK),这里1 <= i1 < i2 < … < iK <= N。比如,对于序列(1, 7, 3, 5, 9, 4, 8),有它的一些上升子序列,如(1, 7), (3, 4, 8)等等。这些子序列中序列和最大为18,为子序列(1, 3, 5, 9)的和. 你的任务,就是对于给定的序列,求出最大上升子序列和。注意,最长的上升子序列的和不一定是最大的,比如序列(100, 1, 2, 3)的最大上升子序列和为100,而最长上升子序列为(1, 2, 3)。
解法:来自评论区
1 | size_n = int(input().strip()) |
2021网易测试开发-第6题
描述
大富翁游戏规则如下
- 玩家起始会获得一定资本M金币
- 玩家每一次可以走一个格,或者跳两个格;走一格耗费2个金币能量;跳两个格,耗费3个金币能量;金币只有满足能量消耗时,才能继续往下走
- 玩家每走到一个格,会得到这个格的奖励,每个格的奖励金币数为非负整数
- 当玩家走到这个格后,总金币数不足以支持下一步金币消耗时,则不能继续往下走,游戏结束
- 玩家第一步可以选择走一步进第1格或者跳2步进第2格起始,玩家可以选择在任意一格结束游戏
问玩家游戏中,最多能得到多少个金币?
分析
创建数组 dp
,dp[i]
代表在第i
个位置可以获得的最大收益,i
从1开始直到结束。
第0
个位置为初始成本。
在第i
个位置的收益,dp[i]
等于跳过去的位置的收益dp[i-j-1]
加上当前位置的收益golds[i-1]
再减去消耗。
代码
1 | def test4(): |
70. 爬楼梯
描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
1 | 输入: 2 |
1 | 输入: 3 |
解法:
思想:dp[i]=dp[i-1]+dp[i-2]
,和废弃那波数列有点像
1 | class Solution: |