字符串匹配算法

文章目录

        • 1.字符串匹配问题的定义
        • 2.暴力匹配算法(Brute Force)
          • 2.1 算法流程:
          • 2.2 代码实现(C++)
          • 2.3 算法的问题
          • 2.4 算法的复杂度
        • 3.KMP算法
          • 3.1 引出
          • 3.2 算法流程
          • 3.3 KMP算法实现
          • 3.4 next数组含义
            • 3.4.1 next数组举例
            • 3.4.2 next数组的定义
            • 3.4.3 next函数的求解
          • 3.5 算法复杂度
        • 4.BM算法
        • 5.Sunday算法
        • 推荐阅读

1.字符串匹配问题的定义

  • 文本(Text) 是一个长度为n的数组T[0...n-1];
  • 模式(Pattern) 是一个长度为m且m≤n的数组P[0...m-1];
  • TP中的元素都属于有有限的字母表 Σ \Sigma Σ;
  • 如果 0≤s≤n-m,并且 T[s+1…s+m] = P[1…m],即对 1≤j≤m,有 T[s+j] = P[j],则说模式 P 在文本 T 中出现且位移为 s,且称 s 是一个有效位移(Valid Shift)
  • 即我们的目的是:在主串中找到子串首次出现的位置。
    字符串匹配示例
    比如上图中,目标是找出在文本 T = abcabaabcabac 中模式 P = abaa 的出现的位置。该模式在此文本中仅出现一次,即在位移 s = 3 处,位移 s = 3 是有效位移。

2.暴力匹配算法(Brute Force)

2.1 算法流程:

假设当前文本串S匹配到i位置(初始为0),模式串P匹配到j位置(初始为0),则有:

  • 如果当前字符串匹配成功(即S[i] == P[j]),则继续匹配下一个字符;
  • 如果匹配失败(即S[i]! = P[j]),则令i = i - j +1,j = 0。相当于每次匹配失败时,i 回溯,j 被置为0。
2.2 代码实现(C++)
//使用暴力匹配的方法返回子串P在主串S中第pos个字符后的位置;
//若不存在,则函数值返回-1
//其中S[0...n-1],P[0...m-1]
int ViolentMatch(string s, string p,int pos) {
	int slen = s.length();
	int plen = p.length();

	int i = pos;
	int j = 0;
	while (i < slen&&j < plen) {
		if (s[i] == p[j]) { i++; j++; } //如果匹配成功,则匹配下一位
		else { i = i - j + 1; j = 0; }  //如果匹配失败,则i回溯,j置为0
	}
	if (j == plen) return i - plen;//匹配成功
	else return -1; //匹配不成功
}
2.3 算法的问题
  • 若主串s和子串p经常出现"部分匹配"的情形,则s的指针i会很容易出现回溯。
    例如像s="00000000000000000001"和p="000001"这种由0和1字符组成的文本串就会很容易出现这种问题。
  • 当一趟匹配过程中出现字符比较不等时,有时不需要回溯i的指针到最开始的地方,而是利用已经得到的"部分匹配"的结果将模式尽可能远的向右"滑动"尽可能远的一段距离后,再继续进行比较。这就需要我们下面介绍的KMP算法
2.4 算法的复杂度

最好的情况下:
O ( n + m ) O(n+m) O(n+m),其中n和m分别是主串和模式的长度
最差的情况下:
O ( n ∗ m ) O(n*m) O(nm)(经常出现"部分匹配",导致指针 i i i不断回溯)

3.KMP算法

3.1 引出

2.3所说的一样,在不匹配的时候,我们可以利用部分匹配的那部分来获得一些文本的信息以减少回溯。
如文本串T=“ababcabcacbab”,模式P=“abcac”。

字符串匹配算法_第1张图片

第一趟匹配:匹配到i=2,j=2时,S[2]!=P[2]。
如果按照暴力匹配方法,则i需要回溯到1但是我们一定会有S[1]!=P[0],
即如果我们利用已经匹配的那部分信息,我们会有S[1]=P[1]!=P[0]。
这就表明利用已经匹配的部分,我们可以不回溯i而是把j对应"滑动"到相应位置,即第一趟匹配后i=2,j=0

字符串匹配算法_第2张图片

第二趟匹配:匹配到i=6,j=4时,我们有S[6]!=P[4].
但是,利用已匹配的部分,我们会有

  • S[3]=P[1]!=P[0];
  • S[4]=P[2]!=P[0];
  • S[5]=P[3]==P[0];

所以,i是不用回溯的,即我们可以直接让i=6,j=1,再继续进行匹配。
这就是,我们KMP算法解决的问题。

3.2 算法流程

KMP算法由D.E.Knuth与V.R.Pratt和J.H.Morris同时发现,所以以他们的名字来命名,叫Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP算法”。
算法流程:
假设当前文本串S匹配到i位置(初始为0),模式串P匹配到j位置(初始为0),则有:

  • 如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),则令i++,j++,继续匹配下一个字符;
  • 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。
    • 这里的j-next[j]就相当于我们在3.1所述的利用"已匹配"部分得出i不回溯但是j相对向右滑动j-next[j]位。
    • 而next数组的实际含义是:代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如next [j] = k,代表j 之前的字符串中有最大长度为k 的相同前缀后缀。
    • 同时,也意味着在某个字符匹配失败后,模式串应该跳到next[j]的位置。
      • 如果next [j] 等于0或-1,则跳到模式串的开头字符;
      • 若next [j] = k 且 k > 0,代表下次匹配跳到j 之前的某个字符,而不是跳到开头,且具体跳过了k 个字符。
3.3 KMP算法实现

代码:

//使用KMP算法返回子串P在主串S中第pos个字符后的位置;
int KmpMatch(string s, string p, int pos) {
	int slen = s.length();
	int plen = p.length();

	int i = pos;
	int j = 0;
	int* nextP = new int[plen];
	get_next(p, nextP); //获得模式串P的next函数并存入数组next
	while (i < slen&&j < plen) {
		if (s[i] == p[j] || j == -1) { i++; j++; }
		else j = nextP[j];
	}
	delete[] nextP;
	if (j == plen) return i - plen;
	else return -1;
}
3.4 next数组含义
3.4.1 next数组举例

3.1的第二趟匹配举例:i=6,j=4
字符串匹配算法_第3张图片
此时,我们有S[6]≠P[4]。但是我们有S[2,3,4,5]=P[0,1,2,3],同时j=4之前,模式串有长度k=1的前缀,即P[0]=P[3]=‘a’,且P[3]=S[5],所以P[0]=P[5],得出我们不用匹配S[3]和P[0/1/2/3]而直接匹配S[6]和P[1]。

这里,我们找的是紧在j(也就是i)之前的前缀,而没有考虑在i的起始位置 i 0 i_{0} i0到位置 i − 1 i-1 i1中间位置的前缀,因为即使有前缀,因为如果不能接着匹配完模式串也是徒劳。

例如下面这种情况:

字符串匹配算法_第4张图片

正如我们看到的,在 j = 0 j=0 j=0 j = 6 j=6 j=6之间,我们能找到两个前缀:

  • P[0,1]和P[2,3]
  • P[0,1]和P[5,6]

如果我们选择P[0,1]和P[2,3],这下次匹配 i i i=6, j j j=2 (因为我们有S[4,5]=P[2,3]=P[0,1]),所以就省略了一些匹配.但是我们会发现由于前缀并不完整,这样的选择方式,虽然能匹配P[0,1]但是却肯定不能接着把模式串P匹配完。
所以,KMP算法选择的是P[0,1]和P[5,6]这样,我们的 i i i不用回溯,即 i = 9 i=9 i=9,同时有S[7,8]=P[5,6]=P[0,1],所以 j j j就直接等于2,即 i = 9 , j = 2 i=9,j=2 i=9,j=2。此时,是有可能将模式串匹配完的,因为 i = 9 i=9 i=9之后的S我们是不知道的。

3.4.2 next数组的定义

假设主串为 s 0 s 1 . . . s n − 1 s_{0}s_{1}...s_{n-1} s0s1...sn1 ,模式串为 p 0 p 1 . . . p m − 1 p_{0}p_{1}...p_{m-1} p0p1...pm1,当前匹配位置为 i 和 j i和j ij,且出现失配,即 s i ≠ p j s_{i}≠p_{j} si̸=pj
KMP算法求解的问题的是 i i i指针不动, j j j应该向右滑动多远,即 i i i和哪个 j j j比较?
假设下次与模式串中的第 k ( k < j ) k(k<j) k(k<j)个字符比较,则模式串前 k − 1 k-1 k1个字符必满足下式,且不存在 k ′ > k k^{'}>k k>k满足下式:
          p 0 p 1 . . . p k − 2 = s i − k + 1 s i − k + 2 . . s i − 1 p_{0}p_{1}...p_{k-2} = s_{i-k+1}s_{i-k+2}..s_{i-1} p0p1...pk2=sik+1sik+2..si1
而通过已匹配的部分有:
          p j − k + 1 p j − k + 2 . . . p j − 1 = s i − k + 1 s i − k + 2 . . s i − 1 p_{j-k+1}p_{j-k+2}...p_{j-1} = s_{i-k+1}s_{i-k+2}..s_{i-1} pjk+1pjk+2...pj1=sik+1sik+2..si1
则,我们有等式:
          p 0 p 1 . . . p k − 2 = p j − k + 1 p j − k + 2 . . . p j − 1 p_{0}p_{1}...p_{k-2} = p_{j-k+1}p_{j-k+2}...p_{j-1} p0p1...pk2=pjk+1pjk+2...pj1 (*)
也就是说,当 i i i j j j失配时,下次 S [ i ] S[i] S[i] P [ k ] P[k] P[k]进行匹配,且 k k k满足 ( ∗ ) (*) ()式。
失配时,next函数的定义:

字符串匹配算法_第5张图片

n e x t [ j ] = k next[j]=k next[j]=k表示 j j j 之前有长度为 k k k 的前缀后缀,注意 j j j0开始。如下图所示:
字符串匹配算法_第6张图片
其中 p 0 p 1 . . . p k − 1 ( 前 缀 ) = p j − k p j − k + 1 . . . p j − 1 ( 后 缀 ) , 长 度 为 k 。 p_{0}p_{1}...p_{k-1}(前缀)=p_{j-k}p_{j-k+1}...p_{j-1}(后缀),长度为k。 p0p1...pk1()=pjkpjk+1...pj1()k

举例说明P=“abaabcaca”

j 1   2   3   4   5   6   7   8
模式串 a   b   a   a   b   c   a   c
next[j] -1   0   0   1   1   2   0   1

再求得next数组后,匹配如下进行:
假设 i i i j j j分别指示主串和模式中正比较的字符

  • s i = p j s_{i}=p_{j} si=pj,则 i + + , j + + i++,j++ i++,j++
  • s i ≠ p j s_{i} \neq p_{j} si̸=pj,则 i 不 动 , j = n e x t [ j ] i不动,j = next[j] i,j=next[j];
    • 若相等,则 i + + , j + + i++,j++ i++,j++;
    • 否则, j j j退到下一个 n e x t next next的位置,依次类推。
      • j j j退到一个next值匹配,则 i 和 j i和j ij各自增1,继续进行匹配;
      • j j j退到了-1(即模式的第一个字符失配),则此时模式继续向右滑动一个位置,即 j = 0 j=0 j=0,同时从主串的下一个字符s_{i+1}和模式重新开始匹配。

注意, j j j退到-1表示主串的第 i i i个字符和模式串的第一个字符(P[0])不等,应该从主串的第 i + 1 i+1 i+1起重新匹配。

3.4.3 next函数的求解

方法:利用递推公式
3.4.2可知,next函数值只取决于模式串P本身,而我们可以通过3.4.2的定义出发利用递推的方法求得next函数值。

  • next[0]=-1;
  • n e x t [ j ] = k next[j]=k next[j]=k,则有 p 0 p 1 . . . p k − 1 = p j − k p j − k + 1 . . . p j − 1 p_{0}p_{1}...p_{k-1} = p_{j-k}p_{j-k+1}...p_{j-1} p0p1...pk1=pjkpjk+1...pj1,其中 0 ≤ k < j 0≤k<j 0k<j,则 n e x t [ j + 1 ] = ? next[j+1]=? next[j+1]=?
    • p [ k ] = p [ j ] p[k]=p[j] p[k]=p[j],则有 p 0 p 1 . . . p k − 1 p k = p j − k p j − k + 1 . . . p j − 1 p j p_{0}p_{1}...p_{k-1}p_{k} = p_{j-k}p_{j-k+1}...p_{j-1}p_{j} p0p1...pk1pk=pjkpjk+1...pj1pj
      n e x t [ j + 1 ] = k + 1 = n e x t [ j ] + 1 {\color{Red}next[j+1]=k+1=next[j]+1 } next[j+1]=k+1=next[j]+1
    • p [ k ] ≠ p [ j ] p[k]\neq p[j] p[k]̸=p[j],则有 p 0 p 1 . . . p k − 1 p k ≠ p j − k p j − k + 1 . . . p j − 1 p j p_{0}p_{1}...p_{k-1}p_{k} \neq p_{j-k}p_{j-k+1}...p_{j-1}p_{j} p0p1...pk1pk̸=pjkpjk+1...pj1pj
      此时,可以把求next函数值的问题也看成一个模式匹配问题,即整个模式串既是主串又是模式串,而且在当前匹配过程中有 p 0 p 1 . . . p k − 1 = p j − k p j − k + 1 . . . p j − 1 p_{0}p_{1}...p_{k-1} = p_{j-k}p_{j-k+1}...p_{j-1} p0p1...pk1=pjkpjk+1...pj1 ( n e x t [ j ] = k next[j]=k next[j]=k)。
      则当 p k ≠ p j p_{k} \neq p_{j} pk̸=pj时,将模式向右滑动让模式串中的第 n e x t [ k ] next[k] next[k]个字符与主串中的第 j j j个字符比较( j j j起始于0)。
      • n e x t [ k ] = k ′ next[k]=k^{'} next[k]=k p j = p k ′ p_{j}=p_{k^{'}} pj=pk,则有
        p 0 . . . p k ′ − 1 p k ′ = p k − k ′ . . . p k − 1 p j = p j − k ′ + 1 . . . p j − 1 p j p_{0}...p_{k^{'}-1}p_{k^{'}}=p_{k-k^{'}}...p_{k-1}p_{j}=p_{j-k^{'}+1}...p_{j-1}p_{j} p0...pk1pk=pkk...pk1pj=pjk+1...pj1pj ( 0 ≤ k ′ < k < j 0≤k^{'}<k<j 0kk<j) (*)
        此时,我们能得出 n e x t [ j + 1 ] = k ′ + 1 next[j+1]=k^{'}+1 next[j+1]=k+1,即 n e x t [ j + 1 ] = n e x t [ k ] + 1 {\color{Red} next[j+1]=next[k]+1} next[j+1]=next[k]+1
      • 同理,若 p j ≠ p k ′ p_{j} \neq p_{k^{'}} pj̸=pk,则 j j j继续和 n e x t [ k ′ ] next[k^{'}] next[k]比较,依次类推,直至 p j p_{j} pj和模式中的某个字符匹配成功或者不存在任何 k ′ ( 0 ≤ k ′ < j ) k^{'}(0≤k^{'}<j) k(0k<j)满足(*)式,则 n e x t [ j + 1 ] = 0 next[j+1]=0 next[j+1]=0

注意,理解递推思想的关键是next[j]=k代表j 之前的字符串中有最大长度为k 的前缀后缀。

代码实现:C++

//求模式串P的next函数并存入数组next[p.size()]。
void get_next(string p, int* next) {
	int plen = p.length();
	int j = 0; //当前求next[j]
	int k = -1;//已求得next[j]=k
	next[j] = k;
	while (j < plen) {
	//p[j]表示后缀,p[k]表示前缀
		if (k == -1 || p[j] == p[k]) { j++; k++; next[j] = k; } //next[j+1]=next[j]+1;next[1]=0;
		else k = next[k];
	}
}

next函数求解优化:
前面定义的next函数在某些情况下有缺陷:
例如模式串P="a a a a b"和主串S="a a a b a a a a b"进行匹配时,
i = 3 , j = 3 i=3,j=3 i=3,j=3时, s [ 3 ] ≠ p [ 3 ] s[3]≠p[3] s[3]̸=p[3],此时, s [ 3 ] s[3] s[3]还要和 p [ n e x t [ 3 ] ] = p [ 2 ] , p [ n e x t [ 2 ] ] = p [ 1 ] , p [ n e x t [ 1 ] ] = p [ 0 ] p[next[3]]=p[2],p[next[2]]=p[1],p[next[1]]=p[0] p[next[3]]=p[2],p[next[2]]=p[1],p[next[1]]=p[0]进行比较,但是因为 p [ 3 ] = p [ 2 ] = p [ 1 ] = p [ 0 ] = ′ a ′ p[3]=p[2]=p[1]=p[0]= 'a' p[3]=p[2]=p[1]=p[0]=a,所以,这3次比较多是多余的。
问题的关键是,不应该出现 p [ j ] = p [ n e x t [ j ] ] {\color{Red} p[j] = p[next[j]]} p[j]=p[next[j]]。因为失配时,有 s [ i ] ≠ p [ j ] s[i]≠p[j] s[i]̸=p[j],也同样会造成 s [ i ] ≠ p [ n e x t [ j ] ] s[i] \neq p[next[j]] s[i]̸=p[next[j]],所以应该避免这种情况,即出现之后要再次对next数组进行递归。
代码实现:C++

//求模式串P的next函数优化值,并存入数组nextval
void get_nextval(string p, int* nextval) {
	int plen = p.length();
	int j = 0;
	int k = -1;
	nextval[j] = k;
	while (j < plen) {
		//p[j]表示后缀,p[k]表示前缀
		if (k == -1 || p[j] == p[k]) {
			j++; k++;
			if (p[j] != p[k]) nextval[j] = k;
			//因为不能出现p[j] = p[ next[j ]],所以当出现时需要继续递归,k = next[k] = next[next[k]]
			else nextval[j] = nextval[k]; 
		}
		else k = nextval[k];
	}
3.5 算法复杂度

next数组求解复杂度: O ( m ) O(m) O(m),其中m是模式串的长度
KMP搜索的复杂度: O ( n ) O(n) O(n),因为指针 i i i是不回溯的
优点:主串的指针不回溯,可以边读入主串边匹配,对处理外设输入的文件很有效。

4.BM算法

KMP的匹配是从模式串的开头开始匹配的,而1977年,德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授发明了一种新的字符串匹配算法:Boyer-Moore算法,简称BM算法。该算法从模式串的尾部开始匹配,且拥有在最坏情况下O(N)的时间复杂度。在实践中,比KMP算法的实际效能高。

未完待续。。。。。。

5.Sunday算法

Sunday算法由Daniel M.Sunday在1990年提出,比BM算法效率更高,且更容易理解。

未完待续。。。。。。

推荐阅读

1.《数据结构 c语言版》 严蔚敏著
2.从头到尾彻底理解KMP. 作者:v_JULY_v
https://blog.csdn.net/v_july_v/article/details/7041827

你可能感兴趣的:(====,数据结构与算法,====)