这道题和之前做过的70.爬楼梯类似,都是有关动态规划的问题,因此在此总结一下动态规划相关的问题。
为什么要用动态规划?
Those who cannot remember the past are condemned to repeat it.
记不起过去的人注定要重蹈覆辙
如计算“1+1+1+1”和计算“1+1+1+1+1”时,显然在式1的基础上再加1来计算式2是比逐步计算更快的方法。上面两个式子在计算时非常快。但是,若有从1开始计算1,1+1,……,231-1个1相加的值,逐步相加的运行速度会非常慢。因此,为了加快程序的运行速度,可以使用动态规划算法。
什么是动态规划?
动态规划(Dynamic Programming,DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划通常适用于有重叠子问题和最优子结构性质的问题。
dynamic programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems.
简单来说,动态规划其实就是给定一个问题,我们把他拆成一个个子问题,知道子问题可以被解决。然后将子问题答案保存起来以减少重复运算。再根据子问题答案反推,得出原问题解的一种方法。
一般这些子问题很相似,可以通过函数关系式递推出来。动态规划就致力于解决每个子问题一次,减少重复计算,比如斐波那契数列就是入门级的经典动态规划问题。
动态规划核心思想
动态规划最核心的思想在于拆分子问题,记住子问题答案,减少重复计算。因为记住子问题的答案需要花费额外的储存空间,所以动态规划算法也被称为“用空间换时间”。
一个例子带你走进动态规划
在之前的文章70.爬楼梯中,有这样一道题:爬楼梯时可以每次上一阶楼梯,也可以每次上两阶楼梯,爬上一个10阶台阶的楼梯有多少种方法。
这道题直接上手的时候可能会有些懵,找不到思路,很容易就把自己绕晕。那么如果我们反过来想,按照这个规则,第10阶台阶只能由第9阶或第8阶台阶上来,第9阶台阶只能由第8阶或者第7阶台阶上来,……,第3阶台阶只能由第2阶或第1阶台阶上来。即:
f(10) = f(9) + f(8)
f(9) = f(8) + f(7)
……
f(3) = f(2) + f(1)
即通用公式为f(n) = f(n - 1) + f(n - 2)
我们得到了通用公式后,就可以用递归方法解决这个问题:
class Solution{
public:
int numWays(int n){
if (n == 1) return 1;
if (n == 2) return 2;
return numWays(n - 1) + numWays(n - 2);
}
}
但是,当我们用递归法提交时,发现运行超时了,这说明我们的程序在遇到较多台阶时运行不够快。那么不够快的原因是什么呢?让我们再来看上面的程序,在计算f(10) = f(9) + f(8)
时计算过1次f(8)
了,而在计算f(8) = f(7) + f(6)
时又计算了一次,所以迭代法中存在大量的重复计算,降低了程序的运行速度。这时候我们从“1+1+1”的例子想到,如果我在之前计算的时候,就把f(n)
的值记录下来,这样在之后的计算中不就可以直接使用了吗?
所以可以用两种动态规划的算法解决这道题:
1. 自顶向下的备忘录法
一般使用一个数组或者一个哈希表充当备忘录:
f(10) = f(9) + f(8)
,将f(9)
和f(8)
计算出来存入备忘录中f(9) = f(8) + f(7)
,,此时因为f(8)
已经计算过了,所以可以不计算,带备忘录的递归算法中,子问题个数=树节点数=n,解决一个子问题的时间复杂度是O(1),因此带备忘录的递归算法的时间复杂度是O(n)。代码如下:
class Solution{
public:
unordered_map<int, int> hash;
int climbStairs(int n) {
if(n == 1) return 1;
if(n == 2) return 2;
auto it = mp.find(n);
if( it != mp.end() )
return it->second;
int sum = climbStairs(n-1) + climbStairs(n-2);
mp.insert(pair<int, int> (n, sum));
return sum;
}
}
自底向上的动态规划
动态规划和带备忘录的递归解法基本思想是一致的,都可以减少重复计算,时间复杂度相差也不多,但:
f(10)
往f(1)
方向延伸求解,所以也称为自顶向下的解法;f(1)
往f(10)
方向求解,所以称为自底向上的解法。动态规划有几个典型特征:最优子结构、状态转移方程、边界、重叠子问题。在爬台阶问题中:
f(n - 1)
和f(n - 2)
称为f(n)
的最优子结构;f(n) = f(n - 1) + f(n - 2)
称为状态转移方程;f(1) = 1
、f(2) = 2
称为边界;f(10) = f(9) + f(8)
、f(9) = f(8) + f(7)
称为重叠子问题动态规划的代码如下:
class Solution {
public:
int climbStairs(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
long res = 0, a = 1, b = 2;
for (int i = 3; i <= n; i++)
{
res = a + b;
a = b;
b = res;
}
return res;
}
}
什么样的问题可以使用动态规划
如果一个问题,可以把所有可能的答案穷举出来,并且穷举出来之后,发现存在重叠子问题,就可以考虑使用动态规划。
比如最长递增子序列、最小编辑距离、背包问题、凑零钱问题等。
动态规划的解题思路
按照上面的思路,我们来看一下力扣第121题,买卖股票的最佳时机。
给定一个数组prices
,它的第i
个元素prices[i]
表示一支给定股票第i
天的价格。你只能选择某一天买入这只股票,并选择在未来的某一个不同的日子卖出该股票。设计一个算法来计算你所能获取的最大利润。返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回0
。
示例1
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
示例2
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 没有交易完成, 所以最大利润为 0。
提示
1 <= prices.length <= 10^5
0 <= prices[i] <= 10^4
思路
在某一天卖出时,其最大利润应该为当天的股票价格减去前几天中最低的股票价格,即maxProfit = prices[i] - minPrice
。
maxProfit = prices[i] - minPrice
;minPrice = min(minPrice, prices[i])
、maxProfit = max(maxProfit, (prices[i] - minPrice))
。所以代码如下:
class Solution {
public:
int maxProfit(vector<int>& prices) {
int maxProfit = 0;
int minPrice = prices[0];
int n = prices.size();
for (int i = 0; i < n; i++)
{
minPrice = min(minPrice, prices[i]);
maxProfit = max(maxProfit, (prices[i] - minPrice));
}
return maxProfit;
}
};
本文题目及部分解答来自力扣,其余参考资料见下文链接。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock
参考:
看一遍就理解:动态规划详解
算法-动态规划 Dynamic Programming–从菜鸟到老鸟