简单字符串
- 难度 提高
- 时限 1s
- 空限 256m
- 线性 DP
数据规模:, 只含小写英文字母,。
思路
状态就是「已经匹配了 hard 的多长前缀」。 固定一组保留的字符后,判断这串里有没有 hard 子序列的标准做法是贪心:从 hard 的第 个字符开始,从左往右扫保留的字符,遇到当前要找的字符就把它配掉、指针后移,指针走到 就说明 hard 出现了。贪心每次都用最早的可能位置去配,所以它配出的长度就是「hard 的最长前缀中,能作为当前字符串子序列的那一个」——既不可能更短(贪心是能配到最长的),也不可能更长。于是「扫描中的指针位置 」就是保留下来的前缀串的一个确定函数,只由 与下一个字符决定,与之前删了哪些字符无关,这正是 DP 需要的无后效性。
因此在处理完前 个原字符之后,只需知道指针停在 ()。 表示已经出现了 hard,是非法局面,不能进入。
转移。 设 为当前指针停在 的最小花费,读入第 个字符 、删除代价 (记 为 hard 的第 个字符, 就是 h):
- 删除 :指针不变,代价增加 ,即 ;
- 保留 且它正好是 :指针前进一位,即 ;
- 保留 而它不推进指针:指针不变、不花代价,即 。
三式都取 合起来写就是:先把 全部置为 ,再按 与 是否相等,从 无代价地更新 (不推进)或 (推进)。
为什么每个状态只留最小值就够。 转移完全由「 与当前字符是保留还是删除」决定,并且 是单调不减的;同一 下的两个不同删除方案对后续的影响完全相同,因此只需保留花费最小的那个。这也是本题能压到只有 个状态的原因。
且 是唯一必须删除的情形。 此时保留会推进到 ,直接构造出一个 hard。所以这一格只能走「删除」的转移,实现里要跳过「保留不推进」那一支,否则会把「已经凑成 hard」的方案当成合法方案继续往下算。
边界与答案。 初始 ,其余为 (表示还配不出对应长度的前缀)。扫描结束后 永远是 (它从不参与转移),而每个 ()都对应一个不含 hard 子序列的删除方案,所以答案是 。 本身不含 hard 时全部字符都可以保留,答案自然是 。
复杂度
一次线性扫描,每个位置做常数次( 个状态)转移,时间 ,额外空间 (只存 个状态和读入的代价数组)。
仓颉实现
字符串按字节读入,hard 也转成字节数组比较,避免逐字符转换。无穷大取 ,与 相比足够大且不会在累加时溢出。
查看仓颉实现
import std.env.*
import std.convert.*
main() {
let reader = getStdIn()
let n = Int64.parse(reader.readln().getOrThrow())
let sc = reader.readln().getOrThrow().toArray()
let a = reader.readln().getOrThrow().split(" ", removeEmpty: true).map({ p: String => Int64.parse(p) })
let hb = "hard".toArray()
let inf: Int64 = 1 << 60
var dp = Array<Int64>(4, { _ => inf })
dp[0] = 0
var i: Int64 = 0
while (i < n) {
let c = sc[i]
let cost = a[i]
var nd = Array<Int64>(4, { _ => inf })
for (j in 0..4) {
nd[j] = dp[j] + cost
}
for (j in 0..4) {
if (j == 3 && c == hb[3]) {
continue
} else if (j < 3 && c == hb[j]) {
if (dp[j] < nd[j + 1]) { nd[j + 1] = dp[j] }
} else {
if (dp[j] < nd[j]) { nd[j] = dp[j] }
}
}
dp = nd
i += 1
}
var ans = dp[0]
for (j in 1..4) {
if (dp[j] < ans) { ans = dp[j] }
}
println(ans)
}