3573. 买卖股票的最佳时机 V

考点

  • 状态机DP
  • 滚动数组
  • 初始值设置

易错点

在状态设计上,不应以单次操作的正负来抉择,比如你设定0代表不变,1代表正,2代表负

1
2
3
4
5

/ \
0 负
/ | \ / \
0 负 正 0 正

可以看见,出现了正0正这种诡异的路线,而我们题目要求的一定是类似正..负负..正的转移路径

所以我们必须按照“交易状态”这个整体进行设计


除此之外,交易次数一共是K次,那么买入卖出的交易次数一共就是2K次,按照2K进行定义时,初始化会方便很多

因为没有任何操作下的最大值买/卖了0次下的最大值的初始化难度完全不一样


答案也不一定是操作了2K次就是最大的,可能你多买卖反而亏本了,所以要遍历所有的f[1~2K][n][0]

思路

1. 状态设计:用“操作次数”而不是“交易次数”

题目中,一笔完整交易可以是:

  • 正向:买入 → 卖出
  • 反向(做空):卖出 → 买回

并且:

  • 任意一天最多进行一次操作(买 / 卖)
  • 同一时刻最多只持有一笔仓位(空仓 / 多头 / 空头)
  • 最多允许进行 \(K\) 笔完整交易

一笔完整交易包含两次操作:开仓一次、平仓一次。 因此可以把“操作次数”作为 DP 的计数轴:最多允许 \(2K\) 次操作。

设数组: \[ f[k][i][s] \] 含义:

  • \(i \in [0,n]\):考虑完前 \(i\) 天(对应 prices[0..i-1]
  • \(k \in [0,2K]\):到目前为止,总共进行了 \(k\) 次操作(买或卖)
  • \(s \in \{0,1,2\}\):当前仓位状态
    • \(s = 0\):空仓(没有持多也没有持空)
    • \(s = 1\):持有多头(之前有一次买入但尚未卖出)
    • \(s = 2\):持有空头(之前有一次卖出但尚未买回)

\[ f[k][i][s] = \text{在考虑前 } i \text{ 天、总共进行了 } k \text{ 次操作且当前状态为 } s \text{ 时的最大利润。} \]


2. 初始值设定

代码中:

1
2
3
memset(f, 0xcf, sizeof(f));
for (int i = 0; i <= n; ++i)
f[0][i][0] = 0;

解释如下。

2.1 非法状态全部置为负无穷

通过

1
memset(f, 0xcf, sizeof(f));

将整个数组填成一个很小的负数(例如 \(-10^{15}\) 量级),记为 \(-\infty\)。这表示:

  • 默认所有状态一开始都是“不可能到达”的非法状态。

这样做的好处是:

  • 后续做 max 转移时,只有真正有路径到达过的状态才能参与竞争;
  • 避免从未初始化的“垃圾状态”中“捡到”无意义的利润。

数学上,相当于: \[ f[k][i][s] = -\infty \quad \text{(初始默认)} \]

2.2 唯一合法起点:0 次操作 + 任意天数 + 空仓

接着设定:

1
2
for (int i = 0; i <= n; ++i)
f[0][i][0] = 0;

含义是:在没有任何操作的情况下,始终保持空仓,利润为 0。 注意这里对所有 \(i\) 都赋值,是因为:

  • “第 0 天之前什么也不做”是合法的;
  • “前 1 天什么也不做”也合法;
  • 一直到“前 n 天什么也不做”都可以视为“0 次操作 + 空仓 = 0 利润”。

因此,合法初始状态为: \[ \forall i \in [0,n]:\quad f[0][i][0] = 0,\quad f[0][i][1] = f[0][i][2] = -\infty. \]


3. 状态转移推导

以第 \(i\) 天价格 \(p = prices[i-1]\) 为例。

外层循环:

1
2
3
4
5
for (int k = 1; k <= K << 1; ++k) {
for (int i = 1; i <= n; ++i) {
...
}
}

表示按“操作次数 k”层层推进,在每一层上把所有天数 i 的状态更新完。

下面分三种状态说明转移方程。

3.1 转移到空仓状态 \(f[k][i][0]\)

代码:

1
2
3
f[k][i][0] = max(
f[k - 1][i - 1][1] + prices[i - 1],
max(f[k - 1][i - 1][2] - prices[i - 1], f[k][i - 1][0]));

数学形式: \[ f[k][i][0] = \max \Bigl( f[k-1][i-1][1] + p,\; f[k-1][i-1][2] - p,\; f[k][i-1][0] \Bigr). \] 含义:

  1. 从多头平仓到空仓:

    • 昨天(第 \(i-1\) 天)是多头,进行了 \(k-1\) 次操作,状态为 \(f[k-1][i-1][1]\)
    • 今天第 \(k\) 次操作:卖出多头,价格为 \(p\),增加收益 \(+p\)
    • 升级为 \(k\) 次操作,变成空仓。 因此这一支贡献为:

    \[ f[k-1][i-1][1] + p. \]

  2. 从空头平仓到空仓:

    • 昨天是空头,进行了 \(k-1\) 次操作,状态为 \(f[k-1][i-1][2]\)
    • 今天第 \(k\) 次操作:买回空头,付出价格 \(p\),收益变化 \(-p\)
    • 升级为 \(k\) 次操作,变成空仓。 对应项为:

    \[ f[k-1][i-1][2] - p. \]

  3. 保持空仓不动:

    • 昨天已经是空仓,进行了 \(k\) 次操作,今天不做任何事;
    • 状态从 \(f[k][i-1][0]\) 延续到 \(f[k][i][0]\),利润不变。

因此三种情况取最大。


3.2 转移到多头状态 \(f[k][i][1]\)

代码:

1
2
f[k][i][1] =
max(f[k][i - 1][1], f[k - 1][i - 1][0] - prices[i - 1]);

数学形式: \[ f[k][i][1] = \max \Bigl( f[k][i-1][1],\; f[k-1][i-1][0] - p \Bigr). \] 含义:

  1. 继续持有多头:
    • 昨天已经是多头,进行了 \(k\) 次操作;
    • 今天不做操作,保持多头,状态从 \(f[k][i-1][1]\) 直接延续。
  2. 从空仓开多:
    • 昨天空仓,进行了 \(k-1\) 次操作,状态 \(f[k-1][i-1][0]\)
    • 今天第 \(k\) 次操作:买入一股,多头开仓,支付价格 \(p\),利润变化 \(-p\)
    • 于是变成 \(f[k][i][1] = f[k-1][i-1][0] - p\)

3.3 转移到空头状态 \(f[k][i][2]\)

代码:

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

数学形式: \[ f[k][i][2] = \max \Bigl( f[k][i-1][2],\; f[k-1][i-1][0] + p \Bigr). \] 含义与多头类似,只是方向相反:

  1. 继续持有空头:
    • 昨天已经是空头,进行了 \(k\) 次操作;
    • 今天不做任何事,空头仓位保持。
  2. 从空仓开空(先卖后买):
    • 昨天空仓,进行了 \(k-1\) 次操作;
    • 今天第 \(k\) 次操作:先卖出一股,开空头,立刻获得 \(+p\) 的现金流;
    • 因此变成 \(f[k][i][2] = f[k-1][i-1][0] + p\)

4. 最终答案的选择

代码部分:

1
2
3
4
long long res = neg;
for (int k = 1; k <= K << 1; ++k)
res = max(res, f[k][n][0]);
return res;

表示: \[ \text{ans} = \max_{1 \le k \le 2K} f[k][n][0]. \] 理由如下:

  1. 最终必须空仓 只有在空仓状态下,所有交易都已经闭合(开仓与平仓成对出现),利润是“真正在手上的钱”。 因此只考虑 \(f[k][n][0]\)
  2. 不强制用满 \(K\) 笔交易 使用的交易次数越多不一定越好(有时多一次交易反而亏钱), 但允许最多 \(K\) 笔交易,对应“最多 \(2K\) 次操作”。 因此只需要在 \(k \in [1, 2K]\) 范围内取最大值即可。 若某些 \(k\) 对应的状态无法到达,就一直保持为 \(-\infty\),不会影响答案。

5. 代码

这份代码很可能超时,因为数组开太大了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
static const int maxn = 2e3 + 5, neg = 0xcfcfcfcf;
long long maximumProfit(vector<int>& prices, int K) {
long long f[maxn / 2][maxn][3];
int n = prices.size();
memset(f, 0xcf, sizeof(f));
for (int i = 0; i <= n; ++i)
f[0][i][0] = 0;
for (int k = 1; k <= K << 1; ++k) {
for (int i = 1; i <= n; ++i) {
f[k][i][0] = max(
f[k - 1][i - 1][1] + prices[i - 1],
max(f[k - 1][i - 1][2] - prices[i - 1], f[k][i - 1][0]));
f[k][i][1] =
max(f[k][i - 1][1], f[k - 1][i - 1][0] - prices[i - 1]);
f[k][i][2] =
max(f[k][i - 1][2], f[k - 1][i - 1][0] + prices[i - 1]);
}
}
long long res = neg;
for (int k = 1; k <= K << 1; ++k)
res = max(res, f[k][n][0]);
return res;
}
};

所以滚动数组一下即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
static const int maxn = 2e3 + 5, neg = 0xcfcfcfcf;
long long maximumProfit(vector<int>& prices, int K) {
long long f[maxn / 2][3];
int n = prices.size();
memset(f, 0xcf, sizeof(f));
f[0][0] = 0;
for (int i = 1; i <= n; ++i) {
for (int k = K << 1; k >= 1; --k) {
f[k][0] = max(f[k - 1][1] + prices[i - 1],
max(f[k - 1][2] - prices[i - 1], f[k][0]));
f[k][1] = max(f[k][1], f[k - 1][0] - prices[i - 1]);
f[k][2] = max(f[k][2], f[k - 1][0] + prices[i - 1]);
}
}
long long res = neg;
for (int k = 1; k <= K << 1; ++k)
res = max(res, f[k][0]);
return res;
}
};