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\) 个。

现在我们考虑分情况:

  1. \(j = m\),说明已经找到一个出现位置,我们输出 \(i - m + 1\),然后将 \((i,j) \to (i - m + 1, 0)\)

  2. \(j < m\)\(s_{i + 1} = t_{j + 1}\),说明匹配上了,我们将 \((i,j) \to (i + 1, j + 1)\)

  3. 当以上情况都不成立时,意味着当前的匹配是失败的,由于这次是从 \(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\) 的前后缀相等。

这个数组有一些很重要的性质。

最重要的性质就是:

\[\{k|0 \le k < i, s[1, k] = s[i - k + 1, i]\} = \{\pi[i], \pi[\pi[i]], \pi[\pi[\pi[i]]]\dots, 0\}\]

为什么?其实不难证明,显然若干个 \(\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]\)。然后分情况:

  1. 如果 \(r < i\),我们直接暴力求就可以了。

  2. 否则,如果 \(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

正着来一遍反着来一遍,然后区间求交即可。

posted @ 2024-08-03 14:20  rlc202204  阅读(13)  评论(0编辑  收藏  举报