1 扫描到一个新元素时,需要知道什么?
给定一个数组,求最长的连续正数段。例如:
下标 i: 0 1 2 3 4 5 6
元素 a: 2 5 -1 3 4 6 0
答案是 3,对应连续的三个正数 [3, 4, 6]。
注意这里要求的是连续段:选中的元素在原数组中必须相邻,不能跳过中间的元素。全文下标都从 0 开始;没有符合条件的非空连续段时,答案记为 0。
最直接的做法是枚举起点,从每个起点向右扩展,遇到非正数就停止。如果所有元素都是正数,同一个位置会被不同起点反复扫描,总时间为
这些重复检查能否共用?
看从 3 开始的这一段:扫描到 4 时,已经知道它前面紧挨着一个正数;扫描到 6 时,只要知道前面紧挨着两个正数,就能得到新的长度。我们不必重新检查 3 和 4。
因此,扫描到位置
紧挨着当前位置左边,已经有多少个连续正数?
这就把“寻找整个区间”的问题,变成了“能否用新元素延长上一段”的问题。
2 为什么定义“以 i 结尾”?
2.1 先把子问题说准确
定义:
如果
这里的“必须以
例如,扫描到前面数组的最后一个正数 6 时,我们关心的是紧邻它的 [3, 4],而不是更早出现过的 [2, 5]。
2.2 再判断新元素能否接上
只有两种情况:
:可以接在前一个连续正数段之后,长度加 1;如果前一段为空,就从当前位置开始一段。 :任何包含当前位置的区间都不可能全是正数,当前连续段结束,长度归零。
于是得到:
为了统一公式,约定 dp[-1]。
为什么正数时的答案恰好是
这两方面一起说明,转移既不会漏掉更长的段,也不会算出不存在的段。
2.3 手算一遍状态
| 位置 |
当前发生了什么 | 扫描至此的最长长度 | ||
|---|---|---|---|---|
| 0 | 2 | 正数,开始新的一段 | 1 | 1 |
| 1 | 5 | 接在位置 0 后面 | 2 | 2 |
| 2 | -1 | 当前段被打断 | 0 | 2 |
| 3 | 3 | 重新开始一段 | 1 | 2 |
| 4 | 4 | 接在位置 3 后面 | 2 | 2 |
| 5 | 6 | 接在位置 4 后面 | 3 | 3 |
| 6 | 0 | 当前段被打断 | 0 | 3 |
这里有两个不同的量:当前段有多长,以及到目前为止,最长的一段有多长。当前位置不合法时,第一个量变为 0,第二个量可以保持不变。
下面只给核心代码,假设已有长度为 n 的数组 a,并为 dp 分配了至少 n 个位置:
int ans = 0;
for (int i = 0; i < n; i++) {
if (a[i] > 0) {
dp[i] = 1;
if (i > 0) {
dp[i] += dp[i - 1];
}
} else {
dp[i] = 0;
}
ans = max(ans, dp[i]);
}
一次扫描就算出了全部状态,时间复杂度为 dp 数组的额外空间为
3 为什么只记录“前面的最大值”不够?
另一种看起来很自然的定义是:
这里“前
这个定义可以记录答案,却没有记录最长段在哪里结束。
把正数记为 1、非正数记为 0,比较两个前缀:
前缀 A:1 1 0
前缀 B:0 1 1
两者都有
A:1 1 0 1 最长连续 1 的长度仍然是 2
B:0 1 1 1 最长连续 1 的长度变成了 3
前缀最优值相同,新加入的元素也相同,但结果不同。这说明:只知道
缺少的正是末尾连续段的长度:A 的末尾连续 1 长度为 0,B 为 2。
因此,我们先用
| 状态 | 回答的问题 | 是否知道合法段紧挨着扫描边界? |
|---|---|---|
| 必须以 |
知道 | |
| 前 |
不知道 |
定义状态时,不仅要考虑“它能不能表示最终答案”,还要考虑“它保留的信息,够不够推导下一个状态”。
4 换一种合法条件,还能这样设计吗?
4.1 最长连续相同字符段
例如字符串 aabbbaa,最长连续相同字符段是 bbb,长度为 3。
定义
这里断开后为什么是 1?因为即使当前字符与前一个不同,单个字符本身仍然是合法的一段。
| 位置 |
0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| 字符 | a | a | b | b | b | a | a |
| 1 | 2 | 1 | 2 | 3 | 1 | 2 |
核心转移为:
for (int i = 0; i < n; i++) {
dp[i] = 1;
if (i > 0 && s[i] == s[i - 1]) {
dp[i] = dp[i - 1] + 1;
}
}
4.2 最长严格递增连续段
例如数组 [2, 4, 3, 5, 7, 1],最长严格递增连续段是 [3, 5, 7],长度为 3。
定义
| 位置 |
0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 2 | 4 | 3 | 5 | 7 | 1 | |
| 1 | 2 | 1 | 2 | 3 | 1 |
核心转移为:
for (int i = 0; i < n; i++) {
dp[i] = 1;
if (i > 0 && a[i] > a[i - 1]) {
dp[i] = dp[i - 1] + 1;
}
}
这求的是连续段。后面的 [
Rbook: 最长上长子序列] 允许跳过元素,不能直接使用这里的相邻位置转移。
4.3 相同的状态形式,不同的边界含义
| 问题 | 能够延续的条件 | 不能延续时的状态 |
|---|---|---|
| 连续正数段 | 当前元素为正数 | 0,当前元素不能组成合法段 |
| 连续相同字符段 | 当前字符与前一个相同 | 1,当前字符单独成段 |
| 严格递增连续段 | 当前元素大于前一个 | 1,当前元素单独成段 |
不要背成“条件成立就加一,否则归零”。应该先问:当前位置单独拿出来,是否也是合法的一段?
5 CF1948D:先转化,再寻找连续段
题目链接:[
codeforces 1948D: Tandem Repeats?]
这道题给定一个由小写字母和 ? 组成的字符串。每个 ? 可以独立替换为任意小写字母,要求找到最长的一个子串,使它由两个相邻且完全相同的字符串拼接而成;不存在时输出 0。
例如 aabaab 的前半段和后半段都是 aab,因此它是一个长度为 6 的合法重复串。
这里不能直接判断相邻字符是否相同,因为我们要比较的是前后两半的对应位置。
5.1 固定一半的长度,比较位置就确定了
设一半的长度为
两字符能够匹配,当且仅当它们相等,或者至少有一个是 ?。
固定
那么,从
也就是说,固定
为什么逐对匹配就能保证问号的替换没有冲突?因为在选定的这两个长度为
5.2 用同一个状态解决转化后的问题
固定
这与连续正数段的状态形式相同:
这里
5.3 手算固定距离的匹配过程
沿用一个没有问号的例子:
下标 i:0 1 2 3 4 5 6 7
字符 s:z a a b a a b z
固定
| 位置 |
比较的字符 | ||
|---|---|---|---|
| 0 | s[0]=z 与 s[3]=b |
0 | 0 |
| 1 | s[1]=a 与 s[4]=a |
1 | 1 |
| 2 | s[2]=a 与 s[5]=a |
1 | 2 |
| 3 | s[3]=b 与 s[6]=b |
1 | 3 |
| 4 | s[4]=a 与 s[7]=z |
0 | 0 |
到
它们对应前半段 s[1..3] = aab 和后半段 s[4..6] = aab,得到长度为 6 的重复串。
一般地,在位置
5.4 恢复对 d 的枚举
固定
从大到小枚举
如果逐个枚举
核心转化是固定距离后得到匹配序列;DP 让这个序列上的连续段检查可以共用前一步的结果。
6 为什么可以只保留一个变量?
观察上面的转移,计算
因此可以用一个变量 cnt 保存当前连续段的长度。以连续正数段为例:
int cnt = 0;
int ans = 0;
for (int i = 0; i < n; i++) {
if (a[i] > 0) {
cnt++;
} else {
cnt = 0;
}
ans = max(ans, cnt);
}
循环不变量是:处理完位置 cnt 就等于原先的 ans 等于前缀最优值
所以,省掉数组并没有改变状态的含义。时间仍为
这通常称为滚动变量或 DP 的空间优化。本书目录中的“状态压缩”主要讨论二进制状态表示,与这里省掉历史数组的做法含义不同。
对于 CF1948D,连匹配数组 b 也不必实际保存,扫描时直接比较字符即可。下面的函数只处理一个字符串,返回最长合法重复串的长度;假设已经引入 C++ 标准库并使用 std 命名空间:
int longest_tandem(const string &s) {
int n = s.size();
for (int d = n / 2; d >= 1; d--) {
int cnt = 0; // 每次更换距离,都重新开始计算连续匹配对数
for (int i = 0; i + d < n; i++) {
if (s[i] == s[i + d] || s[i] == '?' || s[i + d] == '?') {
cnt++;
} else {
cnt = 0;
}
if (cnt >= d) {
return 2 * d;
}
}
}
return 0;
}
对长度为
7 哪些问题可以迁移这个思路?
遇到连续区间问题时,可以依次检查:
- 固定一个结尾后,能否把合法段看成“前一段接上当前元素”?
- 判断能否延续,需要哪些信息?上一个状态和相邻元素是否足够?
- 如果不能延续,当前位置应该独立成段,还是连长度为 1 的段都不合法?
- 最终要求的是某个结尾的答案、全局最大值,还是达到某个长度即可?
这些问题用于设计状态,不能代替对转移的证明。
例如,“区间内最多出现两个不同的数”也要求连续,但只记录末尾合法段的长度通常不够:新元素加入后,还需要知道区间里有哪些数、各出现多少次,才能决定如何调整左边界。“必须以当前位置结尾”可以作为思考起点,却不会自动得到“加一或归零”的转移。
同样,最长相同字符段、最长递增连续段中的每一个较短后缀都合法,所以前一段越长,能延续出的新段也越长;换成别的区间条件时,这一点需要重新检查。
7.1 练习一:失败后保留多少?
求最长的连续非递减段,也就是允许相邻元素相等。
请先写出
点击
状态为“以
7.2 练习二:前缀最优值能否延续?
对于序列 [1, 1, 0, 1],把每个 1 看作合法元素、0 看作不合法元素,分别写出
点击
最后一个位置的状态是 1,虽然它前面的前缀最优值为 2,但那一段与当前位置之间隔着一个 0,无法接上。
7.3 练习三:匹配一对和匹配一个重复串有什么区别?
CF1948D 中,固定 a?ab。写出匹配序列,判断能否得到长度为 4 的重复串,并给出一种问号替换方式。
点击
比较的两对是 (a, a) 和 (?, b),都可以匹配,所以 b 后得到 abab。
如果只匹配了一对,只能说明一个位置的对应字符兼容;要构成长度为