字符串匹配算法

字符串匹配算法

  • 简介
  • 暴力匹配
  • kmp算法
  • BM算法
  • Sunday算法

 

首先是一系列概念定义:

  • 文本Text: 是一个长度为n的数组T[1..n]  (⚠️这里第一位置索引是数字1)
  • 模式Pattern: 是一个长度为m的数组P[1..m],  并且m<=n.
  • T和P的元素都属于有限的字母表Σ 表
  • 概念:有效位移Valid Shift(用字母s代表)。即P在T中出现,并且位置移动s次。如果0<=  s <= n-m ,并且T[s+1..s+m] = P[1..m],则s是有效位移。

 

 上图的有效位移是3。

 

解决字符串的算法非常多:

朴素算法(Naive Algorithm)、Rabin-Karp 算法、有限自动机算法(Finite Automation)、 Knuth-Morris-Pratt 算法(即 KMP Algorithm)、Boyer-Moore 算法、Simon 算法、Colussi 算法、Galil-Giancarlo 算法、Apostolico-Crochemore 算法、Horspool 算法和 Sunday 算法等。

 

字符串匹配算法通常分为2个步骤:预处理和匹配。算法的总运行时间是两者之和。

下文举例:


 

 

朴素的字符串匹配算法(Naive String Matching Algorithm)

就是穷举法,枚举法,也叫暴力匹配。是最低效最原始的算法。特点:

  1. 无预处理阶段。(因为是暴力匹配)
  2. 对Pattern,可以从T的首或尾开始逐个匹配字母,比较顺序没有限制。
  3. 最坏时间复杂度O((n-m+1)*m).

方法是使用循环来检查是否在范围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"

 

 


 


 

Knuth-Morris-Pratt 字符串匹配算法(即 KMP 算法)

⚠️学习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)]两个字符串相同。那么:

  1. 首先,确定T[1..i]的后缀集合和P[1..i]前缀集合。
  2. 然后,查看是否有两个集合的相交集合。
  3. 如果有,确认集合中那个最长的字符串max_string。
  4. ⚠️,max_string就是 P[1..(max_string.length)]。因此下轮再比较时,就无需比较这些字符了。

简单来说就是找到P[1..(i-1)]的前缀等于T[1..(i-1)]的后缀的字符串,这个字符串的字符,无需再比较。

这种算法比穷举法好太多了。

 


 

预先处理模式Pattern字符串

因此当已知字符串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 ..,共有元素最大长度为 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数组:

next[j]是什么?

next_s的意义:代表当前字符j之前的字符串中,有多大长度的相同前缀后缀(可称为最长前缀/后缀)。

例如: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]

 

  • 因为j等于-1,所以判断true,  于是i和j都加+1。 那么下一轮i =1, j= 0, 继续比较。
  • 当j = 0的情况时,如果不匹配,那么j = next_s[j] ,即j等于-1。 然后下一轮 , 又是true。

 

⚠️本质就是一个变通的方法,以适应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也不是时间复杂度最好的算法。还可以优化。

 

那么最重要的问题来了,如何计算kmp或者next 数组?(可以使用代码递归的方法)

前面已经讲解了next[j]的意义👆。这里再强调一下:

next[j]的意义是什么?

j前面的匹配字符串是p[0..j-1], 它的前缀后缀集合的交集中,最长的字符串(最长前缀/最长后缀)的字符数就是next[j]

(为了方便表示,在前缀集合中的这个字符串称为最大前缀,在后缀集合中的这个字符串称为最大后缀,它们是一样的。)

因此next[j]表示的就是

  1. 这个最大前缀/后缀的长度,
  2. 也表示了最大前缀的后面一位字符的索引。

 

先放上代码: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数组。

通过上面文章,我们知道最大前缀中的字符,无需再度匹配。因此下一轮匹配,文本字符串i不变,模式的指针j->最大前缀字符的后一位。

next的求解其实是对模式字符串自身的匹配比较,因此和kmp方法代码类似。

 

理解next的核心是理解if..else语句

假设开始新一轮循环,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]比较。

 

还是不太理解,为何递归前缀索引j = next_s[j], 就能找到长度更短的相同前缀后缀?

假设新增的元素是p[i], 我们求的next[i]就是目的, 即找到p[0..i]的最大前缀/后缀(共同字符串)!

传统办法是找到前缀集合,后缀集合,然后找到其中的的共同字符串,可能有a, b, c 多个共同字符串,它们有2个特点:

  1. 长度比较:a < b < c。
  2. c的前缀后缀中共同字符串中最长的就是b。同理,b的最大共同字符串是a。

根据这个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]有没有最大共同字符串。

由这个过程:我们发现了::

  1. 找p[0..i]的最大共同字符串的问题,其实就是,比较p[0..i-1]的共同字符串集合[a,b,c]的每个字符串的后一位,是否等于新加入的元素p[i]!
  2. 因为要找到最大的共同字符串,同时共同集合字符串的长度a<b<c,所以先比较c的后一位,判断不同后继续比较,最后比较首字符和新增尾字符。

因此,我们使用递归的方法,先比较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]

  • 即2个蓝圈的字符相等,判断true。
  • 不等false。那么需要重新确定最长共有字符串的长度。j = next[j]。next[j]代表上图左下红线的字符串"abab"的最长前缀/后缀的长度。即2条绿线代表“abab”的前缀后后缀。j = 2。
    • 然后再循环判断:if (p[i] == p[j])即p[2] ==p[6], 则next[6] = 2; 若不等, 则j = next[j]
    • 其含义就是向字符串的前面找p[0..6]的“最大共同字符串”,每下一轮都是判断本轮最大前缀的最大前缀的字符串。

j 开始被赋值为-1, 是为了让next[0] = -1,但会导致:

  1. 程序刚开始时,使用p[i] ==p[j]判断,无疑p[j]会边界溢出。
  2. else语句中的j = next[j], j指针不断向字符串头部移动,当j被赋值-1时,溢出.

所以判断语句if上加上 j == -1。

 

 

对next数组的小优化

行文到此,已经理解了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

 

kmp的时间复杂度分析

text长度为n,模式为m, 时间复杂度就是O(n+m)。

Kmp算法流程:

假设文本string匹配到i位置,模式串pattern匹配到j位置:

  • 如果j == -1, 或者s[i] == p[j],则令i, j都➕1。继续匹配下一个字符。 
  • 如果上面的判断都false, 则i指针不变,j指针移动到next[j]。即匹配失败的话,pattern相对于string向右移动了j-next[j]位置。

因为匹配成功,则i+1,j+1,匹配失败则i不动,整个算法最坏的情况是i移动到终点,仍然模式不匹配文本。所以花费时间是线性的O(n)

再加上预处理模式串的时间O(m), 最坏时间复杂度是O(n+m)

 


 

 

BM算法

1977年,德克萨斯大学的2名教授发明了一种新的字符串匹配算法:BM算法。(和kmp一样,也是以名字命名)。

最坏时间复杂度是O(n),。 比kmp算法更高效。

参考阮一峰http://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html

特点是模式串从左向右比较。(比BM算法还早的Horspool算法也是这样。)

两个规则:

  • 坏字符规则:当文本串中的某个字符跟模式串的某个字符不匹配时,我们称文本串中的这个失配字符为坏字符,此时模式串需要向右移动,移动的位数 = 坏字符在模式串中的位置 - 坏字符在模式串中最右出现的位置。此外,如果"坏字符"不包含在模式串之中,则最右出现位置为-1。
  • 好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。

 


 

 

Sunday算法

http://www.voidcn.com/article/p-gcxakovf-sg.html

比kmp算法快的算法非常多,而且比kmp更好理解。kmp的确是很折磨新手。

 

Sunday算法由Daniel M.Sunday在1990年提出,实际上比BM算法还快。

Sunday算法是从前往后匹配,在匹配失败时关注的是主串中参加匹配的最末位字符的下一位字符。

  • 如果该字符没有在模式串中出现则直接跳过,即移动位数 = 模式串长度 + 1;
  • 否则,其移动位数 = 模式串长度 - 该字符最右出现的位置(以0开始) = 模式串中该字符最右出现的位置到尾部的距离 + 1。

 

 

 

 

 

参考:

参考了不少文章,有的太复杂有的太简单。还是推荐知乎上的这个https://www.zhihu.com/question/21923021

和这篇https://subetter.com/algorithm/kmp-algorithm.html

扩展章节摘录:https://blog.csdn.net/v_july_v/article/details/7041827

 

posted @ 2019-10-13 11:30  Mr-chen  阅读(24477)  评论(0编辑  收藏  举报