1 连续段DP进一步
在 连续段DP 中,我们学习了“必须以当前位置结尾”的状态。这个限制保留了连续段与扫描边界的关系,因此可以判断新元素能否接上。
先看最大连续子段和。本文数组下标从
这就是 Kadane 算法。初始化
现在增加一条规则:可以选择至多一个连续区间,把其中的元素都乘以
扫描到同一个位置,两个候选子段即使当前和相同,也可能拥有不同的未来:一个还没开始乘,另一个已经乘完。下一步能否把元素乘以
例如
我们需要在位置之外,再记录一个很小的身份:
扫描到了哪里,以及到这里为止,哪些动作仍然合法?
当这些身份只有有限几种,并能明确列出它们之间的合法转移时,就可以用有限状态机 DP来组织计算。
背包中的容量也是离散状态。这里强调的是第二维只有少量模式,例如“选 / 不选”“操作前 / 操作中 / 操作后”。状态机 DP 是一种建模方法,并不要求代码一定写成二维数组;有时它也能化简为一维递推。
2 选与不选
2.1 相邻不能同时选
给定非负整数数组
处理完第
因此将方案分成两类:
| 状态 | 准确含义 | 下一项能否选择 |
|---|---|---|
| 前 |
可以 | |
| 前 |
不可以 |
2.2 转移图
图中的一条边表示:从处理完第
查看 DOT 源码
digraph Choose {
graph [rankdir=LR, bgcolor="transparent"];
node [shape=box, fontname="Noto Serif CJK SC", color="#555555"];
edge [fontname="Noto Serif CJK SC", color="#555555"];
skip [label="0:当前不选"];
take [label="1:当前选择"];
skip -> skip [label="不选,+0"];
skip -> take [label="选,+a[i]"];
take -> skip [label="不选,+0"];
}
缺少
2.3 转移方程
要得到“不选第
初始化为
答案为
以 [2, 7, 9, 3, 1] 为例:
| 已处理项数 |
当前值 | 不选 |
选择 |
|---|---|---|---|
| 0 | — | 0 | |
| 1 | 2 | 0 | 2 |
| 2 | 7 | 2 | 7 |
| 3 | 9 | 7 | 11 |
| 4 | 3 | 11 | 10 |
| 5 | 1 | 11 | 12 |
答案为
2.4 同状态取最优
考虑处理完同一个位置、并且都“不选当前项”的两个方案。如果收益分别为
反过来,“选当前项”和“不选当前项”允许的下一步不同,就不能这样直接合并。
这给出了状态设计的判断依据:同一状态中的历史,应当具有相同的后续选择;在此基础上,才可以只保留最优值。
如果定义
核心实现如下,假设数组 a 已经读入,且引入了 C++ 标准库:
long long skip = 0;
long long take = -(1LL << 60);
for (long long value : a) {
long long next_skip = max(skip, take);
long long next_take = skip + value;
skip = next_skip;
take = next_take;
}
cout << max(skip, take) << '\n';
每次先算下一层,再覆盖上一层。时间复杂度为
3 单向三阶段
3.1 CF1155D:区间乘法
题目:Codeforces 1155D · Beautiful Array。
给定数组
数据范围为
3.2 贪心的反例
当
看数组 [-5, 2, 2, -5],令
| 操作区间 | 操作前该区间的和 | 操作后的数组 | 最大子段和 |
|---|---|---|---|
| 整个数组 | -6,是全局最小子段和 | [10, -4, -4, 10] |
12 |
| 只选第一个元素 | -5 | [10, 2, 2, -5] |
14 |
选择全局最小子段,反而不如只操作第一个元素。原因是:把两个正数也一起乘成负数,会损失它们对答案的贡献;只把左端的
**操作区间自身的和最小,不代表操作后的最大子段和最大。**我们要一起考虑被乘的部分,以及它能与左右哪些普通元素组成连续子段,不能先独立决定操作区间。
3.3 区间简化
原问题中有两个区间:操作区间
枚举它们的端点代价很高。先固定一个最终答案区间
只有交集
- 如果交集非空,可以把操作区间缩成这个交集,
的和不变。 - 如果交集为空,
的和与不操作时相同;原题允许不操作。
所以我们只需研究:**在一个候选答案子段内部,至多有一个连续部分被乘以
沿着这个答案子段从左向右看,它只能长成:
操作前 操作中 操作后
原值,允许为空 乘 x,使用时非空 原值,允许为空
0 0 0 1 1 1 2 2 2
阶段可以停留,也可以从
3.4 状态定义
定义
| 阶段 | 准确含义 | 当前位置的贡献 |
|---|---|---|
| 0 | 子段内还没有使用操作 | |
| 1 | 操作已经开始,且当前位置仍在操作区间内 | |
| 2 | 操作已经结束,当前位置在操作区间之后 |
特别注意:阶段
查看 DOT 源码
digraph Phases {
graph [rankdir=LR, bgcolor="transparent"];
node [shape=box, fontname="Noto Serif CJK SC", color="#555555"];
edge [fontname="Noto Serif CJK SC", color="#555555"];
before [label="0:操作前"];
inside [label="1:操作中"];
after [label="2:操作后"];
before -> before [label="+a[i]"];
before -> inside [label="开始乘,+x·a[i]"];
inside -> inside [label="继续乘,+x·a[i]"];
inside -> after [label="停止乘,+a[i]"];
after -> after [label="+a[i]"];
}
图展示的是相邻两层之间的转移。**每走一条边,恰好处理一个新元素。**此外,子段可以从任意位置开始,因此还要单独处理“在当前位置新开子段”的情况。
3.5 转移方程
**阶段 0:当前位置保持原值。**可以新开子段,也可以延续尚未使用操作的子段:
**阶段 1:当前位置乘以 x。**有三种来源:从当前位置新开一个已经开始乘的子段;在此前的普通子段后开始乘;继续乘。
**阶段 2:当前位置恢复原值。**可以刚离开操作区间,也可以继续延长操作后的普通部分:
为什么阶段
同样,不需要
3.6 初值与答案
三个状态都描述非空子段,因此在尚未处理元素时:
新开子段的情况已经由方程中的
最终答案是:
这里的三个范围各有作用:
- 对结尾
取最大值:答案不一定在数组最后结束。 - 对三个阶段取最大值:可以不操作,也可以在操作段结束时直接结束答案子段。
- 与
取最大值:题目允许空子段。
变体:如果要求必须操作一次呢?
如果变体要求“答案子段内必须有一个非空操作段”,就不能接受阶段
3.7 手算示例
取数组 [4, -3, -2, 5],令
| 操作前 |
操作中 |
操作后 |
扫描至此的答案 | ||
|---|---|---|---|---|---|
| 1 | 4 | 4 | -4 | 4 | |
| 2 | -3 | 1 | 7 | -7 | 7 |
| 3 | -2 | -1 | 9 | 5 | 9 |
| 4 | 5 | 5 | 4 | 14 | 14 |
得到
对应的操作是把 [-3, -2] 乘以 [4, 3, 2, 5]。
在最后一行,阶段
3.8 正确性证明
一方面,任何合法答案子段,其被乘的部分至多一段,都能按顺序标记成
另一方面,转移图不允许阶段倒退,因此任意一条从新开子段开始的合法路线,都只会包含至多一段连续的阶段
在同一位置、同一阶段下,后续允许的阶段转移相同,后续增加的数值也相同。因此只保留当前和最大的方案,不会损失最优答案。
这分别检查了不会漏解、不会制造非法解,以及合并历史是否安全。
3.9 代码实现
每一层只依赖上一层的三个值,可以滚动保存。下面显式保留不可达标记,避免对不可达状态加权后把它误当作合法值。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
long long x;
cin >> n >> x;
const long long NEG = -(1LL << 60);
auto extend = [NEG](long long value, long long weight) {
return value == NEG ? NEG : value + weight;
};
long long before = NEG, inside = NEG, after = NEG;
long long ans = 0;
for (int i = 1; i <= n; i++) {
long long a;
cin >> a;
long long multiplied = a * x;
long long next_before = max(a, extend(before, a));
long long next_inside = max({multiplied,
extend(before, multiplied), extend(inside, multiplied)});
long long next_after = max(extend(inside, a), extend(after, a));
before = next_before;
inside = next_inside;
after = next_after;
ans = max({ans, before, inside, after});
}
cout << ans << '\n';
return 0;
}
时间复杂度为 long long;NEG 也小于所有合法状态。
4 循环状态
4.1 股票冷冻期
设第
与一次区间操作不同,买入和卖出可以重复发生。状态图需要允许回到先前的身份。
处理完第
| 状态 | 含义 | 第 |
|---|---|---|
| 不持股,而且今天没有卖出 | 可以 | |
| 手里持有一股 | 不可以 | |
| 今天卖出,已经不持股 | 不可以,明天须冷冻 |
这里的“可买”指明天允许买入。例如今天恰好是卖出后的冷冻日,整天休息后,收盘时就进入
查看 DOT 源码
digraph Stock {
graph [rankdir=LR, bgcolor="transparent"];
node [shape=box, fontname="Noto Serif CJK SC", color="#555555"];
edge [fontname="Noto Serif CJK SC", color="#555555"];
ready [label="R:可买"];
hold [label="H:持股"];
sold [label="S:刚卖"];
ready -> ready [label="休息,+0"];
ready -> hold [label="买入,-p[i]"];
hold -> hold [label="持有,+0"];
hold -> sold [label="卖出,+p[i]"];
sold -> ready [label="冷冻一天,+0"];
}
缺少
4.2 转移方程
令
初始化为
以价格 [1, 2, 3, 0, 2] 为例:
| 天数 |
价格 | 可买 |
持股 |
刚卖 |
|---|---|---|---|---|
| 0 | — | 0 | ||
| 1 | 1 | 0 | -1 | |
| 2 | 2 | 0 | -1 | 1 |
| 3 | 3 | 1 | -1 | 2 |
| 4 | 0 | 2 | 1 | -1 |
| 5 | 2 | 2 | 1 | 3 |
答案为
核心实现如下,假设 prices 已保存价格,且总收益在 long long 范围内:
const long long NEG = -(1LL << 60);
long long ready = 0, hold = NEG, sold = NEG;
for (long long price : prices) {
long long next_ready = max(ready, sold);
long long next_hold = max(hold, ready - price);
long long next_sold = (hold == NEG ? NEG : hold + price);
ready = next_ready;
hold = next_hold;
sold = next_sold;
}
cout << max(ready, sold) << '\n';
同样只需
4.3 按时间分层
图上存在
真正计算的节点是
这也解释了为什么不能在同一天反复沿图走。若先覆盖 ready,再用它更新 hold,就可能把“今天刚完成冷冻、明天才能用”的资格提前给今天使用。先保存全部下一层状态,才能保证一天只走一条边。
5 状态设计
先看限制让哪些历史产生了不同的未来。本节的三个模型可以这样对照:
| 题目中的限制 | 影响下一步的历史 | 可以尝试的身份划分 |
|---|---|---|
| 相邻元素不能同时选 | 上一项是否选择 | 选 / 不选 |
| 至多操作一个连续区间 | 操作是否开始、是否结束 | 操作前 / 操作中 / 操作后 |
| 卖出后必须冷冻一天 | 是否持股、今天是否刚卖出 | 可买 / 持股 / 刚卖 |
这些是建模线索。它们帮助我们找到应该记住什么,但不能代替对状态充分性和转移正确性的检查。
面对新题,可以按下面的顺序设计,而不是先给几个数字编号。
- **确定一步处理什么。**是一个数组元素、一天,还是一个字符?先说清楚状态是在处理这一步之前还是之后。
- **找影响未来的历史。**下一步是否合法,依赖上一项是否选择、操作是否结束,还是距离上次动作过去多久?
- 给每个身份写完整含义。“已操作”可能同时包含“正在操作”和“已经结束”,二者允许的未来不同,需要分开。
- **列出合法边和本步贡献。**逐条核对题目限制,再按目标状态收集入边,写成
max、min或求和。 - **单独处理起点和终点。**哪些状态一开始可达?哪里可以新开方案?最后允许停在哪些状态?是否接受空方案?
- **检查能否安全合并历史。**同一位置、同一身份的两条历史,是否真的拥有相同的后续选择?还有遗漏的次数、容量或约束吗?
若每层有
6 思考练习
6.1 练习一:阶段倒退
在区间乘 [1, -10, 1, -10, 1],令
参考分析
新增的边允许“乘一段,停下来,再乘一段”。上例若只把两个 [-10, 1, -10] 整段取反。禁止
6.2 练习二:省略冷冻期
价格为 [1, 2, 0, 2]。如果把“刚卖”和“可买”合成一个不持股状态,并允许次日直接买入,会得到多少收益?合法答案又是多少?
参考分析
错误模型会在第一天买、第二天卖、第三天买、第四天卖,得到
6.3 练习三:冷冻两天
如果卖出后连续两天不能买入,原来的三个状态还够用吗?
参考分析
需要区分距离卖出日还要等待多久。可以保留
7 推荐练习
7.1 选与不选及其扩展
- leetcodecn house-robber-ii: 打家劫舍 II
- leetcodecn delete-and-earn: 删除并获得点数
- luogu P1352: 没有上司的舞会
- roj 1301: 大盗阿福
7.2 单向多阶段
- codeforces 1373D: Maximum Sum on Even Positions
- leetcodecn best-time-to-buy-and-sell-stock-iv: 买卖股票的最佳时机 IV
7.3 循环流转与限制
- leetcodecn best-time-to-buy-and-sell-stock-with-cooldown: 买卖股票的最佳时机含冷冻期
- luogu P1968: 美元汇率
- roj 3612: [NOIP2013-提高]花匠
- luogu P2627: [USACO11OPEN] Mowing the Lawn G
7.4 状态机进阶
8 相关题解
| 题目 | OJ | 难度 |
|---|---|---|
| 大盗阿福 | roj | 入门 |
| 买卖股票的最佳时机 | leetcodecn | 普及- |
| 打家劫舍 | leetcodecn | 普及- |
| 美元汇率 | luogu | 普及- |
| 【力扣】买卖股票的最佳时机 II | luogu | 普及- |
| 股票买卖 | roj | 普及- |
| 大盗阿福 | noi_openjudge | 普及 |
| 股票买卖 | noi_openjudge | 普及+/提高- |
| [noip2015-提高] 子串 | roj | 普及+/提高- |
| 有趣的数 | shumeng | 普及+/提高- |
| [NOIP2013-提高]花匠 | roj | 提高 |
| Beautiful Array | codeforces | 1900 |