新年第一篇博客也是第一次写关于算法的博客。这两天帮同学看《算法与数据结构》试题,其中涉及到字符串匹配KMP算法,借机重新温习整理了一下,也算有了新的体会与感悟,希望能够讲得清楚。
从字符串匹配讲起
我们都说KMP算法是一种高效的字符串匹配算法,所以首先先定义下字符串匹配问题:给定一个文本串T(也叫主串S),和一个模式串P,现在要找出S中与P匹配的子串,如果存在就返回匹配位置,否则返回-1。
暴力匹配
对于这个问题最简单最直接的想法是:从主串起始位置开始,将模式串与主串逐个匹配,如果匹配,就比较模式串下一个位置和主串下一个位置,如果不匹配,将模式串向右移动一位,然后从头开始匹配。对应上图,其匹配过程如下:
定义指针
i
i
i和
j
j
j分别指向主串当前位置和模式串当前位置。如上图,初始时
i
i
i和
j
j
j都指向开头,
i
=
0
i=0
i=0,
j
=
0
j=0
j=0。有
S
[
0
]
=
=
P
[
0
]
S[0]==P[0]
S[0]==P[0],所以将
i
i
i和
j
j
j都后移一位,继续比较。直至
i
=
3
i=3
i=3,
j
=
3
j=3
j=3,此时
S
[
3
]
!
=
P
[
3
]
S[3] !=P[3]
S[3]!=P[3]
所以将模式串向右移动一位,重新匹配,如下图:
对两种情况进行总结,有:
- 如果当前字符匹配成功(即 S [ i ] = = P [ j ] S[i] == P[j] S[i]==P[j]),则 i + + i++ i++, j + + j++ j++,继续匹配下一个字符;
- 如果当前字符匹配失败(即 S [ i ] ! = P [ j ] S[i] != P[j] S[i]!=P[j]),则 i = i − ( j − 1 ) i = i-(j-1) i=i−(j−1), j = 0 j = 0 j=0,即 i i i回溯, j j j重置为0,重新开始匹配。
代码描述如下:
int ViolentMatch(char * s, char* p)
{
int i = 0;
int j = 0; // 初始化
while (i < strlen(s) && j < strlen(p))
{
if (s[i] == p[j]) // 当前位置匹配成功
{
i++;
j++
}
else // 当前位置匹配失败
{
i = i - j + 1; // i回溯
j = 0; // j重置
}
}
if (j == strlen(p)) // 匹配成功
return i -j;
else
return -1;
}
在上面的方法中,每次匹配失败 i i i都要回溯,必然导致匹配效率较低,因此这种方法也被称为暴力匹配。那有没有一种算法,能够让 i i i不用回溯,只需要移动模板串呢?
高效方法的探索
下面以模板串“ABCDABD”为例进行分析
如上图所示,假设文本串某位置开始与模板串“ABCDAB”匹配成功,但模板串最后一个字符D与文本串当前位置字符C不匹配,此时
i
i
i和
j
j
j分别指向图上位置,特别地,
i
i
i指示了当前匹配到文本串的位置,
j
j
j反映了当前匹配的字符串数目。
因为不匹配,按照暴力匹配的方式,模板串右移一位,将首先是模板串的前
j
−
1
j-1
j−1位与文本串当前不匹配位置前的
j
−
1
j-1
j−1位进行比较,即
P
0
P
1
…
P
j
−
2
P_0P_1\dots P_{j-2}
P0P1…Pj−2与
S
i
−
j
+
1
S
i
−
j
+
2
…
S
i
−
1
S_{i-j+1}S_{i-j+2}\dots S_{i-1}
Si−j+1Si−j+2…Si−1进行比较。因为
j
j
j之前的位置都匹配,有
P
0
P
1
…
P
j
−
1
=
S
i
−
j
S
i
−
j
+
1
…
S
i
−
1
P_0P_1\dots P_{j-1}=S_{i-j}S_{i-j+1}\dots S_{i-1}
P0P1…Pj−1=Si−jSi−j+1…Si−1,所以,可以右移一位可以看作是
P
0
…
P
j
−
2
P_0\dots P_{j-2}
P0…Pj−2与
P
1
…
P
j
−
1
P_{1}\dots P_{j-1}
P1…Pj−1。同理,右移两位将是
P
0
…
P
j
−
3
P_0\dots P_{j-3}
P0…Pj−3与
P
2
…
P
j
−
1
P_{2}\dots P_{j-1}
P2…Pj−1。抽象一下,右移
n
n
n位,就是
P
0
…
P
j
−
n
−
1
P_0\dots P_{j-n-1}
P0…Pj−n−1与
P
n
…
P
j
−
1
P_{n}\dots P_{j-1}
Pn…Pj−1进行比较,即模板串前
j
j
j个字符中长度为
j
−
n
j-n
j−n的前缀与后缀进行比较。
再分析一下,当模板串右移1,2,3位时,
P
0
…
P
j
−
n
−
1
!
=
P
n
…
P
j
−
1
,
(
n
=
1
,
2
,
3
)
P_0\dots P_{j-n-1} != P_{n}\dots P_{j-1},(n=1,2,3)
P0…Pj−n−1!=Pn…Pj−1,(n=1,2,3)显然成立。因为模板串前6
(
j
=
6
)
(j=6)
(j=6)位“ABCDAB”中不存在长度为5,4,3的相同前后缀,所以这三次右移是完全不必要的操作,注定会导致失败。而“ABCDAB”中存在长度位2的相同前后缀“AB”,所以右移4
(
6
−
n
=
2
)
(6-n=2)
(6−n=2)位时,有
P
0
P
1
=
=
P
4
P
5
P_0P_1 == P_4P_5
P0P1==P4P5,这样就能保证
i
i
i不动,
j
j
j移动到第3位(对应下标2),然后继续比较。
至此,我们能够总结,当匹配失败时,我们只需找出模式串前
j
j
j个字符中的最长前后缀(假设其长度为
k
k
k),然后将
j
j
j设置为
k
k
k,继续比较
S
[
i
]
S[i]
S[i]与
P
[
j
]
P[j]
P[j]。
以上正是KMP算法的核心,其充分利用已经部分匹配这个有效信息,保持指针
i
i
i不回溯,通过修改
j
j
j的位置,让模式串尽量地移动到有效位置。
最后多说一句, k k k包含两重含义:
- 当前不匹配位置之前的字符串中,最长前后缀的长度;
- 当出现不匹配时,模板串指针 j j j要移动的下一个位置。
KMP算法
因为在模板串P的每一个位置都有可能发生不匹配,也就是说我们要计算每一个位置对应的 k k k,在KMP算法中,用数组next对其进行保存,即 n e x t [ j ] = k next[j] =k next[j]=k,表示当 S [ i ] ! = P [ j ] S[i] != P[j] S[i]!=P[j]时,指针 j j j下一步指向下标 k k k。 假设我们已经求出next数组,我们可以对暴力匹配进行改进,代码如下:
int kmpSearch(char* s, char* p)
{
int i = 0;
int j = 0; // 初始化
while (i < strlen(s) && j < strlen(p))
{
if (j == -1 || s[i] == p[j]) // 当前位置匹配成功
{
i++;
j++
}
else // 当前位置匹配失败
{
j = next[j] // i不回溯,j移动到next[j]位置,相当于模板串右移j - next[j]
}
}
if (j == strlen(p)) // 匹配成功
return i -j;
else
return -1;
}
求解next数组
再明确一遍, n e x t [ j ] next[j] next[j]的值(也就是 k k k)表示当 S [ i ] ! = P [ j ] S[i] != P[j] S[i]!=P[j]时,指针 j j j下一步指向下标 k k k。 下面一步步分析怎么求解next数组。
1、当
j
=
0
j=0
j=0时
如上图,此时
j
j
j已经是模板串的最左边位置,前面不存在任何字符,这时候应该将指针
i
i
i后移一位,并且指针
j
j
j指向模板串串首不变,然后接着比较。因此将
j
j
j设置为-1(因为在if条件中,
i
+
+
i++
i++表示将
i
i
i右移一位,
j
+
+
=
−
1
+
1
=
0
j++ = -1+1=0
j++=−1+1=0保持
j
j
j指向串首不变),所以
n
e
x
t
[
0
]
=
−
1
next[0]=-1
next[0]=−1。
2、当
j
=
1
j=1
j=1时
如上图,此时指针
j
j
j前面只有一个位置,所以指针
j
j
j一定是移动到0位置(相当于模板串右移一位)。
3、当 P k = = P j P_k==P_j Pk==Pj时,求next[j+1]
n
e
x
t
[
j
+
1
]
next[j+1]
next[j+1]是字符串
P
0
…
P
j
P_0\dots P_j
P0…Pj中的最长前后缀,因为我们已经求出
n
e
x
t
[
j
]
=
k
next[j]=k
next[j]=k,即在
P
0
…
P
j
−
1
P_0\dots P_{j-1}
P0…Pj−1中已经有
P
0
…
P
k
−
1
=
=
P
j
−
k
…
P
j
−
1
P_0\dots P_{k-1} == P_{j-k}\dots P_{j-1}
P0…Pk−1==Pj−k…Pj−1,且其是能找到的最长前后缀,此时又有
P
k
=
=
P
j
P_k==P_j
Pk==Pj,我们可以直接得出
P
0
…
P
k
=
=
P
j
−
k
…
P
j
P_0\dots P_k == P_{j-k}\dots P_j
P0…Pk==Pj−k…Pj,并且没有比这更长的前后缀,所以
n
e
x
t
[
j
+
1
]
=
k
+
1
next[j+1]= k+1
next[j+1]=k+1。
4、当
P
k
!
=
P
j
P_k!=P_j
Pk!=Pj时,求next[j+1]
当
P
k
!
=
P
j
P_k!=P_j
Pk!=Pj时,导致
P
0
…
P
k
!
=
P
j
−
k
…
P
j
P_0\dots P_k != P_{j-k}\dots P_j
P0…Pk!=Pj−k…Pj,即
P
0
…
P
j
P_0\dots P_j
P0…Pj中没有长度为
k
+
1
k+1
k+1的相同前后缀,那只能去寻找更短一点的相同前后缀,且是在
P
0
…
P
k
−
1
P_0\dots P_{k-1}
P0…Pk−1中寻找。
如上图(来自july的博客),同样因为已经求出
n
e
x
t
[
j
]
=
k
next[j]=k
next[j]=k,即在
P
0
…
P
j
−
1
P_0\dots P_{j-1}
P0…Pj−1中已经有长度为
k
k
k的相同前后缀(如上图大括号标示区间)。假设我们在
P
0
…
P
k
−
1
P_0\dots P_{k-1}
P0…Pk−1中找到一个位置
x
x
x,满足
P
0
…
P
x
=
=
P
j
−
x
…
P
j
P_0\dots P_x ==P_{j-x}\dots P_j
P0…Px==Pj−x…Pj,除去
P
x
P_x
Px与
P
j
P_j
Pj,即
P
0
…
P
x
−
1
=
=
P
j
−
x
…
P
j
−
1
P_0\dots P_{x-1} ==P_{j-x}\dots P_{j-1}
P0…Px−1==Pj−x…Pj−1。而
P
j
−
x
…
P
j
−
1
P_{j-x}\dots P_{j-1}
Pj−x…Pj−1是
n
e
x
t
[
j
]
next[j]
next[j]对应的一个后缀串,一定有
P
j
−
x
…
P
j
−
1
=
=
P
k
−
x
…
P
k
−
1
P_{j-x}\dots P_{j-1}==P_{k-x}\dots P_{k-1}
Pj−x…Pj−1==Pk−x…Pk−1,因此可以得到
P
0
…
P
x
−
1
=
=
P
k
−
x
…
P
k
−
1
P_0\dots P_{x-1} ==P_{k-x}\dots P_{k-1}
P0…Px−1==Pk−x…Pk−1,这个等式是不是有点似曾相识,对,这正是
k
k
k位置对应的前后缀。受此启发,我们找到
k
k
k位置对应的最长前后缀,即next[k],并且有
P
n
e
x
t
[
k
]
=
=
P
j
P_{next[k]}==P_j
Pnext[k]==Pj,那么
P
0
…
P
n
e
x
t
[
k
]
P_0\dots P_{next[k]}
P0…Pnext[k]就是我们要找的最长前后缀。如果
P
n
e
x
t
[
k
]
!
=
P
j
P_{next[k]}!=P_j
Pnext[k]!=Pj,那么继续迭代,比较p[next[next[k]]] 与pj,即不断的递归k = next[k],直到要么找到长度更短的相同前缀后缀,要么没有长度更短的相同前缀后缀。
综上,可以得到求解next数组的代码
void GetNext(char* p, int next[])
{
int k = -1;
int j = 0;
next[0] = -1;
while (j < strlen(p) - 1)
{
if (k == -1 || p[j] == p[k])
{
k++;
j++;
next[j] = k;
}
else
{
k = next[k];
}
}
}
至此,我们从暴力匹配开始,逐步了解了KMP的原理,以及next数组的求解。关于next数组的优化,next数组与有限状态自动机的转换等一些其他字符串匹配算法大家可以自行了解。
参考文献
https://www.cnblogs.com/yjiyjige/p/3263858.html
https://www.cnblogs.com/ZuoAndFutureGirl/p/9028287.html