Hi,大家好,我是编程小6,很荣幸遇见你,我把这些年在开发过程中遇到的问题或想法写出来,今天说一说字符串匹配算法,希望能够帮助你!!!。
上图的有效位移是3。
朴素算法(Naive Algorithm)、Rabin-Karp 算法、有限自动机算法(Finite Automation)、 Knuth-Morris-Pratt 算法(即 KMP Algorithm)、Boyer-Moore 算法、Simon 算法、Colussi 算法、Galil-Giancarlo 算法、Apostolico-Crochemore 算法、Horspool 算法和 Sunday 算法等。
字符串匹配算法通常分为2个步骤:预处理和匹配。算法的总运行时间是两者之和。
下文举例:
就是穷举法,枚举法,也叫暴力匹配。是最低效最原始的算法。特点:
方法是使用循环来检查是否在范围n-m+1中存在满足条件P[1..m] = T[s+1..s+m]的有效位移s。
伪代码:
Native_string_matcher(T, P) n <- length[T] m <- length[P] for s <- 0 to n - m do if P[1..m] = T[s+1..s+m] then print "Pattern occurs with shift"
⚠️学习kmp算法的时候,很费了一番功夫,参考了多篇文章,主要是从知乎上获得灵感,一名网友建议先熟悉代码然后再理解原理。我是先看原理,但始终不能完全理解。后来改变方法,通过直接看代码,使用数据,最后理解了这个算法。
自己根据理论推导,但有些乱。 j == -1的判断是怎么来的? j = next_s[j] ,next_s是怎么来的?kmp是怎么利用的,用已有的代码倒推。 晚上,根据已有代码来理解,成功了。
这是对Pattern进行预处理的算法。
找到T中对P的第一次匹配,当P[1..(i-1)]等于T[1..(i-1)],但P[i]不匹配T[i]的情况,不使用使用穷举法,而是使用更优化的算法kmp,减少了不必要的字符比较。(⚠️这里指针i, 代表字符串中的第几个字符,不是数组的索引)
因为P[1..(i-1)]成功在Text匹配,即和T[1..(i-1)]两个字符串相同。那么:
简单来说就是找到P[1..(i-1)]的前缀等于T[1..(i-1)]的后缀的字符串,这个字符串的字符,无需再比较。
这种算法比穷举法好太多了。
因此当已知字符串p时,先对它进行预处理。
理论:对p[1], p[1..2],p[1..i]..p[1..n]逐一处理(1<= i <=n),找到每个p[1..i]的前后缀相交的最长字符串,得到这个字符串的长度x。然后存入kmp数组。(但这样花费太多时间)
实际:求解Pattern的kmp或next_s的方法,使用的是递归的方法
由此得到一套字符串P的最大共有字符串的长度集合,即kmp数组。
例如:
下图给出了关于模式 P = “ababababca”的kmp(即前缀和后缀集合,共有的字符串集合中,最长的字符串的的长度的值)的表格,称为部分(即部分字符串)匹配表(Partial Match Table)。
计算过程
kmp[0] = 0,匹配a 仅一个字符,前缀和后缀为空集,共有元素最大长度为 0;
kmp[1] = 0,匹配ab 的前缀 a,后缀 b,不匹配,共有元素最大长度为 0;
kmp[2] = 1,aba,前缀 a ab,后缀 ba a,共有元素最大长度为 1;
kmp[3] = 2,abab,前缀 a ab aba,后缀 bab ab b,共有元素最大长度为 2;
kmp[4] = 3,ababa,前缀 a ab aba abab,后缀 baba aba ba a,共有元素最大长度为 3;
kmp[5] = 4,ababab,前缀 a ab aba abab ababa,后缀 babab abab bab ab b,共有元素最大长度为 4;
kmp[6] = 5,abababa,前缀 a ab aba abab ababa ababab,后缀 bababa ababa baba aba ba a,共有元素最大长度为 5;
kmp[7] = 6,abababab,前缀 .. ababab ..,后缀 .. ababab ..,共有元素最大长度为 6;
kmp[8] = 0,ababababc,前缀和后缀不匹配,共有元素最大长度为 0;
kmp[9] = 1,ababababca,前缀 .. a ..,后缀 .. a ..,共有元素最大长度为 1;
之后就可以利用这个表了。
如果在j处发生不匹配,那么主字符串i指针之前部分与P[1..j-1]相同/匹配。通过求得的上面的表格可以找到j前面一位的kmp.
这共同的字符串就无需参加后续比较。j从新定位到这个字符串的后一位,和i比较。
具体流程是i不动,j指针指向P[1..j -1]字符串的共有字符串的后面一位,j = 4。然后下轮判断p[4] 是否等于 p[6].
例子:
图(a)在i位发生不匹配 (此时j = i = 6, j - 1 = 5),所以P[0..5] = "ababab"是和主string匹配的。找到了“ababab”的共有字符串是“abab”,就是图灰色部分。这部分字符串无需在之后比较了。
利用部分匹配表,可知kmp[5] == 4 ,进行下一轮匹配时,直接移动j指针到P[4],即j = kmp[j-1]从这里开始匹配,达到优化字符串的匹配/查找的目的。
另外,比较到发生不匹配时,需要在匹配表找kmp[j-1], 所以为了编程方便,将kmp数组向后移动一个位置,产生一个next数组, 使用这个next数组即可。⚠️这本身只是为了让代码看起来更优雅。无其他意义。反而对初学者来说,不好理解。
Ruby代码:
def Kmp_matcher(text, pattern) # 传入的text = "abababca",这里kmp已经给出。 kmp = [0,0,1,2,3,4,0, 1] i, j = 0, 0 while i < text.length && j < pattern.length if text[i] == pattern[j] i += 1 j += 1 else if j == 0 #第一个字符不匹配只能继续i++了, 就是穷举法了。 i += 1 else #使用kmp法, 即匹配失败,模式p相对于文本向👉移动。 j = kmp[j - 1] end end end if j == pattern.length # 返回pattern出现在text中的的位置。 return i - j else # pattern不匹配text return -1 end end
上面代码可知当pattern只有一个字母时,就是穷举法,所以i += 1。
例如:next[j] = k ,代表j之前的字符串中,最大前缀/后缀的长度为k。
本例子next_s = [-1, 0,0,1,2,3,4,0], 其实就是在kmp数组头部插入了一个元素-1, 或者说整体向后移动一个位置。
因为代码j = kmp[j - 1],所以使用j = next_s[j],但需要对第一个字符就不匹配的情况改代码:
特殊情况:
pattern只有一个字母"x"时,不匹配,我们设置next[0]等于-1。
当p = 'x', p[0]不等于text[0], 只能是穷举法了,每轮i+1,j不变。
因此要修改一下条件判断 : if j == -1 || text[j] == pattern[j]
⚠️本质就是一个变通的方法,以适应next_s数组。见改后的代码:
def next_s_matcher(text, pattern) # 传入的text = "abababca" next_s = [-1, 0,0,1,2,3,4,0] i, j = 0, 0 while i < text.length && j < pattern.length if j == -1 || text[i] == pattern[j] i += 1 j += 1 else j = next_s[j] end end if j == pattern.length # 返回pattern出现在text中的的位置。 return i - j else # pattern不匹配text return -1 end end
⚠️有知乎网友说:先学AC自动机,就好理解kmp了。另外kmp也不是时间复杂度最好的算法。还可以优化。
前面已经讲解了next[j]的意义👆。这里再强调一下:
j前面的匹配字符串是p[0..j-1], 它的前缀后缀集合的交集中,最长的字符串(最长前缀/最长后缀)的字符数就是next[j]。
(为了方便表示,在前缀集合中的这个字符串称为最大前缀,在后缀集合中的这个字符串称为最大后缀,它们是一样的。)
因此next[j]表示的就是
先放上代码:ruby代码。
def getNext(pattern) pLen = pattern.length next_s = [] next_s[0] = -1 i = 0 # pattern的下标 j = -1 while i < pLen - 1 #只处理前PLen -1个字符的情况。所以是PLen - 1 if j == -1 || pattern[j] == pattern[i] i += 1 j += 1 next_s[i] = j else j = next_s[j] end end return next_s end
(代码好像很类似kmp的代码呵,但是看不懂。看了很多文章才慢慢理解。)
这个代码利用了递归的方法来得到next_s数组。
next的求解其实是对模式字符串自身的匹配比较,因此和kmp方法代码类似。
假设开始新一轮循环,j等于6,i等于4,我们要判断p[6]是否等于p[4]
此时p[0..6]这个字符串是"ababab?"。“?”号代表要判断的p[6]。p[6]前面字符串的“ababab”是已经匹配成功的字符串,它的最大前缀/后缀是“abab”, max_len = 4。
现在"ababab"尾巴增加一个字符“?”,我们求这个“ababab?”的最大前缀/后缀的长度是多少?无需把前缀,后缀都列出来,然后找到它们的交集的笨方法。而是使用递归的方法:
想得到"ababab?"的最大前缀,我们要知道要"?"是什么字符,这里有2种情况:
第一种:(见图2)
如果“?”和"ababab"的最大前缀字符串的后一位字符相同,即“?” == "a", 那么 p[0..6]即"abababa"的最大前缀/后缀就是:“abab”+"a"。p[0..6]的max_len = 4 + 1 等于5。
可以看图2来增进理解,已知一个字符串pattern的最大前缀/后缀“abab”,如果pattern尾巴上增加一个字符"?",即pattern的长度加1, 那么pattern的最大前缀/后缀可能也会加1。这个可能实现的前提条件就是,恰好新增的字符等于最大前缀后面的第一个字符(p[i]==p[j])。即产生“新的最大前缀” == “新的最大后缀”。
这样就理解了if语句的前半部分:
if j == -1 || pattern[j] == pattern[i] i += 1 j += 1 next_s[i] = j else ...
# 如果pattern[j] 等于pattern[i],那么产生的新的最大前缀/后缀,其实就是之前的最大前缀+它后的一个字符。因此新的最大前缀/后缀长度+1.
# 即next_s[i] = j
另一种情况:(见图1)
本例子,不是"a", 那么这个字符串p[0..6]的最大共同字符串的长度不会增长,那么我们就要考虑新的最大共同字符串的长度和之前一样还是更小,甚至没有。
我们当然不会使用先把前缀和后缀的集合列出来,然后找共同的最长字符串,这是个笨方法。
在这里,我们用到了递归的方法。每轮比较,确定上一轮的最大前缀/后缀的最大前缀/后缀,是否是p[0..6]的最大前缀/后缀。
因此要向字符串头部移动指针j。让指针j指向最大前缀的最大前缀。
else j = next_s[j]
如下图2:两条红线的后一位比较不相等,那么就让红线1的最大前缀:绿线1的后一位和红线2的最大后缀黄线2的后一位p[i]比较。
假设新增的元素是p[i], 我们求的next[i]就是目的, 即找到p[0..i]的最大前缀/后缀(共同字符串)!
传统办法是找到前缀集合,后缀集合,然后找到其中的的共同字符串,可能有a, b, c 多个共同字符串,它们有2个特点:
根据这个2特点我们可以使用递归的方法了:
我们在上一轮比较,已经知道p[0..i-1]的最大前缀, 这里设p[0..j-1] == p[i-j, i-1]。即共同字符串集合中的c。
前缀c+p[k]后缀c+"p[i]比较,发现二者不相同。
下一步就是前缀字符串b+它的后一位,和后缀字符串b+p[i]进行比较,二者不相等。
再下一步是前缀字符串c+它的后一位,和后缀字符串c+p[i]进行比较,二者还不相等。
最后一步,是p[0] 和p[j]比较,即字符串的第一个字符,和新增的尾巴字符比较。
通过一步步的递归,推导出p[0..i]有没有最大共同字符串。
由这个过程:我们发现了::
因此,我们使用递归的方法,先比较p[i] 是否等于p[j] , 如果不相等则指针j指向当前前缀的前缀的后一位。并递归下去,直到得到一个结果。
图1:
图2:
假设i和j的位置如上图,则next[i] = j ([0..j]的后一位字符的下标或者说是[0..j-1]的字符的数量)
区段 [0, i - 1] 的最长相同前后缀分别是 [0, j - 1] 和[i - j, i - 1],即这两区段内容相同。
按照算法流程,if (P[i] == P[j])
,则i++; j++; next[i] = j;
;若不等,则j = next[j]
,
j 开始被赋值为-1, 是为了让next[0] = -1,但会导致:
所以判断语句if上加上 j == -1。
行文到此,已经理解了kmp算法的原理,和流程。
这里有一个小的优化,节省一步递归。情况是这样的:
如果遇到:text = 'abacababc', pattern = 'abab', 已经求得next_s = [-1,0,0,1],模拟流程:
当j = 3, i =3时,比较发现p[3] 不等于t[3],于是j = next[j] ,即移动j到1,然后下一轮比较p[1]和t[3]是否相等。
结果发现仍然不相等。当然不相等啦!
观察一下,注意到,模式"abab",p[1]等于p[3],因此上面的一步递归后的再比较p[1]和t[3]完全没有比较。
既然已经知道如果p[1] == p[3], 那么下一步递归,和t[3]的比较同样不相同,就无需这一步的比较了,直接跳过去。
即在比较p[3]和t[3]后,直接进行2次递归。按照这个思路next_s[i]就储存2次递归后的值了:
if p[i] != p[j] next_s[i] = j else # 如果相等,则2次递归 next_s[i] = next[j] end
完全的代码见git:https://github.com/chentianwei411/-/blob/master/kmp.rb
text长度为n,模式为m, 时间复杂度就是O(n+m)。
Kmp算法流程:
假设文本string匹配到i位置,模式串pattern匹配到j位置:
因为匹配成功,则i+1,j+1,匹配失败则i不动,整个算法最坏的情况是i移动到终点,仍然模式不匹配文本。所以花费时间是线性的O(n)
再加上预处理模式串的时间O(m), 最坏时间复杂度是O(n+m)
1977年,德克萨斯大学的2名教授发明了一种新的字符串匹配算法:BM算法。(和kmp一样,也是以名字命名)。
最坏时间复杂度是O(n),。 比kmp算法更高效。
参考阮一峰http://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html
特点是模式串从左向右比较。(比BM算法还早的Horspool算法也是这样。)
两个规则:
http://www.voidcn.com/article/p-gcxakovf-sg.html
比kmp算法快的算法非常多,而且比kmp更好理解。kmp的确是很折磨新手。
Sunday算法由Daniel M.Sunday在1990年提出,实际上比BM算法还快。
Sunday算法是从前往后匹配,在匹配失败时关注的是主串中参加匹配的最末位字符的下一位字符。
参考:
参考了不少文章,有的太复杂有的太简单。还是推荐知乎上的这个https://www.zhihu.com/question/21923021
和这篇https://subetter.com/algorithm/kmp-algorithm.html
扩展章节摘录:https://blog.csdn.net/v_july_v/article/details/7041827
今天的分享到此就结束了,感谢您的阅读,如果确实帮到您,您可以动动手指转发给其他人。
上一篇
已是最后文章
下一篇
已是最新文章