在前面的《最长公共子序列LCS问题》中,我们采用动态规划求解了两个字符串的最长公共子序列的问题,在《最长公共子串》中,我们又用动态规划解决了两个字符串的最长公共子串问题。其中公共子序列问题更加一般,它不要求匹配结果是连续的,公共子串相对特殊一点,它要求匹配连续的子串,二者都不要求其中一个字符串必须完全匹配。在《字典树》中,我们知道字典树也可以用来高效匹配字符串,首先它要求从头开始匹配,其次它匹配的必须是连续的字符串。如果是判断字符串是否相等,还可以利用hash算法。 本篇文章,我们介绍另外一种字符串匹配问题,文章最后对这几种匹配问题进行总结。其形式化定义如下:
假设文本是一个长度为n的数组T[1..n],而模式是一个长度为m的数组P[1..m],其中m<=n,进一步假设P和T的元素都来自一个有限的字母集的字符。
如果0 <= s <= n-m,并且T[s+1..s+m] = P[1..m],那么成模式P在文本T中出现,且偏移为s。如果P在T中以偏移s出现,那么称s是有效偏移;否则,称它是无效偏移。字符串匹配问题就是找到所有的有效偏移。
下面我们来看解决此问题的算法
1.朴素字符串匹配算法
该算法简单暴力,没有任何理解上的问题,伪代码如下:
NAIVE-STRING-NATCHER(T,P)
1. n = T.length
2. m = P.length
3. for s = 0 to n-m
4. if P[1...m] == T[s+1...s+m]
5. print "Pattern occurs with shift" s
时间复杂度O((n-m+1)m),该算法对信息的利用程度较低,效率较差
字符串匹配还有Rabin-Karp算法、有限自动机等,我们暂时略过不表,而重点介绍大名鼎鼎的KMP算法
2.KMP算法
上面提到朴素算法对信息的利用程度不高,现在我们具体看一下:
b a c b a b a b aa b c b a b T
a b a b ac a P
现在红色的位置出现了不匹配的情况,朴素算法的做法是P整体前移一个位置继续匹配,它没有考虑P的结构信息,这正是造成其效率低的原因。因为P的前两字符不相等,而且不匹配的位置出现在第5个位置,所以移动一个位置之后一定不匹配,这是根据目前的信息就可以推出来的结论,却没有被有效利用。在这里P需要移动两个位置,因为在第5个位置之前P移动两个位置能够与自身匹配(只要第5个之前的位置满足即可)。如果我们能够计算出P的所有位置上的有效偏移,在发生不匹配时我们就可以利用事先计算好的信息决定下次的比较位置,从而提高效率。那么如何计算呢?
为了方便描述,我们重新表述上面问题。假设T[i-j+1,i-j+1,...,i-1]与P[1,2,...,j-1]匹配,但是T[i] != P[j],假设移动s个位置之后重新匹配,即:
T[i-j+1+s,....,i+s-1]与P[1,2,...,j-1]匹配,又根据之前的匹配信息知T[i-j+1+s,....,i+s-1] =P[s+1,s+2,...,s+j-1]。那么推出:
P[s+1,s+2,...,s+j-1] = P[1,2,...,j-1],因此我们只要对应P的每个位置计算一个满足:P[1...j]的最大后缀是P[1...m]的最大前缀的串的长度,将其与j对应保存即可。
对于上面例子中的P,计算出的信息为:
|----------------------------|
| P[i] a b a b a c a |
----------------------------|
| pi[i] 0 0 1 2 3 0 1 |
|----------------------------|
pi信息由COMPUTE-PREFIX-FUNCTION来计算,该函数由KMP-MATCHER调用。
伪代码:
KMP-MATCHER(T,P)
1. n = T.length
2. m = P.length
3. pi = COMPUTE-PREFIX-FUNCTION(P)
4. q = 0 //number of characters matched
5. for i = 1 to n
6. while q>0 and P[q+1] != T[i]
7. q = pi[q]
8. if P[q+1] == T[i]
9. q = q+1
10. if q == m
11. print "Pattern occurs with shift" i-m
12. q = pi[q] //look for next match
COMPUTE-PREFIX-FUNCTION(P)
1. m = P.length
2. let pi[1...m] be a new array
3. pi[1] = 0
4. k = 0
5. for q = 2 to m
6. while k > 0 and P[k+1] != P[q]
7. k = pi[k]
8. if P[k+1] == P[q]
9. k = k+1
10. pi[q] = k
11. return pi
时间复杂度:
COMPUTE-PREFIX-FUNCTION的运行时间为theta(m)
KMP-MATCHER的运行时间为theta(n).
KMP算法的Java实现见《KMP算法Java实现》。
小结
本文的字符串匹配算法与我们文章开始时提到的最长公共子序列问题,最长公共子串问题有所不同,首先这里是要求如果匹配,P必须完全包含在T中,其次这里关注的是匹配的位置。但是它们之间又有一定的联系。如果T与P的最长公共子序列长度小于P.length,那么P一定不会在T中出现;如果T与P的最长公共子串长度小于P.length,那么P也一定不会匹配T中的任何子串;如果T与P的最长公共子串长度等于P.length,那么P一定在T中出现。事实上,我们可以用最长公共子串的方法来求解本文的字符串匹配问题,只是复杂度较高。本文中的字符串匹配与字典树也有不同,字典树是从头开始查找的(也就是从T的开始位置),而这里的匹配是从任何位置开始的,当然,我们可以通过枚举T的不同子串建立字典树(中间节点也允许标记为word),记录开始位置信息,最后只要查找一次P即可,但是这样复杂度为O(n^2),同样比较高;还可以把T从不同的切割点进行切割,切割长度选择与P相同,用HashMap进行统计,这种方法切割了(n-m+1)次,但是还要考虑到截取字符串的时间,效率上不如KMP算法,空间上则不如字典树。综上,KMP应该算是解决本文中字符串匹配问题的非常优秀的算法了。想要更轻松的理解KMP算法,强烈建议看一下这篇文章《一个新的视角来看KMP算法 》。