[R80F] 标签
数据规模
, 只含小写英文字母。子任务 1 为 ,子任务 2 附加「至多两种字母」的特殊性质,子任务 3 无特殊限制。每个子串至多分出 段,答案约为 ,需要用 64 位整数保存。
思路
先把 写清楚。 对子串 ,位置 ()能切开,当且仅当 与 没有公共字母。所有能切的位置可以同时切开:若某个字母出现在两个不同的段里,它必然跨过某个切点,而那个切点的两侧就含有公共字母,矛盾。因此
把答案按切点分摊。 每个子串先贡献基础 ,合计 ;再对每个切点统计它在多少个子串中可切。记 为「位置 可作为可切位置」的子串个数,则
固定切点 ,求 。 令 为字母 在 中最后一次出现的位置(没出现记为 )。固定右端点 ,记
右侧出现的每个字母,它在左侧的所有出现位置都必须被排除;而排除它的最后一次出现,就等价于排除它的全部出现。于是 可切当且仅当 ,合法左端点恰有 个。
只会变化 次。 随着 向右扫, 只在 第一次遇到某个新字母时改变,右侧字母集合至多扩张 次,所以右端点被分成至多 段,每段内 恒定,逐段求和即可在 时间内算出 。
实现细节。 从 的角度逐段求和需要按首次出现位置给字母排序,实际上换成下面的等价写法,可以让每个切点只用一次线性扫描。
- 用 表示切点右侧字母 第一次出现的位置(没有则为 )。切点从 右移到 时,只有字母 的 会变,借助「同字母下一次出现」的后继数组即可 更新。
- 把左侧出现过的字母按 降序存进 。切点右移一位后,新进入左侧的字母 其 恰为 ,严格大于其他所有字母;其余字母的 与相对次序都不变。因此只需把 挪到 队首,一次移位就完成维护。
- 设 中的字母为 ,令 ,并约定 。对右端点 ,令 为第一个满足 的下标(不存在时记作 ),则 ,该右端点贡献 。
- 关键一步:因为 (切点字母就在左侧末尾),有
交换求和次序,条件 等价于「 这些字母都没有出现在 中」,也就是 。右端点 的取值范围是 ,其中满足 的恰有 个,故
- 随 单调不增,一旦 ,本项与后面所有项都是 ,可以立即跳出。
复杂度
每个切点内层至多扫 个字母,时间 。空间上只有后继数组是 ,其余数组长度均为 。
仓颉实现
查看仓颉实现
import std.env.*
import std.convert.*
main() {
let reader = getStdIn()
let n = Int64.parse(reader.readln().getOrThrow())
let s = reader.readln().getOrThrow()
let successor = Array<Int64>(n, { _ => 0 })
let nxt = Array<Int64>(26, { _ => n + 1 })
var i = n - 1
while (i >= 0) {
let c = Int64(s[i]) - 97
successor[i] = nxt[c]
nxt[c] = i + 1
i -= 1
}
let last = Array<Int64>(26, { _ => 0 })
let order = Array<Int64>(26, { _ => 0 })
var kinds: Int64 = 0
var answer = n * (n + 1) / 2
for (k in 1..n) {
let c = Int64(s[k - 1]) - 97
var at: Int64 = 0
while (at < kinds && order[at] != c) {
at += 1
}
if (at == kinds) {
kinds += 1
}
var j = at
while (j > 0) {
order[j] = order[j - 1]
j -= 1
}
order[0] = c
last[c] = k
nxt[c] = successor[k - 1]
var limit = n + 1
var idx: Int64 = 0
while (idx < kinds) {
if (nxt[order[idx]] < limit) {
limit = nxt[order[idx]]
}
if (limit == k + 1) {
break
}
var lower: Int64 = 0
if (idx + 1 < kinds) {
lower = last[order[idx + 1]]
}
answer += (last[order[idx]] - lower) * (limit - k - 1)
idx += 1
}
}
println(answer)
}