字符串模式匹配
字符串模式匹配的描述:有两个字符串 T 和 p,若想要在串 T 中查找是否有与串 p 相等的的字串,称串 T 为目标串,串 p 为模式串,并称查找模式串 p 在目标串 T 的匹配位置的运算为模式匹配。
比如有如下两个字符串:
目标串T: abaabcac
模式串p: ab
我们可以看到,模式串 p 的匹配结果会出现两次,分别是从 T[0] 和 T[3] 开始。
字符串匹配是一项非常频繁的任务。例如,有一份名单,你急切地想知道自己在不在名单上;又如,假设你拿到了一份文献,你希望快速的找到某个关键字所在的章节。接下开先最朴素的 B-F 算法说起。
B-F算法
顾名思义,BF算法是由 Brute 和 Force 提出来的,所以被称为 B-F 算法。
其算法思想是:用模式串 p 的字符依次与 目标串 T 中的字符比较。如果比较成功,返回模式串 p 第 0 个字符 p[0] 在目标串中相匹配的位置;如果在其中某个位置 i 出现 p[i] 不等于 T[i],这时候可以将模式串 p 右移一位,且模式串 p 需要从头开始与 T 中字符依次比较。如此反复执行,直到出现以下两种情况之一,就可以结束算法:
第一种情况是:执行到某一趟,模式串的所有字符都与目标串对应的字符串对应字符相等,则匹配成功。
第二种情况是:模式串 p 已经移动到最后可能与目标串 T 比较的位置,但不是每一个字符都能与 T 匹配,则匹配失败,返回 -1。
接下来看一个实例:
假设有目标串 T 和模式串 p:
===========================
a. 先来看一下匹配成功的过程
第一趟:
T: a b b a b a
| | x
p: a b a
第二趟:
T: a b b a b a
x
p: a b a
第三趟:
T: a b b a b a
x
p: a b a
第四趟:
T: a b b a b a
| | |
p: a b a
===========================
b. 再看匹配不成功的过程
第一趟:
T: a b b a b a
| x
p: a a a
第二趟:
T: a b b a b a
x
p: a a a
第三趟:
T: a b b a b a
x
p: a a a
第四趟:
T: a b b a b a
| x
p: a a a
下面是 B-F 算法算法的实现:
//目标串为T,模式串为p,从目标串的下标为k开始匹配
int BruteForce(const char *T, const char *p, int k)
{
int i = 0;
int j = 0;
int len_T = strlen(T);
int len_p = strlen(p);
for (i = k; i <= len_T - len_p; ++i) //逐趟比较
{
for (j = 0; j < len_p; ++j)
{
if (T[i + j] != p[j]) //从目标串下标为i的开始与模式串逐个比较
break;
}
if (j == len_p) //模式串扫描完,匹配成功
return i;
}
return -1; //匹配失败
}
算法分析:
在 B-F 算法中,一旦比较不相等,就将模式串 p 右移一位,再从模式串 p[0] 开始逐个比较,若目标串 T 的长度为 n,模式串 P 的长度为 m,看出第一趟比较失败,需要比较次数 m 次,最坏情况下如果一直失败,最大需要 n -m -1 趟,每趟比较都在最后出现不相等,要做 m 次比较,总的比较次数为 (n - m + 1) * m,因为多数场合下 m 远远小于 n,所以 B-F 的算法时间复杂度为 O(nm)
。
B-F 算法的改进思考
在分析上面的程序可知,B-F 算法慢的原因就是每趟比较失败需要回溯到模式串 p[0] 重新逐个比较,然而回溯是可以避免的,我们还是依上面的实例 a 为例:
a. 先来看一下匹配成功的过程
第一趟:
T: a b b a b a
| | x
p: a b a
第二趟:
T: a b b a b a
x
p: a b a
第三趟:
T: a b b a b a
x
p: a b a
第四趟:
T: a b b a b a
| | |
p: a b a
从第一趟来看:
T[0] = p[0]
,T[1] = p[1]
,T[2] ≠ p[2]
,所以模式串 p 需要右移一位重新从头开始逐个比较,但是 p[0] ≠ p[1]
,由此可推导 T[1](=p[1]) ≠ p[0]
,我们右移一位比较肯定是不相等的,这一趟是否可以不比较直接跳过?
由于 p[0] = p[2]
,所以 T[2] ≠ p[0](=p[2])
,再将模式串 p 右移一位,用 T[2] 和 p[0] 比较也不会相等。我们应当将 p 直接右移 3 位,跳过第二趟和第三趟,直接执行第四趟,也就是用 T[3] 和 p[0] 开始进行比较。而这样的过程就消除了每趟的回溯。
这种处理的思想是由 Knuth、Morris、Pratt 同时提出的,所以称为 KMP 算法,后面我们将会介绍 KMP 算法。
KMP 算法
kmp 的优化思想
下面我们讨论一般的情形,假设:
目标串: T = { T[0], T[1], ... , T[n-1] };
模式串: p = { p[0], p[1], ..., p[m-1] };
如果用朴素模式 B-F 算法做第 s
趟比较时,从目标串 T 的第 s 个位置 T[s] 与 模式串 p 的第 0 个位置 p[0] 开始进行比较,直到在目标串的 T[s + j] 位置失配了:
目标串T:T[0] T[1] ... T[s-1] T[s] T[s+1] T[s+2] ... T[s+j-1] T[s+j] ... T[n-1]
| | | | x
模式串p: p[0] p[1] p[2] ... p[j-1] p[j]
这时候,就有:
T[s], T[s+1], T[s+2], ..., T[s+j-1] = p[0], p[1], p[2], ..., p[j-1]
①
继续按朴素模式 B-F 算法,那么下一趟应该从目标 T 的第 s+1 的位置开始用 T[s+1] 与模式串 p 的 p[0] 位置对齐,重新开始比较,如果我们想要继续匹配成功,那么必须满足:
T[s+1], T[s+2], ..., T[s+j], ..., T[s+m] = p[0], p[1], ..., p[j-1], ..., p[m-1]
同时在模式串 p 中,如果:
p[0], p[1], ..., p[j-2] ≠ p[1], p[2], ..., p[j-1]
②
则第 s+1 趟即使不用进行比较,也能断定必然失配。
由上面的推论 ① 和推论 ② 可以得到:
p[0], p[1], ..., p[j-2] ≠ T[s+1], T[s+2], ..., T[s+j-1] (= p[1], p[2], ..., p[j-1])
既然第 s+1 趟可以不做,那么 s+2 趟又怎样的?由上面的推理可知:
在 s+2 趟中,如果:
p[0], p[1], ..., p[j-3] ≠ p[2], p[3], ..., p[j-1]
那么仍然有:
p[0], p[1], ..., p[j-3] ≠ T[s+2], T[s+3], ..., T[s+j+1] (= p[2], p[3], ..., p[j-1])
那么这一趟比较仍然会失配。
以此类推,直到对于某一个值 k,使得:
p[0], p[1], ..., p[k+1] ≠ p[j-k-2], p[j-k-1], ..., p[j-1]
且:
p[0], p[1], ..., p[k] = p[j-k-1], p[j-k], ..., p[j-1]
才会有:
p[0], p[1], ..., p[k] = T[s+j-k-1], T[s+j-k], ..., T[s+j-1]
| | |
p[j-k-1], p[j-k], ..., p[j-1]
然后我们可以把第 s 趟比较失配的模式串 p 从当前位置直接向右滑动 j-k-1 位,这时候因为目标串 T 中 T[s+j] 之前已经与模式串 p 中 p[j] 之前的字符匹配过了,所以可以直接从目标串 T 中的 T[s+j] (即上一趟失配的位置) 与模式串 p 的 p[k+1] 开始,继续向下进行匹配比较。
在 KMP 算法中,目标串 T 在第 s 趟比较失配时,扫描指针 s 不必回溯,算法下一趟继续从此处开始向下进行匹配比较,而在模式串 p 中扫描指针应该回退到 p[k+1] 位置。
next 数组
上面上面所说的 k 的确定方法,对于不同的 j,k 的取值不同,它仅仅依赖于模式串p 本身前 j 个字符的构成,与目标串 T 无关。
我们可以使用一个 next 特性函数:当模式串 p 中的 p[j] 字符与目标串 T 中相应字符失配时,模式串 p 中应当由哪个字符(设为 p[k+1])与目标中刚失配的字符重新继续进行比较。
设模式串 p = p[0], p[1], ..., p[m-2], p[m-1]
,则它们的 next 特征函数定义如下:
|-1, 当 j = 0
next(j) = |k + 1, 当 0 ≤ k < j-1, 且使得 p[0], p[1], ..., p[k] = p[j-k-1], p[j-k], ..., p[j-1] 的最大整数
|0, 其他情况
我们称 p[0], p[1], ..., p[k]
为串 p[0], p[1], ..., p[j-1]
的前缀子串,p[j-k-1], p[j-k], ..., p[j-1]
为串 p[0], p[1], ..., p[j-1]
的后缀子串,它们都是原串的真子串。
如下实例:
假如模式串 p = “abaabcac”,对应的 next 函数如下所示:
下标j: 0 1 2 3 4 5 6 7
模式串p: a b a a b c a c
next(j): -1 0 0 1 1 2 0 1
过程如下:
当 j = 0 时,next[j] = -1。表示下一趟匹配比较时,模式串的第 -1 个字符与目标串上次失配的位置对其,也就是模式串的起始位置 p[0] 与目标串上次失配的位置的下一个位置对其,继续向后做匹配比较。
当 j = 1 时,满足 0 ≤ k < j-1 的情况找不到,所以 next[j] = 0 (按其他情况),表示下一趟匹配比较时,模式串 p 的第 0 个字符 p[0] 与目标串上次失配的位置对其向后继续比较。
当 j = 2 时,k 的取值可以是 0,因为 p[0] ≠ p[1]
,所以 next(j) = 0,表示下一趟匹配比较时,模式串 p 的第 0 个字符 p[0] 与目标串上次失配的位置对其向后继续比较。
当 j = 3 时,k 的取值可以是 0 和 1,因为 p[0] = p[2]
且 p[0], p[1] ≠ p[1]p[2]
,故 k 取 0,next(j) = k +1 = 1。表示下一趟匹配比较时,模式串 p 的第 1 个字符 p[1] 与目标串上次失配的位置对其向后继续比较。
当 j = 4 时,情况和 j = 3 类似。
当 j = 5 时,k 可以取 0 到 3 的值,因为 p[0] ≠ p[4]
,p[0], p[1] = p[3], p[4]
,此外 p[0], p[1], p[2] ≠ p[2], p[3], p[4]
,且 p[0], p[1], p[2], p[3] ≠ p[1], p[2], p[3], p[4]
,因此 k = 1,next(j) = k+1 = 2。表示下一趟匹配比较时,模式串 p 的第 2 个字符 p[2] 与目标串上次失配的位置对其向后继续比较。此时,模式串 p 右移,p[0], p[1] 覆盖到原来的 p[3], p[4] 的位置,从 p[3] 开始继续向后进行对应字符的比较。
其他情况依次类推。
一般的,假如在进行某一趟匹配时,在模式串 p 的第 j 位失配,如果 j > 0,那么在下一趟比较时模式串 p 的起始比较位置是 pnext(j),目标串 T 的指针不回溯,仍指向上一趟失配的字符;如果 j = 0,则目标串 T 指针进 1,模式串 p 指针回到 p[0],继续进行下一趟匹配比较。这也就是 KMP 算法的核心思想。
next 数组实现
上面说了很多,如何正确的计算出特征函数 next(j) 才是实现 KMP 算法的关键。
从上面的 next(j) 的定义出发,计算 next(j) 就是要在模式串 p[0], p[1], p[2], ..., p[j-1]
中找出最长的相等的前缀子串 p[0], p[1], ..., p[k]
和后缀子串 p[j-k-1], p[j-k], ..., p[j-1]
。
我们可以使用递推的方法求 next(j) 的值。
假设已有 next(j) = k,则有:
0 ≤ k < j-1
且 p[0], p[1], ..., p[k] = p[j-k-1], p[j-k], ..., p[j-1]
③
若设 next(j+1) = max{ k+1 | 0 ≤ k+1 < j}
,使得 p[0], p[1], ..., p[k+1] = p[j-k-1], p[j-k], ..., p[j]
④
如果 p[k+1] = p[j]
,则由 ④ 可知:next(j+1) = next(j) + 1
。
如果 p[k+1] ≠ p[j]
,则由 ③ 出发,在 p[0], p[1], …, p[k] 中寻找使得 p[0], p[1], ..., p[h] = p[k-h], p[k-h+1], ..., p[k]
的 h。 ⑤
这时候存在两种情况:
情况1:找到 h:
由 next(k) 的定义知:next(k) = h。综合 ⑤ 和 ③,就有:
p[0], p[1], ..., p[h] = p [k-h], p[k-h+1], ..., p[k] = p[j-h-1], p[j-h], ..., p[j-1]
即在 p[0], p[1], ..., p[j-1]
中找到了长度位 h + 1
的相等的前缀子串和后缀子串。
这是如果 p[h+1] = p[j]
,则由 next(j+1) 的定义:
next(j+1) = h +1 = next(k) + 1 = next(next(j)) + 1
如果 p[h+1] ≠ p[j]
,则在 p[0], p[1], ..., p[h]
中寻找更小的 next(h) = 1。如此递推下去,可能还需要以同样的方式缩小寻找范围,直到 next(k) = -1 为止。
情况2:找不到h,这时候 next(k) = -1。
通过以上分析,可以给出下面的计算 next(j) 的 getNext 算法代码:
//通过模式串p获得next数组
void getNext(char *p, int *next)
{
int j = 0;
int k = -1;
int len_p = strlen(p);
next[0] = -1;
while (j < len_p)
{
if (k == -1 || p[j] == p[k])
{
++j;
++k;
next[j] = k;
}
else
{
k = next[k];
}
}
}
该函数的时间复杂度为 O(len_p)
。
kmp 算法的实现
上面已经知道了 next 数组的实现,则 KMP 算法的实现就不难了。一般的,假如在进行某一趟匹配时,在模式串 p 的第 j 位失配,如果 j > 0,那么在下一趟比较时模式串 p 的起始比较位置是 pnext(j),目标串 T 的指针不回溯,仍指向上一趟失配的字符;如果 j = 0,则目标串 T 指针进 1,模式串 p 指针回到 p[0],继续进行下一趟匹配比较。
下面是 KMP 算法完整实现:
const int MAX_NEXT = 100;
int next[MAX_NEXT] = {0};
//通过模式串p获得next数组
void getNext(char *p, int *next)
{
int j = 0;
int k = -1;
int len_p = strlen(p);
next[0] = -1;
while (j < len_p)
{
if (k == -1 || p[j] == p[k])
{
++j;
++k;
next[j] = k;
}
else
{
k = next[k];
}
}
}
//目标串T, 模式串p, 从目标串T的下标为k开始匹配
int KMP(const char *T, const char *p, int k)
{
int i = k;
int j = 0;
int len_T = strlen(T);
int len_p = strlen(p);
while ((i < len_T) && (j < len_p))
{
if (j == -1 || T[i] == p[j])
{
++i;
++j;
}
else
{
//这里就是BF和KMP的最大区别
//i = i - j + 1; j = 0; //BF算法如果匹配失败,模式串p需要从头开始
j = next[j]; //KMP算法是取模式串p的next值对应的下标继续下一次匹配
}
}
if (j == len_p)
return i - len_p;
else
return 0;
}
KMP 算法的时间复杂度取决于 while 循环,由于是无回溯算法,目标字符串比较有进无退,要么执行 posT 和 posP 进 1,要么查找 next[] 数组进行模式位置的右移,然后继续向后比较。最多比较次数为 O(lengthT),不超过目标串的长度。
下面给出一个实例:
=================================================
第1趟:
↓
目标串T: a c a b a a b a a b c a c a a b c
| x
模式串p: a b a a b c a c
注:posT = 1, next[1] = 0,下一趟posT不变,posP = 0
=================================================
第2趟:
↓
目标串T: a c a b a a b a a b c a c a a b c
x
模式串p: a b a a b c a c
注:posP = 0, next[0] = -1, 下一趟 posT++, posP=0
=================================================
第3趟:
↓
目标串T: a c a b a a b a a b c a c a a b c
| | | | | x
模式串p: a b a a b c a c
注:posP = 5, next[5] = 2, 下一趟 posT 不变, posP=2
=================================================
第4趟:
↓
目标串T: a c a b a a b a a b c a c a a b c
| | | | | | | |
模式串p: a b a a b c a c
注:posP = 8, PosT - lengthP = 5
通过上面的对 KMP 算法的描述与推理以及实例,将会对于 KMP 算法有更深刻的认识。