算法刷题系列(五十一):状态机 DP——用有限状态表达阶段变化
算法刷题系列(五十一):状态机 DP——用有限状态表达阶段变化
有些优化问题不仅受“处理到第几个元素”影响,还受当前处于哪个阶段影响。例如买卖股票时,今天结束后可能是空仓,也可能持有股票。状态机 DP 把阶段显式列出来,再为每种合法状态写出来自前一天的转移。
本文考虑一个简化交易模型:每天最多进行一次买入或卖出,任意时刻最多持有一股,不收手续费,卖出后可以在之后的天再次买入。目标是最大化利润。
一、定义两个状态
处理完第 i 天后:
cash[i]:不持有股票时能取得的最大利润;hold[i]:持有一股股票时能取得的最大利润。
今天结束时不持股,可以昨天就空仓,或今天卖出昨天持有的股票:
1
cash[i] = max(cash[i - 1], hold[i - 1] + price[i])
今天结束时持股,可以昨天已持有,或今天用空仓状态买入:
1
hold[i] = max(hold[i - 1], cash[i - 1] - price[i])
右侧都使用昨天的状态,确保同一天不会在一次更新中同时买入并卖出。
二、初始化与例子
开始交易前,空仓利润为 0,持仓状态不可达。用很小的负数表示不可达,或直接把第一天初始化为:
1
2
cash = 0
hold = -price[0]
价格为 [3, 1, 4, 2, 6] 时,先在价格 1 买入,再以 4 卖出,之后在 2 买入并以 6 卖出,总利润为 3 + 4 = 7。
状态只记录各自阶段中最优的利润。若两条历史路径到达同一阶段,较低利润的路径不会帮助后续取得更高利润,可以保留较大值。
三、正确性说明
每天结束时的持仓情况只有“持有”与“空仓”两种。对空仓状态,最后一步只能是保持空仓或当天卖出;对持仓状态,最后一步只能是保持持仓或当天买入。两个转移恰好枚举了所有合法最后动作,并且都从前一日合法状态出发。因此逐日归纳,cash 和 hold 分别保存当天两个阶段的最大可达利润。交易结束要求不持股,返回 cash。
四、Java 实现
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
27
28
public class StateMachineDp {
public static long maxProfit(int[] prices) {
if (prices == null) {
throw new IllegalArgumentException("prices must not be null");
}
if (prices.length == 0) {
return 0;
}
long cash = 0;
long hold = -prices[0];
for (int i = 1; i < prices.length; i++) {
long oldCash = cash;
long oldHold = hold;
cash = Math.max(oldCash, oldHold + prices[i]);
hold = Math.max(oldHold, oldCash - prices[i]);
}
return cash;
}
public static void main(String[] args) {
if (maxProfit(new int[]{3, 1, 4, 2, 6}) != 7
|| maxProfit(new int[]{5, 4, 3}) != 0
|| maxProfit(new int[]{}) != 0) {
throw new AssertionError("state machine test failed");
}
}
}
保存 oldCash 和 oldHold 是为了让两条更新都读取前一天的状态。也可以先计算两个新变量,再一起赋回。
五、Python 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def max_profit(prices):
if prices is None:
raise ValueError("prices must not be None")
if not prices:
return 0
cash = 0
hold = -prices[0]
for price in prices[1:]:
old_cash = cash
old_hold = hold
cash = max(old_cash, old_hold + price)
hold = max(old_hold, old_cash - price)
return cash
assert max_profit([3, 1, 4, 2, 6]) == 7
assert max_profit([5, 4, 3]) == 0
assert max_profit([8]) == 0
assert max_profit([]) == 0
六、复杂度与变化规则
每一天只更新两个状态,时间复杂度 O(n);只保存前一天,额外空间 O(1)。如果题目增加限制,例如只能完成一次交易、卖出后必须冷冻一天、最多交易 k 次或每笔交易收手续费,就应新增或调整状态,而不是在原有转移上临时拼条件。
举例说,冷冻期可以添加“今天刚卖出”的状态,或保存前两天的空仓状态;手续费可在卖出转移时扣除。状态数来自题目的阶段规则,不应凭经验随意设定。
七、边界与常见错误
- 初始化持仓为 0:这相当于免费得到股票,会虚增利润。
- 返回
hold:题意通常要求交易结束后空仓,应返回cash。 - 原地更新破坏旧状态:第二条转移读取的可能已是今天更新后的状态;使用临时变量最清楚。
- 忽略空输入:没有交易日时利润为 0。
- 把模型规则混在一起:能否当天买卖、手续费何时扣、交易次数如何限制,都要从题意明确,不同规则有不同转移。
- 把不可达当成普通数值:复杂扩展状态中,应使用足够小的哨兵值,并避免对哨兵做可能溢出的运算。
小结
状态机 DP 把“阶段”纳入状态定义。先列出处理完当前元素后的所有互斥状态,再枚举每个状态的合法来源。交易例子中,空仓与持仓两态构成有限状态机,日更转移后只需常数空间。