本系列文章主要介绍几种常用的字符串比较算法,包括但不限于蛮力匹配算法,KMP算法,BM算法,Horspool算法,Sunday算法,fastsearch算法,KR算法等等。
本文主要介绍KMP算法和BM算法,它们分别是前缀匹配和后缀匹配的经典算法。所谓前缀匹配是指:模式串和母串的比较从左到右,模式串的移动也是从左到右;所谓后缀匹配是指:模式串和母串的的比较从右到左,模式串的移动从左到右。看得出来前缀匹配和后缀匹配的区别就仅仅在于比较的顺序不同。下文分别从最简单的前缀蛮力匹配算法和后缀蛮力匹配算法入手,详细的介绍KMP算法和BM算法以及它们的实现。
KMP算法
首先来看一下前缀蛮力匹配算法的代码(以下代码从linux源码string.h中抠出),模式串和母串的比较是从左到右进行(strncmp()),如果找不到和模式串相同的子串,则从左到右移动模式串,距离为1(s++)。
1
2
3
4
5
6
7
8
9
|
char
*
strstr
(
register
const
char
*s,
register
const
char
*wanted)
{
register
const
size_t
len =
strlen
(wanted);
if
(len == 0)
return
(
char
*)s;
while
(*s != *wanted ||
strncmp
(s, wanted, len))
if
(*s++ ==
'\0'
)
return
(
char
*)NULL;
return
(
char
*)s;
}
|
KMP算法中的KMP分别是指三个人名:Knuth、Morris、Pratt,其本质也是前缀匹配算法,对比前缀蛮力匹配算法,区别在于它会动态调整每次模式串的移动距离,而不仅仅是加一,从而加快匹配过程。下图通过一个直观的例子展示前缀蛮力匹配算法和KMP算法的区别,前文提过,这二者唯一的不同在于模式串移动距离。
上图中,前缀蛮力匹配算法发现匹配不上,就向右移动距离1,而KMP算法根据已经比较过的前缀信息,了解到应该移动距离为2;换句话说针对母串的下一个匹配字符,KMP算法了解它下回应该匹配模式串的哪个位置,比如上图中,针对母串的第i+1个字符,KMP算法了解它应该匹配模式串的第k+1个字符。为什么会是这样,这是因为母串的子串T[i-k, i]=aba,而模式串的子串P[0,k]=aba,这二者正好相等。所以模式串应该移动到这个位置,从而让母串的第i+1个字符和模式串的第k+1个字符继续比较。
那k值又是如何寻找?请注意上图中,模式串位置j已经匹配上母串的位置i,也就是T[i-k, i] = P[j-k, j]=aba;根据前文的T[i-k, i] = P[0, k] = aba, 从而得出P[0, k] = P[j-k, j] = aba。通过观察发现,就是在模式的子串[0, j]中寻找一个最长前缀[0,k],从而使得[j-k, j] = [0,k];
于是可以定义一个jump数组,jump[j]=k,表示满足P[0, k] ==P[j-k, j] 的最大k值,或者表述为:如果模式串j+1匹配不上母串的i+1,那跳转到模式串k+1继续比较。有了这个jump数组,就很容易写出kmp算法的伪代码:
1
2
3
4
5
6
7
8
9
10
|
j:=0;
for
i:=1 to n
do
Begin
while
(j>0) and (P[j+1]<>T[i])
do
j:=jump[j];[
if
P[j+1]=T[i] then j:=j+1;
if
j=m then
Begin
writeln(
'Pattern occurs with shift '
,i-m);
end;
end;
|
KMP算法中jump数组的构建可以通过归纳法来解决,首先确定jump[1]=0;假设jump[j]=k,也就是P[0, k] == P[j-k, k],如果P[j+1] == P[k+1],那么得出[0,k+1] = P[j-k, j+1],从而更加定义得出jump[j+1] = k+1;
如果P[j+1] != P[k+1],那就接着比较P[j+1] ?= P[k1+1],其中(jump[k] = k1),根据(jump[k]=k1)的定义,P[0,k1] == P[k-k1, k],根据(jump[j]=k)的定义,P[0, k] == P[j-k, k],根据这两个等式,推出P[0, k1] == P[j-k1, j],如果此时P[j+1] == P[k1+1],则得出:jump[j+1] = K1 +1 = jump[k] +1。
如果P[j+1] != P[K1+1],继续递归比较P[j+1] 和P[jump[jump[k]]+1] …. P[1];
如果依次比较都不相等,那么jump[j+1] = 0;写成伪代码如下,可以看出其实就是模式串自我匹配的过程。
1
2
3
4
5
6
7
8
|
jump[1]:=0;
j:=0;
for
i:=2 to m
do
begin
while
(j>0) and (P[j+1]<>P[i])
do
j:=jump[j];
if
P[j+1]=P[i] then j:=j+1;
jump[i]:=j;
end;
|
考虑模式串匹配不上母串的最坏情况,前缀蛮力匹配算法的时间复杂度最差是O(n×m),最好是O(n),其中n为母串的长度,m为模式串的长度。KMP算法最差的时间复杂度是O(n);最好的时间复杂度是O(n/m)。
BM算法
后缀匹配,是指模式串的比较从右到左,模式串的移动也是从左到右的匹配过程,经典的BM算法其实是对后缀蛮力匹配算法的改进。所以还是先从最简单的后缀蛮力匹配算法开始。下面直接给出伪代码,注意这一行代码:j++;BM算法所做的唯一的事情就是改进了这行代码,即模式串不是每次移动一步,而是根据已经匹配的后缀信息,从而移动更多的距离。
1
2
3
4
5
6
7
8
|
j = 0;
while
(j <=
strlen
(T) -
strlen
(P)) {
for
(i =
strlen
(P) - 1; i >= 0 && P[i] ==T[i + j]; --i)
if
(i < 0)
match;
else
++j;
}
|
为了实现更快移动模式串,BM算法定义了两个规则,好后缀规则和坏字符规则,如下图可以清晰的看出他们的含义。利用好后缀和坏字符可以大大加快模式串的移动距离,不是简单的++j,而是j+=max (shift(好后缀), shift(坏字符))
先来看如何根据坏字符来移动模式串,shift(坏字符)分为两种情况:
- 坏字符没出现在模式串中,这时可以把模式串移动到坏字符的下一个字符,继续比较,如下图:
- 坏字符出现在模式串中,这时可以把模式串第一个出现的坏字符和母串的坏字符对齐,当然,这样可能造成模式串倒退移动,如下图:
为了用代码来描述上述的两种情况,设计一个数组bmBc['k'],表示坏字符‘k’在模式串中出现的位置距离模式串末尾的最大长度,那么当遇到坏字符的时候,模式串可以移动距离为: shift(坏字符) = bmBc[T[i]]-(m-1-i)。如下图:
数组bmBc的创建非常简单,直接贴出代码如下:
1
2
3
4
5
6
7
|
void
preBmBc(
char
*x,
int
m,
int
bmBc[]) {
int
i;
for
(i = 0; i < ASIZE; ++i)
bmBc[i] = m;
for
(i = 0; i < m - 1; ++i)
bmBc[x[i]] = m - i - 1;
}
|
再来看如何根据好后缀规则移动模式串,shift(好后缀)分为三种情况:
- 模式串中有子串匹配上好后缀,此时移动模式串,让该子串和好后缀对齐即可,如果超过一个子串匹配上好后缀,则选择最靠左边的子串对齐。
- 模式串中没有子串匹配上后后缀,此时需要寻找模式串的一个最长前缀,并让该前缀等于好后缀的后缀,寻找到该前缀后,让该前缀和好后缀对齐即可。
- 模式串中没有子串匹配上后后缀,并且在模式串中找不到最长前缀,让该前缀等于好后缀的后缀。此时,直接移动模式到好后缀的下一个字符。
为了实现好后缀规则,需要定义一个数组suffix[],其中suffix[i] = s 表示以i为边界,与模式串后缀匹配的最大长度,如下图所示,用公式可以描述:满足P[i-s, i] == P[m-s, m]的最大长度s。
构建suffix数组的代码如下:
1
2
3
4
5
6
7
|
suffix[m-1]=m;
for
(i=m-2;i>=0;--i){
q=i;
while
(q>=0&&P[q]==P[m-1-i+q])
--q;
suffix[i]=i-q;
}
|
有了suffix数组,就可以定义bmGs[]数组,bmGs[i] 表示遇到好后缀时,模式串应该移动的距离,其中i表示好后缀前面一个字符的位置(也就是坏字符的位置),构建bmGs数组分为三种情况,分别对应上述的移动模式串的三种情况
构建bmGs数组的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
void
preBmGs(
char
*x,
int
m,
int
bmGs[]) {
int
i, j, suff[XSIZE];
suffixes(x, m, suff);
for
(i = 0; i < m; ++i)
bmGs[i] = m;
j = 0;
for
(i = m - 1; i >= 0; --i)
if
(suff[i] == i + 1)
for
(; j < m - 1 - i; ++j)
if
(bmGs[j] == m)
bmGs[j] = m - 1 - i;
for
(i = 0; i <= m - 2; ++i)
bmGs[m - 1 - suff[i]] = m - 1 - i;
}
|
再来重写一遍BM算法:
1
2
3
4
5
6
7
8
|
j = 0;
while
(j <=
strlen
(T) -
strlen
(P)) {
for
(i =
strlen
(P) - 1; i >= 0 && P[i] ==T[i + j]; --i)
if
(i < 0)
match;
else
j += max(bmGs[i], bmBc[T[i]]-(m-1-i));
}
|
考虑模式串匹配不上母串的最坏情况,后缀蛮力匹配算法的时间复杂度最差是O(n×m),最好是O(n),其中n为母串的长度,m为模式串的长度。BM算法时间复杂度最好是O(n/(m+1)),最差是多少?留给读者思考。
字符串匹配BM(Boyer-Moore)算法学习心得
BM算法 是 Boyer-Moore算法 的缩写,是一种基于后缀比较的模式串匹配算法。BM算法在最坏情况下可以做到线性的,平均情况下是亚线性的(即低于线性)。这也是他在实际应用中优于KMP算法的一个原因吧。
最近突然在看[柔性字符串匹配].[Flexible.Pattern.Matching.in.Strings](Gonzalo.Navarro,.Mathieu.Raffinot),想了解一下字符串匹配的一些算法,以前使用的算法基本都只是和ACM有关的,而且在单串匹配的情况下一般都只用KMP算法,而在这本书中对KMP算法的评价并不是很好,原因主要是出于工程上的“实用”上的原因吧。KMP算法虽然在理论上最坏复杂度也是线性的,可是在实际应用中并没有那么多最坏情况,而且如果用于模式匹配的话,很多情况下模式串也是很短的。
下面来说一说我写这个的原因吧,我搞了两年ACM嘛,现在看书肯定还是和ACM有一定关系的,而且很自然的会将一些事情和ACM中做对比的。[柔性字符串匹配]说KMP在实际应用中不好,那我就想反过来看一下在实际应用中比较好的算法在ACM中效果怎么样了,最先看到的就是BM算法(当然在书中还看到了Shift-or 和Shift-and 算法,但由于长度限制差别太大,实在不具可比性,不过这两个算法个人感觉是相当优雅的)。由于这本书太实用了,对于“不实用”的算法都只做基本介绍(对于KMP算法也一样,只做了一点大概的介绍,其实我非常不明白为什么会出现这种现象,难道理论和应用的差别有这么大么?)。书上说原始BM算法在最坏情况下复杂度是O(n*m)的,O(n*m)的算法在ACM中必然是不“实用”的,但它说BM算法有两个优化版本可以在最坏情况下也能达到线性的。这样一来我觉得就有一定可比性了,于是就想仔细看一下BM算法。
书上说的两个算法是:the Boyer-Moore-Galil [Gal79] and the Turbo-BM[CGR92] algorithms。
我找了找,也没找到什么相关资料,但是回头一想这个算法都出来这么久了,直接搜BM算法应该也能搜出优化过的BM算法吧。
下面是我找到的一些文章。
字符串匹配那些事(一)
这篇文章好像是淘宝的吧,其中没有明确指出BM算法的时间复杂度。
M模式匹配算法原理(图解)
这篇文章对BM进行了大致的讲解,文中分析到了时间复杂度,
文中为:最好情况下的时间复杂度为O(n/m),最坏情况下时间复杂度为O(m·n)。
精确字符串匹配(BM算法) BM 算法中“好后缀”预处理
在第一篇文章中提到了时间复杂度:整个算法的时间复杂度最坏的情况是 O(m),m 是 T 的长度。
可有一点我不明白,按这篇文章中的作法,好像并没有对原始的BM算法进行优化,而原始的BM算法确实是O(m·n)的。
又重新去搜论文了。
A Fast String Searching Algorithm - The University of Texas at Austin
Boyer-Moore-Galil [Gal79] Z. Galil. On improving the worst case running time of the Boyer-Moore string searching algorithm. Communications of the ACM, 22(9):505–508, 1979.
看了一下这两篇论文,看一下第一篇,实在太长了(其实只有11页但相对于第二篇只有4页)。然后果断看第二篇,虽然已经是三十多年前的论文了。
在这里费话两句,之前我因为《柔性字符串匹配》这本书说KMP不怎么实用,心里还感觉有点不爽,你说KMP不实用,那你拿一个实用且理论最坏复杂度为线性的算法让我看看。再加上找了一天多也只有一些BM算法的原始版。看了上面的第二篇文章,实现了一下其中的算法并在POJ上试了试,果然没问题。在这一刻我深深的折服于《柔性字符串匹配》了。这本书果然名副其实。值得推荐。
好了,费话也就说这么多,接下来说点技术性的吧,看看Z. Galil. 是如何将BM的最坏情况复杂度降到线性的。在这里,我默认大家是明白BM算法原理的。加上上面已经有那么多文章是写BM算法的了(如果需要,建议阅读淘宝的那篇),我这里只讨论的优化了。
以下讨论中,文本串用T表示,模式串用p表示。|S|表示字符串S的长度。默认情况下,n=|T|,m=|p|
为什么原始的BM算法在最坏情况下是O(n*m)的呢?一个最简单的实例就是 p=a^m,T=a^n。就是说模式串是由m个a组成,文本是由n个a组成,在这种情况下,找出所有匹配复杂度就是O(n*m)的。造成这种情况最主要的就是BM算法在向后滑动的时候对之前匹配过的字符的利用率并不高,不像KMP,一旦匹配过,就不会再回过头再匹配一次,这也是我感觉KMP相当神的一点。
在此之前先讨论几个概念,一个是字符串的周期性。
直观的理解就是类似:abcabcabcabc,aabaabaab...这样的串,都是有周期性的。我们在这里认为这种模式串也具有周期性的——abcabcab,就是最一个重复单元是不完整的。这里的周期性指至少重复一次以上,这种串不算有周期性——abcdabc。随便提一下,这样的串不具有周期性——ababababc,这里的周期性是针对于整个字符串来说的。
对于具用周期性的模式串,记模式串的最小正周期长度为ord。
再引出一个定义:匹配块,T中的一段匹配块是一段连续的字符串,其中包含有匹配上的匹配串,并且这些匹配上的区域是相互重叠的并且尽可能向左右延伸。当然在匹配块中不包含多余的字符。举一个例子。(这里对匹配块的定义不准确,大家明白这个意思就行了。)
p = aba
T = abababaabaaba
其中前面一部分就是一个匹配块:abababa,后面的abaaba不算,因为匹配上匹配串没有相互重叠。
基于以下几点讨论,我们可以对BM算法进行优化。
(1)如果文本串中不含模式串,那么BM算法在最坏情况下的比较次数为7n次。
(2)BM算法的最坏情况复杂度为O(n+r*m),其中n为|T|,m为|p|,r为p在T中出现次数。
(3)如果在上式中r>2n/m,则p是具有周期性的。
(4)如果p不具周期性,那么BM算法在最坏情况下是线性的。
(5)记一个匹配块中所有匹配上的位置为p0,p1,...,pk(以递增方式排列),
那么对于0<i<=k,pi - p(i-1)=ord。而且每个匹配块中的匹配可以做到线性。
(6)优化之后的BM' 算法是O(n)的。
优化方法:每次成功匹配以后,向右移ord,并且在这种情况下只匹配那ord个字符。
伪代码如下:
1 last = 0; 2 for(k=0; k+|p|<=|T|; ) 3 { 4 for(i=pn-1; i>=last && T[k+i]==p[i]; ) 5 i--; 6 if( i<last ) 7 { /// 成功匹配 8 last = |p| - ord; 9 k += ord; 10 } 11 else 12 { /// 匹配失败 13 last = 0; 14 k += (BM算法中的移动距离); 15 } 16 }
这里就是算法的核心了。这里 hust 有BM实现poj 3461的完整代码。
这个优化只要知道的话就很好做了,在已有的BM代码上加两行就实现了。
这个改进之后的BM' 算法在最坏情况下是线性的。
下面是相关说明,论文上也有证明。这里以比较直白的方式对其进行一些分析。
(1)如果文本串中不含模式串,那么BM算法在最坏情况下的比较次数为7n次。
这一条我也不知道怎么证明的,这里默认他是正确的吧。论文上说比较复杂没有介绍。
(2)BM算法的最坏情况复杂度为O(n+r*m),其中n为|T|,m为|p|,r为p在T中出现次数。
这里我们把所有的r次完整匹配取出来,总共r*m次比较。这里我们想像用这r次匹配上的位置(取最左端)把T分割成了r+1个部分。对于前r个部分,我们还需要在其右端加上(m-1)字符。这里的r+1个文本串已经无法匹配上p了。所以这里可以直接使用(1)了。而这时的总长度约为(n+r*m),代入第一条可得总比较次数少于:
7*n + 8*r*m <- ( 7*(n+r*m) + r*m )
(3)如果在上式中r>2n/m,则p是具有周期性的。
这里可以用鸽笼原理。先讨论当r>n/m时,即r*m>n。所有匹配的长度之和已经大于了|T|,这时,至少有两个匹配上的串是互相重叠的。这样,r>2*n/m就很好理解了吧,r*(m/2)>n,至少存在两次匹配上的串是互相重叠的,并且,重叠部分大于m/2。这代表了什么含意呢?想像一下,一个字符串,可以通过平移一小段距离,使重叠的那一部分互相匹配。这可以推出这个串是具有周期性的。
(4)如果p不具周期性,那么BM算法在最坏情况下也是线性的。
这条可以用(3)的逆否命题得到:如果p不具有周期性,那么在上式中r<=2n/m。再代入(2)中表达式O(n+r*m)。可得此时BM算法复杂度O(3*n)即O(n)。
(5)记一个匹配块中所有匹配上的位置为p0,p1,...,pk(以递增方式排列)。那么对于0<i<=k,pi - p(i-1)=ord。
这一点显然吧。其实我并不是特别明白论文中特意指出这一点的意义何在。
(6)BM' 算法是O(n)的。
在这一点的理解上,很接近第二步的分析。把所有的匹配块提取出来,对于匹配块来说匹配是线性的,匹配块的总长是在O(n)级别的。这里需要说明一点的是匹配块是可以相互重叠的,但重叠部分不会大于m/2。而在剩下部分的匹配依然是O(n)级别的,所以总的时间复杂度也是O(n)的。
最后加一点关于(4)点的讨论:我之前有一个错误的认识,我以为当p=b+a^m,T=a^n 时BM算法也是O(n*m)的,但我分析错了一个地方,我以为当每次匹配到p的最左边的b时,失败后只向右跳一个字符,而实际情况是向右跳了串长这么多。对BM的理解不够啊。
附上poj测试图,虽然从这里看BM算法跑得比kmp慢了一点,但我已经很满足了。