KMP 算法:解决字符串匹配问题的高效算法

一、概述与基本概念

字符串匹配问题,即给定一个匹配串 TTT 和主串 SSS ,在一个 SSS 中寻找一个子串 S′∈SS' \in SSS ,使得 S′=TS'=TS=T 的问题。

KMP 算法是基于最长公共前后缀计算原理实现的字符串匹配算法。一般我们在主串和匹配串分别设置一个指针,通过两个指针对应值的判断调整指针位置,从而完成字符串的匹配。

举例来说,假设我们有两个字符串 SSSTTT ,其中 S=S=S= abcabcdT=T=T= cab ,那么如果使用暴力(枚举)做法,可以从前往后枚举 SSS 的每一位 Si(1≤i≤∣S∣)S_i(1 \le i \le |S|)Si(1iS) ,对于每一个 SSS 的子串 S′=[Si,Si+∣T∣]S'=[S_i,S_{i+|T|}]S=[Si,Si+T] ,将它与 TTT 进行匹配。显然,这个算法的时间复杂度为 O(nm)O(nm)O(nm) ,而 KMP 算法将可以将这个复杂度优化为 O(n+m)O(n+m)O(n+m) ,可以应对 n,m≤106n,m \le 10^6n,m106 数据规模的问题。

二、 KMP 算法的子问题:最长公共前后缀

前缀,就是一个字符串以这个字符串的第一个字符为开头,任意字符为结尾的子串。后缀,就是一个字符串以这个字符串的任意字符为开头,最后一个字符为结尾的子串。最长公共前后缀,即在一个字符串中长度最大的相同前缀和后缀,如 agcabcagc 的最长公共前后缀是 agc

在接下来的讲述中,我将使用 next[i]next[i]next[i] 表示以第 iii 个位置结尾的子串(主串 SSS 的子串 S′=[S1,Si]S'=[S_1,S_i]S=[S1,Si])的最长公共前后缀的长度。

为了更好地理解 next 数组的含义,看这样一个字符串 S=S=S= abacabab
显见,最长公共前后缀不能是字符本身,所以 next1=0next_1=0next1=0
因为在 [S1,S2][S_1,S_2][S1,S2] 中没有最长公共前后缀(a≠ba \neq ba=b) ,所以 next2=0next_2=0next2=0
对于 next3next_3next3 ,因为 S1=S_1=S1= a =S3=S_3=S3 ,所以 next3=1next_3=1next3=1
类似地,我们可以得出 next4=0,next5=1,next6=2,next7=3,next8=2next_4=0,next_5=1,next_6=2,next_7=3,next_8=2next4=0,next5=1,next6=2,next7=3,next8=2

1. 观察 nextnextnext 数组的性质

A. nextinext_inexti 是位置 iii 的对应前缀最后一个字符的位置。
这点还是比较显然的,因为 nextinext_inexti 表示最长公共前后缀的长度,由于开头的位置是 111 ,所以主串 SSS 的子串 S′=[S1,Si]S'=[S_1,S_i]S=[S1,Si] 中的最长公共前后缀的前缀部分结尾应该是 1+nexti−1=nexti1+next_i-1=next_i1+nexti1=nexti

B. Si=SnextiS_i=S_{next_i}Si=Snexti
这点也应该比较容易看出,从 nextnextnext 数组的定义就直接可以得到,但是这个性质非常重要,对于 nextnextnext 数组的构造和 KMP 算法的实现意义重大。它规定了 nextnextnext 数组的可跳转性,且保证了跳转后不会出现错误答案。

2. nextnextnext 数组的构造

lenlenlen 为当前最长公共前后缀中前缀部分的最后一个字符。由上面的性质 A,得到 len=nextilen=next_ilen=nexti
分三种情况讨论:

  • Slen+1=SiS_{len+1}=S_iSlen+1=Si
    此时,最长公共子序列的下一项仍然可以完成匹配,令 nexti=len+1next_i=len+1nexti=len+1 , 变量 len←len+1len \leftarrow len+1lenlen+1
  • Slen+1≠SiS_{len+1} \neq S_iSlen+1=Silen≠0len \neq 0len=0
    由上面的性质 B ,知 Snextlen=SlenS_{next_{len}}=S_{len}Snextlen=Slen ,可以保证最大程度上的相等,而不用考虑前面的字符是否匹配。所以跳转到 nextlennext_{len}nextlen 不会影响最终答案。因为 len≠0len \neq 0len=0 ,所以可以继续进行跳转。令 len←nextlen+1len \leftarrow next_{len}+1lennextlen+1 ,查询该字符是否匹配。
  • Slen+1≠SiS_{len+1} \neq S_iSlen+1=Silen=0len = 0len=0
    此时, SiS_iSi 无法与任何一个前缀相匹配,因此令 nexti=0next_i=0nexti=0

举一个例子来理解 nextnextnext 数组的构造,其中红色代表当前查询的点 iii ,黄色代表 lenlenlen ,蓝色代表将要跳转的点 nextlennext_{len}nextlen
在这里插入图片描述

3. 代码实现与分步讲解

求最长公共前后缀,代码如下。

// 头文件、变量定义省略

void init() // 求解 next
{
	int len = 0, pos = 1;
	while (pos <= n) //此处 n 是 S 的长度
	{
		if (s[pos] != s[len + 1])
		{
			if (len == 0) nxt[pos++] = 0; // 此处 next 改为 nxt 是为了避免与库函数的重复
			// 否则,Linux 下容易 CE,要注意
			else len = nxt[len];
		}
		else
		{
		    nxt[pos++] = ++len;
		}
	}
}

代码分步讲解
while (pos <= n)
对每一个 pos 进行操作。

if (s[pos] != s[len + 1])
即上面讨论的情况 2 和 3 。

if (len == 0) nxt[pos++] = 0
情况 3 ,此时不能进行操作,nxt[pos++] = 0 表示该位置 pos 没有公共前后缀,指针向下一位移动。

else len = nxt[len]
情况 2 , Spos≠Slen+1S_{pos} \neq S_{len+1}Spos=Slen+1 但是 len≠0len \neq 0len=0 , 此时可以令指针后移到一个与 SlenS_{len}Slen 相等的地方,即 SnextlenS_{next_{len}}Snextlen 。因此, len = nxt[len]

else nxt[pos++] = ++len
情况 1 ,Spos=Slen+1S_{pos}=S_{len+1}Spos=Slen+1 ,匹配顺利,增加最长公共前后缀的长度,指针向下一位移动。

三、 KMP 算法的基本步骤

1. 主串指针 iii 与匹配串指针 jjj 的移动规律

如第一部分所述,我们定义一个主串 SSS 的指针 iii ,匹配串 TTT 的指针 jjj 。关于 i,ji,ji,j 的移动规则,下面将展开叙述。

首先,判断条件 Si=TjS_i = T_jSi=Tj 是否成立。

  • Si=TjS_i = T_jSi=Tj,则令 i,ji,ji,j 均后移一位,一定是最优解。(显见,证明略)
  • Si≠TjS_i \neq T_jSi=Tjj>1j > 1j>1,考虑指针 jjj 的后移。由 j≠0j \neq 0j=0 ,得 jjj 还可以继续移动,就让 jjj 移动到下一个让 SSSTTT 已匹配的部分仍然匹配的位置。因为主串已经完成了部分匹配,所以我们应该保留 [Si−j,Si][S_{i-j},S_i][Sij,Si] 部分保持已匹配状态。根据 nextnextnext 数组的性质 A ,我们可以得知, Tnextj−1=Tj−1T_{next_{j-1}}=T_{j-1}Tnextj1=Tj1 ,因此我们可以让 jjj 指针移动到 nextj−1+1next_{j-1}+1nextj1+1 进行尝试。
  • 否则,如果 Si≠TjS_i \neq T_jSi=Tjj=1j = 1j=1 ,则说明不能继续移动,当前位置一定不能匹配到合适的主串字符,令主串的指针 iii 右移一位。

直到 j>∣T∣j > |T|j>T ,说明匹配已经完成,返回匹配位置或者继续匹配(j=nextjj=next_jj=nextjj=1j=1j=1)。

使用 S=S=S= abacababT=T=T= abab ,举例如下。

S: abacabab
T: abab

i=1,j=1i=1,j=1i=1,j=1 时,可以满足条件。
i=4,j=4i=4,j=4i=4,j=4 时,不满足条件,令 j=nextj−1+1=2j=next_{j-1}+1=2j=nextj1+1=2

S: abacabab
T:   abab

i=4,j=2i=4,j=2i=4,j=2 时,不满足条件,主串指针加 111 ,令 i=5i=5i=5

S: abacabab
T:    abab

i=5,j=2i=5,j=2i=5,j=2 时,不满足条件,主串指针加 1 ,令 i=6i=6i=6

S: abacabab
T:     abab

i=6,j=2i=6,j=2i=6,j=2 时,满足条件,TTT 串指针 jjj 可以移动到 j=5j=5j=5 ,完成匹配。

通过上述讲解,我们应该得知, 我们应该先对匹配串 TTT 求出 nextnextnext 数组,再利用 nextnextnext 数组完成 SSSTTT 之间的 KMP 算法匹配过程。

2. 代码实现与分步讲解

使用 KMP 算法解决字符串匹配问题,代码如下。

void init() 
{
	int len = 0, pos = 1;
	while (pos <= n) 
	{
		if (t[pos] != t[len + 1])
		{
			if (len == 0) nxt[pos++] = 0; 
			else len = nxt[len];
		}
		else
		{
		    nxt[pos++] = ++len;
		}
	}
}

int KMP() // 字符串匹配函数
{
	int spos = 1, tpos = 1; 
	
	while (spos <= slen)
	{
		if (s[spos] == t[tpos])
		{
			spos++;
			tpos++;
		}
		else
		{
			if (tpos > 1) tpos = nxt[tpos - 1] + 1;
			else spos++;
		}
		
		if (tpos > tlen)
		{
			return spos - tlen;
		}
	}
	return -1;
}

代码分步讲解:

while (spos <= slen)
对于每一个 1≤i≤∣S∣1 \le i \le |S|1iS ,进行字符串匹配操作。

if (s[spos] == t[tpos]) spos++, tpos++
上述讨论的第一种情况,Si=TjS_i=T_jSi=Tj ,即该字符成功完成匹配,双指针 i,ji,ji,j 均后移一位。

else { if (tpos > 1) tpos = nxt[tpos - 1] + 1
上述讨论的第二种情况, Si≠TjS_i \neq T_jSi=Tjj>1j > 1j>1 ,指针 jjj 移动到下一个匹配点, j=nextj−1+1j=next_{j-1}+1j=nextj1+1

else spos++; }
上述讨论的第三种情况, Si=TjS_i = T_jSi=Tjj=1j = 1j=1 ,指针 iii 右移一位继续匹配 TTTi←i+1i \leftarrow i+1ii+1

四、 KMP 算法的常用变形

1. 求一个字符串 SSS 中包含多少个不重叠的匹配串 TTT

首先,我们先展示一个普通的 KMP 代码:

int KMP() // 字符串匹配函数
{
	int spos = 1, tpos = 1; // 主串指针 spos ,匹配串指针 tpos 
	
	while (spos <= slen)
	{
		if (s[spos] == t[tpos])
		{
			spos++;
			tpos++;
		}
		else
		{
			if (tpos > 1) tpos = nxt[tpos - 1] + 1;
			else spos++;
		}
		
		if (tpos > tlen)
		{
			spos--;
			return spos - tlen;
		}
	}
	return -1;
}

显然,这里我们不能这么简单地进行一次 KMP 算法,应该持续直到 i≥∣S∣i \ge |S|iS 。为了实现这个目的,我们应该忽略已经匹配的 SSS ,让 TTTSSS 的剩余部分 S′S'S 继续完成匹配。
具体来说,假设我们当前 S,TS,TS,T 串的指针分别枚举到了 i,ji,ji,j ,则目标截取的 S′=[Si,S∣S∣]S'=[S_i,S_{|S|}]S=[Si,SS]
。这样,我们可以令 i←i+1i \leftarrow i+1ii+1 ,同时, j=1j=1j=1 。这相当于让 TTT 串和 S′S'S 串重新进行匹配直到 i=ni=ni=n

举个例子,更好理解这个过程:
我们令 S=S=S= abacababccbbababT=T=T= abab
当我们在 i=8,j=4i=8,j=4i=8,j=4 处匹配完成后,按照上面的结论,我们可以令 i=9,j=1i=9,j=1i=9,j=1 ,即忽略前面已经匹配的部分,完成 S9,S16S_9,S_{16}S9,S16TTT 串的匹配。
最后的答案是 SSS 中包含两个 TTT 串。

                                       |<--S'->|
S: abacababccbbabab            abacababccbbabab
       |                =>             |
T:     abab                            abab

代码实现如下:

#include <bits/stdc++.h>

using namespace std;

const int N = 1007;

char a[N], b[N];
int nxt[N];

void getNext(char t[])
{
	int tlen = strlen(t + 1);
	int len = 0;
	nxt[1] = len;
	int pos = 2;
	
	while (pos <= tlen)
	{
		if (t[len + 1] != t[pos])
		{
			if (len == 0) nxt[pos++] = 0;
			else len = nxt[len];
		}
		else
		{
			nxt[pos++] = ++len;
		}
	}
}

int KMP(char s[], char t[])
{
	int slen = strlen(s + 1);
	int tlen = strlen(t + 1);
	
	int spos = 1, tpos = 1, ans = 0;
	
	while (spos <= slen)
	{
		if (s[spos] == t[tpos])
		{
			spos++;
			tpos++;
		}
		else if (tpos > 1)
		{
			tpos = nxt[tpos - 1] + 1;
		}
		else
		{
			spos++;
		}
		
		if (tpos > tlen)
		{
			ans++;
			tpos = 1;
		}
	}
	
	return ans;
}

int main()
{
	while (cin >> a + 1, !(a[1] == '#' && strlen(a + 1) == 1))
	{
		cin >> b + 1; 
		
		getNext(b);
		
		cout << KMP(a, b) << endl;
	}
	
	return 0;
}

2. 求一个字符串 SSS 包含几个可重叠的子串 TTT

方法与上面的问题大致相同,但区别在于该问题的 TTT 可以重叠。
使用 nextnextnext 数组的性质,一次匹配完成后,我们可以令 j=nextj−1+1j=next_{j-1}+1j=nextj1+1 。因为此时 j=∣T∣+1j=|T|+1j=T+1 ,所以 j−1=∣T∣j-1=|T|j1=Tnextj−1+1next_{j-1}+1nextj1+1 刚好对应下一次应该匹配的位置。

代码的改动不大,展示如下:

#include <bits/stdc++.h>

using namespace std;

const int N = 1007;

char a[N], b[N];
int nxt[N];

void getNext(char t[])
{
	int tlen = strlen(t + 1);
	int len = 0;
	nxt[1] = len;
	int pos = 2;
	
	while (pos <= tlen)
	{
		if (t[len + 1] != t[pos])
		{
			if (len == 0) nxt[pos++] = 0;
			else len = nxt[len];
		}
		else
		{
			nxt[pos++] = ++len;
		}
	}
}

int KMP(char s[], char t[])
{
	int slen = strlen(s + 1);
	int tlen = strlen(t + 1);
	
	int spos = 1, tpos = 1, ans = 0;
	
	while (spos <= slen)
	{
		if (s[spos] == t[tpos])
		{
			spos++;
			tpos++;
		}
		else if (tpos > 1)
		{
			tpos = nxt[tpos - 1] + 1;
		}
		else
		{
			spos++;
		}
		
		if (tpos > tlen)
		{
			ans++;
			tpos = next[tpos - 1] + 1;
		}
	}
	
	return ans;
}

int main()
{
	while (cin >> a + 1, !(a[1] == '#' && strlen(a + 1) == 1))
	{
		cin >> b + 1; 
		
		getNext(b);
		
		cout << KMP(a, b) << endl;
	}
	
	return 0;
}

温馨提示:本文的代码均未进行编译和调试,如有问题欢迎在评论区和私信指出,感谢您的反馈。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值