[字符串学习笔记] 2. 前缀函数与 KMP 算法
2.1. 前缀函数的定义
对于字符串 \(s\),若存在一个字符串 \(t\) 使得 \(t\) 既是 \(s\) 的 真前缀,也是 \(s\) 的 真后缀,称 \(t\) 是 \(s\) 的 Border 之一,即 \(t \in \operatorname{Border}(s)\)。
而 \(s\) 的 前缀函数 为一个长度为 \(|s|\) 的有序序列 \(\pi\)。\(\pi[i]\) 被定义为 \(\max\limits_{t \in \operatorname{Border}(s[0 \ldots i])} |t|\)。特别地,\(\pi[0] = 0\)。
例如,\(\texttt{ababab}\) 的前缀函数为 \([0, 0, 1, 2, 3, 4]\)。
2.2. 前缀函数的实现
2.2.1. 朴素实现
直接按照上式模拟即可。考虑到 substr() 的时间复杂度,总复杂度为 \(\Theta(n^3)\)。
2.2.2. 基于前缀函数值增长的优化
首先注意到,\(\pi[i + 1] \leq \pi[i] + 1\)。
若想最大化 \(\pi[i + 1]\),则需让新字符 \(s[i + 1] = s[\pi[i]]\):
只有 当 \(s[\pi[i]] = s[i + 1]\) 时,\(\pi[i + 1] = \pi[i] + 1\),否则 \(\pi[i + 1]\) 一定 \(\leq \pi[i]\)。
在实现中,枚举 \(\pi[i + 1]\) 最大值的 \(j\) 不需要 从 \(i + 1\) 开始,而是从 \(\pi[i] + 1\) 开始。
时间复杂度 \(\Theta(n^2)\)。
2.2.3. 基于前缀函数值非增长的优化
本节讨论 \(s[\pi[i]] \neq s[i + 1]\) 的情况。
令 \(\pi_k[i]\) 代表 \(\operatorname{Border}(s[0 \ldots i])\) 中 第 \(k\) 长的 字符串的长度。下图是 \(k = 2\) 时的例子:
在找到长度 \(\pi_k[i]\) 时,再次比较 \(s[\pi_k[i]]\) 与 \(s[i + 1]\)。
同理,也 只有 \(s[\pi_k[i]] = s[i + 1]\) 时,\(\pi_k[i + 1] = \pi_k[i] + 1\),否则,还需要找到长度 仅次于 \(\pi_k[i]\) 的 \(\pi_{k + 1}[i]\),使 Border 的性质保留。
如此往复,若一直循环到 \(\pi_{k_{\max}} = 0\) 时还是失配(\(s[0] \neq s[i + 1]\)),说明 \(s[0 \ldots i + 1]\) 不存在 Border,\(\pi[i + 1] = 0\)。
根据定义,有
而因为 \(s[i - \pi_2[i] + 1 \ldots i]\) 为 \(s[i - \pi[i] + 1 \ldots i]\) 的后 \(\pi_2[i]\) 位,而 \(s[0 \ldots \pi[i] - 1] = s[i - \pi[i] + 1 \ldots i]\),所以同样取 \(s[0 \ldots \pi[i] - 1]\) 的后 \(\pi_2[i]\) 位:
可以发现,上式的两个子串是子串 \(s[0 \ldots \pi[i] - 1]\) 的一个 Border。而因为 \(\pi_2[i]\) 仅次于 \(\pi[i]\),这两个子串必定是 \(s[0 \ldots \pi[i] - 1]\) 的 最长 Border,即 \(\pi_2[i]\) 相当于 \(\pi[\pi[i] - 1]\)。
同理,若代入 \(k = 3\) 可得到 \(\pi_3[i] = \pi[\pi_2[i] - 1]\)。
因此,得出递推式 \(\pi_k[i] = \pi[\pi_{k - 1}[i] - 1]\),\(\pi_{k - 1}[i] > 0\)。
2.2.4. 线性算法实现
时间复杂度 \(\Theta(|s|)\)。
vector<int> getpi(string s) {
vector<int> pi(s.size()); // pi[0] = 0
for (int i = 1; i < s.size(); i++) {
pi[i] = pi[i - 1];
while (pi[i] != 0 && s[i] != s[pi[i]])
pi[i] = pi[pi[i] - 1];
if (s[i] == s[pi[i]]) pi[i]++;
}
return pi;
}
例题 CF126B Password
题解
假设 \(t\) 在 \(s\) 中间出现时 右端点 为 \(i\),显然有
可以发现,
- \(s[0 \ldots |t| - 1] = s[i - |t| + 1 \ldots i]\) 是 \(s[0 \ldots i]\) 的 Border。
- \(s[0 \ldots |t| - 1] = s[|s| - |t| + 1 \ldots |s| - 1]\) 是 \(s[0 \ldots |s| - 1]\) 的 Border。
于是,问题转变为找到一个 最长的 子串 \(t\),满足 \(t\) 既是 \(s[0 \ldots i]\) 的 Border,又是 \(s[0 \ldots |s| - 1]\) 的 Border。
根据题意,\(0 < i < |s| - 1\),因此 \(|t| \leq \max\limits_{0 < i < |s| - 1} \pi[i]\)。令其为 \(\pi_{\max}\)。
在 基于前缀函数值增长的优化 一节中,已经讨论了递推出第 \(k\) 长的 Border 的方法。在本题中,从 \(\pi[|s| - 1]\) 开始,用该方法推出 第一个 \(\leq \pi_{max}\) 的 Border 长度,也就相当于 最大的 \(|t|\)。
时间复杂度 \(\Theta(|s|)\)。参考代码:
signed main() {
cin >> s;
vector<int> pi = getpi(s);
int p = pi[s.size() - 1], pi_max = 0;
for (int i = 1; i < pi.size() - 1; i++)
pi_max = max(pi_max, pi[i]);
while (p > pi_max)
p = pi[p - 1];
if (p)
cout << s.substr(0, p);
else
cout << "Just a legend";
}
例题 SP34020 ADAPET - Ada and Pet
题解
\(s\) 的前 \(\pi[|s| - 1]\) 位与后 \(\pi[|s| - 1]\) 相等,那么在拼接 \(n\) 个 \(s\) 时就可以去除重叠的 \((n - 1) \times \pi[|s| - 1]\) 个字符。
参考代码:
void solve(int tc) {
cin >> s >> n;
vector<int> pi = getpi(s);
cout << s.size() * n - pi[s.size() - 1] * (n - 1) << '\n';
}
2.3. 字符串匹配:Knuth-Morris-Pratt 算法
给定模式串 \(t\) 与待匹配串 \(s\),找出 \(t\) 在 \(s\) 中出现的所有位置。默认 \(|t| < |s|\)。
构造字符串 \(p = t + \texttt \# + s\)(接下来的所有定义中,\(\texttt \#\) 是 \(s, t\) 字符集中未出现的 分隔符)。
考虑分隔符 \(\texttt \#\) 后 \(p\) 的前缀函数 \(\pi[i]\) 的意义。
由于分隔符的存在,必定有 \(\pi[i] \leq |t|\)。仅有 \(i \geq 2 \cdot |t|\) 时 \(\pi[i]\) 才有可能 \(= |t|\),此时有 \(p[0 \ldots |t| - 1] = p[i - |t| + 1 \ldots i]\)。
根据构造 \(p\) 的定义,\(p[0 \ldots |t| - 1]\) 就是 \(t\) 原串,而 \(p[i - |t| + 1 \ldots i]\) 也就是 \(t\) 在 \(s\) 所 对应的 出现位置。不难发现,\(t\) 出现的下标对应 \(i - 2 \cdot |t|\)。
时间复杂度 \(\Theta(|p|) = \Theta(|s| + |t|)\)。
vector<int> KMP(string s, string t) {
string p = t + '#' + s;
vector<int> pi = getpi(p), res;
for (int i = t.size() * 2; i < p.size(); i++)
if (pi[i] == t.size())
res.push_back(i - t.size() * 2);
return res;
}
例题 CF471D MUH and Cube Walls
题解
由于判断 \(b\) 在 \(a\) 的某一段形状是否相同时,只关注 相对的高度变化。可以利用差分来维护两个相近高度的变化。
具体地,先计算 \(b[1] \sim b[|b| - 1]\) 的差分值,再从 \(a[1]\) 尝试 KMP 匹配。这里将整数数组当作字符串,而分隔符可以取一个不可能在差分数组中出现的值。
参考代码:
int n, m;
vector<int> getpi(vector<int> s) {
vector<int> pi(s.size()); // pi[0] = 0
for (int i = 1; i < s.size(); i++) {
pi[i] = pi[i - 1];
while (pi[i] != 0 && s[i] != s[pi[i]])
pi[i] = pi[pi[i] - 1];
if (s[i] == s[pi[i]]) pi[i]++;
}
return pi;
}
int KMP(vector<int> s, vector<int> t) {
vector<int> p = t;
p.push_back(2e9); // 分隔符
p.insert(p.end(), s.begin() + 1, s.end());
int occ = 0;
vector<int> pi = getpi(p), res;
for (int i = t.size() * 2; i < p.size(); i++)
if (pi[i] == t.size())
occ++;
return occ;
}
signed main() {
cin >> n >> m;
vector<int> a(n), b(m);
for (int &i : a)
cin >> i;
for (int &i : b)
cin >> i;
adjacent_difference(a.begin(), a.end(), a.begin());
adjacent_difference(b.begin(), b.end(), b.begin());
b.erase(b.begin());
cout << KMP(a, b);
}
例题 UVA12467 Secret Word
题解
该题运用了类似 KMP 的思想。
此处定义 \(\operatorname{rev}(p)\) 为 \(p\) 的逆序串。
类似地,因为 Secret Word \(t\) 的逆序 \(\operatorname{rev}(t)\) 是 \(s\) 的 前缀,构造 \(p = s + \texttt \# + \operatorname{rev}(s)\)。
与 KMP 算法相同,考虑分隔符 \(\texttt \#\) 后 \(p\) 的前缀函数 \(\pi[i]\) 的意义。
根据定义,有
代入 \(s\) 中,则有:
当 \(t\) 取 \(s[2 \cdot |s| - i \ldots 2 \cdot |s| - i + \pi[i] - 1]\) 时,\(\operatorname{rev}(t)\) 显然就是 \(s\) 的前缀。
此时已经满足了两点要求:
- \(t\) 是 \(s\) 的非空子串;
- \(\operatorname{rev}(t)\) 为 \(s\) 的前缀。
由于 \(\pi[i]\) 是 最长 Border 的长度,也就一定是 左端点 为 \(2 \cdot |s| - i\) 且符合条件的 最长 子串的长度。因此,只需要求出 \(\pi[i]\) 的 最大值 即可。
时间复杂度 \(\Theta(|s|)\)。参考代码:
void solve() {
cin >> s;
string rev_s = s;
reverse(rev_s.begin(), rev_s.end());
string p = s + '#' + rev_s;
vector<int> pi = getpi(p);
int maxlen = 0;
for (int i = rev_s.size() + 1; i < pi.size(); i++)
maxlen = max(maxlen, pi[i]);
for (int i = maxlen - 1; i >= 0; i--)
cout << s[i];
cout << '\n';
}
例题 CF25E Test
题解
考虑这个问题的弱化版:
给定字符串 \(s_1, s_2\),求最短的字符串 \(t\) 使得 \(s_1, s_2\) 都是 \(t\) 的子串。
首先考虑包含关系:
- 如果 \(s_1\) 是 \(s_2\) 的子串,\(t = s_2\)。
- 如果 \(s_2\) 是 \(s_1\) 的子串,\(t = s_1\)。
若不存在包含关系,假设 \(s_1\) 在 \(t\) 中的出现位置 严格小于 \(s_2\)。
通过计算 \(s_2 + \texttt \# + s_1\) 的前缀函数,去除中间重叠的部分。例如,当 \(s_1 = \texttt{ab} \texttt{\color{#9f3}cab}\),\(s_2 = \texttt{\color{#9f3}cab} \texttt{abc}\) 的情况,则 \(t = \texttt{ab} \texttt{\color{#9f3}cab} \texttt{abc}\)。
同理,题目要求查询 \(s_1, s_2, s_3\) 三个字符串,也就是 \(s_1, s_2\) 的结果与 \(s_3\) 再次处理。而由于 \(s_1, s_2, s_3\) 可以以不同的顺序出现,枚举其所有排列即可。
string concat(string s, string t) {
if (KMP(s, t)) return s;
if (KMP(t, s)) return t;
string p = t + '#' + s;
vector<int> pi = getpi(p);
return s + t.substr(pi[p.size() - 1]);
}
signed main() {
cin >> s[0] >> s[1] >> s[2];
sort(s, s + 3);
do
res = min(res, (int)concat(concat(s[0], s[1]), s[2]).size());
while (next_permutation(s, s + 3));
cout << res;
}
例题 CF1200E Compress Words
题解
按照题意模拟。
同上题,先设立答案串 \(\mathit{res}\),构造字符串 \(p = s + \texttt \# + \mathit{res}\),计算前缀函数并去除重叠部分。
考虑优化。由于 \(\pi[|p| - 1] < |s|\),只保留 \(\mathit{res}\) 的后 \(|s|\) 个字符并不影响答案。
参考代码:
signed main() {
cin >> n >> s;
res = s;
for (int i = 2; i <= n; i++) {
cin >> s;
int l = min(res.size(), s.size());
string p = s + '#' + res.substr(res.size() - l);
vector<int> pi = getpi(p);
res += s.substr(pi[p.size() - 1]);
}
cout << res;
}
本题也可以使用 Hash 完成。
2.4. 字符串循环
如果字符串 \(s\) 由 \(k ~ (k \mid s)\) 个子串 \(t\) 首尾拼接而成,则 \(s\) 是 \(t\) 的循环之一。
在已知 \(s\) 的情况下,定义 长度最小 的 \(t\) 为 \(t_{\min}\)。
由于 \(s\) 是 \(t_{\min}\) 的循环,其前缀函数值有 特殊的规律。
易证,\(\forall |t_{\min}| \leq i \leq |s| - 1\),有 \(\pi[i] = i - |t_{\min}| + 1\)。以 \(\texttt{abaabaaba}\) 为例,
可以看到,
则
但是,如果 \((|s| - \pi[|s| - 1]) \nmid |s|\),易证 不存在 \(t \neq s\) 使得 \(s\) 是 \(t\) 的循环。此时 \(t\) 唯一的可能就是 \(s\) 本身。
例题 UVA10298 Power Strings
题解
模版题。参考代码:
void solve() {
cin >> s;
int n = s.size();
vector<int> pi = getpi(s);
int cycle_len = n - pi[n - 1];
if (n % cycle_len == 0)
cout << n / cycle_len << '\n';
else
cout << 1 << '\n';
}
习题
参见 前缀函数与 KMP 习题。

浙公网安备 33010602011771号