夜深人静写算法(十七)- KMP

一、前言

1、概念

  • 本文主要介绍字符串匹配算法(查询一个字符串是否是另一个字符串的子串、以及位置、匹配次数等等);
  • 令 两个字符串 T 和 M;
    T = A K F L S F S G A W h e r e I s H e r o F r o m S D G S D G E R H G R T Z T Y A W T = AKFLSFSGAWhereIsHeroFromSDGSDGERHGRTZTYAW T=AKFLSFSGAWhereIsHeroFromSDGSDGERHGRTZTYAW
    M = W h e r e I s H e r o F r o m M = WhereIsHeroFrom M=WhereIsHeroFrom

a)目标串

  • 当我们需要在字符串 T 中找子串 M 是否存在时,这里的 T 就是 目标串

b)匹配串

  • 当我们需要在字符串 T 中找子串 M 是否存在时,这里的 M 就是 匹配串

c)真后缀

  • 一个串的真后缀就是不包含它本身的后缀;
  • hereIsHeroFrom、HeroFrom、oFrom 都是 WhereIsHeroFrom 的真后缀(但 WhereIsHeroFrom 不是);

d)真前缀

  • 一个串的真前缀就是不包含它本身的前缀;
  • WhereIs、WhereIsHero 都是 WhereIsHeroFrom 的真前缀(但 WhereIsHeroFrom 不是);

e)0-based

  • 本文要介绍 KMP 字符串下标从 0 开始,和网上一些介绍的文章下标从 1 开始 不同;

二、朴素算法

1、C++实现

  • 以下代码是实现在 目标串 中寻找 匹配串 的朴素(暴力)算法;
#define NULL_MATCH (-1)
#define Type char

int find(Type *targetArray, int targetSize, Type* matchArray, int matchSize) {
    
    
	int i, j;
	for(i = 0; i < targetSize - matchSize + 1; ++i) {
    
          // 1、
		for(j = 0; j < matchSize; ++j) {
    
                       // 2、
			if(targetArray[i+j] != matchArray[j]) {
    
            // 3、
				break;
			}
		}
		if(j == matchSize) {
    
                                   // 4、
			return i;
		}
	}
	return NULL_MATCH;
}
  • 1、枚举所有目标串的可行位置;
  • 2、枚举所有匹配串的可行位置;
  • 3、目标串 和 匹配串 某个位置上的元素一旦有不匹配立即退出;
  • 4、如果目标串第 i 个位置开始的 matchSize 个元素和匹配串的元素都匹配,则返回起始位置;

2、时间复杂度分析

  • 1、n 代表目标串的长度;
  • 2、m 代表匹配串的长度;
  • 3、两个循环嵌套,总的循环次数就是:
    ( n − m + 1 ) ∗ m (n - m + 1) * m (nm+1)m
  • 4、考虑时间复杂度的时候,往往是考虑它的最坏时间复杂度(前面的字符都匹配上了,直到最后1个字符才不匹配),考虑如下情况:
目标串 = “aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa”;
匹配串 = "aaaaaaaaaaaaab";
  • 结论:朴素算法的时间复杂度为 O ( ( n − m ) ∗ m ) O( (n - m)*m ) O((nm)m)特殊的,当目标串长度远大于匹配串长度,朴素算法的时间复杂度就是 O ( n ∗ m ) O (n * m) O(nm)(m 相对于 n 来说属于常数,所以后面的 m2 可以忽略);

3、朴素算法剖析 - 灵魂三问

第一问

  • 问:目标串 和 匹配串 进行匹配的时候,第一个不匹配的位置代表什么?
    在这里插入图片描述

  • 答:如图所示,目标串下标 TPos = 9,匹配串下标 MPos = 5 时,目标串 和 匹配串 在黄色位置出现不匹配,那么代表了前面 红色部分都是匹配的

第二问

  • 问:这时候,我们需要将 匹配串 往后挪一格(即 MPos 回到 0 进行重新匹配),这时候,你看到了什么?
    在这里插入图片描述
  • 答:目标串下标 和 匹配串下标 在当前匹配的位置都回退了,并且又要开始重新匹配,如果能够想到一种办法,使得目标串下标不变,只移动匹配串,那一定能够减掉很多不必要的比较运算;

第三问

  • 问:如何做到 目标串下标 TPos 不变,匹配串下标 MPos 移动 ?
    在这里插入图片描述

答:如上图,代入实际数值以后更加直观,这个就是 KMP 算法的精髓,当 匹配串的前缀不能和当前目标串的子串匹配时,尝试用更短的匹配串前缀去匹配目标串

三、KMP 算法实现

  • 令 目标串 T,长度为 TLen;匹配串 M,长度为 MLen;
  • 目标串 和 匹配串 的下标均为 0 开始 (0 - based);

1、算法核心思想

  • 从 0 到 TLen - 1 开始枚举 目标串:当 T [ T P o s − M P o s . . . T P o s − 1 ] = = M [ 0... M P o s − 1 ] T[ TPos - MPos ... TPos - 1 ] == M[0 ... MPos-1] T[TPosMPos...TPos1]==M[0...MPos1] 完全匹配的情况下,分情况讨论:
    • i. 如果 T[Tpos] == M[MPos],则 TPos++, MPos++;
    • ii. 如果 T[Tpos] != M[MPos],则需要找到一个 MPos’ < MPos,使得 T[ TPos - MPos’ … TPos - 1 ] 和 M[0 … MPos’-1] 完全匹配;

2、核心思想解释

  • 把上述提及到的几个子串列举出来得到如下表:
- -
目标串子串A T[ TPos - MPos … TPos - 1 ]
匹配串子串B M[0 … MPos-1]
目标串子串C T[ TPos - MPos’ … TPos - 1 ]
匹配串子串D M[0 … MPos’-1]
  • 1)由于 MPos’ < MPos,所以 C 为 A 的真后缀;D 为 B 的真前缀;
  • 2)由于 A 和 B 完全匹配,所以 A == B,则 C 也为 B 的真后缀;
  • 3)由于 C 和 D 完全匹配,所以 C == D,则 D 也为 B 的真后缀;

结论:D 既是 B 的真前缀,也是真后缀;

3、最长真前后缀

  • 刚才证明了 M[0 … MPos’-1] 是 M[0 … MPos-1] 的真前后缀(接下来把既是真前缀,又是真后缀的,简称 ‘真前后缀’ ),但是这样的位置 MPos’ 应该不止一个,应该取哪一个呢?
  • MPos’ 当然是越大越好;
  • 举个例子,M 串的某个前缀如下:
0 1 x = MPos’’ - 1 y = MPos’ - 1 z = MPos - 1
M[0] M[1] M[x] M[y] M[z]
  • 假设 M[0 … x] 和 M[0 … y] 均为 M[0 … z] 的真前后缀,很容易推导出 M[0 … x] 必定也是 M[0 … y] 的真前后缀;
  • M[0 … z] 匹配失败的时候,用 M[0 … y] 去匹配,再失败,继续用 M[0 … x] 去匹配,一直往下迭代;
  • 所以,问题就归结为要求出 匹配串M的 每个位置的 最长真前后缀 了;

4、自我匹配算法实现

  • 那么,如何计算一个匹配串每个位置的 最长真前后缀 呢?
  • 我们把这个 最长真前后缀 的值称为 next数组
  • 计算 最长真前后缀 其实是匹配串进行自我匹配的过程,只需要将 KMP 算法中的 目标串 T 替换成 匹配串 M,然后在匹配过程中,不断记录 next数组 的值即可;
  • 实现如下:
#define NULL_MATCH (-1)
#define Type int  // or char __int64 and so on... 

void GenNext(int *next, Type* M, int MLen) {
    
    
	int MPos = NULL_MATCH;
	next[0] = MPos;                                             // 1)
	for (int TPos = 1; TPos < MLen; ++TPos) {
    
    
		while (MPos != NULL_MATCH && M[TPos] != M[MPos + 1])    // 2)
			MPos = next[MPos];
		if (M[TPos] == M[MPos + 1]) MPos++;                     // 3)
		next[TPos] = MPos;                                      // 4)                
	}
}
  • 1)用 NULL_MATCH(-1) 代表没有真前缀,0的位置一定没有真前缀,所以为 NULL_MATCH;
  • 2)代码的这个位置,能够确保一件事情,就是 M[TPos-MPos-1…TPos-1] 和 M[0…MPos] 一定是完全匹配的(特殊的,当 MPos == NULL_MATCH 时,两个空串也一定匹配);所以,这时候只要判断 M[TPos] 和 M[MPos + 1] 这两个元素是否相等,如果不相等,则需要利用 最长真前后缀 来找到下一个 MPos’ = next[MPos];
  • 3)当 M[TPos] == M[MPos + 1] 时,MPos++,TPos++ (会在循环结束的时候自增);
  • 4)M[TPos-MPos…TPos] 和 M[0…MPos] 完全匹配,所以 TPos 的 最长真前后缀 为 M[0…MPos],记 next[TPos] = MPos;

5、KMP 算法实现

  • KMP 算法的实现可以说和计算 next 函数的算法如出一辙,基本只需要把匹配串替换成目标串就完事了;
int KMP(int *next, Type* M, int MLen, Type *T, int TLen) {
    
    
	int MPos = NULL_MATCH;                                             // 1)
	for (int TPos = 0; TPos < TLen; ++TPos) {
    
    
		while (MPos != NULL_MATCH && T[TPos] != M[MPos + 1])           // 2)
			MPos = next[MPos];
		if (T[TPos] == M[MPos + 1]) MPos++;                            // 3)
		if (MPos == MLen - 1) {
    
                                            // 4)
			return TPos - (MLen - 1);
		}
	}
	return -1;
}
  • 1)这里设置成 -1 的目的是:最初认为的情况是 目标串的空串 和 匹配串的空串 一定匹配;
  • 2)和计算 next 函数的逻辑保持一致;
  • 3)和计算 next 函数的逻辑保持一致;
  • 4)因为这个算法是 0 为下标起始的,所以当 MPos == MLen - 1 代表最后一个匹配字符匹配完毕了,则可以返回代表已经找到了一个可行解,并且是下标最小的;

四、KMP 时间复杂度

  • 对于时间复杂度的分析,切入点在于匹配串的位置 MPos,虽然看似有两个循环,外层一个 for, 内层一个 while ,但是实际上内层的 while 的执行次数 和外层的 for 并不是乘法关系;
  • 我们来看,MPos 每执行一次 while 值就会减小,但是它增加的机会只有当串匹配上以后,而且只能加 1,所以对于一个长度为 n 的目标串来说,MPos 减小的机会最多只有 n 次,也就是 while 循环最多执行 n 次,所以均摊下来,内部循环的时间复杂度相对外层的 for 语句来说是 O(1) 的,所以整个匹配过程的时间复杂度其实是 O(n) 的;
  • 再来看计算 next 函数的情况,也是一样,复杂度为 O(m);
  • 综上所述,KMP 的算法时间复杂度是 O ( n + m ) O( n+m ) O(n+m)

五、前后缀

1、概念

一个字符串 A ,它的前缀 A’ 也是 它的后缀,则称 A’ 为 A 的前后缀 (prefix-suffix);

2、计算

  • 如何计算一个字符串的所有 前后缀 呢?

  • 我们已经知道了一个字符串的 最长真前后缀 的计算方法(next 数组),现在就利用这个 next 数组来把所有的前后缀都计算出来;

  • 对于字符串 A 来说,next[i] 代表了 A[ 0 … next[i] ] 和 A[ i-next[i] … i ] 是完全匹配的;

	A[ 0 ... next[i] ] == A[ i-next[i] ... i ] 
  • 那么来看这三个下标之间的关系: i、next[i]、next[ next[i] ];
替代描述 字符串表示
X A[ 0 … next[i] ]
Y A[ i-next[i] … i ]
Z A[ 0 … next[ next[i] ] ]
U A[ next[i] - next[ next[i] ] … next[i] ]
  • 根据上表列出的字符串,可以归纳出以下几条结论:

  • 1)X == Y; // next 数组的定义;

  • 2)Z == U; // next 数组的定义;

  • 3)Z 为 X 的子串; // next 数组的特性,next[i] < i

  • 4)U 为 X、Y 的子串; // 综合 1)2)3)

  • 当这里的 i == ALen - 1 时,可以得出, U 为 A 的长度 仅次于 X 的前后缀;

3、结论

对于字符串 A,长度为 ALen,下标从0开始,那么它的所有前后缀的右下标(左下标恒等于0)只会出现在以下位置:
ALen-1、next[ ALen-1 ]、next[ next[ALen-1] ] …

4、举例

  • 举个例子,字符串 a b a b c a b a b a b a b c a b a b ababcababababcabab ababcababababcabab 的 next数组如下:
i 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
A[i] a b a b c a b a b a b a b c a b a b
next[i] -1 -1 0 1 -1 0 1 2 3 2 3 2 3 4 5 6 7 8
  • 四个前后缀分别是:
  • 1) A [ 0... A L e n − 1 ] = A [ 0...17 ] A[0 ... ALen-1 ] = A[0 ... 17] A[0...ALen1]=A[0...17]
  • 2) A [ 0... n e x t [ 17 ] ] = A [ 0...8 ] A[0 ... next[17] ] = A[0 ... 8] A[0...next[17]]=A[0...8]
  • 3) A [ 0... n e x t [ 8 ] ] = A [ 0...3 ] A[0 ... next[8] ] = A[0 ... 3] A[0...next[8]]=A[0...3]
  • 4) A [ 0... n e x t [ 3 ] ] = A [ 0...1 ] A[0 ... next[3] ] = A[0 ... 1] A[0...next[3]]=A[0...1]

六、叠串

如果一个串 A 可以表示成 XK (X为多个字符的集合,K > 1)的形式,则称 A 为 叠串;

  • 这里,令 字符串 A 的长度为 ALen;
  • 来看下 K = 2 的情况,A = XX,假设 X 由 4 (举个例子而已,几个字符不重要)个字符组成,分别为 w、x、y、z,那么就有:
i 0 1 2 3 4 5 6 7
A w x y z w x y z
  • 因为 A = XX,所以 next[7] 至少等于 3;
  • 如果 next[7] = 4,那么 “wxyzw” == “zwxyz”,则 w == z == x == y,所以 A = w8,和 K = 2 冲突;
  • 如果 next[7] = 5,那么 “wxyzwx” == “yzwxyz”,则 w == y,x == z,所以 A = yx4,和 K = 2 冲突;
  • 如果 next[7] = 6,那么 “wxyzwxy” == “xyzwxyz”,则 w == x == y == z,所以 A = w8,和 K = 2 冲突;
  • 从而证明,当 K = 2 的时候,next[ ALen - 1 ] = ALen/2 - 1;
  • 用同样方法,当 K = 3 的时候, next[ ALen - 1 ] = ALen*2/3 - 1;
  • K = 4 的时候,next[ ALen - 1 ] = ALen*3/4 - 1;
  • 根据数学归纳法,得到:

n e x t [ L e n − 1 ] = L e n ∗ K − 1 K − 1 next[ Len-1] = Len * \frac{K-1}{K} - 1 next[Len1]=LenKK11

将等式化简为 K 的表达式,得到:

K = L e n L e n − n e x t [ L e n − 1 ] − 1 K = \frac{Len}{Len - next[ Len-1] - 1} K=Lennext[Len1]1Len

特殊的,如果 L e n M O D ( L e n − n e x t [ L e n − 1 ] − 1 ) ! ≡ 0 Len MOD (Len - next[ Len-1] - 1) !\equiv 0 LenMOD(Lennext[Len1]1)!0,则 K = 1 K = 1 K=1

七、next 图

  • 有时候,为了让问题更加直观,我们 有时候可以把 next 数组图形化;
  • 例如,还是以字符串 “ababcababababcabab” 为例,它的 next 数组图形化以后,如下:
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-1
  • 每个箭头代表 i 指向 next[i];
  • 这是一个有向无环图,所以有时候也用在动态规划中;

猜你喜欢

转载自blog.csdn.net/WhereIsHeroFrom/article/details/108939550
今日推荐