KMP 与 Z 函数
1. KMP
KMP 用来解决与字符串匹配相关的问题。
1.1 问题描述
我们当前有一个模式串 \(T\),我们希望找到所有 \(T\) 在母串 \(S\) 中的出现位置。
记 \(n = |S|, m = |T|\)。
1.2 Brute-Force 算法
我们考虑依次记录二元组 \((i,j)\) 表示当前已经匹配了 \(T\) 的前 \(j\) 位,最后一位与 \(S\) 匹配的位置是 \(S\) 中的第 \(i\) 个。
现在我们考虑分情况:
-
\(j = m\),说明已经找到一个出现位置,我们输出 \(i - m + 1\),然后将 \((i,j) \to (i - m + 1, 0)\)。
-
\(j < m\) 且 \(s_{i + 1} = t_{j + 1}\),说明匹配上了,我们将 \((i,j) \to (i + 1, j + 1)\)。
-
当以上情况都不成立时,意味着当前的匹配是失败的,由于这次是从 \(i - j + 1\) 开始的,所以我们将 \((i, j) \to (i - j + 1, 0)\),重新开始新一轮的匹配。
然后我们分析一下时间复杂度。显然在最坏情况下会达到 \(O(nm)\),即每一个位置都能匹配。
但是如果 \(S, T\) 都是随机生成的,根据期望,我们发现匹配次数越长概率越小,所以期望时间复杂度是 \(O(n)\) 的。
这个算法肯定不优,但是 KMP 就是从这个算法加入优化改进的。
1.3 失配数组
为了理解 KMP, 我们需要理解一个强大的东西——失配数组 \(\pi\)。
不妨设字符串 \(S = s[1,2, \dots, n]\),则失配数组 \(\pi[0 \dots n]\) 是这个字符串的失配数组。
其中 \(\pi[i]\) 表示 \(\max\{k|0 \le k < i, s[1, k] = s[i - k + 1, i]\}\),也就是长度为 \(k\) 的前后缀相等。
这个数组有一些很重要的性质。
最重要的性质就是:
为什么?其实不难证明,显然若干个 \(\pi\) 的复合依然是相等的前后缀,而如果是相等的前后缀,可以归纳证明其可以表示为 \(\pi\) 的复合。
我们考虑如何求出这个数组,显然直接暴力算是不好的,我们考虑已经计算出了 \(\pi[1, i - 1]\),现在要计算 \(\pi[i]\)。
我们可以借助之前的结果,我们知道,如果 \(s[1, k] = s[i - k + 1, i]\),就会得到:\(s[1, k - 1, i - k + 1, i - 1]\)。
所以我们可以从 \(s[1, i - 1]\) 的前后缀集合递推得到 \(\pi[i]\),我们维护指针 \(cur\),刚开始 \(cur = \pi[i - 1]\),如果 \(s[cur + 1] = s[i]\),则 \(\pi[i] = cur + 1\),否则我们就让 \(cur = \pi[cur]\) 并重复这个过程。
下面是代码,我们将其称作 nxt 数组,并且注意下面是由 \(0\) 编号的:
vector<int> getnxt(string s) {
vector<int> nxt((int)s.size() + 1, 0);
for (int i = 1; i < (int)s.size(); i++) {
int cur = nxt[i];
while (cur > 0 && s[cur] != s[i])
cur = nxt[cur];
if (s[cur] == s[i])
nxt[i + 1] = cur + 1;
}
return nxt;
}
我们考虑其复杂度,我们发现,每次 \(\pi\) 只会至多增加 \(1\),但是每次往回跳都会减少,由于至多增加 \(n\),所以往回跳也至多只有 \(n\) 次。所以时间复杂度是 \(O(|S|)\) 的。
1.4 KMP 主过程
我们思考失配数组能如何帮助我们优化字符串匹配。我们发现,在 BF 算法中,匹配失败后我们会重新开始比较,但是其实有很多位置我们在比较到 \(i\) 时还是不行的。
我们思考什么样的位置可以使得比较到 \(i\) 依然可行,那就是所有的 \(t[1, i]\) 的相等前后缀!
所以我们每次可以不用把 \(j \to 0\),而是把 \(j \to \pi[j]\),然后 \(i\) 保持不变,由于之前的都是匹配的,所以现在只需比较 \(s_{i + 1}\) 和 \(s_{j + 1}\) 即可。这就是 KMP。
下面给出代码:
void kmp(string s, string t) {
vector<int> nxt = getnxt(t);
for (int i = 0, j = 0; i <= (int)s.size(); i++) {
if (j == (int)t.size()) {
cout << i - (int)t.size() + 1 << endl;
j = nxt[j];
}
if (i == (int)s.size())
break;
while (j > 0 && s[i] != t[j])
j = nxt[j];
if (s[i] == t[j])
j++;
}
for (int i = 1; i <= (int)t.size(); i++)
cout << nxt[i] << " ";
}
时间复杂度分析,我们发现每次都有 \(i\) 的增加,而 \(i\) 至多增加 \(n\) 次,\(j\) 也至多增加 \(n\) 次,所以 \(j \to \pi[j]\) 也不会超过 \(n\) 次,所以总时间复杂度是 \(O(n)\) 的。
1.5 KMP 应用
循环同构
判断两个字符串 \(S\) 和 \(T\) 是否循环同构,我们可以通过将一个字符串复制一次接到末尾,然后进行另一个串的字符串匹配即可。
2. Z 函数
Z 函数也称作扩展 KMP。
2.1 问题描述
我们希望求一个数组 \(z\),其中 \(z[i]\) 表示 \(s[1, n]\) 与 \(s[i, n]\) 的最长公共前缀长度。
2.2 Z 函数求解
我们考虑递推的思路,假设当前已经算出了 \(z[1, i - 1]\),我们计算 \(z[i]\)。
我们考虑一个东西叫 Z-Box,由若干个区间 \([l, l + z[l] - 1]\),我们记这些区间最大的右端点为 \(r\),这个区间是 \([l,r]\)。然后分情况:
-
如果 \(r < i\),我们直接暴力求就可以了。
-
否则,如果 \(z[i]\) 设成 \(\min(z[i - l + 1], r - i + 1)\),然后暴力比较。
我们发现这个算法 \(r\) 每次都会增加且至多增加 \(n\) 次,所以这个算法时间复杂度是 \(O(n)\) 的。
vector<int> getZ(string &s) {
vector<int> z((int)s.size() + 1, 0);
z[1] = (int)s.size();
for (int i = 2, l = 0, r = 0; i <= (int)s.size(); i++) {
z[i] = (i > r) ? 0 : min(r - i + 1, z[i - l + 1]);
while (s[z[i]] == s[i + z[i] - 1])
z[i]++;
if (i + z[i] - 1 > r)
l = i, r = i + z[i] - 1;
}
return z;
}
2.3 应用
CF432D Prefixes and Suffixes
失配树子树大小可以做,也可以用 Z 函数统计后缀和。
UVA11475 Extend to Palindrome
翻转一份放到原串前面,然后求 Z 函数,每次用 Z 函数判断是否可行即可。
[ARC055C] ABCAC
正着来一遍反着来一遍,然后区间求交即可。