总言
主要内容:编程题举例,熟悉理解动态规划类题型(斐波那契数列模型、路径问题、简单多状态 dp 问题)。
1、动态规划基本介绍
1)、基本介绍
术语介绍: 动态规划(Dynamic Programming,简称DP)是一种在数学、计算机科学和经济学中使用的,用于找出多阶段决策过程最优解的方法。它通常用于优化递归问题,这些问题可以分解为相同或相似的子问题,或者称为重叠子问题和最优子结构性质。动态规划的基本思想是将问题分解为简单的子问题,并将子问题的解存储起来,以便在需要时直接使用,从而避免重复计算。
核心思想: 在解决一些复杂问题时,我们通常会发现问题可以被分解成一些更小的、相似的子问题。如果我们能够找到这些子问题的解,并将其存储起来,那么当我们再次需要这些解时,就可以直接从存储中取出,而不需要重新计算。这就是动态规划的核心思想。
动态规划的基本要素——dp表: 一般在使用动态规划解题时,往往需要先定义一个dp表,它是一种用于存储子问题解的数据结构,通常定义为一个一维或二维数组。
动态规划的基本路流程: 如下,前两步为核心步骤,后面的三步其实是在做细节处理。
1、确定状态表示(最重要的一步)
2、推导状态转移方程(最难的一步)
3、初始化(细节处理部分)
4、确定填表顺序(细节处理部分)
5、返回值(细节处理部分)
2)、状态表示
状态表示: 根据上述dp表的描述,状态表示指的是dp表中某一位置(或索引)的值所代表的具体含义(或问题的一个特定子问题的解)。这个“状态”是根据问题的特性和需要来定义的,以dp[i]
、dp[i][j]
表示,是一个“虚”的值,但实际落到每一个具体位置上,有具体的值。
通常来讲有两种基本套路:①以 i
位置为结尾,巴拉巴拉……;②以 i
位置为起点,巴拉巴拉……
如何确定一个状态表示? (通常有3种确定方法)
①从题目要求中获取。例如,下面的第 N 个泰波那契数,题目直接给了泰波那契序列的定义,此时dp[i]表示第 i 个泰波那契数的值。
②经验+题目要求。这里经验就需要我们大量的做题。
③分析问题过程中,发现重复子问题。这时候我们可以把这个子问题抽象成为状态表示。
④除了上述三者,也有其它确定状态表示的方法。
3)、状态转移方程
状态转移方程实际就是dp[i]
的填表公式(一个数学表达式),其一般是根据题目要求推导获得的,推导思想为利用已知的状态(或子问题的解)来计算新的状态(或问题的解)。
例如,求第n个斐波那契数列中,状态转移方程就可以写为:dp[i] = dp[n-1] + dp[n-2]
。这意味着为了计算斐波那契数列的第 i 个数,我们需要知道第 i-1
个数和第 i-2
个数的值。
4)、初始化
初始化: 主要是为了保证填充dp表时不会越界,并且确保起始状态或边界状态有正确的值。
例如上述斐波那契数列中dp[i] = dp[n-1] + dp[n-2]
,若i=0或i=1,此时dp表中下标为负数,因此初始化时,要我们手动设置这两处的值。
其它说明(处理边界问题以及初始化的技巧): 为了防止越界,一般在使用动态规划时,可以为dp表引入虚拟节点。
引入虚拟节点一定程度上能够简化初始化的复杂度(尤其是在二维dp表中),但我们需要保证在使用它时,能得到正确的状态关系。因此,我们需要注意:
1、注意如何填充虚拟节点中的值,使得后续填表仍旧正确。(这需要具体题目具体分析)
2、注意引入虚拟节点后,下标的映射关系。例如,一位dp表中,dp[i]和实际arr[i]之间如何映射?二维呢?
当然,不引入虚拟节点也是可以使用动态规划的,比如,我们可以加入一个if
判断语句(4.7举例),或者直接对存在越界的位置进行初始化,等等。看写法风格。
5)、填表顺序、返回值
填表顺序: 比如一维dp表中,填表可以从左向右,也可以从右向左。主要是根据状态转移方程来判断的,为了填写当前状态,其所依赖的状态应该已经计算过(处于已知)。
返回值: 即我们要的最终结果,dp表中存储着各个状态位置的解,而我们需要的有时只是其中某一状态的解。具体是哪一返回值,需要根据 题目要求 + 状态表示 来确定。
例如,斐波那契数列,题目要求返回第n个斐波那契数,而状态表示dp[i]表示第i个斐波那契数列,那么我们只用返回dp[n]即可。
2、斐波那契数列模型
2.1、第 N 个泰波那契数(easy)
题源:链接。
2.1.1、动态规划(基础版)
1)、思路分析
状态表示: 本题可以根据题目的要求直接定义出状态表示。dp[i]
表示,以i
位置为结尾,第 i
个泰波那契数的值。
状态转移方程: 题目已经给定,dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]
初始化: 从我们的递推公式可以看出, dp[i]
在 i = 0、1、2
的时候是没有办法进行推导的,因为 dp[-2]
或 dp[-1]
不是⼀个有效的数据。因此我们需要在填表之前,将 0, 1, 2
位置的值初始化。关于它们的初始值,题目已经告诉了我们 dp[0] = 0
, dp[1] = dp[2] = 1
。
填表顺序: 毫无疑问,此题需要从左往右填表。
返回值: 根据题目,返回dp[n]
,即第n个泰波那契数。
2)、题解
如下,时间复杂度为O(n),空间复杂度为O(n)。
class Solution {
public:
// 使用动态规划解决:根据题目,设状态表示dp[i]为第i个泰波那契数
int tribonacci(int n) {
// 处理边界情况:题目告知 0 <= n <= 37,说明n可以为0,1,2,而我们下述动态规划是从n=3开始
if(n == 0) return 0;
if(n == 1 || n == 2) return 1;
int* dp = new int[n+1];// 1、创建dp表:题目要求返回第n个值,这里dp表需开辟n+1空间
dp[0] = 0; dp[1] = dp[2] = 1;// 2、初始化dp表:用于保证后续填表过程不越界
for(int i = 3; i <= n; ++i)// 3、根据状态转移方程,从左到右填表:这里从i=3开始填,直到i==n为止(注意要把n算入)
dp[i] = dp[i-1] + dp[i-2] + dp[i-3];
return dp[n];// 4、根据状态表示,获取返回值
}
};
2.1.2、动态规划(引入空间优化版)
1)、说明
进行空间优化: 观察此题,上述动态规划能为我们提升时间复杂度,但避免不了空间资源的使用。实际上在动态规划中我们也可以不使用dp表,而是使用变量迭代的方式来代替,即滚动数组(当然,如果你愿意也可以使用一个常量级别的数组来实现 int dp[3] )。
2)、题解
优化的效果: 若空间复杂度是
O
(
n
2
)
O(n^2)
O(n2),优化后为
O
(
n
)
O(n)
O(n);若空间复杂度是
O
(
n
)
O(n)
O(n),优化后为
O
(
1
)
O(1)
O(1)。
class Solution {
public:
// 使用动态规划解决:根据题目,设状态表示dp[i]为第i个泰波那契数
int tribonacci(int n) {
// 处理边界情况:题目告纸 0 <= n <= 37,说明n可以为0,1,2,而我们下述动态规划是从n=3开始
if(n == 0) return 0;
if(n == 1 || n == 2) return 1;
int a = 0, b = 1, c = 1;// 用于进行滚动的变量
int d = 0;// 用于获取每次的泰波那契数
for(int i = 3; i <= n; ++i)
{
// 计算第i个泰波那契数
d = a + b + c;
// 滚动操作:迭代,计算后续的泰波那契数
a = b;
b = c;
c = d;
}
return d;// 4、for循环结束时,此时d中获取到的是第n个泰波那契数
}
};
关于学习重心问题说明: 重点在如何使用动态规划解决问题上,空间优化是学会能够灵活运用后进行的锦上添花的操作。
2.2、三步问题(easy)
题源:链接。
2.2.1、动态规划
1)、思路分析
根据题目,小孩一次可以上1阶、2阶或3阶台阶。也就是说,对第 i 个台阶:
可以从第 i-1 个台阶,迈 1 个台阶到达 i
可以从第 i-2 个台阶,迈 2 个台阶到达 i
可以从第 i-3 个台阶,迈 3 个台阶到达 i
以此类推,对第 i-1 个台阶,也存在着类似的到达方式。这也就意味着,要想知道到达第 i 个台阶一共有多少种走法,只需要知道到达第 i-1、i-2、i-3个台阶的走法即可。到第 i 个台阶,无非是在这三者基础上走对应的1、2、3步。因此,我们可以得出动态规划的状态转移方程dp[i] = dp[i-1] + dp[i-2] + dp[i-3]
。
注意初始化: 在计算 dp[i]
时,我们需要用到 dp[i−1]
, dp[i−2]
, 和 dp[i−3]
的值。当 i
较小时,递推公式中某些项会涉及到负数索引,这在实际情况中是没有意义的,也不是有效的数据。
为了解决这个问题,我们需要在开始填表(即计算 dp 数组的值)之前,先对1
,2
,3
这三个位置进行初始化。
取模问题: 题目中告知我们结果可能很大,需要对结果取模。对于这类需要取模的问题,一般可每次计算(两个数相加/乘等),都需要取一次模。 (以防两次、三次运算后再取模,仍旧发生数据溢出。)
扩展说明: 1e9 + 7
,在科学计数法中,
1
e
9
1e9
1e9 表示
1
×
1
0
9
1×10 ^9
1×109,故此数实际上是
1
e
9
+
7
=
1000000007
1e9 + 7 = 1000000007
1e9+7=1000000007。相关扩展链接。
2)、题解
状态表示: 本题状态表示是根据经验+题目的要求获得。dp[i]
表示:以i
位置为结尾,到达第 i
个台阶时,一共存在多少种上楼方式。
状态转移方程: 我们之前已经推导出,dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]
初始化: 在上述已经讲明了。
填表顺序: 毫无疑问,此题需要从左往右填表。
返回值: 根据题目,返回dp[n]
,到达第n个台阶时存在的上楼方法数。
class Solution {
const int MOD = 1e9 + 7;// 用于大数取模
public:
// 使用动态规划解决:dp[i]表示到达第i个台阶时,总的上楼方式。(例如:dp[1]表示到第1个台阶,dp[0]无意义)
int waysToStep(int n) {
// 0、处理特殊边界情况
if(n == 1 || n == 2) return n;
if(n == 3) return 4;
int* dp = new int[n+1];// 1、创建一个dp表:根据上述状态表示,n个台阶,数组大小为n+1
dp[0] = 0;
dp[1] = 1, dp[2] = 2, dp[3] = 4;// 2、初始化dp表:主要用于后续状态转移方程时,防止越界。
for(int i = 4; i <= n; ++i)
dp[i] = ((dp[i-1] + dp[i-2]) % MOD + dp[i-3]) % MOD;// 3、根据状态转移方程,从左到右填表:这里从i=4开始,到i=n结束。
return dp[n];// 4、返回目标值
}
};
2.3、使用最小花费爬楼梯(easy)
题源:链接。
2.3.1、题解一
1)、思路分析
先来思考一个问题,题目中的楼梯顶指哪里?根据题目示例描述,“从下标为 1 的台阶开始。支付 15 ,向上爬两个台阶,到达楼梯顶部。”,由此说明,对于vector<int>& cost
,设其下标区间在[0,n)内,则楼梯顶在下标为 n 的位置处。
1、确定状态表示:
根据章节1处的介绍,通常这类一维的动态规划中,状态表示通常有两种基本套路:
以 i 位置为结尾,巴拉巴拉(具体含义)……
以 i 位置为起点,巴拉巴拉(具体含义)……
这里,“为起点还是结尾”,通常来源于经验总结,而“具体含义”,通常来源于题目要求。
我们先使用第一种:根据经验+题目要求,对dp[i]
,其表示以 i 为结尾,到达 i 位置处所需要的最小花费。(注意理解:到达 i 位置的时,i 位置的花费并不需要算上。)
2、根据上述状态表示的含义,我们来分析状态转移方程,要到达dp[i]
无非两种情况:
1、以最小的花费先到达 i - 1 下标处的台阶,支付cost[i-1]的经费,走 1 步到达i。
2、以最小的花费先到达 i - 2 下标处的台阶,支付cost[i-2]的经费,走 2 步到达i。
而这里,“以最小的花费先到达i-1、i-1台阶处”,正是我们对状态表示的描述:
dp[i-1]: 到达 i-1 位置处,所需要的最小花费。
dp[i-2]: 到达 i-2 位置处,所需要的最小花费。
因此,上述描述就成为了:
1、dp[i] = dp[i-1] + cost[i-1] : 先以最小的花费到达 i - 1 下标处的台阶,支付cost[i-1]的经费,走 1 步到达i。
2、dp[i] = dp[i-2] + cont[i-2] : 先以最小的花费到达 i - 2 下标处的台阶,支付cost[i-2]的经费,走 2 步到达i。
由于我们计算的是最小花费,因此取两者最小值:
dp[i] = min(dp[i-1] + cost[i-1],dp[i-2] + cont[i-2])
3、初始化: 主要是为了填表时不越界,实则我们在上图中已经分析过了。从这里的递推公式也可以看出,要先初始化 i = 0
及 i = 1
位置的值。由于题目中告知我们可以直接站在第 0 层或第 1 层台阶上,再根据此处对 dp[i] 含义的表述可知,容易得到 dp[0] = dp[1] = 0
,不需要任何花费。
4、填表顺序: 无疑,这里需要从左向右填表。
5、返回值: 题目要求我们返回楼梯顶位置处的最小花费,在上述我们已经分析过了,这里的楼梯顶指的是cost.size()
位置处的花费。设 n = cost.size() , 也就意味着这里要返回的是 dp[n]。
dp[n]:到达n位置处时,所需要的最小花费。
2)、题解
class Solution {
public:
// 使用动态规划解决:dp[i]表示到达第i个位置时,所需的最小花费
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();// 统计一共有多少个台阶:实际下标为[0,n-1],楼梯顶为n
if(n == 0 || n == 1) return 0;// 0、处理边界特殊情况(根据题目2 <= cost.length <= 1000,也不用特殊处理,但谨慎一点可以写上)
vector<int> dp(n+1, 0);// 1、创建dp表:最终要获取dp[n],这里dp表需要大小为n+1
dp[0] = dp[1] = 0;// 2、初始化dp表:根据题目,可以从下标为 0 或下标为 1 的台阶开始爬,故这两台阶的最小花费为0(即无需花费)
for(int i = 2; i <= n; ++i)
dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);// 3、根据状态转移方程,从左到右填表
return dp[n];// 4、根据题目,返回到达楼顶(下标为n的位置处)的最小花费
}
};
2.3.2、题解二
1、确定状态表示: 上述的题解一中,状态表示是 “以 i 位置为结尾” 获得的。这里,我们尝试使用第二种,即“以 i 位置为起点”。
1、以 i 位置为结尾,(具体含义)……
2、以 i 位置为起点,(具体含义)……
若以 i 位置为起点,则dp[i]
可以表示为,从 i 位置开始,到达楼梯顶时,所需要的最小花费。
那么dp[i] 无非两种情况:
1、支付 i 位置的费用cost[i], 往后走1 步,到达i+1位置处。此时,只需要获取到从i+1到楼梯顶的最小费用,即可知道dp[i]位置的最小费用。
2、支付 i 位置的费用cost[i], 往后走2 步,到达i+2位置处。此时,只需要获取到从i+2到楼梯顶的最小费用,即可知道dp[i]位置的最小费用。
2、获取状态方程: 由上述状态表示可知,从i+1到楼梯顶的最小费用即dp[i+1]、同理可得dp[i+2]。则有:
1、dp[i] = cost[i] + dp[i+1];
2、dp[i] = cost[i] + dp[i+2];
取最小费用:dp[i] = min(cost[i] + dp[i+1], cost[i] + dp[i+2]);
3、初始化: 为了保证填表的时候不越界,需要初始化最后两个位置的值,结合状态表示可知:dp[n-1] = cost[n - 1]
,dp[n - 2] = cost[n - 2]
4、填表顺序: 从右往左,这里我们只需要从n-3下标位置开始填表即可。
5、返回值: 根据题目 ,可以从下标为0,或者下标为1的位置到达楼梯顶。而此处dp[i]表示从 i 位置为起点到楼梯顶的最小花费,因此,最终返回值,应该取 dp[0]
、dp[1]
二者最小值。
2)、题解
这里,dp表的空间开辟,只需要n个位置即可。
class Solution {
public:
// 使用动态规划解决:dp[i]表示从第i个位置出发,到达楼顶时的最小花费
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();// 统计一共有多少个楼层,[0,n-1]表示楼层数,n表示楼顶
if(n == 0 || n == 1) return 0;// 0、处理特殊情况:若本身就要到达下标为第0、第1个台阶位置,无需花费
vector<int> dp(n,0);// 1、创建dp表:这里最大从n-1位置出发,到达楼顶(n),因此dp表大小只需n
dp[n-1] = cost[n-1]; dp[n-2] = cost[n-2];// 2、初始化dp表
for(int i = n-3; i >= 0; --i)
dp[i] = min(dp[i+1] + cost[i], dp[i+2] + cost[i]);// 3、根据状态转移方程,从右到左填表
return min(dp[1],dp[0]);// 4、获取返回值:这里要从下标为 0 或下标为 1 的台阶开始,获取到达楼顶的最小花费。
}
};
2.4、解码方法(medium)
题源:链接。
2.4.1、题解
1)、思路分析
根据题目,对A~Z
进行编码,对应数字1~26
。那么,对于给定的数字,解码方式要么是①取1位数字单独进行解码,要么是②取2位数字组合在一起解码。不可能有3位及3位以上的数组解码为1位字符的情况。
1、确定状态表示:(经验+题目要求)这里,我们以 i 为结尾来分析。那么对于dp[i]
,其表示,到达 i 位置时,所存在的解码方法总数。
2、确定状态方程: 在上述我们分析,要对 i 位置解码,有两种解码方式,①选取 i 位置处单独解码,②选取 i-1 、i 位置处的组合解码(因为这里是以 i 位结尾,自然2个数字的组合,选取了 i 之前的数字)。而二者分别有成功、不成功两种解码状态。
由上图可知,最终,dp[i] = dp[i-1] + dp[i-2]
(有条件的!)
3、初始化: 根据题目,需要对i =0 和 i=1初始化。在章节1中我们介绍过,初始化有两种方式,①直接初始化;②添加虚拟节点/虚拟位置/辅助位置。
这里我们先选择直接初始化的方式:
对dp[0]:
1、当 s[0] == '0' 时,没有编码方式,结果dp[0] = 0;
2、当 s[0] != '0' 时,能编码成功,dp[0] = 1
对dp[1]:
1、当s[1]在[1,9]之间时,能单独编码,此时dp[1] += dp[0];
2、当s[0]与s[1]结合后的数在[10,26] 之间时,说明在前两个字符中,又有一种编码方式,此时dp[1] += 1;
引入虚拟节点的初始化方式:
引入虚拟节点后,对dp[1]的初始化,实则只需要看s[0]即可。
反倒是虚拟节点的dp[0]的初始化,需要仔细考虑。注意:在填写时,并非所有虚拟节点dp[0],一上来都不闻不问,直接dp[0] = 0,需要我们具体题目具体分析。
4、填表顺序: 从左往右
5、返回值: 如果选择直接初始化的方式,返回 dp[n - 1] 的值,表示在 [0, n - 1] 区间上的解码方式总数。
2)、题解
直接初始化的解题方式:
class Solution {
public:
// 以动态规划解题:dp[i],以i位置为结尾,表示到达i位置时,解法的方法总数
int numDecodings(string s) {
int n = s.size();// 获取字符串长度:[0,n-1],返回s的解法总数,则意味着返回dp[n-1]
// 1、创建dp表
vector<int> dp(n,0);// 每个位置记录到达该位置的解码方案总数
// 2、初始化dp表
dp[0] = (s[0] != '0') ? 1 : 0;//初始化dp[0]
if(n == 0) return 0;// 处理特殊情况
if(n == 1) return dp[0];// 处理特殊情况
if(s[1] >= '1' && s[1] <= '9') dp[1] += dp[0];// 初始化dp[1]:考虑单数的情况
int tmp = (s[0] - '0') * 10 + (s[1] - '0');// 初始化dp[1]:考虑两位数的情况
if(tmp >= 10 && tmp <= 26) ++dp[1];// 这里判断条件必须是[10,26]而非[1,26],若是后者说明有前导零
// 3、根据状态转移方程,从左到右填表
for(int i = 2; i < n; ++i)
{
// 考虑单位数时
if(s[i] >= '1' && s[i] <= '9') dp[i] += dp[i-1];
// 考虑两位数时
int tmp = (s[i-1] - '0') * 10 + (s[i] -'0');
if(tmp >= 10 && tmp <= 26) dp[i] += dp[i-2];
}
// 4、返回
return dp[n-1];
}
};
使用虚拟节点的写法:这种写法中,由于映射关系,需要注意返回值的下标位置。
class Solution {
public:
int numDecodings(string s) {
// 1、创建dp表,确定状态转移方程:dp[i]表示,到达i位置时,所存在的解码方式总数
int n = s.size();
vector<int> dp(n + 1, 0); // 引入虚拟节点,多开辟一个位置
// 2、初始化dp表
dp[0] = 1;
dp[1] = (s[0] != '0');
// 3、根据状态方程,从左到右填表
for (int i = 2; i <= n; ++i) {
if (s[i - 1] >= '1' && s[i - 1] <= '9')
dp[i] += dp[i - 1];
int tmp = (s[i - 1] - '0') + (s[i - 2] - '0') * 10;
if (tmp >= 10 && tmp <= 26)
dp[i] += dp[i - 2];
}
// 4、返回
return dp[n]; // 注意这里的返回值
}
};
3、路径问题
3.1、不同路径(medium)
题源:链接。
3.1.1、题解
1)、思路分析
此题我们曾介绍过递归+记忆搜索版本。
相关思路大差不差,这里我们以动态规划来实现。
3.1.1.1、题解一
1、确定状态表示: 根据经验+题目要求,这是在二维数组中,则以 (i,j)
为结尾,dp[i][j]
表示,从起点(0,0)
到达(i,j)
时,存在的路径总数。
2、确定状态转移方程: 根据题目,到达 (i,j)
的方式有二,①从 (i-1,j)
出发,往下走一步;②从 (i, j-1)
位置出发,往右走一步。
则有:dp[i][j] = dp[i-1][j] + dp[i][j-1]
3、初始化:
①直接初始化。根据题目和状态转移方程,这里存在特殊情况的是首行和首列。可以在填表前先对其进行初始化。
②引入虚拟节点。二维数组中,虚拟节点的引入通常需要看题目需求。这里,dp[i]是以i为结尾,表示到达i位置处的路径总数,因此。我们可以引入虚拟的首行、首列,方便初始化原先的首行、首列。
根据章节1反复强调的,引入虚拟节点,需要注意两点。
①此时dp表和存储数据的数组,二者的下标映射关系。
②虚拟节点如何填值,以保证动态转移方程能够正确使用。
4、填表顺序:
根据状态转移方程,这里的填表的顺序是 从上往下,从左往右。
5、返回值: 根据状态表示,我们要返回的结果是dp[m][n] (引入了虚拟节点,存在映射关系)。
2)、题解
时间复杂度:
O
(
m
×
n
)
O(m×n)
O(m×n)、空间复杂度:
O
(
m
×
n
)
O(m×n)
O(m×n)
class Solution {
public:
// 以动态规划的方式解题:这里是二维数组,dp表也是二维的,dp[i][j]表示从start起始位置开始到达(i,j)位置有多少种路径
int uniquePaths(int m, int n) {
// 1、创建dp表:这里为了方便后续初始化dp表,采用虚拟节点的方式。此处首行、首列为虚拟节点。因此,这里矩阵大小为(m+1)×(n+1)
vector<vector<int>> dp(m+1,vector<int>(n+1, 0));
dp[0][1] = 1;// 2、初始化dp表:因为引入虚拟节点,这里初始化时就不用对首行、首列进行整行整列初始化。
// 3、根据状态转移方程填表:这里按照矩阵顺序填写即可
for(int i = 1; i < m+1; ++i)
{
for(int j = 1; j < n+1; ++j)
{
dp[i][j] = dp[i-1][j] + dp[i][j-1];// 左→右、上→下
}
}
// 4、返回目标值:这里是(m+1)×(n+1)矩阵,其finish位置就是(m,n)
return dp[m][n];
}
};
3.1.1.2、题解二
1)、思路分析
实际上,本题也可以倒着思考。
1、确定状态表示: 根据经验+题目要求,在二维数组中,以(i,j)位置为起点,则 dp[i][j]
表示以 (i,j)
位置为起点,到达终点位置时存在的路径总数。
2、确定状态转移方程: 根据题目,方式有二。
1、从(i,j)位置出发,往右走一步到达(i,j+1)位置处。那么我们只需要知道(i,j+1)位置处存在的路径总数,即可知道(i,j)位置的路径总数(无非是多走一步)。
2、从(i,j)位置出发,往下走一步到达(i+1,j)位置处。那么我们只需要知道(i+1,j)位置处存在的路径总数,即可知道(i,j)位置的路径总数(无非是多走一步)。
则有dp[i][j] = dp[i+1][j] + dp[i][j+1]
3、初始化: 这里,我们仍旧选择使用虚拟节点的方式。在这种状态转移方程下,我们需要处理的是尾行和尾列。
4、填表顺序: 根据状态转移方程,这里的填表顺序就是从右到左,从下到上。
5、返回值: 题目要求从左上角开始,即以(0,0)位置为起点。
2)、题解
class Solution {
public:
int uniquePaths(int m, int n) {
// 1、确定状态表示:dp[i][j],以(i,j)为起点,到达终点位置时,存在的路径总数
vector<vector<int>> dp(m+1,vector<int>(n+1,0));//引入虚拟节点(尾行、尾列)
// 2、初始化
dp[m][n-1] = 1;
// 3、填表:从右到左,从下到上
for(int i = m-1; i >= 0; --i )
{
for(int j = n-1; j >=0; --j)
dp[i][j] = dp[i][j+1] + dp[i+1][j];
}
// 4、返回:题目要求从左上角走,即以(0,0)位置为起点
return dp[0][0];
}
};
3.2、不同路径II(medium)
题源:链接。
3.2.1、题解
1)、思路分析
此题和上题的思路一致,区别只是在于写动态转移方程时,我们需要根据题目进行一定调整。
1、确定状态表示: 仍旧是经验+题目要求。二维数组,这里,我们以(i,j)
为结尾,则dp[i][j]
表示,从起点位置,到达(i,j)
时所存在的方法总数。
2、确定状态转移方程: 引入了障碍物,这意味着①若当前(i,j)
位置处是障碍,则没有路径。②若当前(i,j)
位置非障碍,则到达 (i,j)
的方式有二:①从 (i-1,j)
出发,往下走一步;②从 (i, j-1)
位置出发,往右走一步。
因此,dp[i][j] = dp[i-1][j] + dp[i][j-1]
(有条件!若碰到障碍物,当前dp[i][j] = 0
)。
3、初始化: 和先前一样,这里我们选择引入虚拟节点方式进行初始化。
4、填表顺序: 在当前这种状态转移方程下,填表方式为从左到右、从上到下。
5、返回值: 因虚拟节点的存在,这里需要返回的结果是dp[m][n]
。
2)、题解
class Solution {
public:
// 这里使用动态规划来解题:二维数组,dp[i][j]表示到达(i,j)位置时,一共有多少种路径
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
if(obstacleGrid[0][0] == 1) return 0;// 起点即障碍物的情况
int m = obstacleGrid.size();// 原始矩阵行
int n = obstacleGrid[0].size();// 原始矩阵列
// 1、创建dp表:这里采用虚拟节点的方式初始化dp表,因此我们多加了一行、一列在首部。
// 则实际矩阵为:(m+1)×(n+1)
vector<vector<int>> dp(m+1, vector<int>(n+1,0));// finish位置在(m,n)处
// 2、初始化dp表:初始化的值保证结果正确即可,这里选择(0,1)、(1,0)初始化均可。
dp[0][1] = 1;
// 3、根据状态转移方程填表:这里按照矩阵顺序填写即可。区别于不同路径Ⅰ,多一个判断障碍物的条件
for(int i = 1; i < m +1; ++i)
{
for(int j = 1; j < n+1; ++j)
{
// dp[i][j]实则映射的是obstacleGrid[i-1][j-1]处的位置,这里判断障碍物是要判断原表的
if(obstacleGrid[i-1][j-1] == 1 ) dp[i][j] = 0;
else
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
// 4、返回finish处的路径
return dp[m][n];
}
};
3.3、珠宝的最高价值(medium)
题源:链接。
3.3.1、题解
1)、思路分析
根据题目描述,我们需要从二维数组frame
的左上角开始拿格子里的珠宝,并每次 向右 或者 向下 移动一格、直到到达二维数组的右下角位置。选择一条路径,使得最终获取到的珠宝值最大。
仍旧是路径问题,可用决策树递归,记忆搜索化等等都方法也可解题。这里我们学习动态规划的解题模式。
1、确定状态表示: 根据经验+题目要求。此处选择一个常见的思考方式。以(i,j)
位置为结尾,那么对于本题,dp[i][j]
表示,从起点位置,到达(i,j)
位置时,所获得的珠宝最大值。
2、确定状态转移方程: 根据题目条件,要想到达(i,j)
位置,可以有两种走法:从上往下,从左往右。
1、从上方位置 (i-1, j) 向下走一步,此时到达 (i, j) 拿到的珠宝价值为 dp[i-1][j] + frame[i][j]。
2、从左边位置 (i, j-1) 向右走一步,此时到达 (i, j) 拿到的珠宝价值为 dp[i][j-1] + frame[i][j]。
我们需要获取的是当前位置珠宝的最大值,因此需要选择的是上述两种路径中较大的那个:
//两种写法都行
dp[i][j] = max(dp[i−1][j] + frame[i][j], dp[i][j−1] + frame[i][j])
dp[i][j] = max(dp[i−1][j],dp[i][j−1]) + frame[i][j]
3、初始化: 这里选择引入虚拟节点的方式初始化。此时需要注意两点:
1、dp表和原先frame表的下标映射关系
2、虚拟节点中的填值,要保证后续填表是正确的。
本题中,虚拟行列的所有值都可为0。
4、填表顺序: 根据状态转移方程,填表的顺序是从左到右,从上到下。
5、返回值: 根据状态表示,由于我们引入了虚拟行列,这里应该返回dp[m][n]
的值。
2)、题解
class Solution {
public:
// 使用动态规划解题:二维矩阵,dp[i][j]表示到(i,j)位置时,最高的珠宝价值
int jewelleryValue(vector<vector<int>>& frame) {
int m = frame.size();
int n = frame[0].size();
if(m == 0 && n == 0) return 0;// 没有任何珠宝的情况
// 1、创建dp表:这里采用虚拟节点的方式,故需要(m+1)×(n+1)的矩阵
vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
// 2、初始化dp表:只需初始化(0,1)、(1,0)的位置
dp[0][1] = dp[1][0] = 0;// 题目说明了珠宝的价值都是大于 0 的,因此这里可以二者初始化为0(不含该条件则初始化为最小值)
// 3、根据状态方程填表:从上到下,从左到右
for(int i = 1; i < m + 1; ++i)
{
for(int j = 1; j < n + 1; ++j)
{
// 因为引入了虚拟节点,这里dp[i][j]实际映射的是珠宝架frame[i-1][j-1]
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + frame[i-1][j-1];// 当前珠宝的价值 = 从上往下或从左往右任意路径的最大珠宝价值+当前(i,j)架子上的珠宝价值
}
}
// 4、返回目标值:这里珠宝架的右下角是(m,n)
return dp[m][n];
}
};
3.4、下降路径最小和(medium)
题源:链接。
3.4.1、题解
1)、思路分析
先来理解下降路径:
1、确定状态表示: 根据经验+题目要求。我们选择以(i,j)
位置为结尾,则dp[i][j]
表示,下降过程中,到达(i,j)
位置时,路径的最小和。
2、确定状态转移方程: 对于坐标(i,j)
,根据题意得,从上到下到达(i,j)
位置可能有三种情况。
1、从左上方(i-1,j-1)位置处,转移到(i,j)位置;
2、从正上方(i-1,j)位置处,转移到(i,j)位置;
3、从右上方(i-1,j+1)位置处,转移到(i,j)位置;
要知道(i,j)
位置处的最小和,只需要知道左上方(i-1,j-1)
、正上方(i-1,j)
、右上方(i-1,j+1)
这三者位置处的最小和,然后再加上当前(i,j)
位置处的值即可。而左上、正上、右上三个位置的最小和,正是我们对状态表示的描述,因此有:
dp[i][j] = min(dp[i−1][j−1],dp[i−1][j],dp[i−1][j+1]) + matrix[i][j]
3、初始化: 根据上述状态方程,在填表时,首行、首列、尾列存在越界。这里为了方便,我们引入虚拟节点进行初始化。仍旧需要面对引入虚拟节点后的两个问题:
1、下标的映射关系
2、虚拟节点中的值,要保证后续填表时是正确的
分析如下:
4、填表顺序: 根据状态表示,填表的顺序是从上往下。
5、返回值: 根据题目表示,这里要返回的实则是,dp表中最后一行的最小值。
2)、题解
需要注意,引入虚拟节点后,dp表中下标映射关系发生改变。填表时,也需要注意遍历的边界问题。
class Solution {
public:
// 以动态规划解题:二维矩阵,这里以(i,j)为结尾,dp[i][j]表示到达(i,j)位置时,最小的下降和
int minFallingPathSum(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix[0].size();
if(m == 0 && n == 0) return 0;// 实则不会发生此情况,因为题目规定1 <= {m,n} <= 100
// 1、创建dp表:这里采用虚拟节点的方式,引入首行、首列、尾列
vector<vector<int>> dp(m+1, vector<int>(n+2, INT_MAX));// 因为要找最小和,题中数值可以为负数,这里我们设默认值为无穷大
// 2、初始化dp表:需要初始化虚拟节点,即首行、首列、尾列。
// 首列、尾列已经在创建dp表时初始化为合法值,这里我们处理一下首行即可:将其设为0(可根据状态方程理解,设为无穷大有误)
for(int j = 0; j < n+2; ++j) dp[0][j] = 0;
// 3、根据状态方程填表:从上到下
for(int i = 1; i < m + 1; ++i)
{
for(int j = 1; j < n + 1; ++j)// 注意此处列的填表边界:我们设置的虚拟列是首列和尾列,无需填表
dp[i][j] = min(dp[i-1][j-1], min(dp[i-1][j], dp[i-1][j+1])) + matrix[i-1][j-1];// 这里注意三数取小的写法
}
// 4、返回目标值:最后一行的最小值
int ret = INT_MAX;
for(int j = 1; j < n + 1; ++j)
ret = min(ret, dp[m][j]);
return ret;
}
};
3.5、最小路径和(medium)
题源:链接。
3.5.1、题解
1)、思路分析
此题和之前的珠宝最大值思路类似。只是那里求的是最大和,这里求的是最小和。
从左上角的起点,到右下角的终点,有非常多的路径可供选择,我们需要找出其中和最小的一条路径。
1、确定状态表示: 根据经验+题目要求。此处选择一个常见的思考方式。以(i,j)
位置为结尾,那么对于本题,dp[i][j]
表示,从起点开始,到达(i,j)
位置时,最小的路径和。
2、确定状态转移方程: 根据题目条件,要想到达(i,j)
位置,可以有两种走法:从上往下,从左往右。
1、从上方位置 (i-1, j) 往下走一步到达 (i, j) ,此时的路径和为 dp[i-1][j] + grid[i][j]。
2、从左边位置 (i, j-1) 往右走一步到达 (i, j) ,此时的路径和为 dp[i][j-1] +grid[i][j]。
3、题目要求最小和,因此, dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]
3、初始化: 引入虚拟节点进行初始化。需要注意两点。
1、dp表和原先grid表的下标映射关系。(尤其要注意状态转移中的映射关系,以及遍历填表时的隐射关系)
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i-1][j-1]
2、虚拟节点中的填值,要保证后续填表是正确的。
4、填表顺序: 根据状态转移方程,填表的顺序就是从上往下填每一行,每一行从左往右填写。
5、返回值: 因为这里引入了虚拟节点,我们要返回的结果是dp[m][n]
。
2)、题解
class Solution {
public:
// 以动态规划解题:二维数组,dp[i][j]表示以(i,j)位置为结尾,到达(i,j)位置时的最小路径和
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
if(m == 0 && n == 0) return 0;// 特殊情况(实则不用处理,特意标出来只是想说明做题有时也要注意看给的变量范围)
// 1、创建dp表:这里采用虚拟节点的方式,为了方便后续初始化,引入虚拟首行、首列
vector<vector<int>> dp(m+1, vector<int>(n+1, INT_MAX));// 设值为MAX_INT也是在对初始化做铺垫
// 2、初始化dp表:由于采用了虚拟节点的方式,这里只需特殊处理dp[0][1]和dp[1][0]两处位置。使得dp[1][1] == grid[0][0]
dp[0][1] = dp[1][0] = 0;// PS:不能设置其余虚拟行、列位置为0(用状态方程推到算一下就明白了)
// 3、根据状态转移方程,从左到右,从上到下填表
for(int i = 1; i < m + 1; ++i)
{
for(int j = 1; j < n + 1; ++j)
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i-1][j-1];// 注意这里的映射关系
}
// 4、返回目标值
return dp[m][n];
}
};
3.6、地下城游戏(hard)
题源:链接。
3.6.1、题解
1)、思路分析
先来分析此题,骑士每次只 向右 或 向下 移动一步后,其健康值要么不变,要么减少,要么增加。我们需要计算的是他顺利从左上角走到右下角,所需要的最低初始健康点数。
1、确定状态表示:
根据章节一,状态表示通常有两种,①以(i,j)
为起点,②以(i,j)
为结尾。
在本题中,如果将状态表示定义成以(i,j)
为结尾,那么,dp[i][j]
表示:从起点开始,到达(i, j)
位置的时候,所需的最低初始健康点数。
仔细分析过程就会发现问题:在推导状态转移的时候,当前(i,j)
位置的健康点数不仅仅受到前面路径的影响,还会受到后续的路径的影响。也就是说,以这种状态表示,不能很好地推导出状态方程。
基于上述因素,我们选择以(i,j)
位置为起点。那么此时dp[i][j]
表示,从(i,j)
位置开始,到达终点时所需要的最低初始健康点数。
2、确定状态转移方程:
根据此题,要从(i,j)
位置走到终点,有往右或者往下两种走法。
①如果骑士在当前(i, j)
位置时健康点数降为0或以下,他会立即死亡。因此,我们需要确保骑士在(i, j)
位置时的健康点数至少为1(不考虑(i, j)位置本身对健康点数的影响)。
②此后,骑士可以选择向右走到(i, j+1)
位置或向下走到(i+1, j)
位置。我们需要确保骑士有足够的健康点数,能安全地走到右侧或下方的房间。
考虑到有两条路径可选,但要求最低健康值,有dp[i][j] = min( dp[i][j+1], dp[i+1][j]) - dungeon[i][j]
。
但需要注意,打开一个房间,骑士可能面对减少血条或加血条的情况。而当其遇到的是增加健康点数的魔法球时, dungeon[i][j]
为正数,就意味着dp[i][j]
做减法后,有可能会得到一个负值,表示即使骑士的生命值为负数,也能成功到达终点。
但这并不符合题目的逻辑,因为骑士的健康值下降到0后就会死亡。因此就需要保证,无论骑士是否获得了超级大的魔法球,在此过程中骑士的健康值始终为正数。因此,有dp[i][j] = max(1, dp[i][j]))
。
dp[i][j] = max(1, min( dp[i][j+1], dp[i+1][j]) - dungeon[i][j])
//因为获得血球包后,有可能导致dp表中的健康值变为负数
3、初始化: 这里,选择引入虚拟节点的方式进行初始化。在本题中,会发生越界的是尾行和尾列,因此,虚拟节点需要添加在尾行、尾列。
4、填表顺序: 根据状态转移方程,我们需要从下往上填每一行,每一行从右往左。
5、返回值: 根据状态表示,需要返回dp[0][0] 的值。
2)、题解
class Solution {
public:
// 以动态规划解决此题:二维数组,dp[i][j]表示以(i,j)为起点,从(i,j)位置到达终点时,所需要的最低初始健康点数。
int calculateMinimumHP(vector<vector<int>>& dungeon) {
int m = dungeon.size();
int n = dungeon[0].size();
// 1、创建dp表:这里采用了虚拟节点的方式,多引入了虚拟尾行、尾列,因此dp表的规模为 (m+1)×(n+1)
vector<vector<int>> dp(m+1, vector<int>(n+1, INT_MAX));// 这里初始化为INT_MAX是为了方便虚拟行、列
// 2、初始化dp表:在上述创建表时解决了大部分,这里只需处理特殊情况
dp[m][n-1] = dp[m-1][n] = 1;// 到达终点位置,拯救完公主时,骑士至少还剩下一滴残血。(因为题目要求了要算起点和终点位置的值)
// 3、根据状态转移方程,从下往上、从右往左填表
for(int i = m-1; i >=0; --i)
{
for(int j = n-1; j >= 0; --j)
{
dp[i][j] = max(min(dp[i+1][j], dp[i][j+1]) - dungeon[i][j], 1);
// 内层min:用于计算从(i,j)位置到达终点,是走右侧划算,还是走下侧划算。
// 外层max:由于存在加血量的魔法球,那么这里的减法有可能为负数,而我们的健康值不可能为负数,因此在该格处,最少要保持1滴残血。
}
}
// 4、返回目标值
return dp[0][0];// 从(0,0)位置为起点,到达终点时所需的最低初始健康值。
}
};
4、简单多状态 dp 问题
4.1、按摩师(easy,打家劫舍Ⅰ)
题源:链接。
4.1.1、题解
1)、思路分析
先理解题目含义,相邻下标不能选,不相邻的下标,可以连续跳过多个。
1、确定状态表示:
根据经验+题目要求,这里我们以i
为结尾。则dp[i]表示,到达i位置时,所获得的最长预约时长。
继续分析细化,在本题中,对于某一个位置 i
,实则有两种状态:选择接受 i
处的预约,或选择不接受 i
处的预约。因此,实则在 i 处的状态表示可细分为两种:
1、f[i]:到达i位置时,选择接受i位置处的预约(nums[i] 必选),此时的最长预约时长
2、g[i]:到达i位置时,选择不接受i位置处的预约(nums[i] 必不选),此时的最长预约时长
2、确定状态转移方程:
由此,状态转移方程为:
f[i] = num[i] + g[i-1]
g[i] = max(f[i-1], g[i-1])
3、初始化: 这道题的初始化比较简单,可以不使用虚拟节点。根据状态方程,此时只需要初始化i=0
处的位置即可。
1、若接受i=0处的预约,则f[0] = num[0]
2、若不接受i=0处的预约,则g[0] = 0
4、填表顺序: 根据状态转移方程,从左往右,两个表一起填。
5、返回值: 我们的状态表示有两种,两种情况都有可能,那么应该返回 max(f[n - 1], g[n - 1])
。
2)、题解
class Solution {
public:
// 以动态规划解题:一维数组,dp[i]表示以i位置为结尾,到达i位置时最长预约时长
int massage(vector<int>& nums) {
int n = nums.size();
if(n == 0) return 0;// nums无数据时
// 1、创建dp表:对i,可细分为两种状态表示,选择i或不选择i。因此我们需要建立不同选择下的状态表
vector<int> select(n, 0);// 到达i位置时,nums[i]必选,此时的最长预约时长
vector<int> unselect(n, 0);// 到达i位置时,nums[i]必不选,此时的最长预约时长
// 2、初始化dp表:分别对上述两个dp表进行初始化。这里一维数组相对简单,可直接初始化(不必搞虚拟节点,当然,要搞也行)
// 在起点位置处,其前侧无序号,故这里最长时长只考虑nums[0]
select[0] = nums[0]; // 必选nums[0]
unselect[0] = 0;// 不选nums[0](实则此步不必要,这里写出来是为了完整)
// 3、根据状态方程,从左到右填表。(这里是一次同时填两个表)
for(int i = 1; i < n; ++i)
{
// 选择i位置的预约号的情况:
select[i] = unselect[i-1] + nums[i];// 选了i,i-1位置必然不可选,要找出这种状况下[0,i-1]中最长的时长,再加上当前位置的时长
// 不选择i位置的预约号的情况:
unselect[i] = max(select[i-1], unselect[i-1]);// 不选i,i-1位置可选可不选,因此两种状态取最大
}
// 4、返回:
return max(select[n-1], unselect[n-1]);
}
};
4.2、打家劫舍II (medium)
题源:链接。
4.2.1、题解
1)、思路分析
分析此题可以发现,它和之前4.1的思路基本一致。区别点只是在于题目给出了限制,需要解决首位成环,属于相邻点的问题。
这就意味着,①如果我们选择拿取 i=0 处的现金,那么 i = 1 和 i = n-1 处的现金必然不能被拿取(因为属于相邻位置)。②如果我们选择不拿取 i = 0 处的现金,那么 i = 1 和 i= n-1 处的现金可以被选择。
由此,我们就将本题分为了两种情况,对这两种情况分别按照4.1中的思路求取即可。
最后,我们再汇总上述两种情况,求一个最大值即可。
2)、题解
这里,要注意判断边界问题。
class Solution {
public:
// 使用动态规划来做:这里,以首位元素区分为两种情况,然后使用打家劫舍Ⅰ的解法。
// 状态表示:dp[i],以i位置为结尾,表示到达i位置时,窃取到的最高金额。
int rob(vector<int>& nums) {
int n = nums.size();
if(n == 0) return 0;
// 选择窃取首元素时:此时不能窃取尾元素。对[2,n-2]区间,使用打家劫舍Ⅰ的解法
int ret1 = ROB(nums, 2, n-2) + nums[0];
// 选择不窃取首元素时:此时能窃取尾元素。对[1,n-1]区间,使用打家劫舍Ⅰ的解法
int ret2 = ROB(nums, 1, n-1);
return max(ret1,ret2);
}
int ROB(vector<int>& nums, int begin, int end)
{
if(begin > end) return 0;
int n = nums.size();
// 1、创建dp表:对同一位置i,这里有两种状态,偷or不偷
vector<int> select(n, 0);// 到i位置并选择偷窃i处时,最高的偷窃金额
vector<int> unselect(n, 0);// 到i位置但不选择偷窃i处时,最高的偷窃金额
// 2、初始化dp表:虽然我们开辟了[0,n-1]的区间,但只偷窃[begin,end]区间中的金额
select[begin] = nums[begin];
unselect[begin] = 0;
// 3、根据状态转移方程,初始化dp表(从左到右)
for(int i = begin + 1; i <= end; ++i)
{
// 选择i:此时不能选择i-1
select[i] = unselect[i-1] + nums[i];
// 不选择i:此时i-1任意
unselect[i] = max(select[i-1], unselect[i-1]);
}
// 4、返回
return max(select[end],unselect[end]);
}
};
4.3、删除并获得点数(medium)
题源:链接。
4.3.1、题解
1)、思路分析
先来分析此题,题目给定数组是乱序的,但假如我们选择了nums[i]
,要删除的固定就只会是该数左右两侧的数nums[i] - 1
和 nums[i] + 1
。因此,我们不妨以一个有序数组来分析:
再来分析一下题目含义,它实际是在说,选择 x
数字的时候,相邻的数 x - 1
与 x + 1
是不能被选择的。有没有觉得很熟悉?这不就是4.1、4.2中打家劫舍的问题吗。
但要注意此题中的细节,题目给定的数字并非是从0
到n
完整地数字,也就是说,选择nums[i]
元素时,其左右两侧的数nums[i] - 1
和 nums[i] + 1
在数组中可能不存在。而根据我们之前做打家劫舍时,使用动态规划的经验,我们在遍历填表时,是需要知道相邻位置处的值的。
题目中1 <= nums[i] <= 10^4
,题中元素最大值为10000
,因此,我们不妨创建一个大小为10001
的hash数组,hash数组中下标为x的位置,即num[i] = x 的元素。这样一来,只需遍历一次原num数组获得hash数组,对hash数组来一次打家劫舍即可。
2)、题解
class Solution {
public:
int deleteAndEarn(vector<int>& nums) {
// 先统计一遍数组nums,建立hash映射
const int N = 10001;
int hash[N] = {0};
for (int i = 0; i < nums.size(); ++i)
hash[nums[i]] += nums[i];
// 开始打家劫舍:对数组hash,选择任意x,不能选择其相邻两数x-1、x+1,求最大和。
// 1、创建dp表
vector<int> f(N, 0); // f[i]:到达i位置时,必然选择hash[i],此时获得的最大点数
vector<int> g(N, 0); // g[i]:到达i位置时,必然不选hash[i],此时获得的最大点数
// 2、初始化
f[0] = hash[0]; // 选择0处下标
g[0] = 0; // 不选择0处下标
// 3、填表:从左到右
for (int i = 1; i < N; ++i)
{
f[i] = g[i - 1] + hash[i];
g[i] = max(g[i - 1], f[i - 1]);
}
// 4、返回:
return max(f[N - 1], g[N - 1]);
}
};
4.4、粉刷房子(medium)
题源:链接
4.4.1、题解
1)、思路分析
先来分析题目中 n x 3 的正整数矩阵 costs :
1、确定状态表示: 根据经验+题目要求,这里我们选择以i为结尾,则dp[i]
表示,粉刷到 i
位置时,所需要的最小花费。
根据题目,我们粉刷时可以选择三种颜色,实则这里的状态表示,可以根据粉刷的颜色进行细化。 一种方法是用三个一维数组分别表示三种动态规划的状态。另外一种方式,我们也可以向题目给出的costs数字学习,用一个二维数组表示三种状态:dp[i][j]
。
这里我们选择使用二维数组一次同时描述上述三种状态表示:
和题目照应,二维数组的列表示选择的颜色状态:0为红,1为蓝,2为绿。
0、dp[i][0]:粉刷到第i号房间时,将第i个位置粉刷为红色,此时所需要的最小花费
1、dp[i][1]:粉刷到第i号房间时,将第i个位置粉刷为蓝色,此时所需要的最小花费
2、dp[i][2]:粉刷到第i号房间时,将第i个位置粉刷为绿色,此时所需要的最小花费
2、确定状态转移方程:
根据上图分析可得:
0、把i处粉成红色:dp[i][0] = min(dp[i][1],dp[i][2]) + costs[i][0];
1、把i处粉成蓝色:dp[i][1] = min(dp[i][0],dp[i][2]) + costs[i][1];
2、把i处粉成绿色:dp[i][2] = min(dp[i][0],dp[i][1]) + costs[i][2];
3、初始化: 这里,我们引入虚拟节点,添加一列。此时需要注意两点。
①下标的映射关系:上述推导的dp表,以及后续填表时遍历的位置,这些涉及下标的地方,都需要注意下标映射关系。
②虚拟节点的初始值要保证填表正确。
4、填表顺序: 根据状态转移方程,这三个状态之间是相互依赖的,因此填表时,需要从左往右,三个表一起填。
5、返回值: 根据状态表示,应该返回最后一个位置粉刷上三种颜色情况下的最小值。
2)、题解
class Solution {
public:
int minCost(vector<vector<int>>& costs) {
int m = costs.size(); // 有多少个房子
// 1、创建dp表并初始化
vector<vector<int>> dp(m + 1, vector<int>(3, 0)); // m+1行,3列
// 2、填表:注意下标映射
for (int i = 1; i < m + 1; ++i) {
dp[i][0] = min(dp[i - 1][1], dp[i - 1][2]) + costs[i - 1][0]; // 选i为红色
dp[i][1] = min(dp[i - 1][0], dp[i - 1][2]) + costs[i - 1][1]; // 选i为蓝色
dp[i][2] = min(dp[i - 1][1], dp[i - 1][0]) + costs[i - 1][2]; // 选i为绿色
}
// 3、返回
return min(min(dp[m][0], dp[m][1]), dp[m][2]);
}
};
4.5、买卖股票的最佳时机含冷冻期(medium)
题源:链接。
此类系列题扩展学习:股票问题系列通解
4.5.1、题解
1)、思路分析
根据题目含义,对某一天的股票,存在三种状态:已买入、可交易(待买入)、冷冻期。这三者之间有如下关系:
1、处于「买入」状态的时候,我们现在有股票,此时不能买股票,只能继续持有股票,或者卖出股票;
2、处于「卖出」状态的时候:如果在冷冻期,不能买⼊股票;如果不在冷冻期,才能买⼊股票。
1、确定状态表示: 通常,根据经验+题目要求,我们在描述状态时一般有两种情况:①以某个位置为结尾,……;②以某个位置为开始,……。
这里我们选择前者,则有dp[i]
表示,第 i 天结束后,此时的最大利润。但根据上述分析,第 i
天存在多个状态,因此状态表示dp[i]
可以细化为上述三种状态:第 i
天结束后,处于已买入、可交易(待买入)、冷冻期时,此时的最大利润值。
这里,我们用一个二维数组同时表示这三种状态,则有:
1、dp[i][0]:第 i 天结束后,处于"已买入"状态,此时的最大利润。
2、dp[i][1]:第 i 天结束后,处于"可交易"(即待买入)状态,此时的最大利润。
3、dp[i][2]:第 i 天结束后,处于"冷冻期"状态,此时的最大利润。
2、确定状态转移方程: 可以看到,dp
表同时存在多个状态,为了方便推导状态方程,这里我们可以画图分析各个状态之间的联系(此类图也称为“状态机”)。
dp[i]
表示第 i
天结束后的状态。要推导获取 i 处的状态方程,主要看第 i-1
天结束时的状态(dp[i-1]
),以及第 i
天当天的操作(prices[i]
)。
Ⅰ、分析已买入:
①“已买入”→“已买入”: 若第 i-1 天结束后,处于“已买入”状态,能否让第 i 天结束后,处于“已买入”状态?
分析:当然可以,第 i-1 天结束后处于已买入,说明此时我们手里持有股票,那么第 i 天我们什么也不做(既不卖出也不买入),那第 i 天结束后,我们手中仍旧持有股票,即“已买入”状态。
②“冷冻期”→“已买入”: 若第i-1天结束后,处于“冷冻期”状态,能否让第 i 天结束后,处于“已买入”状态?
分析:不可以。因为冷冻期我们不能买入股票,所以无法在第i天结束后,进入“已买入”状态。
③“可交易”→“已买入”: 若第i-1天结束后,处于“可交易”状态,能否让第 i 天结束后,处于“已买入”状态?
分析:可以。第 i-1 天结束后,处于“可交易”状态,说明此时我们手里没有股票,是可以在第 i 天进行买入的,那么当第 i 天结束后,我们当然就处于了“已买入”状态。需要注意,第i天进行买入,此时利润减少,-prices[i]
。
Ⅱ、分析冷冻期:
①“冷冻期”→“冷冻期”: 若第 i-1 天结束后,处于“冷冻期”状态,能否让第 i 天结束后,处于“冷冻期”状态?
分析:不可以,因为冷冻期只能维持一天,在第 i 天结束后,此时就进入了“可交易”状态。
②“可交易”→“冷冻期”: 若第i-1天结束后,处于“可交易”状态,能否让第 i 天结束后,处于“冷冻期”状态?
分析:不可以。第 i-1天结束我们手里没有股票,不能在第i天卖出,自然无法进入冷冻期。
③“已买入”→“冷冻期”: 若第i-1天结束后,处于“已买入”状态,能否让第 i 天结束后,处于“冷冻期”状态?
分析:可以。这说明第i-1天结束后,我们手里是持有股票的,只需要在第i天卖出,自然就进入了冷冻期。需要注意,卖出股票会获取到利润,+prices[i]
。
Ⅲ、分析可交易:
①“可交易”→“可交易”: 若第 i-1 天结束后,处于“可交易”状态,能否让第 i 天结束后,处于“可交易”状态?
分析:可以。第 i-1 天结束后处于可交易,说明此时我们手里没有股票,那么第 i 天我们什么也不做(既不卖出也不买入),那第 i 天结束后,我们手中仍旧没有股票,即“可交易”状态。
②“冷冻期”→“可交易”: 若第i-1天结束后,处于“冷冻期”状态,能否让第 i 天结束后,处于“可交易”状态?
分析:当然可以。处于冷冻期的第 i 天本身就不能买入股票,这一天什么都不做,那么在第 i 天结束时,冷冻期结束自然就进入了可交易状态。
③“已买入”→“可交易”: 若第 i-1天结束后,处于“已买入”状态,能否让第 i 天结束后,处于“可交易”状态?
分析:不可以。第 i-1 天结束时处于“已买入”状态,说明此时手头持有股票,在第 i 天时,我们要么只能卖出股票进入冷冻期,要么只能继续持有股票保持在已买入状态。
综上分析,可得最终结果图如下。我们可根据此推导三个状态表示之间的关系:
1、第i天结束后,处于"已买入"状态,到达该情况有两种方式,我们只要其中最大利润,则有:
dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i]);
2、第i天结束后,处于"可交易"状态,到达该情况有两种方式,我们只要其中最大利润,则有:
dp[i][1] = max(dp[i-1][1], dp[i-1][2]);
3、第i天结束后,处于"冷冻期"状态,到达该情况有一种方式,则最大利润为:
dp[i][2] = dp[i-1][0] + prices[i];
3、初始化: 根据上述三个状态方程,为了防止越界,需要对 dp[0][j]位置进行初始化。根据状态表示,有
1、dp[0][0] = -prices[0];// 必须把这天的股票买了,才能在这天结束后进入已买入状态
2、dp[0][1] = 0; // 啥也不⽤⼲即可
3、dp[0][2] = 0; // ⼿上没有股票,当天买入当天卖出,就能进入冷冻期,此时收益为0
4、填表顺序: 根据上述分析,三个表之间存在相互依赖关系,因此需要一起填写。对每个表,按照天数累加填写。
5、返回值: 返回最后一天结束时的最大利益值。实则只需要看最后一天的“可交易”状态和“冷冻期”状态。因为最后一天结束后处于“买入”状态,此时手里有票,势必会比其它两种状态亏损。
max(dp[n-1][1],dp[n-1][2]);
max(max(dp[n-1][1],dp[n-1][2]),dp[n-1][0]);// 不放心也可以三者进行比较取最大值
2)、题解
一次遍历,时间复杂度为O(n)。常数列可以忽略,空间复杂度为O(n)。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();//一共存在多少天
// 1、创建状态表
vector<vector<int>> dp(n,vector<int>(3,0));// 这里用一个二维数组同时表示三种状态
// dp[i][0]:第i天结束后,处于“买入”状态,此时的最大利润
// dp[i][1]:第i天结束后,处于“可交易”状态,此时的最大利润
// dp[i][2]:第i天结束后,处于“冷冻期”状态,此时的最大利润
// 2、初始化:直接初始化
dp[0][0] = -prices[0];
dp[0][1] = dp[0][2] = 0;
// 3、填表:从左到右,三表一起填(因为三者动态转移方程相互依赖)
for(int i = 1; i < n; ++i)
{
dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i]);
dp[i][1] = max(dp[i-1][1],dp[i-1][2]);
dp[i][2] = dp[i-1][0] + prices[i];
}
// 4、返回最大值
return max(dp[n-1][1],dp[n-1][2]);//dp[n-1][0]可加可不加
}
};
4.6、买卖股票的最佳时期含手续费(medium)
题源:链接。
4.6.1、题解
1)、思路分析
有了上一题的经验,此题学习理解起来会相对顺畅许多。这里仍旧以动态规划来解题。
先来分析题目,对于某一天的股票,我们可以做两种操作:买入、卖出。当我们完成这样一个完整流程时,算作一笔交易,此时需要支付一次手续费。
1、确定状态表示: 根据经验+题目要求,这里选择以 i 为结尾。则 dp[i] 表示,当第 i 天结束时,所拥有的最大利润值。继续细分:对第 i 天,可以有买入(持有股票)、卖出(未持有股票)两种状态。
1、f[i]:第 i 天结束之后,处于"买入"状态(持有股票),此时所拥有的最大利润。
2、g[i]:第 i 天结束之后,处于"卖出"状态(未持有股票),此时所拥有的最大利润。
// 此处,可以使用一个n×2的二维数组表示,也可以如上述一样,分别用两个一维数组表示。
2、确定状态转移方程: 对于此类状态彼此相互转换的,可以通过画图分析,这样能做到不重不漏。
Ⅰ、分析“买入”:
①“买入”→“买入”: 若第 i-1 天结束之后,处于“买入”状态,能否让第 i 天结束之后,仍旧处于“买入”状态?
分析:可以。在第 i 天时,我们什么都不干(不卖出股票),即可在第 i 天结束之后,处于“买入”状态。
②“卖出”→“买入”: 若第 i-1 天结束之后,处于“卖出”状态(此时手里无股票),能否让第 i 天结束之后,处于“买入”状态?
分析:可以。只需要我们在第 i 天时,买入股票,即可在第 i 天结束之后,处于“买入”状态。需要注意,买入股票,需要减去本金。
Ⅱ、分析“卖出”:
①“卖出”→“卖出”: 若第 i-1 天结束之后,处于“卖出”状态,能否让第 i 天结束之后,仍旧处于“卖出”状态?
分析:可以。在第 i 天时,我们什么都不干(不买入股票),即可在第 i 天结束之后,处于“卖出”状态。此时第 i 天结束后的股票最大利润,就是第i-1天结束后,卖出状态的最大利润。
②“买入”→“卖出”: 若第 i-1 天结束之后,处于“买入”状态(此时手里持有股票),能否让第 i 天结束之后,处于“卖出”状态?
分析:可以。只需要我们在第 i 天时,卖出股票,即可在第 i 天结束之后,处于“买入”状态。需要注意,卖出股票,获得利润,完成一次交易,需要减去手续费。
综上,最终状态方程为:
1、f[i] = max(f[i-1], g[i-1] - prices[i]);// max(第i天啥也不做,第i天买入股票)
2、g[i] = max(g[i-1], f[i-1] + prices[i] - free);// max(第i天啥也不做,第i天卖出股票完成一次交易)
3、初始化: 由于此处初始化比较简单,我们直接初始化即可。根据题目,为了防止越界,这里需要对i=0
处进行初始化。
1、f[0] = -prices[0]
2、g[0] = 0
4、填表顺序: 两种状态相互依赖,填表一起填,从左往右按照天数增加填写。
5、返回值: 实际只需要返回f[n-1]
即可,因为最后一天买入股票,利润受到亏损。
return f[n-1];
return max(f[n-1],g[n-1]);//这样写也行
2)、题解
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
int n = prices.size();// 总天数
// 1、创建dp表并确定状态表示
vector<int> f(n,0);// 第 i 天结束之后,处于"买入"状态(持有股票),此时所拥有的最大利润。
vector<int> g(n,0);// 第 i 天结束之后,处于"卖出"状态(未持有股票),此时所拥有的最大利润。
// 2、初始化
f[0] = -prices[0];
g[0] = 0;//可省略
// 3、填表
for(int i = 1; i < n; ++i)
{
f[i] = max(f[i-1], g[i-1] - prices[i]);
g[i] = max(g[i-1], f[i-1] + prices[i] -fee);
}
// 4、返回
return max(f[n-1],g[n-1]);
}
};
4.7、买卖股票的最佳时机III(hard)
题源:链接。
4.7.1、题解
1)、思路分析
分析此题,对某一天 i ,我们能做两种操作:买入股票,卖出股票。对于所给的 n 天,我们需要控制交易次数在 2 次以内。这就说明,对于某一天 i 的状态,同时受到上述买卖状态,以及交易次数的影响。
1、确定状态表示: 根据经验+题目要求,这里以 i 为结尾进行分析。则dp[i]表示,第 i 天结束之后,所获得的最大利润。但由于第 i 天的状态同时受到买卖状态和交易次数的影响。若我们单独用f[i]
、g[i]
两个一维数组分别表示买入、卖出两种状态,这远远不够,因为在这两种状态之下,还可以细分出各种交易状态。因此,我们在此基础上再追加一维,表示交易次数,即f[i][j]
、g[i][j]
。(当然,这里也可以直接用一个三维数组表示dp[i][j][k]
)
f[i][j]: 第 i 天结束时,完成了 j 次交易,处于"买入"状态下的最大的利润值。
g[i][j]: 第 i 天结束时,完成了 j 次交易,处于"卖出"状态下的最大的利润值。
2、确定状态转移方程: 这里的状态转换和之前类似,但需要注意
对于f[i][j]
,要求第 i 天结束时,完成了 j 次交易,处于"买入"状态下的最大的利润值。有两种方式到这个状态:
1、在 i-1 天结束时,交易了 j 次,处于"买入"状态,第 i 天啥也不干,仍旧处于"买入"状态,则有最大利润为: f[i-1][j] ;
2、在 i-1 天结束时,交易了 j 次,处于"卖出"状态,第 i 天的时候把股票买了,则有最大利润为: g[i-1][j]-prices[i] 。
综上,第 i 天的最大利润为: f[i][j] = max( f[i - 1][j], g[i - 1][j] - prices[i])。
对于g[i][j]
,要求第 i 天结束时,完成了 j 次交易,处于"卖出"状态下的最大的利润值。也有两种方式到达这个状态:
1、在 i-1 天结束时,交易了 j 次,处于"卖出"状态,第 i 天啥也不干,仍旧处于"卖出"状态,则有最大利润为: g[i - 1][j] ;
2、在 i-1 天结束时,交易了 j-1 次,处于"卖出"状态,在第 i 天把股票卖出,增加一次交易次数到 j 次,则有最大利润为: f[i - 1][j -1] + prices[i]。
综上,第 i 天的最大利润为: g[i][j] = max( g[i - 1][j], f[i - 1][j - 1] + prices[i])
// 要注意理解这里的f[i - 1][j - 1]
3、初始化: 根据上述推导的两个状态方程,对f[i][j]
,需要对i=0
行进行初始化。但对g[i][j]
,需要对i = 0
行、j=0
列进行初始化。
按照我们之前的做题经验,可以选择直接初始化,也可以选择引入虚拟节点。
除了上述引入虚拟节点,其实我们还可以使用 if 条件判断,稍微改变一下状态转移方程,然后就方便我们直接进行初始化:
1、f[i][j] = max( f[i - 1][j], g[i - 1][j] - prices[i])// 不变
2、g[i][j] = g[i - 1][j];// 对于(啥也不干)这种状态,j = 0列是不会发生越界行为的。因此我们可以先将赋值为这种状态
if(j >= 1) // 然后,在(从买入→卖出)交易次数成立时,再进行最大值的取舍(也就是说,j=0时,都没有买入过票,更别谈卖出了。)
g[i][j] = max( g[i][j], f[i - 1][j - 1] + prices[i])
将状态转移方程修改为上述值后,来判断初始化,即 i = 0 处于第一天时:
关于这里∞
的取值,如果不做运算,一般我们想到的无穷,是对应类型的最大、最小值。比如此处的int
类型,则选择INT_MAX、INT_MIN,但这里,在负无穷的基础上进行减法运算,则存在数据溢出的风险,因此,一般情况下,选择正负无穷时,我们需要折办:
+∞ : 选择 0x3f3f3f //16进制,INT_MAX的一半
-∞ : 选择 -0x3f3f3f
4、填表顺序: 从上往下填每一行,每行从左往右,两个表一起填。
5、返回值: 首先,能确定的是,最大利润在卖出状态的最后一行(若最后一行是买入状态,此时手里还持有股票,达不到最大利润的)。但由于我们并不清楚第 j 次卖出获利最大,因此需要对卖出状态的最后一行进行遍历找寻最大值。
2)、题解
class Solution {
const int minint = -0x3f3f3f; // 定义负无穷,用于初始化
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
// 1、创建dp表,确定状态表示(这里总交易次数不超过2)
vector<vector<int>> f(n, vector<int>(3, minint)); // f[i][j]: 第 i 天结束时,完成了 j 次交易,处于"买入"状态下的最大的利润值。
vector<vector<int>> g(n, vector<int>(3, minint)); // g[i][j]: 第 i 天结束时,完成了 j 次交易,处于"卖出"状态下的最大的利润值。
// 2、初始化
f[0][0] = -prices[0];
g[0][0] = 0;
// 3、填表
for (int i = 1; i < n; ++i)
{
for (int j = 0; j < 3; ++j)
{
f[i][j] = max(f[i - 1][j], g[i - 1][j] - prices[i]);
g[i][j] = g[i - 1][j];
if (j >= 1)
g[i][j] = max(g[i][j], f[i - 1][j - 1] + prices[i]);
}
}
// 4、返回
int ret = 0;
for (int j = 0; j < 3; ++j)
ret = max(g[n - 1][j], ret);
return ret;
}
};
4.8、买卖股票的最佳时机IV(hard)
题源:链接。
4.8.1、题解
1)、思路分析
此题思路和上题基本一致,区别在于上一题中,交易次数最多不超过2次。本题中,给定交易次数最多不超过 k 次。
由于我们的交易次数是不会超过整个天数的⼀半的,因此我们可以先把k处理⼀下,优化⼀下问题的规模:
k = min(k, n / 2)
2)、题解
class Solution {
const int minint = -0x3f3f3f; // 定义负无穷,拥有初始化
public:
int maxProfit(int k, vector<int>& prices) {
int n = prices.size();
k = min(k, n / 2);//进行优化
// 1、创建dp表,确定状态表示(这里总交易次数不超过k)
vector<vector<int>> f(n, vector<int>(k+1, minint)); // f[i][j]: 第 i 天结束时,完成了 j 次交易,处于"买入"状态下的最大的利润值。
vector<vector<int>> g(n, vector<int>(k+1, minint)); // g[i][j]: 第 i 天结束时,完成了 j 次交易,处于"卖出"状态下的最大的利润值。
// 2、初始化
f[0][0] = -prices[0];
g[0][0] = 0;
// 3、填表
for (int i = 1; i < n; ++i)
{
for (int j = 0; j <= k; ++j)
{
f[i][j] = max(f[i - 1][j], g[i - 1][j] - prices[i]);
g[i][j] = g[i - 1][j];
if (j >= 1)
g[i][j] = max(g[i][j], f[i - 1][j - 1] + prices[i]);
}
}
// 4、返回
int ret = 0;
for (int j = 0; j <= k; ++j)
ret = max(g[n - 1][j], ret);
return ret;
}
};