字符串匹配之BF与KMP算法

字符串模式匹配

字符串模式匹配的描述:有两个字符串 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-1p[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 算法有更深刻的认识。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

code_peak

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值