ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

动态规划解决含冷冻期的股票买卖问题

动态规划解决含冷冻期的股票买卖问题

1. 问题背景与核心挑战

股票买卖问题是算法领域的经典动态规划题目,而含冷冻期的变种在实际投资场景中尤为常见。想象一下,你是一名短线交易员,每天需要决定是否买卖某支股票。但交易所规定:卖出股票后必须隔一天才能再次买入。这种限制条件就是我们所说的"冷冻期"。

这个约束看似简单,却彻底改变了问题的状态转移逻辑。常规的无限次交易问题只需要考虑当天是否持有股票,而冷冻期规则引入了时间维度的依赖关系。我们需要设计一个算法,在O(n)时间复杂度内计算出最大利润,同时严格遵守交易规则。

2. 算法设计思路解析

2.1 状态定义与记忆化设计

我们采用自上而下的记忆化递归方法,定义两个核心状态:

  • 持有股票(is_hold = true)
  • 不持有股票(is_hold = false)

记忆化数组memo是一个二维数组,memo[i][0]表示第i天不持有股票时的最大利润,memo[i][1]表示第i天持有股票时的最大利润。这种设计将时间复杂度从指数级O(2^n)降到了线性O(n)。

关键技巧:记忆化数组的初始化值为-1,用于标记该状态尚未计算。这种设计比使用特殊值表示未计算状态更安全可靠。

2.2 递归边界条件处理

递归的终止条件需要谨慎处理:

if(end < 0) { if(is_hold) return -0xFFFF; // 非法状态:没有天数却持有股票 return 0; // 合法状态:没有天数也不持有股票 }

这里使用-0xFFFF表示非法状态,比INT_MIN更安全,避免了可能的整数溢出问题。

2.3 状态转移方程

2.3.1 持有股票的状态转移

当第end天持有股票时,有两种可能:

  1. 前一天已经持有,今天不操作
  2. 今天新买入(需考虑冷冻期)
res = max( dfs(end-1, true, prices), // 情况1:继续持有 dfs(end-2, false, prices) - prices[end] // 情况2:买入操作 );

特别注意买入操作的前驱状态是end-2天,这确保了卖出后至少隔一天才能买入,完美实现了冷冻期限制。

2.3.2 不持有股票的状态转移

当第end天不持有股票时,也有两种可能:

  1. 前一天已经不持有,今天不操作
  2. 今天卖出持有的股票
res = max( dfs(end-1, false, prices), // 情况1:继续不持有 dfs(end-1, true, prices) + prices[end] // 情况2:卖出操作 );

卖出操作不受冷冻期限制,因此前驱状态仍然是end-1天。

3. 完整代码实现与解析

class Solution { public: vector<vector<int>> memo; // 记忆化数组 int dfs(int end, bool is_hold, vector<int>& prices) { if(end < 0) { return is_hold ? -0xFFFF : 0; } int state = is_hold ? 1 : 0; if(memo[end][state] != -1) { return memo[end][state]; } int res; if(is_hold) { res = max( dfs(end-1, true, prices), (end >= 1 ? dfs(end-2, false, prices) : 0) - prices[end] ); } else { res = max( dfs(end-1, false, prices), dfs(end-1, true, prices) + prices[end] ); } return memo[end][state] = res; } int maxProfit(vector<int>& prices) { int n = prices.size(); if(n <= 1) return 0; memo.assign(n, vector<int>(2, -1)); return dfs(n-1, false, prices); } };

3.1 边界条件处理增强

在实现中,我们对买入操作做了更严格的边界检查:

(end >= 1 ? dfs(end-2, false, prices) : 0) - prices[end]

这确保了当end=0时(即第一天)尝试买入不会出现数组越界访问。

3.2 空间复杂度优化

虽然我们使用了O(n)的额外空间存储记忆化数组,但在实际应用中可以通过滚动数组技术将空间复杂度优化到O(1)。不过对于算法题而言,O(n)的空间通常已经足够。

4. 算法正确性验证

4.1 示例测试

以题目给出的示例1进行验证: 输入: prices = [1,2,3,0,2] 预期输出: 3

算法执行过程:

  1. 第0天买入(价格1)
  2. 第1天卖出(价格2),利润1
  3. 第2天冷冻期
  4. 第3天买入(价格0)
  5. 第4天卖出(价格2),利润2 总利润:1 + 2 = 3,与预期一致。

4.2 极端情况测试

测试空数组: 输入: prices = [] 输出: 0

测试单元素数组: 输入: prices = [1] 输出: 0

测试递减序列: 输入: prices = [5,4,3,2,1] 输出: 0(无法获利)

测试递增序列: 输入: prices = [1,2,3,4,5] 输出: 4(1买5卖)

5. 性能分析与优化

5.1 时间复杂度分析

由于记忆化数组的存在,每个状态最多计算一次。对于n天的价格序列,共有2n个状态(每天有持有/不持有两种状态),因此时间复杂度为O(n)。

5.2 空间复杂度分析

记忆化数组需要O(n)空间,递归栈深度在最坏情况下也是O(n),因此总空间复杂度为O(n)。

5.3 实际运行优化

在实际编码竞赛中,可以考虑以下优化:

  1. 将递归改为迭代,避免递归栈开销
  2. 使用滚动数组减少空间使用
  3. 对于C++,使用全局变量而非类成员变量可能稍快

6. 常见问题与调试技巧

6.1 为什么买入操作要查看end-2的状态?

这是冷冻期规则的核心体现。卖出股票后,第二天不能买入,因此买入操作必须保证前一天没有卖出操作。通过查看end-2的状态,我们确保了在end-1天没有卖出操作。

6.2 如何处理第一天和第二天买入的情况?

对于第一天(end=0):

  • 可以买入(没有前一天的限制)
  • 但不能卖出(因为没有持有股票)

对于第二天(end=1):

  • 如果第一天买入了,可以卖出
  • 如果要买入,需要查看end-2=-1天的状态(即初始状态)

6.3 记忆化数组初始值为什么是-1?

-1在这里表示该状态尚未计算。使用-1而不是0是因为0可能是某些状态的合法值(如不持有股票时的利润)。如果使用0作为初始值,就无法区分"未计算"和"计算后结果为0"两种情况。

6.4 如何验证状态转移的正确性?

可以手动模拟小规模输入,如prices = [1,2,3]。记录每个memo[i][j]的值,检查是否符合预期。特别是注意:

  • 买入操作是否真的隔了一天
  • 利润计算是否正确累加

7. 算法变种与扩展思考

7.1 含交易费用的变种

如果每次交易需要支付固定费用fee,只需修改状态转移方程:

// 卖出时扣除费用 res = max( dfs(end-1, false, prices), dfs(end-1, true, prices) + prices[end] - fee );

7.2 限制交易次数的变种

如果需要限制总交易次数k次,状态需要增加一个维度记录已完成的交易次数。这会增加时间复杂度到O(nk)。

7.3 多支股票的扩展

如果有多支股票可以交易,状态需要增加股票种类的维度。复杂度会随股票数量线性增长。

8. 实际应用中的注意事项

  1. 价格序列预处理:实际股票数据可能有缺失值,需要先进行清洗
  2. 整数溢出问题:对于极端价格序列,利润可能超过INT_MAX
  3. 浮点数精度:如果价格是浮点数,比较操作需要设置epsilon
  4. 实时性要求:高频交易场景可能需要更高效的实现

9. 从递归到动态规划的转换

虽然记忆化递归直观易懂,但在实际工程中,迭代式的动态规划通常更高效。我们可以将上述算法转换为自底向上的DP:

int maxProfit(vector<int>& prices) { int n = prices.size(); if(n <= 1) return 0; vector<vector<int>> dp(n, vector<int>(2)); dp[0][0] = 0; dp[0][1] = -prices[0]; dp[1][0] = max(dp[0][0], dp[0][1] + prices[1]); dp[1][1] = max(dp[0][1], -prices[1]); for(int i = 2; i < n; i++) { 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]); } return dp[n-1][0]; }

这种实现完全避免了递归开销,运行速度更快,也更适合大规模数据。

10. 算法选择建议

对于不同场景,可以考虑以下选择:

  1. 编程竞赛:记忆化递归更易编写和调试
  2. 工程实现:迭代式DP更高效稳定
  3. 教学讲解:记忆化递归更直观易懂
  4. 超大数据量:考虑空间优化的DP变种

在实际面试中,建议先实现记忆化递归版本,再讨论如何优化为DP,展示完整的思考过程。

返回列表