子问题视角:分治与动态规划

状态机 DP

六道题,一个模型

力扣的股票买卖是一个系列:121(只能交易一次)、122(不限次数)、 123(最多两次)、188(最多 k 次)、309(有冷冻期)、714(有手续费)。

⚠️ 如果一道一道背,就是六套代码。它们其实是同一道题。

⭐ 转换的关键一步:别想「什么时候买、什么时候卖」,想「每一天我处于什么状态」。

        买入
   ┌──────────────┐
   ↓              │
[持有]          [不持有]
   │              ↑
   └──────────────┘
        卖出

每天只有两种身份:手上有股票、手上没有股票。 买入和卖出是这两个状态之间的边。求最大利润 = 在这张图上走 n 天, 最后停在「不持有」,路径上的收益最大。

📌 这就是「状态机 DP」这个名字的来源:状态是图上的点,转移是边。

基础框架

// dp[i][0] = 第 i 天结束时「不持有」的最大利润
// dp[i][1] = 第 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-1][0] - prices[i]);   // 不动 / 买入

⭐ 这两行读出来就是人话: 「今天不持有,要么昨天就不持有、今天啥也没干,要么昨天持有、今天卖了。」

因为每一行只依赖前一天,可以直接压成两个变量:

function maxProfit(prices) {              // 力扣 122:不限交易次数
  let hold = -Infinity, free = 0;
  for (const p of prices) {
    const prev = free;                        // ⭐ 先把昨天的 free 存下来
    free = Math.max(free, hold + p);
    hold = Math.max(hold, prev - p);          // ⚠️ 用的必须是昨天的 free
  }
  return free;
}

⚠️ 一条被到处复制、但在这道题上不成立的告诫

几乎每篇讲状态压缩的文章都会说:压成变量后要小心「用的是今天的还是昨天的」。 按这个说法,下面这版应该是错的:

free = Math.max(free, hold + p);
hold = Math.max(hold, free - p);   // 用的是「今天的」 free

但它在 122 上是对的。 5000 组随机数据实测, 和正确写法零差异。

⭐ 原因值得想清楚:用今天的 free 买入,含义是「今天卖掉、又在今天买回来」。 同价买卖的净收益是 0 —— 这条转移永远不会让答案变大,也不会让它变小。 它是一条无害的多余边。

🚨 别把这条经验推广:同样的方向问题在 0-1 背包上是真 bug。 2000 组随机数据里(物品 18 件、容量 112、重量 16、价值 110), 正序和倒序有 70% 不一样:

W=6  重量 [1,2,6,1]  价值 [5,8,2,4]
   倒序(正确)  17
   正序          30   ← 同一件物品被拿了多次

📌 差别在于:背包里正序意味着「同一件物品拿两次」,那是实实在在多出来的收益; 股票里正序意味着「同日买卖」,那是零收益操作。 ⭐ 判断方向要不要紧,得看多出来的那条转移会不会改变最优值, 不能靠背「一维压缩要倒序」。

⚠️ 下面 309 冷冻期那一节里,同样的顺序问题就真的会错 —— 因为那时「昨天」和「前天」是题目语义的一部分,不再是实现细节。 (错的比例完全取决于测试数据的形状,从 4% 到 100%,那一节有一张表。)

🚨 hold 的初值为什么必须是 -Infinity

这是这个系列最容易踩、也最值得理解的一个点。

hold = 0 的意思是「第 0 天之前我就已经持有股票,而且没花钱」—— 白捡一支股票。实测两种初值(前四行是 122 不限次数,最后一行是 k=2):

输入                       正确   hold 初值写 0   差
[5]                          0         5         5
[2,1]                        0         2         2
[7,6,4,3,1](单调下降)       0         7         7
[7,1,5,3,6,4]                7        14         7
[3,3,5,0,0,3,1,4] (k=2)      6         9         3

⚠️ [5] 那一行最能说明问题:只有一天、什么都做不了, 正确答案必须是 0,而 hold = 0 的版本凭空赚了 5 块 —— 它把「免费得到的股票」在第一天卖掉了。

🚨 顺带提防一处容易记混的地方:[7,1,5,3,6,4] 是 121(只能交易一次) 的经典样例,那道题的答案是 5(买 1 卖 6)。但上表用的是 122(不限次数) 的实现,答案是 7(1→5 赚 4、3→6 赚 3)。 ⚠️ 这一行以前就写着 121 的那个 5 —— 同一个数组在六道题里有六个答案, 抄样例的时候连答案一起抄,就会串台。

⭐ 错误量有闭式解:恰好多赚一个 prices[0]

看上表最后一列:差值分别是 5、2、7、7 —— 正是每个输入的 prices[0]。 (k=2 那行差 3,那是 k 有限时被额度截断的情形。)

不限次数时这是可以推出来的,不必靠统计: 白捡的那支股票最优处置是第 0 天立刻卖掉(得 prices[0]), 因为卖掉后当天就能重新买入,后续的交易空间分毫不受影响。

hold 初值写 0 的答案 ≡ 正确答案 + prices[0]

穷举 n=18、值域 03 的全部 87380 组,零例外。

🚨 于是「它在每一个测试用例上都出错」这个说法不成立(这里以前就是这么写的): prices[0] === 0 时两者完全相同,空数组也相同。 2 万组随机数据里有 2869 组答案一致 —— 其中空数组 1968 组、 首日价格为 0 的 797 组、全为 0 的 104 组。

⭐ -Infinity 表达的是「这个状态不可达」。 📌 这是所有 DP 的通用约定:base case 里不合法的状态用 ±Infinity, 不要用 0 —— 0 是一个合法的值,会被 max 当成真实方案选中。

📌 和树形 DP 里那两个相比, 这个错响得大声得多(差值等于首日价格,只要首日价格不为 0 就露), 但它并不是「必然露」——判据是 prices[0] !== 0,不是「所有用例」。

加维度:交易次数 k

121(k=1)、123(k=2)、188(k 任意)的区别只是多一个维度:

// dp[k][0] / dp[k][1]:还剩 k 次交易额度时,不持有 / 持有 的最大利润
function maxProfitK(K, prices) {
  const n = prices.length;
  if (!n || K === 0) return 0;

  // ⭐ k >= n/2 时额度用不完,等价于不限次数(122)
  if (K >= n / 2) {
    let sum = 0;
    for (let i = 1; i < n; i++) sum += Math.max(0, prices[i] - prices[i - 1]);
    return sum;
  }

  const dp = Array.from({ length: K + 1 }, () => [0, -Infinity]);
  for (let i = 0; i < n; i++)
    for (let k = K; k >= 1; k--) {                 // 🚨 k 倒序,见下
      dp[k][0] = Math.max(dp[k][0], dp[k][1] + prices[i]);
      dp[k][1] = Math.max(dp[k][1], dp[k - 1][0] - prices[i]);
    }
  return dp[K][0];
}

三个要点:

① 额度在哪一步消耗。 上面写的是买入时消耗(dp[k-1][0])。 写成卖出时消耗也行,但必须全程一致 —— 混着写会算出多一次或少一次交易。

② k 的方向。 上面写的是倒序。很多题解会说「必须倒序, 否则 dp[k-1][0] 已经被今天更新过,就成了同一天用两次额度」。

⚠️ 这个理由对,但结论不成立。 实测:3000 组随机数据对着暴力解校验, 正序、倒序都完全正确;再拿 n ≤ 45、K ≤ 6 的 2000 组比对两种顺序, 零差异。

⭐ 还是那个原因:同一天用两次额度 = 同价买卖 = 零收益,最优值不受影响。 📌 倒序仍然值得写 —— 它让代码的含义和你脑子里的推导一致, 而不是靠「恰好无害」蒙混过去。但别把它当成正确性的必要条件。

③ K >= n/2 的短路。 一次完整交易至少占两天, 所以 n 天里最多做 n/2 次。不加这个判断,188 传进来 k = 10⁹ 会直接爆内存。 ⚠️ 这不是优化,是必须的。

122 的贪心为什么等价

不限次数时,常见写法是「把所有上涨段的差值加起来」:

let sum = 0;
for (let i = 1; i < n; i++) sum += Math.max(0, prices[i] - prices[i - 1]);

⭐ 它和状态机 DP 完全等价 —— 2000 组随机数据实测结果一模一样。

原因:[1, 5] 涨了 4,拆成 [1,3] 的 2 加 [3,5] 的 2 也是 4。 不限次数意味着可以把一次长交易拆成任意多次短交易, 所以「吃掉每一段上涨」和「找最优买卖点」是同一件事。

🚨 但这个贪心只在 k = ∞ 时成立。k 有限时必须用 DP —— 你得挑出「哪几段上涨最值钱」,那是选择问题,贪心不管用。

加状态:冷冻期与手续费

309 冷冻期(卖出后一天不能买):卖出之后要经过一个「冷冻」状态才能回到可买。 不用真的加第三个点,只要买入时看的是前天的 free:

function maxProfitCooldown(prices) {
  let hold = -Infinity, free = 0, prevFree = 0;   // prevFree = 前天的 free
  for (const p of prices) {
    const t = free;
    free = Math.max(free, hold + p);
    hold = Math.max(hold, prevFree - p);          // 🚨 前天,不是昨天
    prevFree = t;
  }
  return free;
}

⚠️ 实测 [1,2,3,0,2]:

正确答案                        3
完全忘记冷冻期(当成 122)        4
prevFree 误写成 free            4

📌 两个不同的错症状完全一样 —— 都是 4。 所以「答案偏大 1」这个现象没法区分是哪个原因,得回去读代码。

🚨 ⭐ 注意对比上面 122 那一节:那里 prev 和 free 混用完全无害 (穷举 n≤6、值域 0~3 的全部 5461 组也零差异),这里同一个混用真的会错。

⚠️ 但「错多少比例」这个问题没有一个数字能回答 —— 它完全由数据口径决定。 同一个错法,5000 组随机数据实测:

价格序列长度 值域 0~2 值域 0~9 值域 0~99
0~5 4.4% 7.7% 9.3%
0~10 19.9% 31.8% 36.7%
0~30 58.5% 71.7% 74.9%
30~60 97.9% 99.9% 100%

📌 序列越长、值域越大,越容易撞上「当天卖当天买」能占到便宜的形状。 (这一节以前写的是「29% 出错」,那大约对应「长度 010、值域 09」那一格 —— 而没写口径的百分比,读者换个生成器就得到 4% 或 100%。) ⭐ 所以这个数字该读成「会错」,不该读成「错得多频繁」。

差别在于加了冷冻期之后,「隔一天」不再是实现细节,而是题目规则本身 —— 用今天的 free 买入,等于允许了当天卖出当天买回,正是冷冻期禁止的事。

714 手续费:更简单,卖出时扣掉就行。

free = Math.max(free, hold + p - fee);   // ⭐ 只改这一处

⭐ 手续费必须只扣一次(要么买时扣要么卖时扣,别两边都扣)。

六道题的对照

题 状态数 相对基础框架的改动
121 k=1 2 买入时从 0 转移(不能累加之前的利润)
122 k=∞ 2 就是基础框架;也可以用贪心
123 k=2 2×3 加一维 k(方向不影响正确性,见上)
188 k任意 2×(k+1) 同上 + K >= n/2 短路
309 冷冻期 2(+1 延迟) 买入看前天的 free
714 手续费 2 卖出时 - fee

⭐ 六道题里,只有 123/188 真的多了一个维度,其余四道都是两个状态、 改一两个符号的事。

不只是股票

状态机 DP 的适用范围比股票宽得多。判据是:

每个位置有几种「身份」,身份之间的转移有规则。

  • 打家劫舍(198):状态 = 这间偷 / 不偷,规则 = 相邻不能都偷
  • 粉刷房子(256):状态 = 刷成红/蓝/绿,规则 = 相邻不能同色
  • 交错字符串 / 正则匹配:状态 = 匹配到哪、是否处于通配
function rob(nums) {                       // 198:和股票是同一个骨架
  let no = 0, yes = 0;
  for (const x of nums) {
    const prevNo = no;
    no = Math.max(no, yes);                // 不偷这间:上一间随意
    yes = prevNo + x;                      // 🚨 偷这间:上一间必须没偷
  }
  return Math.max(no, yes);
}

📌 和上面的股票代码放在一起看 —— 两个变量、每轮各自从对方更新、 注意别用到今天的值。骨架一模一样。

⭐ 而「别用到今天的值」这句告诫,在这三处的分量完全不同 —— 把它量出来,就不用靠背了(把 prevNo / prev 改成当天的值,2 万组随机数据):

哪一处 用今天的值会怎样
122 股票(k=∞) 零差异(连穷举 5461 组也一样)—— 多出来的是「同价买卖」,收益 0
198 打家劫舍 85.9% 出错 —— 多出来的是「相邻两间都偷」,那是实实在在的收益
309 冷冻期 会错,比例见上面那张表 —— 多出来的正是题目禁止的操作

🚨 判据始终是同一条:看那条多出来的转移会不会改变最优值。 「一维压缩要倒序」「别用今天的值」都是经验规则,不是定理 —— 它们在 122 上恰好无害,在打家劫舍上是六成以上的错。

⭐ 顺带:树形 DP 里的打家劫舍 III 就是把这个状态机搬到树上,[no, yes] 从两个变量变成递归的返回值。 状态机的形状没变,只是遍历的结构从数组变成了树。

状态形状总表

子问题视角这一章的 DP 部分到此为止。 把状态的形状排一排,整章就是一句话:先想清楚状态是什么,方程是自然的结果。

状态定义在 类型
前 i 个 / 以 i 结尾 子序列问题
前 i 个 + 剩余容量 背包问题
区间 [i..j] 区间 DP
树的一个节点 树形 DP
位置 + 当前身份 状态机 DP

下一步

上面每一类的最后一步都是同一件事:降维。 两个变量代替一整行、一行代替一整张表 —— 这就是状态压缩, 以及什么时候不该压。

练习

勾选记录做过哪些,0 / 7 道。进度只存在这台设备的浏览器里;同一道题在别篇勾过,这里也会显示已做。