给定一个文本 t 和一个字符串 s,我们尝试找到并展示 s 在 t 中的所有出现(occurrence)。
为了简便起见,我们用 n 表示字符串 s 的长度,用 m 表示文本 t 的长度。
我们构造一个字符串 s + \# + t,其中 \# 为一个既不出现在 s 中也不出现在 t 中的分隔符。接下来计算该字符串的前缀函数。现在考虑该前缀函数除去最开始 n + 1 个值(即属于字符串 s 和分隔符的函数值)后其余函数值的意义。根据定义,\pi[i] 为右端点在 i 且同时为一个前缀的最长真子串的长度,具体到我们的这种情况下,其值为与 s 的前缀相同且右端点位于 i 的最长子串的长度。由于分隔符的存在,该长度不可能超过 n。而如果等式 \pi[i] = n 成立,则意味着 s 完整出现在该位置(即其右端点位于位置 i)。注意该位置的下标是对字符串 s + \# + t 而言的。
因此如果在某一位置 i 有 \pi[i] = n 成立,则字符串 s 在字符串 t 的 i - (n - 1) - (n + 1) = i - 2n 处出现。
正如在前缀函数的计算中已经提到的那样,如果我们知道前缀函数的值永远不超过一特定值,那么我们不需要存储整个字符串以及整个前缀函数,而只需要二者开头的一部分。在我们这种情况下这意味着只需要存储字符串 s + \# 以及相应的前缀函数值即可。我们可以一次读入字符串 t 的一个字符并计算当前位置的前缀函数值。
在该节我们将同时讨论两个问题。给定一个长度为 n 的字符串 s,在问题的第一个变种中我们希望统计每个前缀 s[0 \dots i] 在同一个字符串的出现次数,在问题的第二个变种中我们希望统计每个前缀 s[0 \dots i] 在另一个给定字符串 t 中的出现次数。
首先让我们来解决第一个问题。考虑位置 i 的前缀函数值 \pi[i]。根据定义,其意味着字符串 s 一个长度为 \pi[i] 的前缀在位置 i 出现并以 i 为右端点,同时不存在一个更长的前缀满足前述定义。与此同时,更短的前缀可能以该位置为右端点。容易看出,我们遇到了在计算前缀函数时已经回答过的问题:给定一个长度为 j 的前缀,同时其也是一个右端点位于 i 的后缀,下一个更小的前缀长度 k < j 是多少?该长度的前缀需同时也是一个右端点为 i 的后缀。因此以位置 i 为右端点,有长度为 \pi[i] 的前缀,有长度为 \pi[\pi[i] - 1] 的前缀,有长度为 \pi[\pi[\pi[i] - 1] - 1] 的前缀,等等,直到长度变为 0。故而我们可以通过下述方式计算答案。
vector<int> ans(n + 1);
for (int i = 0; i < n; i++) ans[pi[i]]++;
for (int i = n - 1; i > 0; i--) ans[pi[i - 1]] += ans[i];
for (int i = 0; i <= n; i++) ans[i]++;
在上述代码中我们首先统计每个前缀函数值在数组 \pi 中出现了多少次,然后再计算最后答案:如果我们知道长度为 i 的前缀出现了恰好 \text{ans}[i] 次,那么该值必须被叠加至其最长的既是后缀也是前缀的子串的出现次数中。在最后,为了统计原始的前缀,我们对每个结果加 1。
现在考虑第二个问题。我们应用来自 Knuth-Morris-Pratt 的技巧:构造一个字符串 s + \# + t 并计算其前缀函数。与第一个问题唯一的不同之处在于,我们只关心与字符串 t 相关的前缀函数值,即 i \ge n + 1 的 \pi[i]。有了这些值之后,我们可以同样应用在第一个问题中的算法来解决该问题。
我们将迭代的解决该问题。换句话说,在知道了当前的本质不同子串的数目的情况下,我们要找出一种在 s 末尾添加一个字符后重新计算该数目的方法。
令 k 为当前 s 的本质不同子串数量。我们添加一个新的字符 c 至 s。显然,会有一些新的子串以字符 c 结尾。我们希望对这些以该字符结尾且我们之前未曾遇到的子串计数。
构造字符串 t = s + c 并将其反转得到字符串 t^{\sim}。现在我们的任务变为计算有多少 t^{\sim} 的前缀未在 t^{\sim} 的其余任何地方出现。如果我们计算了 t^{\sim} 的前缀函数最大值 \pi_{\max},那么最长的出现在 s 中的前缀其长度为 \pi_{\max}。自然的,所有更短的前缀也出现了。
由于 \gcd(k, p) 整除 p,这意味着 \gcd(k, p) 是 r 的一个周期。又因为 \pi[n - 1] > n - p,故有 n - \pi[n - 1] = k < p,所以 \gcd(k, p) 是一个比 p 更小的 r 的周期。因此字符串 s 有一个长度为 \gcd(k, p) < p 的压缩表示,同 p 的最小性矛盾。
因此,即使没有字符串 t,我们同样可以应用构造转移表的算法构造一个转移表 ( \text { old } \pi , c ) \rightarrow \text { new } _ { - } \pi:
void compute_automaton(string s, vector<vector<int>>& aut) {
s += '#';
int n = s.size();
vector<int> pi = prefix_function(s);
aut.assign(n, vector<int>(26));
for (int i = 0; i < n; i++) {
for (int c = 0; c < 26; c++) {
int j = i;
while (j > 0 && 'a' + c != s[j]) j = pi[j - 1];
if ('a' + c == s[j]) j++;
aut[i][c] = j;
}
}
}
然而在这种形式下,对于小写字母表,算法的时间复杂度为 O(|\Sigma|n^2)。注意到我们可以应用动态规划来利用表中已计算过的部分。只要我们从值 j 变化到 \pi[j - 1],那么我们实际上在说转移 (j, c) 所到达的状态同转移 (\pi[j - 1], c) 一样,但该答案我们之前已经精确计算过了。
void compute_automaton(string s, vector<vector<int>>& aut) {
s += '#';
int n = s.size();
vector<int> pi = prefix_function(s);
aut.assign(n, vector<int>(26));
for (int i = 0; i < n; i++) {
for (int c = 0; c < 26; c++) {
if (i > 0 && 'a' + c != s[i])
aut[i][c] = aut[pi[i - 1]][c];
else
aut[i][c] = i + ('a' + c == s[i]);
}
}
}
最终我们可在 O(|\Sigma|n) 的时间复杂度内构造该自动机。
该自动机在什么时候有用呢?首先,记得大部分时候我们为了一个目的使用字符串 s + \# + t 的前缀函数:寻找字符串 s 在字符串 t 中的所有出现。
因此使用该自动机的最直接的好处是 加速计算字符串 s + \# + t 的前缀函数。
通过构建 s + \# 的自动机,我们不再需要存储字符串 s 以及其对应的前缀函数值。所有转移已经在表中计算过了。
但除此以外,还有第二个不那么直接的应用。我们可以在字符串 t 是 某些通过一些规则构造的巨型字符串 时,使用该自动机加速计算。Gray 字符串,或者一个由一些短的输入串的递归组合所构造的字符串都是这种例子。
出于完整性考虑,我们来解决这样一个问题:给定一个数 k \le 10^5,以及一个长度 \le 10^5 的字符串 s,我们需要计算 s 在第 k 个 Gray 字符串中的出现次数。回想起 Gray 字符串以下述方式定义: