当你面对LeetCode上的六道股票买卖问题时,第一反应可能是:每道题都要单独学一种解法?这正是无数开发者的误区。实际上,这六道题可以用同一套状态转移方程统一解决——区别只在于边界条件的细微差异。
股票买卖问题系列是动态规划中"状态机DP"的教科书级案例。它的核心思想是将问题抽象为有限状态的转换:每一天,你只有两种可能的状态——持有股票或不持有股票。而在这两种状态之间转换的动作只有三种:买入、卖出、或什么都不做。这个简洁的状态模型,能够覆盖从单次交易到无限次交易、从冷冻期到手续费的所有变体。
一个框架,六道题目
先看这六道题的约束差异:
| 题目 | 难度 | 交易次数限制 | 额外约束 |
|---|---|---|---|
| 121. 买卖股票的最佳时机 | 简单 | 最多1次 | 无 |
| 122. 买卖股票的最佳时机 II | 简单 | 无限次 | 无 |
| 123. 买卖股票的最佳时机 III | 困难 | 最多2次 | 无 |
| 188. 买卖股票的最佳时机 IV | 困难 | 最多k次 | 无 |
| 309. 买卖股票的最佳时机含冷冻期 | 中等 | 无限次 | 卖出后隔一天才能买 |
| 714. 买卖股票的最佳时机含手续费 | 中等 | 无限次 | 每次交易收取fee |
看似差异巨大,但它们共享同一个状态定义:dp[i][k][0/1] 表示第i天结束时,最多允许进行k次交易,当前不持有/持有股票时的最大利润。
状态转移的核心方程
状态机的精髓在于明确每个状态可以转换到哪些状态。对于股票问题,状态转移图如下:
买入 卖出
┌─────────→ 持有 ─────────→
│ (1) │
未持有 未持有
(0) (0)
│ ↑
└────── rest ──── rest ──────┘
基于这个状态图,通用状态转移方程为:
$$dp[i][k][0] = \max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])$$$$dp[i][k][1] = \max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])$$第一个方程的含义:今天不持有股票,要么昨天就不持有(rest),要么昨天持有今天卖出(sell)。第二个方程的含义:今天持有股票,要么昨天就持有(rest),要么昨天不持有今天买入(buy)——注意买入会消耗一次交易额度,所以是从 k-1 转移过来。
边界条件是整个框架的基石:
- $dp[-1][k][0] = 0$:还没开始时,利润为0
- $dp[-1][k][1] = -\infty$:还没开始时不可能持有股票
- $dp[i][0][0] = 0$:不允许交易时,利润为0
- $dp[i][0][1] = -\infty$:不允许交易时不可能持有股票
LeetCode 121:只允许一次交易
当 k = 1 时,状态维度可以简化。因为 k 始终为1,不再需要跟踪交易次数:
public int maxProfit(int[] prices) {
int n = prices.length;
// dp[i][0] = 第i天不持有股票的最大利润
// dp[i][1] = 第i天持有股票的最大利润
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]);
dp[i][1] = Math.max(dp[i-1][1], -prices[i]);
}
return dp[n-1][0];
}
空间优化版本——只保留前一个状态:
public int maxProfit(int[] prices) {
int dp0 = 0; // 不持有股票
int dp1 = Integer.MIN_VALUE; // 持有股票
for (int price : prices) {
dp0 = Math.max(dp0, dp1 + price);
dp1 = Math.max(dp1, -price);
}
return dp0;
}
时间复杂度 $O(n)$,空间复杂度 $O(1)$。
这道题还有一个直观的贪心解法:遍历数组,维护最小买入价格和最大利润:
public int maxProfit(int[] prices) {
int minPrice = Integer.MAX_VALUE;
int maxProfit = 0;
for (int price : prices) {
minPrice = Math.min(minPrice, price);
maxProfit = Math.max(maxProfit, price - minPrice);
}
return maxProfit;
}
LeetCode 122:无限次交易
当交易次数无限制时,k 和 k-1 等价,状态方程简化为:
public int maxProfit(int[] prices) {
int dp0 = 0;
int dp1 = Integer.MIN_VALUE;
for (int price : prices) {
int temp = dp0;
dp0 = Math.max(dp0, dp1 + price);
dp1 = Math.max(dp1, temp - price);
}
return dp0;
}
注意这里需要用 temp 保存旧的 dp0,因为计算 dp1 时需要用到更新前的 dp0。
这道题有一个更优雅的贪心解法:只要价格上涨就卖出。为什么这个策略正确?因为任何上涨区间,都可以分解为若干个相邻上涨:
价格序列: 1, 2, 3, 4, 5
贪心策略: (2-1) + (3-2) + (4-3) + (5-4) = 4
一次买卖: 5 - 1 = 4
两者结果相同,但贪心策略将一次大交易拆成了多次小交易,在无限次交易的条件下完全等价。
public int maxProfit(int[] prices) {
int profit = 0;
for (int i = 1; i < prices.length; i++) {
if (prices[i] > prices[i-1]) {
profit += prices[i] - prices[i-1];
}
}
return profit;
}
贪心算法的正确性可以用数学归纳法证明:假设最优解在某天持有了股票而没有卖出,而第二天价格上涨。那么在这两天之间卖出再买入,不会减少总利润,但符合贪心策略。通过不断交换,任何最优解都可以转化为贪心解的形式。
LeetCode 123:最多两次交易
k = 2 时,必须完整保留状态维度。但 k 很小,可以直接展开:
public int maxProfit(int[] prices) {
// 四个状态:第一次买入后、第一次卖出后、第二次买入后、第二次卖出后
int buy1 = Integer.MIN_VALUE;
int sell1 = 0;
int buy2 = Integer.MIN_VALUE;
int sell2 = 0;
for (int price : prices) {
// 注意更新顺序:从后往前,避免状态污染
sell2 = Math.max(sell2, buy2 + price);
buy2 = Math.max(buy2, sell1 - price);
sell1 = Math.max(sell1, buy1 + price);
buy1 = Math.max(buy1, -price);
}
return sell2;
}
理解这四个状态的关键是:它们代表的是在第i天结束时处于该状态的最大利润。buy1 表示已完成第一次买入(持有第一支股票),sell1 表示已完成第一次卖出,以此类推。
状态转移方程为:
$$buy1[i] = \max(buy1[i-1], -prices[i])$$$$sell1[i] = \max(sell1[i-1], buy1[i-1] + prices[i])$$$$buy2[i] = \max(buy2[i-1], sell1[i-1] - prices[i])$$$$sell2[i] = \max(sell2[i-1], buy2[i-1] + prices[i])$$LeetCode 188:最多k次交易
这是最一般的情形,121和123都是它的特例。核心代码:
public int maxProfit(int k, int[] prices) {
int n = prices.length;
if (n == 0 || k == 0) return 0;
// 关键优化:当k >= n/2时,等价于无限次交易
if (k >= n / 2) {
int profit = 0;
for (int i = 1; i < n; i++) {
if (prices[i] > prices[i-1]) {
profit += prices[i] - prices[i-1];
}
}
return profit;
}
// dp[j][0] = 最多j次交易,不持有股票
// dp[j][1] = 最多j次交易,持有股票
int[][] dp = new int[k + 1][2];
// 初始化:第一天买入后的状态
for (int j = 1; j <= k; j++) {
dp[j][1] = -prices[0];
}
for (int i = 1; i < n; i++) {
// 必须倒序遍历k,避免状态污染
for (int j = k; j >= 1; j--) {
dp[j][0] = Math.max(dp[j][0], dp[j][1] + prices[i]);
dp[j][1] = Math.max(dp[j][1], dp[j-1][0] - prices[i]);
}
}
return dp[k][0];
}
这里有一个重要优化:当 $k \geq n/2$ 时,问题退化为无限次交易。因为一次完整的交易至少需要两天(买入一天,卖出一天),所以最多只能进行 $n/2$ 次交易。当 k 超过这个上限时,交易次数的约束就失去了意义。
为什么要倒序遍历 k?因为 dp[j][1] 依赖于 dp[j-1][0]。如果正序遍历,dp[j-1][0] 已经被更新为当前天的值,导致在同一天既买入又卖出,这是不允许的。
时间复杂度 $O(n \times k)$,空间复杂度 $O(k)$。
LeetCode 309:含冷冻期
冷冻期的约束是:卖出后必须等待一天才能买入。状态转移方程需要调整:
$$dp[i][0] = \max(dp[i-1][0], dp[i-1][1] + prices[i])$$$$dp[i][1] = \max(dp[i-1][1], dp[i-2][0] - prices[i])$$买入时的转移从 i-1 变成了 i-2,因为买入必须在卖出后的第二天。
public int maxProfit(int[] prices) {
int n = prices.length;
if (n < 2) return 0;
int[][] dp = new int[n][2];
dp[0][0] = 0;
dp[0][1] = -prices[0];
dp[1][0] = Math.max(0, prices[1] - prices[0]);
dp[1][1] = Math.max(-prices[0], -prices[1]);
for (int i = 2; i < n; i++) {
dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
dp[i][1] = Math.max(dp[i-1][1], dp[i-2][0] - prices[i]);
}
return dp[n-1][0];
}
空间优化版本:
public int maxProfit(int[] prices) {
int dp0 = 0; // 今天不持有
int dp1 = Integer.MIN_VALUE; // 今天持有
int dpPre0 = 0; // 昨天不持有(用于冷冻期)
for (int price : prices) {
int temp = dp0;
dp0 = Math.max(dp0, dp1 + price);
dp1 = Math.max(dp1, dpPre0 - price);
dpPre0 = temp;
}
return dp0;
}
另一种视角是将状态分为三种:持有股票、不持有股票(可买入)、不持有股票(冷冻期)。这种三状态模型更直观:
public int maxProfit(int[] prices) {
int hold = Integer.MIN_VALUE; // 持有股票
int sold = 0; // 刚卖出(冷冻期)
int rest = 0; // 不持有,可买入
for (int price : prices) {
int prevHold = hold;
int prevSold = sold;
hold = Math.max(hold, rest - price);
sold = prevHold + price;
rest = Math.max(rest, prevSold);
}
return Math.max(sold, rest);
}
LeetCode 714:含手续费
手续费的约束更简单:每次交易扣除 fee。可以在买入时扣除,也可以在卖出时扣除——两者等价:
// 方案一:买入时扣除
public int maxProfit(int[] prices, int fee) {
int dp0 = 0;
int dp1 = Integer.MIN_VALUE;
for (int price : prices) {
int temp = dp0;
dp0 = Math.max(dp0, dp1 + price);
dp1 = Math.max(dp1, temp - price - fee);
}
return dp0;
}
// 方案二:卖出时扣除
public int maxProfit(int[] prices, int fee) {
int dp0 = 0;
int dp1 = Integer.MIN_VALUE;
for (int price : prices) {
int temp = dp0;
dp0 = Math.max(dp0, dp1 + price - fee);
dp1 = Math.max(dp1, temp - price);
}
return dp0;
}
注意使用 long 类型避免整数溢出,特别是当 prices[i] 和 fee 都很大时。
状态机DP的本质
股票问题系列展示了状态机DP的完整方法论:
第一步:定义状态。股票问题的核心状态是"是否持有股票",加上"交易次数"这个维度。状态定义要满足两个条件:一是能够完全描述问题,二是状态之间有明确的转换关系。
第二步:画出状态图。状态图清晰展示了所有可能的转换路径。在股票问题中,从"不持有"可以通过"买入"转换到"持有";从"持有"可以通过"卖出"转换到"不持有"。
第三步:写出转移方程。转移方程是状态图的数学表达。每个状态的值等于所有可能的前驱状态值加上转换代价的最大值。
第四步:处理边界条件。边界条件决定了动态规划的起点。股票问题中,“还没开始"和"不允许交易"是两种边界情况。
第五步:空间优化。当状态转移只依赖于前一个或前几个状态时,可以用滚动变量替代整个数组。
面试中的常见陷阱
陷阱一:混淆交易次数的定义。k 指的是最多允许的买入次数(或完整的买卖对数),而不是卖出次数。状态转移时,买入消耗一次额度,卖出不消耗。
陷阱二:状态更新顺序错误。空间优化时,必须保证用到的是上一轮的值。对于股票问题,通常需要倒序遍历 k,或用临时变量保存旧值。
陷阱三:边界条件遗漏。dp[0][1] = -prices[0] 还是 dp[0][1] = Integer.MIN_VALUE?这取决于状态定义。如果定义是"在第i天结束时持有股票”,则前者正确;如果定义是"在第i天进行了买入操作",则需要更仔细考虑。
陷阱四:整数溢出。当 prices[i] 达到 $10^4$,fee 达到 $10^4$,且交易次数较多时,累加可能溢出。建议使用 long 类型,或在比较前检查边界。
统一模板总结
public int maxProfit(int[] prices) {
// 基础状态:不持有/持有
int dp0 = 0, dp1 = Integer.MIN_VALUE;
for (int price : prices) {
int temp = dp0;
// 根据具体问题调整转移方程
dp0 = Math.max(dp0, dp1 + price); // 卖出或不操作
dp1 = Math.max(dp1, temp - price); // 买入或不操作
}
return dp0;
}
这个模板可以通过简单修改适应所有变体:
- k=1:
dp1 = Math.max(dp1, -price) - k=∞:
dp1 = Math.max(dp1, temp - price) - 冷冻期:引入
dpPre0变量 - 手续费:买入或卖出时减去
fee - k次交易:扩展为二维数组
dp[k][2]
股票问题系列的魅力在于,它用最简洁的状态模型解释了动态规划的核心思想——状态定义、状态转移、边界处理。掌握这个框架后,类似的状态机DP问题(如打家劫舍系列、编辑距离系列)都将迎刃而解。