文章目录
一、前言
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 (n−m+1)∗m - 4、考虑时间复杂度的时候,往往是考虑它的最坏时间复杂度(前面的字符都匹配上了,直到最后1个字符才不匹配),考虑如下情况:
目标串 = “aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa”;
匹配串 = "aaaaaaaaaaaaab";
- 结论:朴素算法的时间复杂度为 O ( ( n − m ) ∗ m ) O( (n - m)*m ) O((n−m)∗m)特殊的,当目标串长度远大于匹配串长度,朴素算法的时间复杂度就是 O ( n ∗ m ) O (n * m) O(n∗m)(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[TPos−MPos...TPos−1]==M[0...MPos−1] 完全匹配的情况下,分情况讨论:
-
- 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...ALen−1]=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[Len−1]=Len∗KK−1−1
将等式化简为 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=Len−next[Len−1]−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(Len−next[Len−1]−1)!≡0,则 K = 1 K = 1 K=1
七、next 图
- 有时候,为了让问题更加直观,我们 有时候可以把 next 数组图形化;
- 例如,还是以字符串 “ababcababababcabab” 为例,它的 next 数组图形化以后,如下:
- 每个箭头代表 i 指向 next[i];
- 这是一个有向无环图,所以有时候也用在动态规划中;