P9576 「TAOI-2」Ciallo~(∠・ω< )⌒★ 解题报告
P9576 「TAOI-2」Ciallo~(∠・ω< )⌒★ 解题报告
1. 题目解读:我们要干什么?
首先,我们来弄清楚题目到底要求我们计算什么。
题目描述了一个两步的“造词”过程:
- 第一步(删除): 从模板串
s
中删掉一个连续的子串s[l...r]
,得到一个新的、更短的字符串s'
。 - 第二步(提取): 从新字符串
s'
中再选出一个子串s''
。
我们的目标是,找出总共有多少种不同的方案(即 l, r, l', r'
的组合),可以使得最终造出的词 s''
正好是目标串 t
。
换个角度思考:
这个过程有点绕。我们可以简化一下思考方式。第一步删除操作,实际上是保留了 s
的一个前缀 s[1...l-1]
和一个后缀 s[r+1...|s|]
,然后把它们拼接起来。
所以,整个问题等价于:
核心问题: 我们可以从
s
中选择任意一个前缀和任意一个后缀(只要它们不重叠,即删除的中间部分至少有一个字符),将它们拼接成新串s'
。我们需要计算,在所有可能拼接出的s'
中,目标串t
一共出现了多少次。
例如,s = aabbaaba
, t = aba
。
一种方案是删除 s[3...4]
(即 bb
),s
的前缀 aa
和后缀 aaba
拼接成 s' = aaaaba
。t="aba"
在 s'
中出现了 2 次。
我们要求的就是所有删除方案产生的 t
的出现次数的总和。
2. 整体思路:分类讨论
直接枚举所有删除方案 (l, r)
再去新串里找 t
,效率太低。我们不妨换个思路:从 t
的角度出发。
一个 t
的出现,在拼接后的 s'
中,有三种可能性:
- 完全来自前缀:
t
是s
的某个前缀s[1...l-1]
的一个子串。 - 完全来自后缀:
t
是s
的某个后缀s[r+1...|s|]
的一个子串。 - 横跨前后缀:
t
的一部分来自前缀的末尾,另一部分来自后缀的开头。
我们可以分别计算这三种情况的方案数,然后加起来。
3. 算法详解
3.1. 情况一和情况二:t
未横跨拼接点
这种情况相对简单。我们改变求和的顺序:不先枚举删除的区间,而是先在原串 s
中找到一个 t
的出现。
假设 t
在 s
中出现,起始于位置 p
,结束于位置 p + |t| - 1
。
- 要让这个
t
完整地保留在前缀部分,我们必须删除它右边的任意一段区间s[l...r]
。这意味着l
必须大于p + |t| - 1
。有多少种这样的删除方案呢?l
可以取p + |t|
到|s|
。对于每个l
,r
可以取l
到|s|
。总方案数是(|s| - (p+|t|) + 1) + ... + 1
,这是一个等差数列求和,结果是k(k+1)/2
,其中k = |s| - (p+|t|-1)
。
- 同理,要让这个
t
完整地保留在后缀部分,我们必须删除它左边的任意一段区间。这意味着r
必须小于p
。r
可以取1
到p-1
。对于每个r
,l
可以取1
到r
。总方案数是1 + 2 + ... + (p-1)
,结果是(p-1)p/2
。
所以,我们只需要在 s
中找到所有 t
的出现位置 p
,对每个 p
把这两种情况的方案数累加到总答案 ans
中即可。找 t
的位置可以用字符串哈希在 O(|s|)
时间内完成。这部分对应了代码的最后几行。
3.2. 情况三:t
横跨拼接点(核心难点)
这是最复杂,也是本题的精髓所在。
目标串 t
是由模板串 s
的两部分拼接而成的。我们找到了这样的充要条件:
存在一对 (i, j)
满足 i < j
,以及一个分割长度 p
(1 <= p < |t|
),使得:
s
中以i
结尾的长度为p
的子串,等于t
的长度为p
的后缀。s
中以j
开头的长度为|t|-p
的子串,等于t
的长度为|t|-p
的前缀。
为了方便计算,我们预处理了两个数组(使用题解代码中的定义):
L[k]
:s
中以k
结尾的子串,能与t
的最长后缀匹配的长度。R[k]
:s
中以k
开头的子串,能与t
的最长前缀匹配的长度。
对于一个固定的 (i, j)
对(i < j
),它能贡献的方案数,就是满足 p <= L[i]
且 |t|-p <= R[j]
的 p
的数量。
将第二个不等式变形得到 p >= |t| - R[j]
。
所以,合法的 p
的取值范围是 [|t| - R[j], L[i]]
。
这个区间的长度(即 p
的取值数量)为 max(0, L[i] - (|t| - R[j]) + 1)
。
我们的总目标就是计算这个总和:
这个公式很难直接处理,所以我们只对满足 L[i] \ge |t| - R[j]
的 (i, j)
对求和:
现在,我们把括号拆开,就得到了你提到的那个公式:
我们的任务就是用扫描线和数据结构来分别求出 Part A
和 Part B
。
扫描线 + 双树状数组解法
我们将 j
从 1
遍历到 |s|
,把 j
看作是扫描线。在处理每个 j
的时候,我们想一次性算出所有 i < j
对 j
产生的贡献。
核心思想:
当扫描线在 j
时,j
是一个查询点。我们需要查询所有已经处理过的 i
(即 i < j
)的信息。查询完毕后,j
就变成了一个数据点,需要将其信息加入我们的数据结构中,供后续的 j'
(j' > j
)查询。
1. 计算 Part B
Part B
的求和项是 |t| - R[j]
。对于一个固定的 j
,这个值是常数。所以 Part B
在 j
这一步的贡献可以写作:
这下问题就清晰了:我们需要一个数据结构,能快速查询满足某个条件的 i
的数量。
数据结构: 我们用一个树状数组(Fenwick Tree),命名为 BIT_count
。
- 用途:
BIT_count
用来维护L[i]
值的分布。它的下标范围是[0, |t|]
。 - 定义:
BIT_count
在k
处的值,表示已经处理过的i
中,L[i] = k
的有多少个。
扫描过程(for Part B):
当我们处理到 j
时:
- 查询:我们需要找到满足
L[i] >= |t| - R[j]
的i
的数量。这等价于在BIT_count
中查询下标在[|t| - R[j], |t|]
这个范围内的值的总和。
count = BIT_count.query_range(|t| - R[j], |t|)
- 计算贡献:
j
对Part B
的贡献就是(|t| - R[j]) * count
。 - 累加到总答案:
Total_Part_B += (|t| - R[j]) * count
。
2. 计算 Part A
Part A
的求和项是 L[i] + 1
。它依赖于 i
。
我们需要一个数据结构,能快速查询满足条件的 i
的 L[i] + 1
的总和。
数据结构: 我们再用一个树状数组,命名为 BIT_sum
。
- 用途:
BIT_sum
用来维护L[i] + 1
的和的分布。它的下标范围也是[0, |t|]
。 - 定义:
BIT_sum
在k
处的值,表示所有已处理的、且L[i] = k
的i
,它们的L[i] + 1
的总和。
扫描过程(for Part A):
当我们处理到 j
时:
- 查询:我们需要找到满足
L[i] >= |t| - R[j]
的所有i
的L[i] + 1
的总和。这等价于在BIT_sum
中查询下标在[|t| - R[j], |t|]
这个范围内的值的总和。
sum_val = BIT_sum.query_range(|t| - R[j], |t|)
- 计算贡献:
j
对Part A
的贡献就是sum_val
。 - 累加到总答案:
Total_Part_A += sum_val
。
3. 整合算法流程
现在我们把两部分合在一起。
预处理阶段:
- 用字符串哈希和二分,计算出
L[1...|s|]
和R[1...|s|]
数组。复杂度O(|s| log |t|)
。
扫描线阶段:
-
初始化总答案
Ans = 0
。 -
初始化
BIT_count
和BIT_sum
,所有值为 0。 -
循环
j
从1
到|s|
:
a. 查询步骤 (计算j
的贡献):
i. 令y = |t| - R[j]
。这是一个阈值。
ii. 从BIT_sum
中查询区间[y, |t|]
的和,得到current_part_A
。
iii. 从BIT_count
中查询区间[y, |t|]
的和,得到current_count
。
iv. 计算current_part_B = y * current_count
。
v. 将j
的贡献累加到总答案:Ans += current_part_A - current_part_B
。b. 更新步骤 (将
j
作为数据点加入):
i. 令x = L[j]
。
ii. 更新BIT_count
:在x
位置加上1
。BIT_count.add(x, 1)
。
iii. 更新BIT_sum
:在x
位置加上x + 1
。BIT_sum.add(x, x + 1)
。 -
循环结束后,
Ans
就是情况二的总方案数。
总复杂度: 预处理是 O(|s| log |t|)
。扫描线部分,循环 |s|
次,每次在树状数组上进行常数次查询和更新,每次操作复杂度 O(log |t|)
。所以扫描线部分也是 O(|s| log |t|)
。总复杂度为 O(|s| log |t|)
。
总结
这种“双树状数组”的方法是解决这类二维计数求和问题的“标准模板”。它思路清晰,将复杂的求和式拆解成两个更简单、可以用数据结构维护的部分(一个求数量,一个求总和)。