文章目录
- 1、动规思路简介
- 2、按摩师
- 3、打家劫舍Ⅱ
- 4、删除并获得点数
- 5、粉刷房子
- 6、买卖股票的最佳时机含冷冻期
- 7、买卖股票的最佳时机含手续费
- 8、买卖股票的最佳时机Ⅲ
- 9、买卖股票的最佳时间Ⅳ
每一种算法都最好看完第一篇再去找要看的博客,因为这样会帮你梳理好思路,看接下来的博客也就更轻松了。当然,我也会尽量在写每一篇时都可以不懂这个算法的人也能边看边理解。
1、动规思路简介
动规的思路有五个步骤,且最好画图来理解细节,不要怕麻烦。当你开始画图,仔细阅读题时,学习中的沉浸感就体验到了。
状态表示
状态转移方程
初始化
填表顺序
返回值
动规一般会先创建一个数组,名字为dp,这个数组也叫dp表。通过一些操作,把dp表填满,其中一个值就是答案。dp数组的每一个元素都表明一种状态,我们的第一步就是先确定状态。
状态的确定可能通过题目要求来得知,可能通过经验 + 题目要求来得知,可能在分析过程中,发现的重复子问题来确定状态。还有别的方法来确定状态,但都大同小异,明白了动规,这些思路也会随之产生。状态的确定就是打算让dp[i]表示什么,这是最重要的一步。状态表示通常用某个位置为结尾或者起点来确定,这点在下面的题解中慢慢领会。
状态转移方程,就是dp[i]等于什么,状态转移方程就是什么。像斐波那契数列,dp[i] = dp[i - 1] + dp[i - 2]。这是最难的一步。一开始,可能状态表示不正确,但不要紧,大胆制定状态,如果没法推出转移方程,没法得到结果,那这个状态表示就是错误的。所以状态表示和状态转移方程是相辅相成的,可以帮你检查自己的思路。
要确定方程,就从最近的一步来划分问题。
初始化,就是要填表,保证其不越界。像第一段所说,动规就是要填表。比如斐波那契数列,如果要填dp[1],那么我们可能需要dp[0]和dp[-1],这就出现越界了,所以为了防止越界,一开始就固定好前两个值,那么第三个值就是前两个值之和,也不会出现越界。初始化的方式不止这一点,有些问题,假使一个位置是由前面2个位置得到的,我们初始化最一开始两个位置,然后写代码,会发现不够高效,这时候就需要设置一个虚拟节点,一维数组的话就是在数组0位置处左边再填一个位置,整个dp数组的元素个数也+1,让原先的dp[0]变为现在的dp[1],二维数组则是要填一列和一行,设置好这一行一列的所有值,原先数组的第一列第一行就可以通过新填的来初始化,这个初始化方法在下面的题解中慢慢领会。
第二种初始化方法的注意事项就是如何初始化虚拟节点的数值来保证填表的结果是正确的,以及新表和旧表的映射关系的维护。
填表顺序。填当前状态的时候,所需要的状态应当已经计算过了。还是斐波那契数列,填dp[4]的时候,dp[3]和dp[2]应当都已经计算好了,那么dp[4]也就出来了,此时的顺序就是从左到右。还有别的顺序,要依据前面的分析来决定。
返回值,要看题目要求。
多状态的题,如果每一天都包含多种含义,比如是什么状态以及还有附加的控制选项,dp表要用多维来表示,如果只是表示什么状态,那就一维数组。多状态的几个状态之间的分析要画图来理解。
2、按摩师
面试题 17.16. 按摩师
按照动态规划的五步,状态表示,状态转移方程,初始化,填表顺序,返回值。接下来会一步步展开。
我们先分析题,相邻的不能选择,要选择最长时间的那个。所以最长时间我们就能够想到代码中一定比较的部分,max()。相邻不能选择,从1开始走,13579,从第2个开始走,246810,从3个开始走,就和从第1个开始走重复了,所以我们可以把情况分为2种,从第一个位置和第二个位置走。
现在看一看动态规划。状态表示通常是2种,以某个位置结尾,或者某个位置起点。刚才的分析会发现这道题是从左到右走,所以很可能是以某个位置为结尾。那么我们就可以把dp[i]设置为选择到i位置的时候,此时的最长预约时长,为何会是最长预约时长?因为题目中要求求最长时长,不过求的是到达最后时的最长时长,现在我们是要一个个位置走下去,所以就变成了到i位置的时候,dp[i]是最长预约时长。
不过这种选择也有两个子选择,到达i位置时,dp[i]的最长预约时长要不要加上i位置的时长。假设f[i]是加上选择当前位置的值,g[i]是不选当前位置的值。如果要加上当前位置的值,那么前面一个值必定不能选,而它要表示最长预约时长,那么其实这时候f[i] = g[i - 1] + nums[i],前面的不选,就相当于到达前面那个位置,我们不加上当前位置的值,所以就是这个等式;如果到了i位置,我们不选当前这个值,那么前面那个值可选可不选,那么这就又会分出两个情况,i - 1的位置选不选,选的话,其实就相当于到了i - 1位置,我们加上当前位置的值,所以此时g[i] = f[i - 1],不选的话,就相当于到了i - 1位置,我们不加上当前位置的值,所以此时g[i] = g[i - 1],然后这两个值取最大值。现在状态转移方程一共有3个式子。
初始化的时候,dp表如何初始化?这道题不需要加上虚拟节点,因为从dp[0]开始循环,循环里放上方程就可。有些时候加虚拟节点会让代码更简洁,也更无误。根据之前的分析,我们分为两种情况f和g,f[0] = nums[0],g[0] = 0,因为一个选当前节点,一个不选当前节点。
填表顺序是从左到右,两个表一起填。返回值就是两个表的最后一个位置的最大值。
代码书写比较固定,按照分析顺序来就好。当然肯定要先创建dp表。以及这个题需要注意空数组。
int massage(vector<int>& nums) {
int n = nums.size();
if(!n) return 0;
vector<int> f(n);
auto g = f;
f[0] = nums[0], g[0] = 0;
for(int i = 1; i < n; i++)
{
g[i] = max(f[i - 1], g[i - 1]);
f[i] = g[i - 1] + nums[i];
}
return max(f[n - 1], g[n - 1]);
}
3、打家劫舍Ⅱ
213. 打家劫舍 II
还有一个打家劫舍Ⅰ,这个题和按摩师是完全一样的代码。也就是说像这样相邻不能选择的题,要想到两个dp表f和g,两个式子f[i] = g[i - 1],g[i] = max(g[i - 1], f[i - 1])。
现在相对于Ⅰ,Ⅱ多了一个条件,第一个房子和最后一个房子是相邻的。也就是选择第一个位置,最后一个位置就不能偷了;从第二个位置开始,最后一个位置就可以偷。如果偷第一个位置,那么其实这相当于从nums[2]到nums[n - 2]位置进行打家劫舍Ⅰ的操作;如果不偷第一个位置,那就从nums[1]到n - 1位置。这就把环形问题换成线性问题。
现在想想代码怎么写。既然要比较两个操作,不如把打家劫舍Ⅰ的操作单独写成一个函数,调用两次,来比较结果,里面调整一下参数。
int rob(vector<int>& nums) {
int n = nums.size();
return max(nums[0] + rob1(nums, 2, n - 2), rob1(nums, 1, n - 1));
}
int rob1(vector<int>& nums, int left, int right)
{
if(left > right) return 0;
int n = nums.size();
if(!n) return 0;
vector<int> f(n);
auto g = f;
f[left] = nums[left], g[left] = 0;
for(int i = left + 1; i <= right; i++)
{
g[i] = max(f[i - 1], g[i - 1]);
f[i] = g[i - 1] + nums[i];
}
return max(f[right], g[right]);
}
4、删除并获得点数
740. 删除并获得点数
这道题看起来貌似和打家劫舍不同,但我们还是要尽量靠近一些。如果数字都是有序的,比如12345,那么是不是就和不能取相邻元素一样,选择1,2,4,那么3和5都不会被选择,按照这个题来看,它们也确实会被删除,多选一些数字,发现也是一样。但如果是1122222344555怎么办?按照题目以及例子2来看,我们可以将选中的数字多次选中,并且即使该删除的元素没有了,也可以选择,所以这里就可以转换成一个数乘它出现的次数就好了。这个出现的次数字眼就容易想到一个常用的解题办法,创建一个数组arr,下标对应着例子给的数组中的元素,比如下标1就是给定数组中的1,而arr[i]则存储i在给定数组中出现了几次。我们创建好这样的一个数组后,对这个数组做一次打家劫舍就好。
int deleteAndEarn(vector<int>& nums) {
const int N = 10001;//因为nums的数最多是1万
int arr[N] = {0};
for(auto e : nums)
{
arr[e] += e;//如果按照分析,这里应当加1,记录次数,但当写到后面时会发现f[i]需要加arr[i] * i,那么不如就让这一步操作放到这里,直接+=本身的元素,有多少个加多少个,会比用*的写法时间消耗更少
}
vector<int> f(N);
auto g = f;
//本身g里面都是0,所以不用写g[0] = 0,而f[0]是0 * 次数,所以也是0
for(int i = 1; i < N; i++)
{
g[i] = max(f[i - 1], g[i - 1]);
f[i] = g[i - 1] + arr[i];
}
return max(f[N - 1], g[N - 1]);
}
5、粉刷房子
LCR 091. 粉刷房子
也就是说每一个行数都代表一个房间号,每一行的三个元素分别代表红蓝绿色,以及相邻房间颜色得不同。
那么动态规划的思路应该是什么样的?先确定一下状态。我们以i位置时的最小花费。到达i位置时,i位置也有3个颜色,所以我们可以分成dp[i][0],dp[i][1],dp[i][2]。假设选择红色,那么前面就得是蓝或者绿,所以dp[i][0] = min(dp[i - 1][1], dp[i - 1][2]) + dp[i][0],选择其它颜色也可以按照这个分析推出表达式。这也就是状态转移方程。
初始化的时候,因为填i位置需要前一个位置的值,所以需要初始化第一个值,防止0位置去找-1位置了。对于这样的初始化,有两种方式,一个是我们直接手动填写0位置的值;另一个就是利用虚拟节点,虚拟节点的值不能影响填表以及要注意下标的映射关系改变,比如原表的0位置变成现在的1位置。那么我们需要把虚拟的第一行的3个元素全设置为0才能不影响。
填表顺序是从左往右填表,一次填三个位置,因为是3个颜色,3个分别设置为涂红蓝绿色。返回值是最后一行三个位置的最小值。
int minCost(vector<vector<int>>& costs) {
int n = costs.size();
vector<vector<int>> dp(n + 1, vector<int>(3));
for(int i = 1; i <= n; i++)
{
dp[i][0] = min(dp[i - 1][1], dp[i - 1][2]) + costs[i - 1][0];
dp[i][1] = min(dp[i - 1][0], dp[i - 1][2]) + costs[i - 1][1];
dp[i][2] = min(dp[i - 1][1], dp[i - 1][0]) + costs[i - 1][2];
}
return min(min(dp[n][0], dp[n][1]), dp[n][2]);
}
6、买卖股票的最佳时机含冷冻期
309. 买卖股票的最佳时机含冷冻期
像这种从左到右填表的题,还是以某个位置为结尾或者起点。我们定状态为第i天结束后最大的利润。每一天结束后都可以表示成三种状态,也就是买入,可交易,冷冻期,买入意思就是当天结束后我有股票,可交易是卖出状态,没有股票,也不在冷冻期,冷冻期状态就是冷冻期。既然这样我们就用二维数组来表示dp表,三种状态对应dp[i][0],dp[i][1],dp[i][2]。dp[i][1]就表示第i天结束之后,处于可交易状态,此时的最大利润。
第i天的状态我们还需要看第i - 1天的状态。dp[i][0]表示第i天结束后处于买入状态。如果前一天结束后是买入状态,也就是有了股票,我们可以当天什么都不做,还是买入状态;如果前一天结束后是可交易状态,那么当天还是可以处于买入状态,买一个股票就行;如果前一天结束后是冷冻期状态,那么意味着我们不能做任何操作,所以当天结束后不能进入买入状态。
如果前一天结束后是冷冻期,那么当天之后冷冻期就过了,就不是了;如果前一天结束后是买入状态,那么当天只要卖出股票,当天之后就是冷冻期了;如果前一天是可交易状态,也就是已经卖出了,那么当天只能买股票或者什么都不做,不能进入冷冻期。
如果前一天结束后是可交易状态,当天结束之后依旧可以是可交易状态,当天什么也不错;如果前一天结束后是买入状态,那么当天结束后最多只能卖出股票,处于冷冻期;如果前一天结束是冷冻期状态,那么当天什么也不做,冷冻期结束,这时候我没有股票,也不在冷冻期,所以就是可交易状态。
经过整体的分析,我们可以这么写状态转移方程,dp[i][0] = max(dp[i - 1][0],dp[i - 1][1] - price[i]),dp[i][1] = max(dp[i - 1][1],dp[i - 1][2]),dp[i][2] = dp[i - 1][0] + price[i]。
因为需要前一天,所以初始化第一天的状态。根据方程来看,dp[0][0] = -price[0],dp[0][1] = 0,dp[0][2] = 0,所以只需要初始化dp[0][0]就行。
填表顺序是从左到右,从上到下。返回值返回dp[n - 1]那一行的最大值,但其实不需要考虑最后一天是买入状态,因为买入的话就需要减去当天的值,所以肯定最小。
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(3));
dp[0][0] = -prices[0];
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];
}
return max(dp[n - 1][1], dp[n - 1][2]);
}
7、买卖股票的最佳时机含手续费
714. 买卖股票的最佳时机含手续费
分析一下这道题,根据题目和例子可以发现,某一天买入后,不一定哪一天卖出,比如买的时候1块钱,卖的时候10块钱,手续费2块,利润就是10 - 1 - 2。
接下来确定状态。以某一天为结尾,也就是某一天结束后能获得的最大利润。题目中只有买入和卖出,所以第i天结束后的状态我们也可以表示成买入和卖出状态,买入状态意味着有股票,卖出状态意味着股票已卖出,手头没有股票,也就是上一个题的可交易状态。
这次我们创建两个数组f和g。g[i]表示第i天结束后,处于卖出状态,此时的最大利润。如果前一天结束后是买入状态,那么当天可以继续处于买入状态,什么也不做;如果前一天是卖出状态,当天也可以处于买入状态,当天再买一个就行。如果前一天是买入状态,当天自然是可以处于卖出状态;如果前一天是卖出状态,当天也可以继续处于卖出状态。
所以状态转移方程就是这样:f[i] = max(f[i - 1],g[i - 1] - prices[i]),g[i] = max(g[i - 1], f[i - 1] + prices[i] - fee)。初始化的时候,因为要用到前一天的状态,所以我们需要考虑第一天的状态,所以f[0]和g[0]都需要考虑,第0天结束后,处于买入状态,那么就需要买这天的股票,如果处于卖出状态,手里没有股票,那么当天只要什么都不做就是这个状态了,因为买入的话,需要至少第二天才能卖出,所以f[0] = -prices[0],g[0] = 0。填表顺序是从左往右,两个表一起填。返回值就是两个表的最后一个值的最大值,但其实因为g的状态,所以最大值应当是g[n - 1]。
int maxProfit(vector<int>& prices, int fee) {
int n = prices.size();
vector<int> f(n);
auto g = f;
f[0] = -prices[0];
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);
}
return g[n - 1];
}
8、买卖股票的最佳时机Ⅲ
123. 买卖股票的最佳时机 III
这道题的一个特点就是最多只有两笔交易,且只能同时拥有一个股票。买入直到卖出的这一天才算一场交易,买入到卖出中间的天数可以不止一天。
现在来确定状态,第i天结束后是什么状态,状态有两个买入和卖出,以及它还有交易次数,所以每天都要表示交易次数和状态,所以就用多维,如果不是这样的,比如只需要表示状态,那就一维。买入和卖出状态写成f和g,f[i][j]表示第i天结束后,完成了j次交易,此时处于买入状态,以及此时的最大利润。
前一天结束后处于买入状态时,当天还可以处于买入状态,交易次数不变;前一天结束后是卖出状态,当天也可以处于买入状态,但如果交易次数已经满2次了,就不能变成买入状态。前一天结束后是卖出状态,可以继续处于卖出状态;前一天结束后是卖出状态,那么得看交易次数,如果满2次,当天就不能再进入买入状态了。根据分析,现在先不管交易次数的限制,没有最多次数的话,最大利润f[i][j] = max(f[i - 1][j], g[i - 1][j] - prices[i]) ,g[i][j] = max(g[i - 1][j] - f[i - 1][j - 1] + prices[i])。对于次数的控制,我们就让j < 3,表示完成012笔交易。
初始化,因为g状态需要i - 1和j - 1,所以需要初始化第一行和第一列,这样的话,我们就换一下方程写法,先让g[i][j] = g[i - 1][j],然后判断j - 1 >= 0的话,也就是说j - 1存在,那么再去判断两值较大者在赋值给g[i][j]。f和g两个状态,初始化第0天的状态,f[0][0]如同上一个题一样,按照它的方程,我们得买一次股票,所以是-prices[i],而g[0][0]则是0,为了不妨碍后续的交易次数,第一行的[1]和[2]位置都是负无穷。但负无穷INT_MIN可能有的编译器对它进行加减操作时就会越界,所以我们用一半值,0x3f3f3f3f来当作最小值,这个值也非常小。
填表顺序是从左到右,从上到下。返回值,因为f状态是买入,也就是当天结束后是买入状态,已经扣去钱买股票了,所以最大值应当在g表。而g表中,最大值应从最后一行中取到,最大利润出现某一次交易之后。
const int INF = 0x3f3f3f3f;
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> f(n, vector<int>(3, -INF));
auto g = f;
f[0][0] = -prices[0], g[0][0] = 0;
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]);
}
}
int ret = 0;
for(int j = 0; j < 3; j++)
{
ret = max(ret, g[n - 1][j]);
}
return ret;
}
9、买卖股票的最佳时间Ⅳ
188. 买卖股票的最佳时机 IV
可以发现,它和上一题买卖股票Ⅲ的区别就在于是指定最多2次交易和k次交易。按照上一题的思路,我们定义二维数组,列数就是交易次数 + 1,先不管交易次数的限制,根据方程填表,填完后,进入循环j = 0, j < k + 1,找出g表最后一行的最大值即可。不过这里有个细节问题,k的值其实不需要一定要取这个值,交易次数小于等于k就行,而通过看例子,可以发现,k可以取数组长度一半的值,也可以取k本身的值,有时候0次也可以。
const int INF = 0x3f3f3f3f;
int maxProfit(int k, vector<int>& prices) {
int n = prices.size();
k = min(k, n / 2);
vector<vector<int>> f(n, vector<int>(k + 1, -INF));
auto g = f;
f[0][0] = -prices[0], g[0][0] = 0;
for(int i = 1; i < n; i++)
{
for(int j = 0; j < k + 1; 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]);
}
}
int ret = 0;
for(int j = 0; j < k + 1; j++)
{
ret = max(ret, g[n - 1][j]);
}
return ret;
}
结束。