123. 买卖股票的最佳时机III (Best Time to Buy and Sell Stock III)

4,007 阅读5分钟

"给血雨腥风的二级市场留下八个大字——巴菲特就那么回事"

中规中矩的动态规划

LeetCode 123. 买卖股票的最佳时机3 :给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格 prices[i]。设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例1

输入:prices=[3,3,5,0,0,3,1,4]prices = [3,3,5,0,0,3,1,4]

输出:66

解释:在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3。 随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4-1 = 3 。

示例 2

输入:prices=[1,2,3,4,5]prices = [1,2,3,4,5]

输出:44

解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4。 注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

1、确定 dpdp 状态数组以及含义

由于限制条件多,我们逐一进行分析:

  • 定义交易:一买一卖才算是一次交易;
  • 交易前,我们手上没有钱(即为0),属于“空手套白狼”,交易结束后,如果手上有钱,即为利润;
  • 由于不能同时参与多笔交易,即不能同时持有两支股票,必须卖出手中股票才能再次购买;
  • 由于最多交易两次,即某天交易后,交易次数达两次,次日及以后将不能再交易。

因此规划出三个维度,即天数 ii 、当前天是否持股 jj、交易次数 kk。因此,用动态规划数组 dp[i][j][k]dp[i][j][k] 表示第 ii 天、股状态为 jj、交易次数为 kk 时,手上的现金数(即利润),其中,

  • 天数 i[0,n),n=prices.lengthi \in [0, n), n = prices.length
  • 是否持股 j=0,1j = 0, 1j=0j = 0 指不持股(持有现金), j=1j = 1 指持股;
  • 交易次数 k=0,1,2k = 0, 1, 2k=0k = 0 指尚未交易,k=1k = 1指交易过1次,k=2k = 2 指交易过2次。

2、确定 dpdp 状态转移方程

ii 天应有 66 种状态,即

  • 未持股(j=0j = 0)且交易 00 次,dp[i][0][0]dp[i][0][0]

  • 未持股且交易 11 次,dp[i][0][1]dp[i][0][1]

  • 未持股且交易 22 次,dp[i][0][2]dp[i][0][2]

  • 有持股(j=1j = 1)且交易 00 次,dp[i][1][0]dp[i][1][0]

  • 有持股且交易 11 次,dp[i][1][1]dp[i][1][1]

  • 有持股且交易 22 次,dp[i][1][2]dp[i][1][2]

我们逐一写出状态转移方程

  • ii 天,未持股交易 00,即 dp[i][0][0]dp[i][0][0]:不持股也不交易,那利润一定为 00,即 dp[i][0][0]=0dp[i][0][0] = 0

  • ii 天,未持股交易 11,即 dp[i][0][1]dp[i][0][1]:只能有两种情况

    • i1i-1 天,有持股但交易过 00 次,第 ii 天卖出(交易变成 11 次),即 dp[i1][1][0]+prices[i]dp[i - 1][1][0] + prices[i]

    • i1i-1 天,也没有持股,也交易过 00 次,第 ii 天 什么也不干,即 dp[i1][0][1]dp[i - 1][0][1]

    两者取较大值,即 dp[i][0][1]=max(dp[i1][1][0]+prices[i],dp[i1][0][1])dp[i][0][1] = max(dp[i - 1][1][0] + prices[i], dp[i - 1][0][1])

  • ii 天,未持股交易 22,即 dp[i][0][2]dp[i][0][2]:只能有两种情况

    • i1i - 1 天,有持股但交易过 11 次,第 ii 天卖出(交易变成 22 次),即 dp[i1][1][1]+prices[i]dp[i - 1][1][1] + prices[i]

    • i1i-1 天,也没有持股,也交易过 22 次,第 ii 天 什么也不干,即 dp[i1][0][2]dp[i - 1][0][2]

    两者取较大值,即 dp[i][0][2]=max(dp[i1][1][1]+prices[i],dp[i1][0][2])dp[i][0][2] = max(dp[i - 1][1][1] + prices[i], dp[i - 1][0][2])

  • ii 天,持股交易 00dp[i][1][0]dp[i][1][0]:只能有两种情况

    • i1i-1 天,未持股且交易过 00 次,第 ii 天买入,即 dp[i1][0][0]prices[i]dp[i - 1][0][0] - prices[i]

    • i1i-1 天,未持股且交易过 00 次,第 ii 天什么也不干,即 dp[i1][1][0]dp[i - 1][1][0]

    两者取较大值,即 dp[i][1][0]=max(dp[i1][0][0]prices[i],dp[i1][1][0])dp[i][1][0] = max(dp[i - 1][0][0] - prices[i], dp[i - 1][1][0])

  • ii 天,持股交易 11,即 dp[i][1][1]dp[i][1][1]:只能有两种情况

    • i1i-1 天,未持股且交易过 11 次,第 ii 天买入,即 dp[i1][0][1]prices[i]dp[i - 1][0][1] - prices[i]

    • i1i-1 天,未持股且交易过 11 次,第 ii 天啥也不干,即 dp[i1][1][1]dp[i - 1][1][1]

    两者取较大值,即 dp[i][1][1]=max(dp[i1][0][1]prices[i],dp[i1][1][1])dp[i][1][1] = max(dp[i - 1][0][1] - prices[i], dp[i - 1][1][1])

  • ii 天,持股且交易过 22 次,dp[i][1][2]dp[i][1][2]:已经交过两次,手上还有股票,此时的收益一定不是最大的(落袋为安才是利润,其他的都是浮盈与浮亏),所以可以直接定义:dp[i][1][2]=infinitydp[i][1][2] = -infinity

3、确定 dpdp 初始状态

  • dp[0][0][0]=0dp[0][0][0] = 0,代表第0天不持股且尚未交易时,我们手上的现金

  • dp[0][0][1]=infinitydp[0][0][1] = -infinity,代表第0天不持股且交易1次,我们手上的现金(非法状态,第 00 天不可能完成过一次交易,给个最小值即可)

  • dp[0][0][2]=infinitydp[0][0][2] = -infinity,代表第0天不持股且交易2次,我们手上的现金(非法状态,给个最小值即可)

  • dp[0][1][0]=prices[0]dp[0][1][0] = -prices[0],代表第0天持股但还尚未交易时,我们手上的现(说明第 00 天买入了股票)

  • dp[0][1][1]=infinitydp[0][1][1] = -infinity,代表第0天持股且交易1次,我们手上的现金(非法状态,给个最小值即可)

  • dp[0][1][2]=infinitydp[0][1][2] = -infinity,代表第0天持股且交易2次,我们手上的现金(非法状态,给个最小值即可)

4、确定遍历顺序

从第 11 天到第 n1n - 1 天。第 00 天的状态(边界子问题的最优解)就是初始值。

5、确定返回值

这里的最大值有三种可能:

  • n1n-1 天,不持有股票,交易0次,即 dp[n1][0][0]dp[n-1][0][0]
  • n1n-1 天,不持有股票,交易1次,即 dp[n1][0][1]dp[n-1][0][1]
  • n1n-1 天,不持有股票,交易2次,即 dp[n1][0][2]dp[n-1][0][2]

综合考虑,返回值为 max(dp[n1][0][0],dp[n1][0][1],dp[n1][0][2])max(dp[n-1][0][0],dp[n-1][0][1],dp[n-1][0][2])

6、示例代码

/**
 * 时间复杂度 O(N) N是prices数组长度
 * 空间复杂度 O(N) 实际为 O(3*2*N)
 */
function maxProfit(prices: number[]): number {
	const len = prices.length;
	
	if (len < 2) return 0;
	
        // 初始化dp数据,js的初始多维数据比较麻烦,仅供参考
	const dp = new Array(len).fill(0)
		.map(() => [0,0].map(() => [0, 0, 0]));
	
	dp[0][0][0] = 0;
	dp[0][0][1] = Number.MIN_SAFE_INTEGER;
	dp[0][0][2] = Number.MIN_SAFE_INTEGER;
	dp[0][1][0] = -prices[0];
	dp[0][1][1] = Number.MIN_SAFE_INTEGER;
	dp[0][1][2] = Number.MIN_SAFE_INTEGER;
	
	for(let i = 1; i < len; i ++) {
		dp[i][0][0] = 0;
		dp[i][0][1] = Math.max(dp[i - 1][1][0] + prices[i], dp[i - 1][0][1]);
		dp[i][0][2] = Math.max(dp[i - 1][1][1] + prices[i], dp[i - 1][0][2]);
		dp[i][1][0] = Math.max(dp[i - 1][0][0] - prices[i], dp[i - 1][1][0]);
		dp[i][1][1] = Math.max(dp[i - 1][0][1] - prices[i], dp[i - 1][1][1]);
		dp[i][1][2] = Number.MIN_SAFE_INTEGER;
	}
	
	return Math.max(dp[len-1][0][0], dp[len-1][0][1], dp[len-1][0][0]);
}

总结

状态转移方程中,以及初始状态中,包含了很“不可能”或者“妥妥亏损”的状态,如

  • dp[0][0][1]dp[0][0][1]: 第 00 天就有一次交易(交易至少需要两天)

  • dp[0][1][2]dp[0][1][2]: 第 ii 天已经交易 22 次后手上依然有股票

为了状态方程方便处理,而且本身是求最大利润,那这些明知不可能有利润的状态节点对应的金额统统设置为最小值即可,在状态转移的过程会被局部相对较大的值所覆盖掉。