股票买卖问题也是一种很典型的动态规划题型。通过对力扣上一系列此类问题的分析来记录这一类问题的解题思路与技巧。
最佳买卖股票时机 1(只能买卖 1 次)
1 | 给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。 |
动态规划解法
问题分析
本题是股票题型吃的基础问题,规定只能买卖各一次,所以在每一个交易日,股民只有两种状态:
- 持有(可能是当天购买,也可能是在前面的交易日就已经购买了)
- 不持有
$dp$ 数组定义
因为股票的价格是变动的,而买入时所花费的额度是固定的,最终能获得的收益只有在股票卖出时才能确定。所以我们假设初始状态下手中的现金数是 $0$,那么在第 $i$ 天买入手中剩余的现金就是 $0 - prices[i]$
$dp[i][0]$ 表示第 $i$ 天持有,剩余的现金数。
$dp[i][1]$ 表示第 $i$ 天不持有,手中剩余的现金数。
递推公式
第 $i$ 天持有有两种情况:
- 第 $i$ 天买入,那么第 $i - 1$ 天必然为不持有,所以买入后剩余现金为 $0 -prices[i]$
- 第 $i-1$ 天时就已经处于持有状态,那么必然与第 $i-1$ 天保持一致,为 $dp[i - 1][0]$。
两者取最大值:
第 $i$ 天不持有同样有两种情况:
- 第 $i$ 天卖出,那么第 $i - 1$ 天必然持有,所以卖出后剩余现金就是第$i-1$ 天持有剩余的现金加上第 $i$ 天的股票价格, $dp[i-1][0] + prices[i]$
- 第 $i-1$ 天时就已经处于不持有状态,那么必然与第 $i-1$ 天保持一致,为 $dp[i - 1][1]$。
两者取最大值:
dp数组初始化
边界条件就是第一天,后面每个交易日的推导都是从第一天开始,第一天在两种状态下剩余剩余的现金为:
- 买入:$-prices[0]$
- 不买入:$0$
确定遍历顺序
由上分析第 $i$ 天的状态依赖于第 $i-1$ 天的状态,所以从前向后遍历。
举例推导
以题目示例为例,直接展示一下推导的最总终结果:
获取结果
通过递推公式的状态转移推导,最大的收益必然是经过了所有交易日后,在最后一个交易日处于不持有状态时的剩余现金。
golang 代码实现
1 | func maxProfit(prices []int) int { |
时间复杂度 $O(n)$,空间复杂度 $O(n)$,可以利用滚动数组优化空间复杂度至 $O(1)$。
根据 $dp$ 数组的定义与推导分析,每一天的状态只依赖于前一天的状态,在计算完第 $i$ 天的状态后,第 $i - 1$ 天的状态已经不再需要了,所以我们可以只定义一个长度为 $2$ 的一维 $dp$ 数组,$dp[0]$ 表示第 $i$ 天持有,$dp[1]$ 表示第 $i$ 天不持有,每一次遍历就将数组的两个状态计算并覆盖,最终得到的就是表示最后一个交易日的状态的数组,而 $dp[1]$ 就是答案。
这样就能够把算法的空间复杂度优化到 $O(1)$。
贪心解法
问题分析
本题限制只能买卖一次股票,所以利用贪心思想:
对于所有的交易日,只要在左侧最低的价格买入,在右侧最高的价格卖出,这样就得到了最大的收益。
golang 代码实现
1 | func maxProfit(prices []int) int { |
时间复杂度 $O(n)$
空间复杂度 $O(1)$
买卖股票的最佳时机 2(可以买卖多次)
1 | 给定一个数组 prices ,其中 prices[i] 是一支给定股票第 i 天的价格。 |
问题分析
本题不再是只能进行一次买卖,而是可以在交易日内完成任意次交易,但是不能同时参与多笔交易,也就是买入操作必须在不持有的状态下才能进行。
与上一题唯一的区别是在推导 $dp[i][0]$ 的时候,上一题中,因为只能进行一次交易,所以在计算买入状态时,它就必然是第一次买入,也就是 $-prices[i]$。
而本题中,可以进行任意次交易,当计算某个交易日的持有状态 $dp[i][0]$ 时,可能在此之前已经完成了若干笔交易,那么手中剩余的现金可能就不是 $0$ 了,所以当日买入后剩余的现金就是前一个交易日卖出后剩余的现金减去当日股票价格, $dp[i - 1][1] - prices[i]$
其他的推导过程与第一题完全相同。
举例推导
golang 代码实现
1 | func maxProfit(prices []int) int { |
时间复杂度 $O(n)$,空间复杂度 $O(n)$,可以利用滚动数组优化空间复杂度至 $O(1)$。
买卖股票的最佳时机 3(最多只能买卖 2 次)
1 | 给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。 |
问题分析
本题进一步增加限制,最多只能完成 $2$ 笔交易。这样就让买卖操作有了次序限制,在之前的题目中,每一个交易日只需要持有和不持有两个状态标识就可以了。但是本题中的状态就需要进一步细分。
假设我们最终进行了 $2$ 笔交易,那么每个交易日都可能存在 $5$ 种状态:
- 从第 $1$个交易日到当前还未进行过任何操作
- 第 $1$ 次持有:当日买入,或前一日一经处于此状态
- 第 $1$ 次不持有:当日卖出,或前一日一经处于此状态
- 第 $2$ 次持有:当日买入,或前一日一经处于此状态
- 第 $2$ 次不持有:当日卖出,或前一日一经处于此状态
这样实际上跟前两题就十分类似了,只是需要将 $dp$ 数组中标记状态的第 $2$ 维数组扩容至大小为 $5$ 就可以了,省下的推导思想都是一样的
举例推导
以题目示例为例,列举推导最终结果:
结果获取
前两题中,收益最大的是在最后一个交易日不持有状态时的剩余现金。
而本题也类似,收益最大的是在最后一个交易日第二次不持有状态时的剩余现金。
golang 代码实现
1 | func maxProfit(prices []int) int { |
时间复杂度 $O(n)$,空间复杂度 $O(n)$,可以利用滚动数组优化空间复杂度至 $O(1)$。
买卖股票的最佳时机 4(最多可以买卖 k 次)
1 | 给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。 |
问题分析
本题与第三题基本一致,唯一的区别就在于第三题限制最多只能交易两次,而本题的交易次数根据 $k$ 值动态变化,也就是 $dp$ 数组的第二维子数组随着 $k$ 值动态变化:
1 | dp[i] = make([]int, (2 * k) + 1) |
$dp$ 数组的推导过程完全与第三题一致。
golang 代码实现
1 | func maxProfit(k int, prices []int) int { |
时间复杂度 $O(n)$,空间复杂度 $O(n)$,可以利用滚动数组优化空间复杂度至 $O(1)$。
最佳买卖股票时机 5(可以买卖多次,每次卖出后有一天冷冻期)
1 | 给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。 |
问题分析
本题增加了名为冷冻期的交易状态限制,也就是在股票卖出之后的一天不能买入。如果没有冷冻期这一限制,那么就是上面记录的第二题了。增加了冷冻期后,实际就是将第二题中的不持有状态进行细分(卖出和不持有中间多了一个冷冻期状态),对于每一个交易日,可能存在如下几种状态:
- 持有
- 当日买入
- 前一天就已经是持有状态
- 当日卖出
- 冷冻期
- 冷冻期后不持有
- 前一天是冷冻期,当日不操作
- 前一天就已经是冷冻期后不持有的状态
推导过程就与前面的题目一样了,结果选取最后一个交易日所有的不持有状态(当日卖出、冷冻期、冷冻期后不持有)下的剩余现金的最大值。
举例推导
golang 代码实现
1 | func maxProfit(prices []int) int { |
时间复杂度 $O(n)$,空间复杂度 $O(n)$,可以利用滚动数组优化空间复杂度至 $O(1)$。
买卖股票的最佳时机 6(可以买卖多次,每次卖出需要负手续费)
1 | 给定一个整数数组 prices,其中第 i 个元素代表了第 i 天的股票价格 ;整数 fee 代表了交易股票的手续费用。 |
问题分析
本题就是在第leetcode 122的基础上增加了一笔交易手续费,也就是在计算股票卖出后剩余的现金时需要减去一笔手续费 $fee$。而交易日状态分析及状态转移过程等都与 $122$ 题是完全相同的。
举例推导
golang 代码实现
1 | func maxProfit(prices []int, fee int) int { |
时间复杂度 $O(n)$,空间复杂度 $O(n)$,可以利用滚动数组优化空间复杂度至 $O(1)$。
总结
股票买卖问题的的重点在于两个方面,第一是对每个交易日可能存在的状态的定义(这也是动态规划问题的重点),不同的交易限制条件就决定了每个交易日可能存在的状态的数量;第二是对 $dp$ 数组下标的定义,本系列均定义 $dp[i][j]$ 表示第 $i$ 个交易日在 $j$ 状态下手中剩余的现金数,这是因为在购买了股票之后到卖出股票之前这段时间内的股票价格变动是不需要关心的(因为还没有卖出,没卖就没有收益,那那价格高低都无所谓),只有在卖出时用卖出的价格减去买入的价格就是本次交易的收益金额,并且我们定义了起始现金为 $0$,也就是说在第一次购买之后剩余现金就是一个负值,这也是为了方便计算股票卖出的收益(买入后剩余现金是负数,相当于记录了买入时股票的价格,在卖出时只有加上卖出时的股票价格就得到了收益)。