目录
什么是动态规划?
动态规划(Dynamic Programming),简称DP,是一种将一个复杂问题分解为多个简单的子问题求解的方法。将子问题的答案存储在记忆数据结构中,当子问题再次需要解决时,只需查表查看结果,而不需要再次重复计算,因此节约了计算时间。
国外知乎 Quora 上一个帖子问应该怎样给四岁的孩子解释什么是动态规划,其中一个非常经典的回答如下:
动态规划通常基于一个递推公式和一个或多个初始状态。当前问题的解可以分解为多个子问题解得出。使用动态规划只需要多项式时间复杂度,因为比回溯法和暴力法快很多。
动态规划的解题步骤
动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立dp表,推导状态转移方程,确定边界条件等。一般我们可以分为三步:
- 第一步:思考每轮的决策,定义状态,从而得到dp表
- 第二步:找出最优子结构,进而推导出状态转移方程
- 第三步:确定边界条件和状态转移顺序
线性DP之股票问题
股票问题是指在给定的股票价格序列中,通过买入和卖出股票,以获取最大利润的问题。这类问题通常需要设计一个算法来确定何时买入和卖出股票,以获得最大的收益。
解决股票问题的一种常见方法是利用动态规划。在股票问题中,动态规划的关键在于定义合适的状态和状态转移方程。
通常情况下,我们可以定义两种状态:
- 持有股票状态(hold): 表示在第 i 天结束时持有股票所能获得的最大利润。
- 不持有股票状态(not hold): 表示在第 i 天结束时不持有股票所能获得的最大利润。
然后,我们根据题目要求设计状态转移方程,以便在每一天结束时更新这两种状态。最终,我们可以在最后一天结束时选择持有股票状态和不持有股票状态中的较大值作为最终的答案。
(1).买卖股票的最佳时机 II
定义状态 dp[i][0] 表示第 i 天交易完后手里没有股票的最大利润,dp[i][1] 表示第 i 天交易完后手里持有一支股票的最大利润(i 从 0 开始)。
考虑 dp[i][0] 的转移方程,如果这一天交易完后手里没有股票,那么可能的转移状态为前一天已经没有股票,即 dp[i−1][0],或者前一天结束的时候手里持有一支股票,即 dp[i−1][1],这时候我们要将其卖出,并获得 prices[i] 的收益。因此为了收益最大化,我们列出如下的转移方程:
dp[i][0] = max{dp[i−1][0], dp[i−1][1] + prices[i]}
再来考虑 dp[i][1],按照同样的方式考虑转移状态,那么可能的转移状态为前一天已经持有一支股票,即 dp[i−1][1],或者前一天结束时还没有股票,即 dp[i−1][0],这时候我们要将其买入,并减少 prices[i] 的收益。可以列出如下的转移方程:
dp[i][1] = max{dp[i−1][1], dp[i−1][0] − prices[i]}
对于初始状态,根据状态定义我们可以知道第 0 天交易结束的时候 dp[0][0] = 0,dp[0][1] = -prices[0]。
因此,我们只要从前往后依次计算状态即可。由于全部交易结束后,持有股票的收益一定低于不持有股票的收益,因此这时候 dp[n−1][0] 的收益必然是大于 dp[n−1][1] 的,最后的答案即为 dp[n−1][0]。
int maxProfit(int* prices, int pricesSize) {
// 定义动态规划数组,dp[i][0]表示第i天交易结束后手里没有股票的最大利润,
// dp[i][1]表示第i天交易结束后手里持有一支股票的最大利润
int dp[pricesSize][2];
// 初始状态,第0天交易结束时,手里没有股票利润为0,手里持有一支股票的利润为-prices[0]
dp[0][0] = 0;
dp[0][1] = -prices[0];
// 从第1天开始遍历股票价格数组
for (int i = 1; i < pricesSize; ++i) {
// 更新当天手里没有股票的最大利润
dp[i][0] = fmax(dp[i - 1][0], dp[i - 1][1] + prices[i]);
// 更新当天手里持有一支股票的最大利润
dp[i][1] = fmax(dp[i - 1][1], dp[i - 1][0] - prices[i]);
}
// 返回最后一天交易结束后手里没有股票的最大利润
return dp[pricesSize - 1][0];
}
(2).买卖股票的最佳时机 III
由于我们最多可以完成两笔交易,因此在任意一天结束之后,我们会处于以下五个状态中的一种:
- 未进行过任何操作;
- 只进行过一次买操作;
- 进行了一次买操作和一次卖操作,即完成了一笔交易;
- 在完成了一笔交易的前提下,进行了第二次买操作;
- 完成了全部两笔交易。
由于第一个状态的利润显然为0,因此我们可以不用将其记录。对于剩下的四个状态,我们分别将它们的最大利润记为buy1、sell1、buy2和sell2。
如果我们知道了第i−1天结束后的这四个状态,那么如何通过状态转移方程得到第i天结束后的这四个状态呢?
对于buy1而言,在第i天我们可以不进行任何操作,保持不变;也可以以prices[i]的价格买入股票,那么buy1的状态转移方程即为:
buy1 = max (buy1', -prices[i])
这里我们用buy1'表示第i−1天的状态,以便于和第i天的状态buy1进行区分。
对于sell1而言,在第i天我们可以不进行任何操作,保持不变;也可以以prices[i]的价格卖出股票,那么sell1的状态转移方程即为:
sell1 = max (sell1', buy1' + prices[i])
同理我们可以得到buy2和sell2对应的状态转移方程:
buy2 = max (buy2', sell1' - prices[i])
sell2 = max (sell2', buy2' + prices[i])
在考虑边界条件时,我们需要理解下面的这个事实,无论题目中是否允许「在同一天买入并且卖出」这一操作,最终的答案都不会受到影响,这是因为这一操作带来的收益为零。因此,在状态转移时,我们可以直接写成:
buy1 = max (buy1, -prices[i])
sell1 = max (sell1, buy1 + prices[i])
buy2 = max (buy2, sell1 - prices[i])
sell2 = max (sell2, buy2 + prices[i])
那么对于边界条件,我们考虑第i=0天时的四个状态:
- buy1即为以prices[0]的价格买入股票,因此buy1=−prices[0];
- sell1即为在同一天买入并且卖出,因此sell1=0;
- buy2即为在同一天买入并且卖出后再以prices[0]的价格买入股票,因此buy2=−prices[0];
- 同理可得sell2=0。
我们将这四个状态作为边界条件,从i=1开始进行动态规划,即可得到答案。
在动态规划结束后,由于我们可以进行不超过两笔交易,因此最终的答案在0、sell1、sell2中,且为三者中的最大值。然而我们可以发现,由于在边界条件中sell1和sell2的值已经为0,并且在状态转移的过程中我们维护的是最大值,因此sell1和sell2最终一定大于等于0。同时,如果最优的情况对应的是恰好一笔交易,那么它也会因为我们在转移时允许在同一天买入并且卖出这一宽松的条件,从sell1转移至sell2,因此最终的答案即为sell2。
//求最大值的宏
#define max(a, b) ((a) < (b) ? (b) : (a))
int maxProfit(int* prices, int pricesSize) {
// 初始化第一次买入和卖出的状态
int buy1 = -prices[0], sell1 = 0;
// 初始化第二次买入和卖出的状态
int buy2 = -prices[0], sell2 = 0;
// 遍历股票价格数组
for (int i = 1; i < pricesSize; ++i) {
// 更新第一次买入的状态
buy1 = max(buy1, -prices[i]);
// 更新第一次卖出的状态
sell1 = max(sell1, buy1 + prices[i]);
// 更新第二次买入的状态
buy2 = max(buy2, sell1 - prices[i]);
// 更新第二次卖出的状态
sell2 = max(sell2, buy2 + prices[i]);
}
// 返回最大利润,即第二次卖出的利润
return sell2;
}
(3).买卖股票的最佳时机 IV
与其余的股票问题类似,我们使用一系列变量存储「买入」的状态,再用一系列变量存储「卖出」的状态,通过动态规划的方法即可解决本题。
我们用 buy[i][j] 表示对于数组 prices[0..i] 中的价格而言,进行恰好 j 笔交易,并且当前手上持有一支股票,这种情况下的最大利润;用 sell[i][j] 表示恰好进行 j 笔交易,并且当前手上不持有股票,这种情况下的最大利润。
那么我们可以对状态转移方程进行推导。对于 buy[i][j],我们考虑当前手上持有的股票是否是在第 i 天买入的。如果是第 i 天买入的,那么在第 i-1 天时,我们手上不持有股票,对应状态 sell[i-1][j],并且需要扣除 prices[i] 的买入花费;如果不是第 i 天买入的,那么在第 i-1 天时,我们手上持有股票,对应状态 buy[i-1][j]。那么我们可以得到状态转移方程:
buy[i][j] = max (buy[i−1][j],sell[i−1][j] − price[i])
同理对于 sell[i][j],如果是第 i 天卖出的,那么在第 i-1 天时,我们手上持有股票,对应状态 buy[i−1][j−1],并且需要增加 prices[i] 的卖出收益;如果不是第 i 天卖出的,那么在第 i-1 天时,我们手上不持有股票,对应状态 sell[i−1][j]。那么我们可以得到状态转移方程:
sell[i][j] = max(sell[i−1][j], buy[i−1][j−1] + price[i])
由于在所有的 n 天结束后,手上不持有股票对应的最大利润一定是严格由于手上持有股票对应的最大利润的,然而完成的交易数并不是越多越好(例如数组 prices 单调递减,我们不进行任何交易才是最优的),因此最终的答案即为 sell[n−1][0..k] 中的最大值。
int maxProfit(int k, int* prices, int pricesSize) {
int n = pricesSize;
if (n == 0) {
return 0;
}
// 限制交易次数为 k 次
k = fmin(k, n / 2);
// 用于记录买入和卖出的状态
int buy[n][k + 1], sell[n][k + 1];
memset(buy, 0, sizeof(buy)); // 初始化为 0
memset(sell, 0, sizeof(sell)); // 初始化为 0
buy[0][0] = -prices[0]; // 第一天买入
sell[0][0] = 0; // 第一天不卖出
// 初始化第一天交易次数不同的情况
for (int i = 1; i <= k; ++i) {
buy[0][i] = sell[0][i] = INT_MIN / 2; // 将第一天的买入状态初始化为最小值
}
// 动态规划过程
for (int i = 1; i < n; ++i) {
buy[i][0] = fmax(buy[i - 1][0], sell[i - 1][0] - prices[i]); // 不交易时保持上一次买入的状态,或者今天买入
for (int j = 1; j <= k; ++j) {
buy[i][j] = fmax(buy[i - 1][j], sell[i - 1][j] - prices[i]); // 不交易时保持上一次买入的状态,或者今天买入
sell[i][j] = fmax(sell[i - 1][j], buy[i - 1][j - 1] + prices[i]); // 不交易时保持上一次卖出的状态,或者今天卖出
}
}
// 最后求得最大利润
int ret = 0;
for (int i = 0; i <= k; i++) {
ret = fmax(ret, sell[n - 1][i]); // 取最后一天卖出时的最大利润
}
return ret;
}
(4). 买卖股票的最佳时机含冷冻期
我们用 f[i] 表示第 i 天结束之后的「累计最大收益」。根据题目描述,由于我们最多只能同时买入(持有)一支股票,并且卖出股票后有冷冻期的限制,因此我们会有三种不同的状态:
- 我们目前持有一支股票,对应的「累计最大收益」记为 f[i][0];
- 我们目前不持有任何股票,并且处于冷冻期中,对应的「累计最大收益」记为 f[i][1];
- 我们目前不持有任何股票,并且不处于冷冻期中,对应的「累计最大收益」记为 f[i][2]。
这里的「处于冷冻期」指的是在第 i 天结束之后的状态。也就是说:如果第 i 天结束之后处于冷冻期,那么第 i+1 天无法买入股票。如何进行状态转移呢?在第 i 天时,我们可以在不违反规则的前提下进行「买入」或者「卖出」操作,此时第 i 天的状态会从第 i−1 天的状态转移而来;我们也可以不进行任何操作,此时第 i 天的状态就等同于第 i−1 天的状态。那么我们分别对这三种状态进行分析:
-
对于 f[i][0],我们目前持有的这一支股票可以是在第 i−1 天就已经持有的,对应的状态为 f[i−1][0];或者是第 i 天买入的,那么第 i−1 天就不能持有股票并且不处于冷冻期中,对应的状态为 f[i−1][2] 加上买入股票的负收益 prices[i]。因此状态转移方程为:
f[i][0] = max(f[i−1][0], f[i−1][2]−prices[i])
-
对于 f[i][1],我们在第 i 天结束之后处于冷冻期的原因是在当天卖出了股票,那么说明在第 i−1 天时我们必须持有一支股票,对应的状态为 f[i−1][0] 加上卖出股票的正收益 prices[i]。因此状态转移方程为:
f[i][1] = f[i−1][0] + prices[i]
-
对于 f[i][2],我们在第 i 天结束之后不持有任何股票并且不处于冷冻期,说明当天没有进行任何操作,即第 i−1 天时不持有任何股票:如果处于冷冻期,对应的状态为 f[i−1][1];如果不处于冷冻期,对应的状态为 f[i−1][2]。因此状态转移方程为:
f[i][2] = max(f[i−1][1], f[i−1][2])
这样我们就得到了所有的状态转移方程。如果一共有 n 天,那么最终的答案即为:
max(f[n−1][0], f[n−1][1], f[n−1][2])
注意到如果在最后一天(第 n−1 天)结束之后,手上仍然持有股票,那么显然是没有任何意义的。因此更加精确地,最终的答案实际上是 f[n−1][1] 和 f[n−1][2] 中的较大值,即:
max(f[n−1][1], f[n−1][2])
int maxProfit(int* prices, int pricesSize) {
if (pricesSize == 0) {
return 0;
}
// f[i][0]: 手上持有股票的最大收益
// f[i][1]: 手上不持有股票,并且处于冷冻期中的累计最大收益
// f[i][2]: 手上不持有股票,并且不在冷冻期中的累计最大收益
int f[pricesSize][3];
//状态初始化,买入时金额-prices[0],其他都是0
f[0][0] = -prices[0];
f[0][1] = f[0][2] = 0;
for (int i = 1; i < pricesSize; ++i) {
//股票可以i−1天已经持有。或者是第i天买入,那么i-1天就不能持有股票并且不处于冷冻期中,对应的状态要加上买入股票的负收益
f[i][0] = fmax(f[i - 1][0], f[i - 1][2] - prices[i]);
//第i天处于冷冻期是在当天卖出了股票,那么第i−1天时我们必须持有一支股票,对应的状态要加上卖出股票的正收益
f[i][1] = f[i - 1][0] + prices[i];
//第i天结束之后不持有任何股票并且不处于冷冻期,说明当天没有进行任何操作
f[i][2] = fmax(f[i - 1][1], f[i - 1][2]);
}
return fmax(f[pricesSize - 1][1], f[pricesSize - 1][2]);
}
(5).买卖股票的最佳时机含手续费
考虑到「不能同时参与多笔交易」,因此每天交易结束后只可能存在手里有一支股票或者没有股票的状态。
定义状态 dp[i][0] 表示第 i 天交易完后手里没有股票的最大利润,dp}[i][1]表示第 i 天交易完后手里持有一支股票的最大利润 i 从 0 开始。
考虑 dp[i][0] 的转移方程,如果这一天交易完后手里没有股票,那么可能的转移状态为前一天已经没有股票,即 dp[i-1][0],或者前一天结束的时候手里持有一支股票,即 dp[i-1][1],这时候我们要将其卖出,并获得 prices[i] 的收益,但需要支付 fee的手续费。因此为了收益最大化,我们列出如下的转移方程:
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i] - fee)
再来按照同样的方式考虑 dp[i][1] 按状态转移,那么可能的转移状态为前一天已经持有一支股票,即 dp[i-1][1],或者前一天结束时还没有股票,即 dp[i-1][0] ,这时候我们要将其买入,并减少 prices}[i] 的收益。可以列出如下的转移方程:
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])
对于初始状态,根据状态定义我们可以知道第 0 天交易结束的时候有 dp[0][0] = 0 以及 dp[0][1] = -prices[0]。
因此,我们只要从前往后依次计算状态即可。由于全部交易结束后,持有股票的收益一定低于不持有股票的收益,因此这时候 dp[n-1][0] 的收益必然是大于 {dp[n-1][1] 的,最后的答案即为 dp[n-1][0]。
int maxProfit(int* prices, int pricesSize, int fee){
// 创建一个二维数组 dp,用于记录每一天结束时的最大利润
int dp[pricesSize][2];
// 初始化第一天结束时的状态
dp[0][0] = 0; // 第一天结束时手里没有股票,利润为 0
dp[0][1] = -prices[0]; // 第一天结束时手里持有一支股票,利润为买入价格的负值
// 从第二天开始遍历每一天的状态
for (int i = 1; i < pricesSize; ++i) {
// 计算当天结束时手里没有股票的最大利润
dp[i][0] = fmax(dp[i - 1][0], dp[i - 1][1] + prices[i] - fee);
// 计算当天结束时手里持有一支股票的最大利润
dp[i][1] = fmax(dp[i - 1][1], dp[i - 1][0] - prices[i]);
}
// 返回最后一天结束时手里没有股票的最大利润
return dp[pricesSize - 1][0];
}