前缀函数及 Knuth–Morris–Pratt 算法学习笔记

\(\text{1 引言 Preface}\)

对于形如以下的问题:

给予一个模式串 \(T\) 和主串 \(S\),在主串中寻找 \(T\)

我们称之为字符串匹配。

很显然朴素算法时间复杂度是 \(O(n^2)\) 的:

枚举字符串起点,向后逐位比较。

所以需要对其进行优化,一般使用 \(\text{hash}\) 或者 \(\text{Knuth–Morris–Pratt}\)(下文简称为 \(\text{KMP}\))算法,而 \(\text{hash}\) 在本文不进行讨论。而 \(\text{KMP}\) 即为一种可以在 \(O(|S|+|T|)\) 的时间复杂度以及 \(O(|S|)\) 的空间复杂度下完成这件事的优秀算法。

\(\text{2 核心 Main Idea}\)

\(\text{2.1 前缀函数 }\pi(x)\text{ The function of prefix}\)

\(\text{2.1.1 定义 Definition}\)

对于主字符串 \(S\),我们称后缀函数 \(\pi(x)\) 为该字符串长度为 \(x\) 的前缀的所有的真前缀中等于真后缀的最长长度。是的这是它冗长的定义,转化为数学语言就是如下:

记字符串 \(A\) 从左往右数第 \(i\) 位为 \(A_i\)。令字符串 \(P\)\(S\) 长度为 \(x\) 的前缀,即 \(P= S_{1,2,\cdots,x}\),那么 \(\pi(x)\) 等于满足 \(P_{1,2,\cdots,y}=P_{x-y+1,x-y+2,\cdots,x}\) 的最大的 \(y\) 值。

\(\text{2.1.2 朴素求解方式 The simple way to calculate}\)

注:下文中的“求解”指的均是对于串 \(S\),求解出每个 \(\pi(i)\) 的值,其中 \(i=1,2,\cdots,|s|\)

一个很显然的求解方式就是先枚举前缀长度 \(x\),然后枚举 \(y\) 值,然后截取字符串前后缀逐位比较,时间复杂度为 \(O(n^3)\)

实现:

char S[N]; int pi[N];
void Prefix_Func(int n) { // 此处字符串下标由 1 开始
	for (int i = 1; i <= n; i ++) {
		for (int j = 1; j < i; j ++) {
			bool f = true;
			for (int k = 1; k <= j; k ++)
				if (S[k] != S[i - j + k]) // 失配
					f = false;
			if (f) pi[i] = j;
		}
	}
	return ;
}

\(\text{2.1.3 第一类优化 The first optimization}\)

若我们现在正在处理字符串 \(S\) 的到第 \(i\) 位的前缀的 \(\pi(i)\) 值,假设我们已经处理完了 \(\pi(1),\pi(2),\cdots,\pi(i-1)\),则可见的,前缀从 \(i-1\) 位增加到 \(i\) 位,则 \(\pi(i)\) 值至多会增加 \(1\)(当且仅当 \(S_i=S_{\pi(i-1)+1}\)),或者不变,或者减少。那么我们求解 \(pi(i)\) 可以如下实现:

char S[N];
int pi[N];

void Prefix_Func(int n) { // 此处字符串下标由 1 开始
	for (int i = 1; i <= n; i ++) {
		for (int j = min(pi[i - 1] + 1, i - 1); j >= 1; j --) {
			bool f = true;
			for (int k = 1; k <= j; k ++)
				if (S[k] != S[i - j + k]) // 失配
					f = false;
			if (f) pi[i] = j;
		}
	}
	return ;
}

此时时间复杂度为 \(O(n^2)\)

\(\text{2.1.4 第二类优化 The second optimization}\)

下文中,我们记 \(S[i,j]\) 表示字符串 \(S\) 从第 \(i\) 个字符开始到第 \(j\) 个字符的子串。

以下我们讨论的情况均是以处理完毕 \(\pi(1),\pi(2),\cdots,\pi(i)\),然后希望求出 \(\pi(i+1)\) 的情形。

观察第一类优化,我们发现只有在 \(S_{\pi(i)+1}=S_{i+1}\)\(\pi(i+1)\) 能很快求出,那么我们继续讨论不等于的情况。

当我们发现失配时,我们希望很快能又找到一个最大的 \(j<\pi(i)\),满足 \(S[1,j]=S[i-j+1,i]\),然后与上文类似地判断 \(S[j+1]\) 是否等于 \(S[i+1]\),如果是,则 \(\pi[i+1]=j+1\)。问题就在于如何快速求出 \(j\)。首先,由于 \(j<\pi(i)\) 那么由 \(\pi(i)\) 的定义可以得出 \(S[1,j]=S[i-j+1,i]\),进一步地,我们发现 \(S[1,\pi(i)]=S[i-\pi(i)+1,i]\),所以可以得到 \(S[i-j+1,i]=S[\pi(i)-j+1,\pi(i)]\),综上我们得出了 \(S[1,j]=S[i-j+1,i]=S[\pi(i)-j+1,\pi(i)]\),即 \(j=\pi(\pi(i))\)

于是,我们可以这样求解函数值:

  1. \(j_0=pi(i)\)

  2. 判断 \(S_{i+1}\) 是否等于 \(S_{j+1}\),若是,则 \(\pi(i+1)=j+1\),否则进入下一步。

  3. \(j_1=\pi(j_0)\),若其等于 \(0\),则 \(\pi(i+1)=0\),否则以类似的方式重复 1 和 2 两个步骤。

这样我们便得到了一种能在 \(O(n)\) 时间内求解前缀函数的算法了。

void Prefix_Func(int n, char* S) { // 此处字符串下标由 1 开始
	for (int i = 1; i <= n; i ++) {
		int j = pi[i];
		while (j) {
			if (S[j + 1] == S[i + 1]) break;
			j = pi[j];
		}
		if (S[j + 1] == S[i + 1]) pi[i + 1] = j + 1;
	}
	return ;
}

\(\text{2.2 Knuth–Morris–Pratt 算法 The KMP Algorithm}\)

\(\text{KMP}\) 算法是前缀函数的一个巧妙的应用。

原问题为:在文本串 \(S\) 中寻找模式串 \(T\)

我们令 \(n=|S|\)\(m=|T|\)。那我们拼接一个新的字符串 \(P=T+C+S\),其中 \(C\) 为分隔符,在 \(S\)\(T\) 中均为出现。则 \(|C|=n+m+1\)

我们先求出 \(C\) 每一位的前缀函数值,然后考虑属于字符串 \(S\) 的部分,即 \(C[n+2,n+1+m]\)。我们假设当前处理的是第 \(i\) 位,我们有当 \(\pi(i)=m\) 时字符串 \(T\) 便在 \(S\) 中出现了一次。

原因是:首先,由于分隔符 \(C\) 的存在,任何一个 \(\pi(i)\) 都小于等于 \(m\),因为 \(C\) 永远无法匹配,当 \(\pi(i)=m\) 时,由于前缀函数的定义,我们有 \(C[1,m]=C[i-m+1,i]\),又因为 \(C[1,m]\) 就是 \(T\),所以 \(C[i-m+1,i]=T\)\(T\)\(C\) 的后部出现了一次,即在 \(S\) 中出现了一次,进一步地,\(C[i-m+1,i]=S[i-2\times m,i-m-1]\),所以这一次 \(T\) 出现在了 \(S\) 从左向右第 \(i-2\times m\) 位。

\(\text{3 实现 Code}\)

#include<bits/stdc++.h>
using namespace std;
const int N = 2e6 + 10;
// pi(x)
char S[N], T[N], P[N];
int pi[N], n, m;

void Prefix_Func(int n, char* S) { // 此处字符串下标由 1 开始
	for (int i = 1; i <= n; i ++) {
		int j = pi[i];
		while (j) {
			if (S[j + 1] == S[i + 1]) break;
			j = pi[j];
		}
		if (S[j + 1] == S[i + 1]) pi[i + 1] = j + 1;
	}
	return ;
}

// \pi

int main() {
	ios :: sync_with_stdio(0); cin.tie(0); cout.tie(0);
	cin >> (S + 1) >> (T + 1);
	n = strlen(S + 1), m = strlen(T + 1);
	for (int i = 1; i <= m; i ++) P[i] = T[i];
	P[m + 1] = '#';
	for (int i = 1; i <= n; i ++) P[i + m + 1] = S[i];
	Prefix_Func(n + m + 1, P);
	for (int i = m + 2; i <= n + m + 1; i ++) {
		if (pi[i] == m) cout << i - 2 * m << "\n";
	}
	for (int i = 1; i <= m; i ++)
		cout << pi[i] << " ";
	return 0;
}

\(\text{4 后记}\)

总计花了约四五个小时完成此文,时间跨越了约三天,\(\text{KMP}\) 是我很久以前就知道但一直没有去学的算法之一,我突然想到学他是因为模拟赛出了一道 \(\text{Manacher}\)\(\text{KMP}\) 以及前几天的 \(\text{ABC362}\) 出了一题板子 \(\text{AC}\) 自动机,但我赛时是贺题解过的

posted @ 2024-07-15 08:04  LaDeX  阅读(61)  评论(0编辑  收藏  举报