动态规划专项训练
参考:代码随想录官⽹www.programmercarl.com
更新记录
第一次更新 2024.05.13
第二次更新 2024.05.17
一般解题步骤
- 确定dp数组(dp table)以及下标的含义
dp数组(状态转移数组)可以是一维的可以是二维的
- 确定递推公式 (状态转移方程)
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
实战训练
基础问题
1.斐波那契数
斐波那契数 (通常用 F(n)
表示)形成的序列称为 斐波那契数列 。该数列由 0
和 1
开始,后面的每一项数字都是前面两项数字的和。也就是:
1 | F(0) = 0,F(1) = 1 |
给定 n
,请计算 F(n)
。
示例 1:
1 | 输入:n = 2 |
示例 2:
1 | 输入:n = 3 |
示例 3:
1 | 输入:n = 4 |
提示:
0 <= n <= 30
code:
用模拟的思想做的,非dp
1 | /** |
用动态规划来做
确定dp数组(dp table)以及下标的含义
dp[i]表示第i个斐波那契数
确定递推公式
题目 中有 dp[i] = dp[i - 1] + dp[i - 2]
dp数组如何初始化
题目中有 dp[0] = 0 , dp[1] = 1
确定遍历顺序
是从后向前还是从前向后,是一层还是两层,如果是两层,哪层在外面
打印推导dp数组(debug用的)
70. 爬楼梯
假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
1 | 输入:n = 2 |
示例 2:
1 | 输入:n = 3 |
提示:
1 <= n <= 45
code:
在前面知识的基础上,我大概能了解到迈上第n阶的方法其实是有前面的迈上n-1和迈上n-2阶的种数有关的,但是如果没有关键点的想,很容易想岔去。
下面的代码是我第一个自己思考的代码,是错误的,一开始想的是想的是比如想要到第4阶,有可能是dp[1] + dp[3], dp[2]+ dp[2], dp[3] + dp[1];但是如果这样想的话,所有种类中是会有重复值的。所以我也想到了要去重,但是去重的规律并没有找对;
1 | /** |
看了卡哥的视频解说,反复想了一下理解了,其实思路是可以有很多种的,但是如果想用动态规划的思路来写,关键点在于状态转移
,如果从0阶开始向上迈,那么状态转移方程其实很不好把握,容易像我之前一样想岔了,会想:先走几阶有几种方法,后走几阶有几种方法,很容易把自己绕进去。
但是如果想要专注于状态转移,应该从台阶上往下看,而且必须注意状态转移的条件(即每次只能爬1或2个台阶),那么对于一个人来说,他走到n层台阶的上一个状态只可能有两种:
- 上一个状态是走到了n-1个台阶,他是迈了一步才走到n台阶的
- 上一个状态是走到了n-2个台阶,他是迈了两步才走到n台阶的
可以想到,这样递归出来的种类数其实是很干净的,不会存在重复值,因为不同于我第一次的想法,我第一次想的是(**step1:**从0-k有几种方法; **step2:**从k-n有几种方法),而动态规划中,强制了我的step2只能有一种方法。举个例子:
易得dp[1] = 1, dp[2]= 2
如果我想求dp[4]
先看看我原本的想法:可能性有先走一步,再走三步;先走二步,再走两步;先走三步,再走一步
那么这个dp[4]=dp[1]*dp[3]+ dp[2]*dp[2]+dp[3]*dp[1]
,但是这样的递归推导是不干净的,因为里面存在了很多的重复值
再看看动态规划的思想:如果我想根据step1+step2的思路来思考的话,首先强制step2的方法只能有一种,即当走过step1方法到达中间的某个台阶后,我只能通过一种方法来到达台阶n。
虽然step只能有一种方法,但是step可以有两种情况:即先走 2步,然后走2步到n;或者先走3步,然后走1步到n;
由此可得到:dp[4] = d[2] * 1 + d[3] * 1;
所以我才得出了dp的关键在于:到达这个状态的上一个状态是怎么样的,因为上一个状态总是通过一次操作(一次状态转移)达到这个状态,这样递归下来的数据才是干净的。
1 | /** |
使用最小花费爬楼梯
给你一个整数数组 cost
,其中 cost[i]
是从楼梯第 i
个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0
或下标为 1
的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
示例 1:
1 | 输入:cost = [10,15,20] |
示例 2:
1 | 输入:cost = [1,100,1,1,1,100,1,1,100,1] |
提示:
2 <= cost.length <= 1000
0 <= cost[i] <= 999
Code:
这道题的难点在于搞清楚ct[i]
到底指的是什么?
1 | /** |
不同路径【Mid】
一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例 1:
1 | 输入:m = 3, n = 7 |
示例 2:
1 | 输入:m = 3, n = 2 |
示例 3:
1 | 输入:m = 7, n = 3 |
示例 4:
1 | 输入:m = 3, n = 3 |
用dp来写
code
d[x][y]表示从start开始到点(x,y)有d[x][y]种走法
关键点在于只能向下走或者向右走,所以x和y都智能递增,是不可能回头的,所以对于边缘d[x][1]和d[1][y]都应该初始化为1;
1 | /** |
DFS的基本写法
在JavaScript中,深度优先搜索(DFS)的一般写法与其他编程语言类似。下面是一个基本的DFS函数示例,用于在一个图或树结构中进行深度优先搜索:
1 | function dfs(node, visited) { |
在这段代码中,dfs()
函数表示深度优先搜索的递归函数,接受一个节点node
和一个记录访问情况的visited
集合作为参数。在DFS过程中,首先检查当前节点是否已经被访问过,如果已经访问过则直接返回;否则将当前节点标记为已访问,并递归地对当前节点的相邻节点进行DFS遍历。
用dfs,会超时,时间复杂度会达到2^(m+n)次,所以不合适,但是思路是可行的,如果m和n较小的话
1 | /** |
不同路径II
一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1
和 0
来表示。
示例 1:
1 | 输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]] |
示例 2:
1 | 输入:obstacleGrid = [[0,1],[0,0]] |
提示:
m == obstacleGrid.length
n == obstacleGrid[i].length
1 <= m, n <= 100
obstacleGrid[i][j]
为0
或1
1 | /** |
整数拆分
给定一个正整数 n
,将其拆分为 k
个 正整数 的和( k >= 2
),并使这些整数的乘积最大化。
返回 你可以获得的最大乘积 。
示例 1:
1 | 输入: n = 2 |
示例 2:
1 | 输入: n = 10 |
提示:
2 <= n <= 58
code:
把数尽量拆成平均的
1 | /** |
dp思想:对于递推公式还是有点疑惑
1 | /** |
不同的二叉搜索树
给你一个整数 n
,求恰由 n
个节点组成且节点值从 1
到 n
互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
示例 1:
1 | 输入:n = 3 |
示例 2:
1 | 输入:n = 1 |
提示:
1 <= n <= 19
Code:
如果清楚二叉搜索树的特性和知道用动态规划来做这道题,这道题其实还不算特别难,但是需要画图分析一下,其实就是找规律的过程。
由于二叉搜索树的特性(比跟节点小的数一定放在左子树中,比根节点大的数一定放在右子树中),所以就需要考虑当j
作为根节点的时候,会有几种情况,在对j
从0
遍历到n
;这里为什么从0
开始遍历,后面会解释。
在实际分析前,需要注意的一点的,如果一棵树只有3个节点,那无论这三个节点里面的数是(1,2,3),还是(2,3,4),或者是(11,12,13)等等,只要是连续的数字,那么他们的二叉搜索树的种类个数就一定是相等的。即树有几种可能性是考虑树中有几个节点,而不是考虑树中节点的实际值。
具体来说,考虑n=4的情况,在n=4时,分为4种情况讨论:
当root=1,那么可以得知对于2,3,4节点来说,这三个节点都将放在以
1
为根节点的树的右子树下,因为 2,3,4都大于1,那么对于右子树而言,只需要计算(2,3,4)节点的可能种类即d[3]
;在考虑左子树,由于没有比1还小的树节点,所以此时的左子树是为空的,即d[0]
。一般来说,如果考虑d[0]
的实际意义,可能会给d[0]
赋值为0;但是需要考虑到,一棵树的种类,如果确定了根节点,那么这颗树的可能种类就是左子树的可能种类个数乘上右子树的可能种类个数,即
d[3]*d[0]
,如果我们给d[0]
赋值为0,那么d[3]*d[0]==0
,这很明显和实际情况不符。再加上题目中1<=n<=19
,所以我们不妨将d[0]
赋值为1
。那么当roo=2,我们同理分析,只有(
1
)节点(1个节点)在右子树中,而(2,3
)节点(2个节点)在左子树中,那么可能性就是d[1]*d[2]
同理,当root=3,有(
1,2
)2个节点在右子树,只有(4
)1个节点在左子树中,所以可能性是d[2]*d[1]
当root=4,有(
1,2,3
)3个节点在右子树,0个节点在左子树,即d[3]*d[0]
总结分析得
root==1 d[3]*d[0]
总结分析得
root==2 d[2]*d[1]
总结分析得
root==3 d[1]*d[2]
总结分析得
root==4 d[0]*d[3]
即
root==j d[i-j-1]*d[j]
,j
从0
到i-1
(0和i-1都去得到)从实际意义上来理解其实也很好,如果是一颗有
i
个节点的树,其中一个节点已经确定了是根节点了,所以只有剩下的i-1
个节点分别分配在左右子树中,所以左右子树的节点个数相加是i-1
,所以上面的d[i-j-1]*d[j]
中的i-j-1
加上j
是一定等于i-1
的。
现在只需要对上面的4种情况(i==4
种情况)进行求和就可以得出dp[4]
即dp[i]
这种规律从i=3
就开始了,所以可以从i=3
开始遍历,一直到题目所求的i==n
1 | /** |
背包问题
416. 分割等和子集
给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
1 | 输入:nums = [1,5,11,5] |
示例 2:
1 | 输入:nums = [1,2,3,5] |
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 100
思路
取和不取的问题,对于一个正数数组,数组中所有值的和事确定的,所以分成两个子集后,两个子集的元素和就为整个和的一半