题解 POI 2006 OKR-Periods of Words
前排提醒:fail
函数 / 数组 和 next
函数 / 数组 是同一个东西,叫法存在差异,是用于解决失配情况的。个人在这篇题解里使用了 next
的说法,若有差异,敬请理解包涵。
题目大意
个人认为上述 OJ 上的题面足够简洁易懂,故不作解释。
大致思路
见下例,可见答案即为给定字符串所有前缀的最短公共前后缀长度之和。
假定给出的输入为 abababababa
,首先我们找出它的其中一个前缀 ababababab
并处理。过程如下:
- 在
ababababab
中揪出来一个前缀abababab
; - 将
abababab
翻倍变成abababababababab
; - 合格性检验:将
abababab|abababab
与abababab|ab
进行比对(因为方便观看所以加了竖线),发现后者较提取出的前缀多出来一个ab
,这个ab
为揪出来的前缀abababab
的最短公共前后缀。
详细做法
此题重难点在「将题目转换成求取最短公共前后缀问题」、「求取最短公共前后缀」和「优化求取过程」这三方面。
· 问题转化
我们还对上述样例进行分析。
假定给出的输入为 abababababa
,首先我们找出它的其中一个前缀 ababababab
并处理。处理使用手推,过程如下:
- 在
ababababab
中揪出来一个前缀abababab
(因为是手推所以找一些方便找规律的); - 将
abababab
翻倍变成abababababababab
; - 合格性检验:将
abababab|abababab
与abababab|ab
进行比对,发现后者较提取出的前缀多出来一个ab
,这个ab
为揪出来的前缀abababab
的前缀。
接下来一组对照:
- 在
ababababab
中重新揪出来一个前缀ababab
(因为是手推所以再找一个,程序里不会这么做); - 将
ababab
翻倍变成abababababab
; - 合格性检验:将
ababab|ababab
与ababab|abab
进行比对,发现后者较提取出的前缀多出来一个abab
,这个abab
为揪出来的前缀ababab
的前缀。
诶,阅读题目,我们发现我们揪出来的两个前缀都是在处理的原串前缀的「周期」。而我们要的是最大周期,所以需要保留 abababab
,而不保留 ababab
。
分析 abababab
的性质,多出来的 ab
为揪出来的前缀 abababab
的最短公共前后缀,周期最大就需要公共前后缀最短。而这些原串前缀所需要累加的长度为 最大周期长度 = 字符串长度 - 最短公共前后缀长度。
这个题目就这样通过模拟一下转换完了。
· 求取答案
求取最短公共前后缀的过程不难,但是需要对 KMP 算法的 next
数组有一定的理解。
个人理解:next
数组的意义是,当跑到模式串的第 \(i\) 位时若失配,则跳转到第 \(next_i\) 位继续匹配,是以 \(i\)(下标从 \(1\) 开始)结尾的后缀与串的前缀的最长公共字符串长度,性质满足在长度为 \(i\) 的字符串前缀中,从第 \(1\) 位到第 \(next_i\) 位的字符串是其最长公共前后缀。
等下,出现了最长公共前后缀,和最短公共前后缀只差一个字了,我们需要把它转换成最短公共前后缀。我们可以通过递归 next
数组的方式找到最短公共前后缀:一直递归直到 next[POINTER] = 0
,最短公共前后缀则为从 \(1\) 到 POINTER
的字符串。
用一个例子来解释会更清晰明了。
我们现在有一个字符串 abababcababxyzabababcabab
,我们需要求它的最短公共前后缀。
首先跑出来 next
数组的结果为 0 0 1 2 3 4 0 1 2 3 4 0 0 0 1 2 3 4 5 6 7 8 9 10 11
。
然后设定变量 POINTER = 25
,为需要处理的字符串长度。然后进行处理:
- 在这个字符串中,
next[POINTER] = 11
,指向字母b
,指向长度为 \(11\),指向字符串为abababcabab
,赋值:POINTER = next[POINTER]
。 - 在这个字符串(
abababcabab
)中,next[POINTER] = 4
,指向字母b
,指向长度为 \(4\),指向字符串为abab
,赋值:POINTER = next[POINTER]
。
特殊说明:无需再跑一遍next
数组,因为整个字符串的next
数组的前面11
位即为此字符串的next
数组。 - 在这个字符串(
abab
)中,next[POINTER] = 2
,指向字母b
,指向长度为 \(2\),指向字符串为ab
,赋值:POINTER = next[POINTER]
。 - 在这个字符串(
ab
)中,next[POINTER] = 0
,递归结束。从第一位到第POINTER
位(字符串ab
)则为大字符串abababcababxyzabababcabab
的最短公共前后缀,长度为POINTER = 2
。
代码实现就很简单了,while (nxt[POINTER]) POINTER = nxt[POINTER];
一句就可以完成。
所有的有最长公共前后缀的字符串都可以拆成 \(\rm ABA\) 的形式,其中 \(\rm A\) 长度不为 \(0\)。我们将 \(\rm A\) 拆到最小、不可拆的形式的时候,这个最终的 \(\rm A\) 就是最短公共前后缀。
· 优化过程
考虑一个极端情况:出题人嘿嘿一笑,「我将,扭转万象!」然后给了你一个数据。我将其缩小到了原来的 \(\frac{1}{40000}\),放到了第一行:
25 aaaaaaaaaaaaaaaaaaaaaaaaa
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
第二行,如你所见,是跑完 next
数组的结果。这意味着处理这个长字符串的时候你要递归 \(24\) 次,原数据 \(999999\) 次。而这是输入数据,是你要跑的最长的字符串,总共跑完要 \(499999500000\) 次,这个 \(O(n^2)\) 的算法直接会时间超限。
提供两种优化,第一种可以优化到 \(O(n\log n)\),第二种可以到 \(O(n)\)。第一种是倍增,不再赘述,可以去参考「倍增求 LCA」。
这里主要讲解记忆化搜索。很简单,马上就好。
当你跑完 while
之后,如果 POINTER
不等于你在处理的字符串长度,这证明 while
跑了至少一次。接下来覆盖原先的 next
数组。将处理顺序写为长度从短到长,写好 next
数组的覆盖,就可以了。
代码
Click to show code
#include <bits/stdc++.h>
using namespace std;
int nxt[1919810]; // next 数组
string p; // 读入的字符串,但是因为要跑 getNext 所以写了 pattern 而非 string
int lp; // 字符串的长度(length)
void getNext () { // 跑 next 数组,如果这里不清楚请重新学习 KMP
int j = 0;
nxt[1] = 0;
for (int i = 1; i < lp; i++){
while (j && p[j] != p[i]) j = nxt[j];
if(p[j] == p[i]) j++;
nxt[i + 1] = j;
}
}
signed main () {
cin >> lp >> p;
getNext ();
long long ans = 0;
for (int i = 1; i <= lp; i++) {
int j = i; // j 是指针(POINTER),意义见上文
while (nxt[j]) j = nxt[j]; // 求最短公共前后缀
if (j != i) nxt[i] = j; // 记忆化搜索
ans += i - j; // 处理字符串长度 - 最短公共前后缀长度
}
cout << ans;
}