学习笔记 // KMP

智力问我,为什么要学 KMP 呢?时间复杂度甚至不如字符串哈希!

我说,智力,你要不猜猜为什么这个世界上有扩展 KMP,但是没有扩展字符串哈希?


考虑暴力匹配字符串,我们以长串中的每一位作为起点,和整个短串进行匹配。整体时间复杂度 \(\mathcal O(n^2-n\times m)\)。GM 的说法是直接写成 \(\mathcal O(n\times m)\),但这也太扯了!明明 \(n=m=10^7\) 也能跑得出溜快!

那么到底是什么导致暴力跑得这么慢呢?其实是多余的比较操作!


我们平常肉眼匹配的时候有没有一些小技巧呢?比如说当长串是 "ABABDCABABCD",短串是 "ABABCD" 的时候,我们从最开始打量长串,发现最开始的 "ABAB" 和短串的最开始的 "ABAB" 匹配,但是长串后面的 'D' 就不匹配了。接下来我们一眼望到长串第三位和第四位的 "AB",发现它和短串开头的 "AB" 匹配,但是后面的 'D' 又不匹配了。

我们从第五位的 'D' 开始继续打量,慢慢地看到了后面的 "ABABCD",和短串完全一样,可喜可贺,匹配成功。

KMP 从某种意义上说就是参考了这种方法。我们用科学的视角复盘刚刚的例子:

  • 从长串起点作为匹配起点开始匹配,其中 "ABAB" 与短串匹配成功。再往后的内容匹配失败。
  • 我们现在已经看到了长串中的前四位内容。在我们已知的内容中,从长串的第三位和第四位的 "AB" 开始匹配成功率最高。但到了第五位 'D' 仍然不能匹配。
  • 从长串的第四位开始,一直到第六位,作为起点都完全不能匹配。
  • 到了长串的第七位,匹配成功。

我们发现,在长串第一位作为起点匹配失败后,我们直接选择了已知内容中匹配成功率最高的第三位作为起点开始匹配。是什么导致它的成功率高呢?因为他作为长串的已知内容中最后一段,和短串的最开头长得一样。

所以我们得出一个猜想:采用 已知长串内容短串最长公共后 / 前缀 位置作为起点,匹配成功率最高。

接下来我们尝试验证一下这个猜想的正确性。假如长串中,比最长公共后 / 前缀更靠前的地方出现了短串的前缀,它的成功率会更高吗?

考虑长串为 "ABABCDABABCC",短串为 "ABABCC",第一次匹配时,匹配到了 "ABABC" 即停止,最长公共后 / 前缀为空。假如从第三位和第四位的 "AB" 开始匹配,那么…… 就寄了。因为在我们已知的内容中,以这个 "AB" 作为起点就已经不匹配了!

那么这是为什么呢?如果以这个比最长公共后 / 前缀更靠前的位置作为起点,还能在已知长串内容中不夭折的话,这意味着什么?

因为短串的前面部分和已知长串的后面部分是可以匹配的,也就是说,已知长串最后一截和短串的最前面一截是一样的,就相当于是短串已匹配内容的前部和后部匹配。以这个位置为起点,在长串已知内容中的匹配结果属于已匹配短串的最后一截。又因短串前面一截和长串已知内容前面一截是匹配的,说明它也应该在最长公共后 / 前缀之中!与条件矛盾,故猜想成立。

根据上面的思想,我们可以得到另一个结论:已知长串和已匹配短串的最长公共后 / 前缀,其实就是已匹配短串的前 / 后缀,因为已知长串均经过和短串的匹配。但注意,短串的已匹配长度是随着长串的已知长度变化而变化的,但毋容置疑的是,它一定是短串的前缀,因为匹配是从前往后的。

那么接下来假设我们已经得到了对于短串的任意前缀,其最长公共前 / 后缀,记为 nex[i]

我们从长串的第一位开始,不断向右增加已知内容。每右移一位,判断短串是否能在已匹配的基础上进行下一位匹配;如果可以,则完成下一位匹配,否则,从成功率最高的位置开始匹配。

// l 为长串,s 为短串
inline int KMP(str &l, str &s) {
	nex[0] = -1; // 便于判断一位都无法匹配的情况
	i = 0, j = 0;
	while (i < (int)l.length()
				&& j < (int)s.length()) {
		if (j == -1 || l[i] == s[j])
			++i, ++j;
		else j = nex[j];
	}
	return j == (int)s.length() ? i : -1;
}

接下来是另一个难点内容:nex 数组的求解。

不难发现,这其实是另一种意义上的匹配:短串的前部和后部的匹配。

对于 nex[0]nex[1],他们的值是固定的,因为一个前面没有内容,另一个前面没有长度大于 1 的内容。

所以从 nex[2] 开始求解,其前部为第一个字符,后部为第二个字符。

如果当前前部和后部不匹配,那么前部就回退到其 nex,继续匹配,因为只有从 nex 开始才能保证其内容是公共的,前文已证明过。

inline int KMP(str &l, str &s) {
	static int nex[maxn];
	int i = 0, j = -1;
	nex[0] = -1;
	while (i < (int)s.length()
				&& j < (int)s.length()) {
		if (j == -1 || s[i] == s[j])
			nex[++i] = ++j;
		else j = nex[j];
	}
	i = 0, j = 0;
	while (i < (int)l.length()
				&& j < (int)s.length()) {
		if (j == -1 || l[i] == s[j])
			++i, ++j;
		else j = nex[j];
	}
	return j == (int)s.length() ? i : -1;
}

那么 KMP 的时间复杂度真的更优吗?回退的操作难道不会大大增加复杂度吗?

我们把匹配成功时的右移看做加法,回退看做减法。不难发现 \(j\) 指针最小值为 -1。右移的操作只会在匹配成功时进行,根据 \(i\) 指针只加不减,\(j\)\(i\) 同加可以看出,\(j\) 最多执行右移操作 \(\left| l\right|\) 次,则左移操作最多为 \(\left| l\right| + 1\) 次。求解 nex 数组时的操作同理。

故最终复杂度 \(\mathcal O(\left| l\right| + \left| s\right|)\)


A. KMP

http://222.180.160.110:1024/contest/3333/problem/1

板子。

namespace XSC062 {
using namespace fastIO;
const int maxn = 1e6 + 5;
using str = std::string;
int res;
str l, s;
inline int KMP(str &l, str &s) {
	static int nex[maxn];
	int i = 0, j = -1;
	nex[0] = -1;
	while (i < (int)s.length()
				&& j < (int)s.length()) {
		if (j == -1 || s[i] == s[j])
			nex[++i] = ++j;
		else j = nex[j];
	}
	i = 0, j = 0;
	while (i < (int)l.length()
				&& j < (int)s.length()) {
		if (j == -1 || l[i] == s[j])
			++i, ++j;
		else j = nex[j];
	}
	return j == (int)s.length() ? i : -1;
}
int main() {
	std::cin >> l >> s;
	res = KMP(l, s);
	if (~res) {
		puts("yes");
		print(res - (int)s.length());
	}
	else puts("no");
	return 0;
}
} // namespace XSC062

B. 子串查找

http://222.180.160.110:1024/contest/3333/problem/2

我们之前在匹配成功后跳出循环并停止匹配,现在我们需要反复匹配,不难发现,在匹配成功后让 \(j\) 回退到 nex[j] 即可反复匹配。

namespace XSC062 {
using namespace fastIO;
const int maxn = 1e6 + 5;
using str = std::string;
str l, s;
inline int KMP(str &l, str &s) {
	static int nex[maxn];
	int i = 0, j = -1, cnt = 0;
	nex[0] = -1;
	while (i < (int)s.length()
				&& j < (int)s.length()) {
		if (j == -1 || s[i] == s[j])
			nex[++i] = ++j;
		else j = nex[j];
	}
	i = 0, j = 0;
	while (i < (int)l.length()
				&& j < (int)s.length()) {
		if (j == -1 || l[i] == s[j])
			++i, ++j;
		else j = nex[j];
		if (j == (int)s.length())
			j = nex[j], ++cnt;
	}
	return cnt;
}
int main() {
	std::cin >> l >> s;
	print(KMP(l, s));
	return 0;
}
} // namespace XSC062

C. Power Strings

http://222.180.160.110:1024/contest/3333/problem/3

如果串 \(s\) 由其某个子串 \(t\) 重复拼接而成,那么明显地,其最长公共前 / 后缀应该是整个串减去最末端 / 最开头的这个子串得到。

也就是说,在这种情况下,nex[s.length()] 记录 \(\left|s\right| - \left|t\right|\) 的值。而 (s.length() - nex[s.length()]) 的值应为 \(\left|t\right|\),且这个值应整除 \(\left|s\right|\)

那我们怎么保证,不被最长公共前 / 后缀包含的这个字符串就一定是我们所需的那个子串呢?

既然其长度整除 \(n\) 且不等于 \(n\),那么它至多为 \(\dfrac n2\)。我们将最长公共前 / 后缀填充入整个字符串,在最极端的 \(\dfrac n2\) 的情况下,填充后的字符串也不会有多余空位,也就是说我们没办法在中间找到一个位置


E. 最长前缀

http://222.180.160.110:1024/contest/3333/problem/5

我们在学习字典树的时候做过类似的 一道题目,我们可以考虑用相同的思路来解决(其实只要把用于匹配的字典树换成 KMP 就行了)。

在长串的最开头进行对每个短串的匹配,若能匹配成功则在匹配末尾打标记。

因为一个字符串只有前面是都能被短串分割的,才能从中间开始找点继续匹配,所以我们只以打了标记的下标作为一次匹配的起点。寻找最远的标记点即可。


F. Seek the Name, Seek the Fame


G. 字符串大师

http://222.180.160.110:1024/contest/3333/problem/7

你是字符串带师,我是字符串欻师,我们都有美好的未来。


H. 基因改造

http://222.180.160.110:1024/contest/3333/problem/8

因为这道题看起来最友好,所以我先开这道(不知道为什么没人做呢)。

不妨思考萌萌哒序列有什么性质。我们可以很轻易地发现,萌萌哒序列在更改前和更改后,相同的元素相对位置是不变的。

就是说,如果一个萌萌哒序列修改后,在位置 \(x\) 和位置 \(y\) 的数是相同的,那么在修改前,这两个位置也是相同的。

所以我们记录一下在 \(s\)\(t\) 中,元素距离上一个相同元素的距离,匹配一下就可以了。

这样做明显是不够的,因为光是样例都会得不到所有答案。这是为什么呢?我们考虑漏了一种情况。如果 \(j\) 没有前驱,但 \(i\) 的前驱在当前匹配范围外,本来是可以匹配上的,但我们没有计算到。所以添加这个判断。

还有一个小细节,就是在这里 \(j\) 是要重复匹配的,这个问题我们都知道如何处理,如果短串用完了 j = nex[j] 即可。

namespace XSC062 {
using namespace fastIO;
const int maxn = 1e6 + 5;
int lst[maxn];
int T, op, n, m;
int s[maxn], t[maxn];
std::vector<int> res;
int d1[maxn], d2[maxn];
inline void KMP(void) {
	static int nex[maxn];
	int i = 0, j = -1;
	nex[0] = -1;
	while (i < m && j < m) {
		if (j == -1 || d2[i] == d2[j]
			|| (j < d2[i] && d2[j] == -1))
			nex[++i] = ++j;
		else j = nex[j];
	}
	i = 0, j = 0;
	while (i < n && j < m) {
		if (j == -1 || d1[i] == d2[j]
			|| (j < d1[i] && d2[j] == -1))
			++i, ++j;
		else j = nex[j];
		if (j == m) {
			res.push_back(i - m + 1);
			j = nex[j];
		}
	}
	return;
}
int main() {
	read(T), read(op);
	while (T--) {
		read(n), read(m);
		memset(lst, -1, sizeof (lst));
		for (int i = 0; i < n; ++i) {
			read(s[i]);
			if (lst[s[i]] == -1)
				d1[i] = -1;
			else d1[i] = i - lst[s[i]];
			lst[s[i]] = i;
		}
		memset(lst, -1, sizeof (lst));
		for (int i = 0; i < m; ++i) {
			read(t[i]);
			if (lst[t[i]] == -1)
				d2[i] = -1;
			else d2[i] = i - lst[t[i]];
			lst[t[i]] = i;
		}
		res.clear();
		KMP();
		print(res.size(), '\n');
		for (auto i : res)
			print(i, ' ');
		putchar('\n');
	}
	return 0;
}
} // namespace XSC062

I. 似乎在梦中见过的样子

http://222.180.160.110:1024/contest/3333/problem/9

题意很简单,就是说呢,给你一个限定长度 \(k\),你需要找到对于这个字符串的所有子串,有多少个是 \(ABA\) 的形式。其中要求 \(\left|A\right|\geqslant k\)

乍一看很迷茫,因为子串的个数是平方级别。但是


TO BE CONTINUED...

posted @ 2023-02-25 09:14  XSC062  阅读(89)  评论(3编辑  收藏  举报