43、第九章 动态规划part10

本节内容

    1. 买卖股票的最佳时机
  • 122.买卖股票的最佳时机II

121. 买卖股票的最佳时机※

建议

题目链接: https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/
文章讲解: https://programmercarl.com/0121.%E4%B9%B0%E5%8D%96%E8%82%A1%E7%A5%A8%E7%9A%84%E6%9C%80%E4%BD%B3%E6%97%B6%E6%9C%BA.html
视频讲解:

题目分析



方案一:暴力求解

暴力求解似乎可以解决此问题,时间复杂度较高为 n ^ 2 。此方案也不是做本题的本意,先做一下吧。

1
2
3
4
5
6
7
8
9
10
11
class Solution {  
public int maxProfit(int[] prices) {
int max = 0;
for (int i = 0; i < prices.length - 1; i++) {
for (int j = i + 1; j < prices.length; j++) {
if (prices[j] - prices[i] > max) max = prices[j] - prices[i];
}
}
return max;
}
}

结果

运行失败:
Time Limit Exceeded

分析

时间复杂度:
O( n ^ 2 )

空间复杂度:
O( 1 )

方案二:动态规划

计算今天和昨天的股票差异数值,并加上以前赚的,如果大于0,则代表本天赚的,负责赋值为0。

使用一个max变量,记录曾经遇到过的最大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {  
public int maxProfit(int[] prices) {

int[] dp = new int[prices.length];
dp[0] = 0;
int temp, max = 0;

for (int i = 1; i < prices.length; i++) {
temp = dp[i - 1] + prices[i] -prices[i- 1];
dp[i] = temp > 0 ? temp : 0;
if (dp[i] > max) max = dp[i];
}
return max;
}
}

结果

解答成功:
执行耗时:4 ms,击败了28.85% 的Java用户
内存消耗:55.3 MB,击败了94.19% 的Java用户

分析

时间复杂度:
O( n )

空间复杂度:
O( n )

DP数组的空间复杂度还可以再优化一下,这里就不在编写相关代码。

方案三:动态规划

按照官方题解重写编写的相关程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {  
public int maxProfit(int[] prices) {

// 动态规划
int[][] dp = new int[prices.length][2]; // [i][0]:代表第i天持有股票的金额; [i][1]:代表第i天不持有股票的金额
dp[0][0] = -1 * prices[0];
dp[0][1] = 0;

for (int i = 1; i < prices.length; i++) {
dp[i][0] = Math.max(dp[i - 1][0], -1 * prices[i]);
dp[i][1] = Math.max(dp[i - 1][1], prices[i] + dp[i - 1][0]);

// System.out.println(dp[i][0] + "====" + dp[i][1]);
}
return dp[prices.length - 1][1];
}
}

结果

解答成功:
执行耗时:23 ms,击败了18.19% 的Java用户
内存消耗:56.7 MB,击败了78.32% 的Java用户

分析

时间复杂度:
O( n )

空间复杂度:
O( n )

代码随想录

https://programmercarl.com/0121.%E4%B9%B0%E5%8D%96%E8%82%A1%E7%A5%A8%E7%9A%84%E6%9C%80%E4%BD%B3%E6%97%B6%E6%9C%BA.html

暴力

这道题目最直观的想法,就是暴力,找最优间距了。

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
int maxProfit(vector<int>& prices) {
int result = 0;
for (int i = 0; i < prices.size(); i++) {
for (int j = i + 1; j < prices.size(); j++){
result = max(result, prices[j] - prices[i]);
}
}
return result;
}
};
  • 时间复杂度:O(n^2)
  • 空间复杂度:O(1)

当然该方法超时了

贪心

因为股票就买卖一次,那么贪心的想法很自然就是取最左最小值,取最右最大值,那么得到的差值就是最大利润。

C++代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
int maxProfit(vector<int>& prices) {
int low = INT_MAX;
int result = 0;
for (int i = 0; i < prices.size(); i++) {
low = min(low, prices[i]); // 取最左最小价格
result = max(result, prices[i] - low); // 直接取最大区间利润
}
return result;
}
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

动态规划

动规五部曲分析如下:

  1. 确定dp数组(dp table)以及下标的含义

dp[i][0] 表示第i天持有股票所得最多现金 ,这里可能有同学疑惑,本题中只能买卖一次,持有股票之后哪还有现金呢?

其实一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数。

dp[i][1] 表示第i天不持有股票所得最多现金

注意这里说的是“持有”,“持有”不代表就是当天“买入”!也有可能是昨天就买入了,今天保持持有的状态

很多同学把“持有”和“买入”没区分清楚。

在下面递推公式分析中,我会进一步讲解。

  1. 确定递推公式

如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来

  • 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
  • 第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]

那么dp[i][0]应该选所得现金最大的,所以dp[i][0] = max(dp[i - 1][0], -prices[i]);

如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来

  • 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
  • 第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即:prices[i] + dp[i - 1][0]

同样dp[i][1]取最大的,dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);

这样递推公式我们就分析完了

  1. dp数组如何初始化

由递推公式 dp[i][0] = max(dp[i - 1][0], -prices[i]); 和 dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);可以看出其基础都是要从dp[0][0]和dp[0][1]推导出来。

那么dp[0][0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0][0] -= prices[0];

dp[0][1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0;

  1. 确定遍历顺序

从递推公式可以看出dp[i]都是由dp[i - 1]推导出来的,那么一定是从前向后遍历。

  1. 举例推导dp数组

以示例1,输入:[7,1,5,3,6,4]为例,dp数组状态如下:

dp[5][1]就是最终结果。

为什么不是dp[5][0]呢?

因为本题中不持有股票状态所得金钱一定比持有股票状态得到的多!

以上分析完毕,C++代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 版本一
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
if (len == 0) return 0;
vector<vector<int>> dp(len, vector<int>(2));
dp[0][0] -= prices[0];
dp[0][1] = 0;
for (int i = 1; i < len; i++) {
dp[i][0] = max(dp[i - 1][0], -prices[i]);
dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
}
return dp[len - 1][1];
}
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

从递推公式可以看出,dp[i]只是依赖于dp[i - 1]的状态。

1
2
dp[i][0] = max(dp[i - 1][0], -prices[i]);
dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);

那么我们只需要记录 当前天的dp状态和前一天的dp状态就可以了,可以使用滚动数组来节省空间,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 版本二
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
vector<vector<int>> dp(2, vector<int>(2)); // 注意这里只开辟了一个2 * 2大小的二维数组
dp[0][0] -= prices[0];
dp[0][1] = 0;
for (int i = 1; i < len; i++) {
dp[i % 2][0] = max(dp[(i - 1) % 2][0], -prices[i]);
dp[i % 2][1] = max(dp[(i - 1) % 2][1], prices[i] + dp[(i - 1) % 2][0]);
}
return dp[(len - 1) % 2][1];
}
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

这里能写出版本一就可以了,版本二虽然原理都一样,但是想直接写出版本二还是有点麻烦,容易自己给自己找bug。

所以建议是先写出版本一,然后在版本一的基础上优化成版本二,而不是直接就写出版本二。

代码实现

贪心法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public int maxProfit(int[] prices) {
// 找到一个最小的购入点
int low = Integer.MAX_VALUE;
// res不断更新,直到数组循环完毕
int res = 0;
for(int i = 0; i < prices.length; i++){
low = Math.min(prices[i], low);
res = Math.max(prices[i] - low, res);
}
return res;
}
}

动态规划:版本一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 解法1
class Solution {
public int maxProfit(int[] prices) {
if (prices == null || prices.length == 0) return 0;
int length = prices.length;
// dp[i][0]代表第i天持有股票的最大收益
// dp[i][1]代表第i天不持有股票的最大收益
int[][] dp = new int[length][2];
int result = 0;
dp[0][0] = -prices[0];
dp[0][1] = 0;
for (int i = 1; i < length; i++) {
dp[i][0] = Math.max(dp[i - 1][0], -prices[i]);
dp[i][1] = Math.max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
}
return dp[length - 1][1];
}
}

动态规划:版本二(使用二維數組(和卡哥思路一致),下面還有使用一維滾動數組的更優化版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public int maxProfit(int[] prices) {
int len = prices.length;
int dp[][] = new int[2][2];

dp[0][0] = - prices[0];
dp[0][1] = 0;

for (int i = 1; i < len; i++){
dp[i % 2][0] = Math.max(dp[(i - 1) % 2][0], - prices[i]);
dp[i % 2][1] = Math.max(dp[(i - 1) % 2][1], prices[i] + dp[(i - 1) % 2][0]);
}
return dp[(len - 1) % 2][1];
}
}

动态规划:版本二(使用一維數組)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public int maxProfit(int[] prices) {
int[] dp = new int[2];
// 记录一次交易,一次交易有买入卖出两种状态
// 0代表持有,1代表卖出
dp[0] = -prices[0];
dp[1] = 0;
// 可以参考斐波那契问题的优化方式
// 我们从 i=1 开始遍历数组,一共有 prices.length 天,
// 所以是 i<=prices.length
for (int i = 1; i <= prices.length; i++) {
// 前一天持有;或当天买入
dp[0] = Math.max(dp[0], -prices[i - 1]);
// 如果 dp[0] 被更新,那么 dp[1] 肯定会被更新为正数的 dp[1]
// 而不是 dp[0]+prices[i-1]==0 的0,
// 所以这里使用会改变的dp[0]也是可以的
// 当然 dp[1] 初始值为 0 ,被更新成 0 也没影响
// 前一天卖出;或当天卖出, 当天要卖出,得前一天持有才行
dp[1] = Math.max(dp[1], dp[0] + prices[i - 1]);
}
return dp[1];
}
}

122.买卖股票的最佳时机II※

建议

题目链接: https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii/
文章讲解: https://programmercarl.com/0122.%E4%B9%B0%E5%8D%96%E8%82%A1%E7%A5%A8%E7%9A%84%E6%9C%80%E4%BD%B3%E6%97%B6%E6%9C%BAII%EF%BC%88%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%EF%BC%89.html
视频讲解:

题目分析



方案一

本题曾经做过一次,具体请看下方链接。
https://www.yuanql.top/2023/08/12/02_1_%E4%BB%A3%E7%A0%81%E9%9A%8F%E6%83%B3%E5%BD%95%E7%AE%97%E6%B3%95%E8%AE%AD%E7%BB%83%E8%90%A518%E6%9C%9F/29%E3%80%81%E7%AC%AC%E5%85%AB%E7%AB%A0%20%E8%B4%AA%E5%BF%83%E7%AE%97%E6%B3%95%20part02/#122-%E4%B9%B0%E5%8D%96%E8%82%A1%E7%A5%A8%E7%9A%84%E6%9C%80%E4%BD%B3%E6%97%B6%E6%9C%BAII%E2%80%BB

1
2
3
4
5
6
7
8
9
10
class Solution {  
public int maxProfit(int[] prices) {
// 动态规划
int dp = 0;
for (int i = 1; i < prices.length; i++) {
dp = Math.max(dp, dp + prices[i] - prices[i - 1]);
}
return dp;
}
}

结果

解答成功:
执行耗时:1 ms,击败了70.51% 的Java用户
内存消耗:43.3 MB,击败了23.85% 的Java用户

分析

时间复杂度:
O( n )

空间复杂度:
O( 1 )

代码随想录

https://programmercarl.com/0122.%E4%B9%B0%E5%8D%96%E8%82%A1%E7%A5%A8%E7%9A%84%E6%9C%80%E4%BD%B3%E6%97%B6%E6%9C%BAII%EF%BC%88%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%EF%BC%89.html

思路

本题我们在讲解贪心专题的时候就已经讲解过了贪心算法:买卖股票的最佳时机II ,只不过没有深入讲解动态规划的解法,那么这次我们再好好分析一下动规的解法。

本题和 121. 买卖股票的最佳时机 的唯一区别是本题股票可以买卖多次了(注意只有一只股票,所以再次购买前要出售掉之前的股票)

在动规五部曲中,这个区别主要是体现在递推公式上,其他都和 121. 买卖股票的最佳时机 一样一样的

所以我们重点讲一讲递推公式。

这里重申一下dp数组的含义:

  • dp[i][0] 表示第i天持有股票所得现金。
  • dp[i][1] 表示第i天不持有股票所得最多现金

如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来

  • 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
  • 第i天买入股票,所得现金就是昨天不持有股票的所得现金减去 今天的股票价格 即:dp[i - 1][1] - prices[i]

注意这里和 121. 买卖股票的最佳时机  唯一不同的地方,就是推导dp[i][0]的时候,第i天买入股票的情况
121. 买卖股票的最佳时机 中,因为股票全程只能买卖一次,所以如果买入股票,那么第i天持有股票即dp[i][0]一定就是 -prices[i]。

而本题,因为一只股票可以买卖多次,所以当第i天买入股票的时候,所持有的现金可能有之前买卖过的利润。

那么第i天持有股票即dp[i][0],如果是第i天买入股票,所得现金就是昨天不持有股票的所得现金 减去 今天的股票价格 即:dp[i - 1][1] - prices[i]。

再来看看如果第i天不持有股票即dp[i][1]的情况, 依然可以由两个状态推出来

  • 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
  • 第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即:prices[i] + dp[i - 1][0]

注意这里和 121. 买卖股票的最佳时机  就是一样的逻辑,卖出股票收获利润(可能是负值)天经地义!

代码如下:(注意代码中的注释,标记了和121.买卖股票的最佳时机唯一不同的地方)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
vector<vector<int>> dp(len, vector<int>(2, 0));
dp[0][0] -= prices[0];
dp[0][1] = 0;
for (int i = 1; i < len; i++) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]); // 注意这里是和121. 买卖股票的最佳时机唯一不同的地方。
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
}
return dp[len - 1][1];
}
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

大家可以本题和 121. 买卖股票的最佳时机 的代码几乎一样,唯一的区别在:

1
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]);

这正是因为本题的股票可以买卖多次! 所以买入股票的时候,可能会有之前买卖的利润即:dp[i - 1][1],所以dp[i - 1][1] - prices[i]。

想到到这一点,对这两道题理解的就比较深刻了。

这里我依然给出滚动数组的版本,C++代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 版本二
class Solution {
public:
int maxProfit(vector<int>& prices) {
int len = prices.size();
vector<vector<int>> dp(2, vector<int>(2)); // 注意这里只开辟了一个2 * 2大小的二维数组
dp[0][0] -= prices[0];
dp[0][1] = 0;
for (int i = 1; i < len; i++) {
dp[i % 2][0] = max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1] - prices[i]);
dp[i % 2][1] = max(dp[(i - 1) % 2][1], prices[i] + dp[(i - 1) % 2][0]);
}
return dp[(len - 1) % 2][1];
}
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 动态规划
class Solution
// 实现1:二维数组存储
// 可以将每天持有与否的情况分别用 dp[i][0] 和 dp[i][1] 来进行存储
// 时间复杂度:O(n),空间复杂度:O(n)
public int maxProfit(int[] prices) {
int n = prices.length;
int[][] dp = new int[n][2]; // 创建二维数组存储状态
dp[0][0] = 0; // 初始状态
dp[0][1] = -prices[0];
for (int i = 1; i < n; ++i) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]); // 第 i 天,没有股票
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]); // 第 i 天,持有股票
}
return dp[n - 1][0]; // 卖出股票收益高于持有股票收益,因此取[0]
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//DP using 2*2 Array (下方還有使用一維滾動數組的更優化版本)
class Solution {
public int maxProfit(int[] prices) {
int dp[][] = new int [2][2];
//dp[i][0]: holding the stock
//dp[i][1]: not holding the stock
dp[0][0] = - prices[0];
dp[0][1] = 0;

for(int i = 1; i < prices.length; i++){
dp[i % 2][0] = Math.max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1] - prices[i]);
dp[i % 2][1] = Math.max(dp[(i - 1) % 2][1], dp[(i - 1) % 2][0] + prices[i]);
}
return dp[(prices.length - 1) % 2][1];
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 优化空间
class Solution {
public int maxProfit(int[] prices) {
int[] dp = new int[2];
// 0表示持有,1表示卖出
dp[0] = -prices[0];
dp[1] = 0;
for(int i = 1; i <= prices.length; i++){
// 前一天持有; 既然不限制交易次数,那么再次买股票时,要加上之前的收益
dp[0] = Math.max(dp[0], dp[1] - prices[i-1]);
// 前一天卖出; 或当天卖出,当天卖出,得先持有
dp[1] = Math.max(dp[1], dp[0] + prices[i-1]);
}
return dp[1];
}
}

43、第九章 动态规划part10
http://yuanql.top/2023/08/18/02_1_代码随想录算法训练营18期/43、第九章 动态规划part10/
作者
Qingli Yuan
发布于
2023年8月18日
许可协议