字符串模式匹配:
给定字符串,要求在该字符串(主串)中找到所有匹配一个模式串的子串(一般是返回子串在字符串中的开头位置)。这里把问题简化一下--在该字符串中找到第一个匹配对应模式串的子串即可。要找出剩下的匹配子串,只需往后沿用相同的算法。
问题进一步精炼:给定主串S、模式串P,主串S中的一个索引pos,要求使用一种算法,找到主串S的从索引pos开始的,第一个匹配模式串P的子串。如果能找到,则返回子串的开始位置在S中的索引。
朴素的模式匹配算法:
在进入正题之前,先引入最朴素的模式匹配算法--从S的第pos个字符串起,与P的第一个字符进行比较。若相同,则继续往后比较。若碰到不相同的字符,则从S的第pos+1个字符串起,和P的第一个字符进行比较...... 直至有一个S的子串和P匹配成功或找不到这样的子串。算法用C++代码描述:
朴素模式匹配:
int find(string s , string p , int pos) {
int lenS = s.length(), lenP = p.length();
if(lenP > lenS-pos)
return -1;
int i = pos, j = 0;
while(i < lenS && j < lenP)
{
if(s[i] == p[j])
{
i ++;
j ++;
}
else
{
i = i-j+1;
j = 0;
}
}
if(j == lenP)
return i-lenP;
return -1;
}
设串S的长度为n,串P的长度为m,则算法的时间复杂度为O(nm)
仔细观察,不知道你有没有发现这个算法运行过程中,有许多冗余的比较?假设匹配发展到如下情况:
索引: 0 1 2 3 4 5 6 7 8 9
S: a b a b c a b c a c
P: a b c a c
索引: 0 1 2 3 4
情况1
这时,我们发现 S[6] != P[4]。按照上面的算法,下一步就是要重新将S[3] 和 P[0] 对准,重新进行比较。S的指针从 i = 6 回溯到 i = 3,如下所示:
索引: 0 1 2 3 4 5 6 7 8 9
S: a b a b c a b c a c
P: a b c a c
索引: 0 1 2 3 4 情况2
其实,经过我们观察就可以发现,后续的比较 i = 3,j = 0 和 i = 4,j = 0 和 i = 5,j = 0 都是没有必要进行的。遇到情况1,我们直接将模式向右滑动3个字符,跳到情况3进行比较即可。此时,S 的指针仍然为 i = 6,不用回溯。
索引: 0 1 2 3 4 5 6 7 8 9
S: a b a b c a b c a c
P: a b c a c
索引: 0 1 2 3 4情况3
以上说明,我们可以通过利用前面的比较过的信息,当匹配出现字符不等的时候,向后跳跃,不做无用的比较 ! KMP算法说,"让我们来一起跳跃吧 ~!"
KMP算法:
前期分析:
KMP算法,是用于解决模式匹配问题的快速算法,由D.E.Knuth、V.R.Pratt和J.J.Morris同时发现,也因此而得名。这个算法非常直观,推导过程简洁优美。
讨论一般情况,设主串:,模式串:
为改进算法,当匹配过程中产生失配()时,设主串S的第i个字符应与模式中的第k(k < j)个字符继续比较,则模式串P中的索引 k 前面长度为k-1的子串必与主串的指针 i 前面长度为 k-1 的子串匹配。则有:
(1)
回到失配的情况,虽然,但是主串S的指针 i 前面的长度为 k-1 的子串和模式串P的指针 j 前面的长度为 k-1 的子串必相匹配。则有:
(2)
由(1)、(2)可得:(3)
至此,我们发现 k 的选择竟只和模式串P相关
总结一下前面的信息,这个改进的算法中主串S的指针 i 无回溯,模式串P的指针 j 可能需要反复回溯。那么算法快速的关键就在于--i 无回溯,j 尽可能地少回溯。当 k(k < j) 越大的时候,它离 j 越近,j 的回溯步长就越短。
因此,我们要找的 k 就是满足式(3)的最大 k,即 (4)
next 数组的定义:
令next[ j ] = k,k 表示当模式中第 j 个字符与主串中第 i 个字符 “失配” 时,需要滑动模式串(回溯 j 指针),让模式串的第 k 个字符和主串中的第 i 个字符对齐,继续匹配(从这两个字符开始继续匹配,尚不知相不相等)。
综上,我们已经可以给出KMP算法的框架
//pos从1开始算起, 字符串的索引从0开始算起
int KMP(string s , string p , int pos) {
int i = pos, j = 1;
int lenP = p.length(), lenS = s.length();
while( i <= lenS && j <= lenP )
{
if( j == 0 || s[i-1] == p[j-1])
{
++ i;
++ j;
}
else
j = next[j];
}
if(j > lenP)
return i - lenP;
return -1;
}
求next数组:
1. 当 j = 1时,令next[ j ] = 0,直观上表示模式串P的第0位和主串的第 i 位比较,相当于让模式串的第1位和主串的第 i+1 位比较。
2. 当 j 不等于1时,若集合不空,则令
next[ j ] = 。
3. 其他情况下,令next[ j ] = 1,就是让模式串的第1位和主串的第 i 位进行比较。
综上,next函数的定义为:
(5)
现如今,问题的关键在于求 ,我们通过分析发现可以使用递推的方法来求得这个值。
设next[ 1 ~ j ]已知,要求 next[ j+1 ] = 使得,
是满足
的最大值。
由等式(3)可知,问题被转化为模式串P自身的模式匹配问题:当 “第二个串P” 的第 j+1 位与 “第一个串P” 的第 i +1 位失配的时候,应当移动 “第二个串P” 使得其第 next[ j + 1] 位与 “第一个串P”的第 i+1 位对齐,继续匹配。
令 k = = next[ j ]( k =
,表示第一代k),则:
等式 (1 < k < j)成立,且不存在
(
),使得这个等式也成立。(6)
1) 若,则进一步有
(7),且不可能存在
(
)也满足(7),则next [ j+1 ] =
+ 1,即 next [ j+1 ] = next[ j ] + 1。
用反证法:假设存在 (
)满足(7),则有
,包含子情况:
,与 式(6) 矛盾。故原假设不成立,不存在这样的
,得证!
2) 若,则必有
,不符合next[ j + 1]的定义。
令 = next[
] ,则
成立,若
,则进一步有
,且是满足情况的最大k(证明与之前相仿),next[ j + 1 ] = next[
] + 1 =
+ 1。
同理,如果,令
= next[
] ,若
,则令 next[ j + 1 ] = next[
] + 1=
+ 1。若这代的k还不等于
,就一路迭代直至第
代为止--
=next[
],
,next [ j + 1 ] =
。
最底层的情况是 =next[
] = 0,此时 “第二个串P” 移动,使其第1个位置和“第一个串P”的第 i + 1 个位置对齐,继续匹配。
以上递推的推导过程,需要一些想像力,一旦 get 到递推时,第二个串沿着第一个串蹭来蹭去的画面,就豁然开朗了。
求next函数的过程图例:
是不是觉得非常简单,非常清晰,非常明了!
使用C++代码来描述求next函数的过程:
//_next数组从1开始数起
//字符串从0开始数起
void Next(string p) {
int i = 1,j = 0 ; _next[1] = 0;
while( i < p.length())
{
if(j == 0 || p[i-1] == p[j-1])
{
++ i;
++ j;
_next[i] = j;
}
else
j = _next[j];
}
}
/******************************
* author: ace_yom (Peizhen Zhang)
* date: 2015-8-17
* description: KMP
*
* copy right reserved.
******************************/
#include <iostream>
#include <string>
using namespace std;
//_next数组从1开始数起
//字符串从0开始数起
const int maxn = 101;
int _next[maxn];
//_next数组从1开始数起
//字符串从0开始数起
void Next(string p) {
int i = 1,j = 0 ; _next[1] = 0;
while( i < p.length())
{
if(j == 0 || p[i-1] == p[j-1])
{
++ i;
++ j;
_next[i] = j;
}
else
j = _next[j];
}
}
//这里的pos和函数的返回索引都是从1开始数起的
//字符串的索引是从0开始数起的
int KMP(string s , string p , int pos) {
int i = pos, j = 1;
int lenP = p.length(), lenS = s.length();
while( i <= lenS && j <= lenP )
{
if( j == 0 || s[i-1] == p[j-1])
{
++ i;
++ j;
}
else
j = _next[j];
}
if(j > lenP)
return i - lenP;
return -1;
}
int main() {
string s = "acbfyacafud";
string p = "ac";
Next(p);
//将返回1
cout << KMP(s,p,1);
return 0;
}
设串S的长度为n,串P的长度为m,Next算法的时间复杂度为O(n+m)
以上,算法之优美简洁莫过于此~!
然而,你以为这样就结束了吗?No ~ ! next数组还可以进一步地优化。不过这就留给读者自己进行探究了吧。实在想不出来,可以参考 [1] 的末尾部分。
Reference:
[1] 数据结构(C 语言版) 严蔚敏 吴伟民 编著--4.3 串的模式匹配算法