E. Martian Strings - 题解
比赛与标签
比赛: Codeforces Round 106 (Div. 2) 标签: string suffix structures, strings 难度: *2300
火星人的奇妙早晨喵~
主人,早上好喵~!今天我们来帮一位懒洋洋的火星人解决一个有趣的难题吧!(ฅ'ω'ฅ)
这位火星人有一排眼睛,当他醒来时,他会睁开两段不重叠的连续区间的眼睛。他眼睛上的眼罩内侧都写着一个大写字母,所以他睁开眼睛后,会按从左到右的顺序看到一个由两部分拼接起来的字符串。
我们有一个长长的主字符串 s,代表所有眼罩上的字母。还有 m 个火星人认为很“美丽”的单词 p。
我们的任务就是,对于每一个美丽的单词 p,判断它能否通过火星人睁开两段不重叠眼睛的方式,由主字符串 s 的两段子串拼接而成。最后,我们要数出总共有多少个不同的美丽单词可以这样形成。
举个栗子呐: 如果 s = ABCBABA,一个美丽单词是 ABBA。 火星人可以睁开 [1, 2] 区间的眼睛看到 AB,再睁开 [6, 7] 区间的眼睛看到 BA。因为 2 < 6,这两段不重叠,拼接起来就是 ABBA!所以 ABBA 就是一个可以被看见的美丽单词。
KMP的双向奔赴!
这个问题看起来是要我们在一个大字符串 s 中,找到一个美丽串 p 的前缀和后缀,并且它们在 s 中的位置要满足不重叠的条件,对吧喵?
如果我们暴力枚举 p 的所有分割方式,再暴力地在 s 中寻找这两部分,那肯定会慢得像蜗牛一样,会被 TLE 大魔王抓走的!所以,我们需要更聪明的办法,这就要请出我们的字符串匹配神器——KMP算法啦!
我们的核心思路是这样的:对于每个美丽单词 p,我们将它拆分成一个非空前缀 A 和一个非空后缀 B。我们的目标,就是在 s 中找到 A 和 B,使得 A 的出现位置完全在 B 的出现位置之前。
为了让 A 和 B 不重叠的概率最大化,我们应该让 A 尽可能地出现在 s 的最左边,同时让 B 尽可能地出现在 s 的最右边。
第一步:寻找最靠左的前缀 A
我们可以用 KMP 算法来处理这个问题!对于一个模式串 p,KMP 算法在匹配过程中,实际上能告诉我们 p 的所有长度的前缀在主串 s 中的匹配情况。
我们可以定义一个数组 F,F[k] 用来记录 p 的长度为 k 的前缀 p[0...k-1] 在 s 中第一次出现的起始位置。我们只需要跑一遍标准的 KMP 匹配,当匹配到长度为 j 的前缀时,如果 F[j] 还是初始值,就立刻记录下当前的起始位置。这样就能保证我们找到的是最靠左的匹配位置啦!
第二步:寻找最靠右的后缀 B
找最右边的后缀有点麻烦,但这里有一个超级喵喵赞的技巧——把所有东西都反过来!
我们将主串 s 和模式串 p 都进行反转,得到 s_rev 和 p_rev。 现在,寻找 p 的一个后缀 B 就等价于在 p_rev 中寻找一个前缀 B_rev(B 的反转)。 在 s 中寻找 B 的最右出现位置,就等价于在 s_rev 中寻找 B_rev 的最左出现位置!
看吧,问题瞬间就和第一步一样了!我们可以用同样的方法,再跑一遍 KMP,找到 p_rev 的所有前缀在 s_rev 中的最左出现位置,并记录在数组 H 中。
第三步:合并结果,检查重叠
现在我们有了两个法宝:
F[k]:p的长度为k的前缀在s中的最左起始位置。H[len]:p的长度为len的后缀(在反转串中是长度为len的前缀)在s中的最右匹配的起始位置。
我们遍历 p 的所有可能的分割点 k(k 从 1 到 |p|-1):
- 前缀
A的长度是k。它在s中的匹配区间是[F[k], F[k] + k - 1]。 - 后缀
B的长度是L-k(L是p的总长)。它的反转串是p_rev中长度为L-k的前缀。 - 这个反转的后缀在
s_rev中的最左匹配起始于H[L-k]。我们需要把它换算回s中的坐标。s_rev中从H[L-k]开始的匹配,对应到原串s中,其起始位置是n - (H[L-k] + (L-k))(这里n是s的长度)。 - 现在,我们只需要检查前缀
A的结束位置是否小于后缀B的开始位置:F[k] + k - 1 < n - (H[L-k] + (L-k)) - 只要有任何一个分割点
k满足这个条件,就说明这个美丽的单词p是可以被火星人看到的!我们立刻把它计数,然后就可以开心地去检查下一个美丽单词啦,喵~
猫娘的代码魔法~
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
using namespace std;
int main() {
string s;
cin >> s;
int m;
cin >> m;
vector<string> words(m);
for (int i = 0; i < m; i++) {
cin >> words[i];
}
int n = s.size();
int ans = 0;
// 对每一个美丽单词进行检查
for (const string& p : words) {
int L = p.size();
// 长度小于2的单词无法被分割成两个非空部分
if (L < 2)
continue;
// --- 第一步: KMP 寻找 p 的所有前缀在 s 中的最左出现位置 ---
// F[j] 存储 p 的长度为 j 的前缀在 s 中首次出现的起始下标
vector<int> F(L + 1, -1);
// 1. 构建 p 的 fail 数组 (前缀函数)
vector<int> fail(L, 0);
int j = 0;
for (int i = 1; i < L; i++) {
while (j > 0 && p[i] != p[j]) {
j = fail[j - 1];
}
if (p[i] == p[j]) {
j++;
}
fail[i] = j;
}
// 2. KMP匹配,填充 F 数组
j = 0;
for (int i = 0; i < n; i++) {
while (j > 0 && s[i] != p[j]) {
j = fail[j - 1];
}
if (s[i] == p[j]) {
j++;
}
// 如果长度为 j 的前缀是第一次找到,就记录它的起始位置
if (F[j] == -1) {
F[j] = i - j + 1;
}
}
// --- 第二步: 反转字符串,寻找 p 的所有后缀在 s 中的最右出现位置 ---
// 1. 反转模式串 p 和主串 s
string p_rev = p;
reverse(p_rev.begin(), p_rev.end());
string s_rev = s;
reverse(s_rev.begin(), s_rev.end());
// H[j] 存储 p_rev 的长度为 j 的前缀在 s_rev 中首次出现的起始下标
vector<int> H(L + 1, -1);
// 2. 构建 p_rev 的 fail 数组
vector<int> fail_rev(L, 0);
j = 0;
for (int i = 1; i < L; i++) {
while (j > 0 && p_rev[i] != p_rev[j]) {
j = fail_rev[j - 1];
}
if (p_rev[i] == p_rev[j]) {
j++;
}
fail_rev[i] = j;
}
// 3. KMP匹配,填充 H 数组
j = 0;
for (int i = 0; i < n; i++) {
while (j > 0 && s_rev[i] != p_rev[j]) {
j = fail_rev[j - 1];
}
if (s_rev[i] == p_rev[j]) {
j++;
}
// 同样,只记录第一次找到的位置
if (H[j] == -1) {
H[j] = i - j + 1;
}
}
// --- 第三步: 检查是否存在不重叠的分割 ---
bool found = false;
// 遍历所有可能的分割点 k (前缀长度为 k)
for (int k = 1; k < L; k++) {
// 前缀长度为k, 后缀长度为 L-k
if (F[k] == -1 || H[L - k] == -1) {
// 如果前缀或后缀在 s 中不存在,跳过这个分割
continue;
}
// 前缀部分在 s 中的结束位置 (0-indexed)
int end_A = F[k] + k - 1;
// 后缀部分在 s 中的开始位置 (0-indexed)
// H[L-k] 是在 s_rev 中的起始位置 (0-indexed)
// 换算回 s 中的坐标
int start_B = n - (H[L-k] + (L - k));
// 检查是否不重叠
if (end_A < start_B) {
found = true;
break; // 找到一个就行了!
}
}
if (found) {
ans++;
}
}
cout << ans << endl;
return 0;
}算算时间喵!
时间复杂度: O(Σ|pᵢ| + m * |s|) 的说。 对于
m个美丽单词中的每一个,我们都需要:- 构建
p和p_rev的fail数组,耗时 O(|p|)。 - 在
s和s_rev上进行KMP匹配,耗时 O(|s|)。 所以处理一个单词的总时间是 O(|p| + |s|)。总的来说就是所有单词长度之和,再加上m次扫描主串s的时间。因为m和|p|都比较小,这个复杂度是完全可以接受的!
- 构建
空间复杂度: O(|p|{max} + |s|) 的说。 我们需要空间来存储
s_rev,这需要 O(|s|) 的空间。对于每个单词,我们还需要p_rev、fail数组、F和H数组,这些都与|p|的最大长度有关,即 O(|p|)。所以总空间由这两部分主导。
喵喵小课堂~
这道题真是太棒了,把 KMP 算法玩出了花,主人你学会了吗?总结一下核心知识点呐:
KMP 算法的灵活应用: KMP 不仅仅是用来判断一个串是否在另一个串里出现过。它的
fail数组和匹配过程蕴含了模式串所有前缀的匹配信息,非常强大!正反串技巧: 这是字符串问题中的一个经典“大招”!当你需要处理后缀相关的问题,或者需要从字符串尾部开始匹配时,不妨试试把字符串整个反转过来。这样,后缀问题就变成了我们更熟悉的前缀问题,处理起来就顺手多啦!
预处理与组合: 我们的解法完美体现了“预处理”的思想。我们不是对每个分割点都去
s中暴力查找,而是先通过两次 KMP 预处理出所有前缀和后缀的最优位置信息,最后再用一个简单的循环将这些信息组合起来进行判断,大大提高了效率。
希望这篇题解能帮到你,喵~!继续加油,算法的世界还有更多有趣的冒险在等着我们呢!(>ω<)ノ