前言:
本篇博会讲解学习KMP算法中遇到的痛难点问题(如:next数组的定义、如何求解next数组、回溯时为什么要回溯到next[i]、前缀和后缀的概念和求解过程等等),这也是我在学习中遇到的困惑点,由此写下本篇博客,希望对大家有所帮助。老规矩,先由列题来引入知识点。
题目:
给定一个字符串 S,以及一个模式串 P,所有字符串中只包含大小写英文字母以及阿拉伯数字。
模式串 P 在字符串 S 中多次作为子串出现。
求出模式串 P 在字符串 S 中所有出现的位置的起始下标。
输入格式
第一行输入整数 N,表示字符串 P 的长度。
第二行输入字符串 P。
第三行输入整数 M,表示字符串 S 的长度。
第四行输入字符串 S。
输出格式
共一行,输出所有出现位置的起始下标(下标从 0 开始计数),整数之间用空格隔开。
数据范围
1
≤
N
≤
1
0
5
1≤N≤10^5
1≤N≤105
1
≤
M
≤
1
0
6
1≤M≤10^6
1≤M≤106
输入样例:
3
aba
5
ababa
输出样例:
0 2
思路:
看到本题,首先想到的应该是朴素匹配算法,也就是老生常谈的暴力法,这在蓝桥杯中不失是一种很好拿分的手段。
其思想为从主串s 和子串t 的第一个字符开始,将两字符串的字符一一比对,如果出现某个字符不匹配,主串回溯到第二个字符,子串回溯到第一个字符再进行一一比对。如果出现某个字符不匹配,主串回溯到第三个字符,子串回溯到第一个字符再进行一一比对…一直到子串字符全部匹配成功。
这种算法在最好情况下时间复杂度为 O ( n ) O(n) O(n),即子串的n个字符正好等于主串的前n个字符,而最坏的情况下时间复杂度为 O ( m ∗ n ) O(m*n) O(m∗n)。对比题目中给出的数据范围 1 ≤ N ≤ 1 0 5 1≤N≤10^5 1≤N≤105, 1 ≤ M ≤ 1 0 6 1≤M≤10^6 1≤M≤106 这显然大于 1 0 6 10^6 106,超时。
那么如何对其时间复杂度进行优化呢?这里就需要用到KMP算法。
KMP算法:
首先来进行枯燥无味的文字概述(下面的概述不是什么重点也不好理解,大家略读或者跳过即可)
定义与由来:
KMP算法,全称The Knuth-Morris-Pratt Algorithm,是一种改进的字符串匹配算法。该算法由D.E.Knuth、J.H.Morris和V.R.Pratt三位计算机科学家共同提出,因此得名KMP算法。
核心思想:
- 朴素匹配算法的不足:朴素匹配算法是一种暴力匹配的方式,它依次枚举主串的每一个字符作为匹配模式串的起始字符,然后将两字符串的字符从起始位置一一比对。若出现某个字符不匹配,则将主串的起始比对位置重新回溯到上一个起始字符的下一位,模式串则回溯到第一个字符,重新开始匹配过程。这种算法的时间复杂度为O(n*m),效率较低。
- KMP算法的改进:KMP算法利用匹配失败时失败之前的已知部分匹配信息,保持主串的指针不回溯,通过修改模式串(子串)的指针,使模式串尽量地移动到有效的匹配位置。具体来说,在匹配失败时,不再按照朴素匹配算法的规则重新回溯主串指针和模式串指针,而是保持主串指针不动,尽可能地移动模式串指针到有效匹配位置。
关键概念与数据结构:
next数组:next数组是KMP算法的核心数据结构,它是一个与子串等长的数组,用来存储模式串在某个位置匹配失败时,子串指针应该回退的合适位置。next数组的值表示以当前字符结尾的子串的最大相同前后缀长度(最长相同前后缀),也表示在该位置匹配失败时子串回溯比较的下一个字符位置。
算法实现:
- next数组的求解:通过遍历模式串,利用已知的部分匹配信息(即已求得的next数组值)和当前字符的比较结果,来求解next数组的下一个值。
- 匹配过程:在主串和子串的匹配过程中,利用next数组来指导模式串的移动。当匹配失败时,根据next数组的值来确定模式串应该回退的位置,并继续匹配过程。
时间复杂度:
KMP算法的时间复杂度为 O ( m + n ) O(m+n) O(m+n),其中m是子串的长度,n是主串的长度。这相较于朴素匹配算法的 O ( n ∗ m ) O(n*m) O(n∗m)时间复杂度有了显著的提升。
下面就开始讲解重点了
什么是前缀?后缀?最长相等前后缀?
首先,大家要理解前缀和后缀的概念,举个例子:
现有一个字符串 abceabc
对于该字符串来说
它的前缀集合为{a, ab, abc, abce, abcea, abceab}
(以a开头的字串是它的前缀)
它的后缀集合为{c, bc, abc, eabc, ceabc, bceabc}
(以c结尾的字串是它的后缀)
那么该字符串最长相等前后缀就是abc
那么对于字符串abcafabca
的最长相等前后缀是什么呢?
答案是:abca
什么是前缀?什么是后缀?什么是最长相等前后缀?这些问题我们现在就搞明白了(这些问题很重要),接着咱们再往下看。
KMP的流程图:
算法思路:
现在我们来画图理解KMP算法的思路:
第一个长条代表主串,第二个长条代表子串。红色部分代表两串中已匹配的部分,绿色和蓝色部分分别代表主串和子串中不匹配的字符。
再具体一些:这个图代表主串"abcabeabcabcmn"
和子串"abcabcmn"
。
现在发现了不匹配的地方,根据KMP的思想我们要将子串向后移动,现在解决要移动多少的问题。之前提到的最长相等前后缀的概念有用处了。因为红色部分也会有最长相等前后缀。如下图:
灰色部分就是红色部分字符串的最长相等前后缀,我们子串移动的结果就是让子串的红色部分最长相等前缀和主串红色部分最长相等后缀对齐。
这一步弄懂了,KMP算法的精髓就差不多掌握了。接下来的流程就是一个循环过程了。事实上,每一个字符串都有最长相等前后缀(可以为0),而且最长相等前后缀的长度是我们移位的关键,所以我们单独用一个next数组存储子串的最长相等前后缀的长度。而且next数组的数值只与子串本身有关。
可能有人会想,如果上述图中的字符主串变成abcdfeabcabcmn
,字串变为abcdfcmn
的话怎么办,如下图所示:
如果是这种情况的话,那么子串字符c的最长相等前后缀为0,那么此时的做法就跟暴力做法一样。将子串移动到如下图所示:
n e x t [ i ] = j next[i]=j next[i]=j,含义是:以下标为 i i i为终点的字符串的最长相等前后缀的长度为 j j j。 重点 !!!
n e x t next next数组回溯过程:
下面我们来看一下上述例子中子串"abcabcmn"的next数组值为多少,
n
e
x
t
[
0
]
=
0
next[0]=0
next[0]=0(字符a前面没有字符串单独处理)
a | b | c | a | b | c | m | n |
---|---|---|---|---|---|---|---|
next[0] | next[1] | next[2] | next[3] | next[4] | next[5] | next[6] | next[7] |
0 | 0 | 0 | 1 | 2 | 3 | 0 | 0 |
在上述样例中主串设为
s
s
s,子串设为
t
t
t,蓝色c处与绿色e不匹配,即
t
[
5
]
!
=
s
[
5
]
t[5]!=s[5]
t[5]!=s[5]
我们把子串移动,也就是让绿色
e
e
e与 蓝色
c
c
c前面字符串(abcab
)的最长相等前缀(第一个灰色的
a
b
ab
ab)的后一个字符(红色的
c
c
c)再比较,而该字符(红色
c
c
c)的位置就是
t
[
?
]
t[?]
t[?],很明显这里的
?
?
?是
2
2
2,就是不匹配的字符前的字符串的最长相等前后缀的长度(也就是abcab
的最长相等长度为
2
2
2)。
主串和子串比较的过程是双指针移动的过程,指针 i i i指向主串,指针 j j j指向子串。移动子串相当于回溯指针 j j j。此时为了方便处理结果并且跳过位置 0 0 0的处理(很多代码解决都会尽量避开0处理,类似背包问题等等,这样做可以减少代码工作量,绕开一些问题的出现)(无脑用就行,不用思考为什么),我们需要在主串和子串前分别加入一个空字符串,此时next数组如下:
a | b | c | a | b | c | m | n | |
---|---|---|---|---|---|---|---|---|
next[0] | next[1] | next[2] | next[3] | next[4] | next[5] | next[6] | next[7] | next[8] |
0 | 0 | 0 | 0 | 1 | 2 | 3 | 0 | 0 |
在子串匹配主串的过程中,我们永远是先移动指针 i i i,然后再移动 j j j,看移动 j j j之后的值是否与 s [ i ] s[i] s[i]相等,这里画个图来表示(这里用 ‘_’ 表示空字符,中间的数字是字符串下标)。
此时,指针 i i i指向绿色 e e e( i i i先移动, j j j还没有移动), j j j指向 t [ 5 ] t[5] t[5],因为 s [ i ] ! = t [ j + 1 ] s[i]!=t[j + 1] s[i]!=t[j+1]所以 j j j要回溯( i i i只会向前移动、永远不能向后移动),此时 j j j指向 t [ 2 ] t[2] t[2](之前的 j = 5 j = 5 j=5, 回溯后 j = n e x t [ j ] j = next[j] j=next[j],也就是现在 j = n e x t [ 5 ] = 2 j=next[5]=2 j=next[5]=2,因为要回到之前最长相等前后缀的长度的位置,字符串前添加空字符的妙用这里就体现出来了,现在的next数组存的最长相等前后缀长度还可以确定要回溯的位置),上面没有听太懂也没有关系,这里继续举几个例子,如下图所示:
现在的情况如上图所示,
i
=
6
,
j
=
2
i=6,j=2
i=6,j=2,因为
s
[
i
]
!
=
t
[
j
+
1
]
s[i]!=t[j+1]
s[i]!=t[j+1],所以
j
j
j要继续回溯,那么此时以
j
j
j结尾的字符串(
a
b
ab
ab)的最长相等前后缀为
n
e
x
t
[
2
]
next[2]
next[2](也就是
0
0
0),所以j要回溯到
0
0
0位置(原先
j
=
2
j=2
j=2,回溯后
j
=
n
e
x
t
[
j
]
j=next[j]
j=next[j],也就是
j
=
n
e
x
t
[
2
]
=
0
j=next[2]=0
j=next[2]=0)
下面再来举个例子,如下图所示,此时
i
=
8
,
j
=
7
i=8,j=7
i=8,j=7,因为
s
[
i
]
!
=
t
[
j
+
1
]
s[i]!=t[j+1]
s[i]!=t[j+1],所以j要进行回溯,因为以
j
j
j结尾的字符串abcabcm
的最长相等前后缀为
0
0
0,所以
j
j
j要回溯到
0
0
0位置。
通过上面的例子,大家应该理清了KMP算法的具体思路。从中不难发现,next数组的含义贯彻始终,大家一定要记住 n e x t [ i ] = j next[i]=j next[i]=j的含义: n e x t [ i ] = j next[i]=j next[i]=j,含义是:以下标为 i i i为终点的字符串的最长相等前后缀的长度为 j j j。
下面我们就来看一下 n e x t next next数组该如何来求?
n e x t next next数组的求法:
其实构造 n e x t next next数组的思路就是KMP匹配的思路,同样这里直接拿例子说话(我个人觉得拿实例来讲更好理解,只讲理论的话太晦涩难懂了)。
求子串的 n e x t next next数组,其实就是复制一个相同的子串作为主串 s s s,然后主串 i i i指针从 2 2 2开始向后依次移动(为什么从 2 2 2开始呢?因为从 1 1 1开始的话就是单个字符,而单个字符无需考虑前后缀),子串 j j j指针初始值为 0 0 0。
现在判断 s [ i ] ! = t [ j + 1 ] s[i]!=t[j+1] s[i]!=t[j+1],并且 j = = 0 j==0 j==0,所以 n e x t [ i ] = j next[i]=j next[i]=j(时刻记住 n e x t next next数组的含义,以下标为 i i i为终点的字符串的最长相等前后缀的长度为 j j j,所以 n e x t [ 2 ] = 0 next[2]=0 next[2]=0), i i i向前移动。
可能有人会问为什么要判断
j
=
=
0
?
j == 0?
j==0?,别急下面我会进行讲解。
接下来的流程跟上面一样,此时 n e x t [ 3 ] = 0 next[3]=0 next[3]=0。
如下图所示,此时
s
[
i
]
=
=
t
[
j
+
1
]
s[i]==t[j+1]
s[i]==t[j+1],所以
j
j
j可以向前移动了,
j
+
=
1
,
n
e
x
t
[
4
]
=
j
=
1
j+=1,next[4]=j=1
j+=1,next[4]=j=1。
重复上步操作直到
i
=
=
7
,
j
=
=
3
i==7,j==3
i==7,j==3时,因为
s
[
i
]
!
=
t
[
j
]
s[i]!=t[j]
s[i]!=t[j],而此时
j
j
j又不为
0
0
0,所以
j
j
j要进行回溯,就跟之前的KMP思路一样,
j
j
j要回溯到以
j
j
j结尾的字符串的最长相等前后缀的位置,
j
=
n
e
x
t
[
j
]
j=next[j]
j=next[j]也就是
j
=
n
e
x
t
[
3
]
=
0
j=next[3]=0
j=next[3]=0(因为字符串abc
的最长相等前后缀的长度为
0
0
0)
回溯后的图:
之后继续判断,直到
i
>
8
i>8
i>8。
n
e
x
t
next
next数组也就全部求出来了。
以上就是next数组的求解过程,其求解思路是不是就是KMP算法匹配的思路呢?思路近似,代码写起来也是近似的,所以完整的KMP算法的代码实现起来其实很简单的,就是它的思想很绕,比较难以理解。所以这里我通过图解和详细实例讲解,让比较绕的题目比较通俗易懂,便于大家理解。
代码与详细注释:
n = int(input())
# 读取文本字符串,并在前面加上一个空格,方便后续处理
t = " " + input()
m = int(input())
# 读取模式字符串,同样在前面加上一个空格
s = " " + input()
# 初始化next数组,长度为n+1,用于存储部分匹配表
# next[i]表示t[0...i-1]的最长相等真前缀后缀的长度
next = [0] * (n + 1)
# 初始化结果列表,用于存储所有匹配的位置
res = []
# 计算部分匹配表(next数组)
j = 0 # j表示当前最长相等真前缀后缀的末尾索引
for i in range(2, n + 1): # 从t的第二个字符开始遍历
while j and t[j + 1] != t[i]: # 当j不为0且t[j+1]不等于t[i]时,回退j
j = next[j] # 根据next数组回退到之前的最长相等真前缀后缀的末尾索引
if t[j + 1] == t[i]: # 如果t[j+1]等于t[i],则扩展当前的最长相等真前缀后缀
j += 1
next[i] = j # 更新next[i]
# 使用KMP算法在文本中查找模式字符串的所有出现位置
j = 0 # j同样表示当前匹配到的模式字符串的末尾索引
for i in range(1, m + 1): # 遍历模式字符串s的每一个字符
while j and t[j + 1] != s[i]: # 当j不为0且t[j+1]不等于s[i]时,回退j
j = next[j] # 根据next数组回退到之前的最长相等真前缀后缀的末尾索引
if t[j + 1] == s[i]: # 如果t[j+1]等于s[i],则扩展当前的匹配
j += 1
if j == n: # 如果j等于n,表示模式字符串s完全匹配了文本t的一个子串
res.append(i - j) # 将匹配到的起始位置(注意要减去j,因为s前面加了一个空格)添加到结果列表
j = next[j] # 根据next数组回退到下一个可能的匹配位置
# 输出所有匹配的位置
print(" ".join(map(str, res)))
总结:
本篇博客主要细致的讲解了KMP算法的几大难点,一个是前缀、后缀和最长相等前后缀的概念,一个是 n e x t next next数组的定义、一个是 n e x t next next数组的回溯过程、还有 n e x t next next数组的求解过程。这里再次强调 n e x t next next数组的定义一定要记住!!!
KMP算法主要应用于字符串模式匹配,其核心思想在于使用前缀表从而实现在不匹配时利用之前已经匹配过的信息来减少回溯的过程。希望本篇博客可以帮助大家更好地理解KMP算法。