【算法详解 | BF算法 & KMP算法】单次遍历解字符串匹配问题 | 字符串匹配算法

BF算法 & KMP算法

by.Qin3Yu

本文需要读者掌握 顺序表 的操作基础,完整代码将在文章末尾展示。
特别声明:本文为了尽可能使用简单描述,以求简单明了,可能部分专有名词定义不准确。

顺序表相关操作可以参考我的往期博文:
【C++数据结构 | 顺序表速通】使用顺序表完成简单的成绩管理系统.by.Qin3Yu

文中所有代码使用 C++ 举例,且默认已使用std命名空间

using namespace std;

概念速览

以下内容较为抽象,您可以选择性略读,在后文中会详细讲解。

在以下内容中,我们用S串表示被查找的串,即较长的串(也称为文本串)。用T串表示我们要查找的串,即较短的串(也称为模式串)。

什么是BF算法?

  • BF算法(Boyer-Moore),也称朴素字符串匹配算法或暴力匹配算法,是一种简单的字符串匹配算法。它的思想是从S串的第一个字符开始,逐一匹配T串的每一个字符,在匹配失败时回溯到S串的第二个字符,重新开始匹配,以此类推。
  • 当T串与S串的某个子串匹配成功时,返回该子串在S串中的起始位置。虽然BF算法实现起来简单易懂,但是在实践中由于效率较低,不适合处理大规模的字符串匹配。

优势

  1. 简单易懂:逻辑简单,即逐个匹配。
  2. 节省空间:只需逐步比较S串和T串,不需要额外内存。

缺点

  1. 效率低:时间复杂度为O(nm),需要遍历多次,且几乎每个元素都会被多次遍历。

什么是KMP算法?

  • KMP算法(Knuth-Morris-Pratt)是一种高效的字符串匹配算法。KMP算法的思想是在匹配时不回溯S串的位置,而是利用一个表格记录已经匹配成功的部分来跳过已经比较过的字符,从而提高匹配效率。具体而言,KMP算法是通过计算针对T串的一个最长公共前后缀的数组(即next数组)来实现。
  • 这个表格记录了T串中各位置可重叠的最长公共前后缀长的信息。在匹配过程中,该表格用来指导匹配操作,遇到不匹配字符时,能够快速地跳过已经比较过的字符

优势

  1. 效率高:时间复杂度为O(m+n),通过next数组跳过部分T串元素,且只需遍历一次S串。

缺点

  1. 实现复杂:KMP算法相对于BF算法来说,实现上稍微复杂一些,一大难点在于计算并构建next数组
  2. 额外空间开销:需要使用额外的空间来存储next数组。

通常在实际应用中我们所匹配的字符串都是大规模的字符串,且算法调用次数较少。
因此在大多数情况下,我们普遍认为:KMP算法比BF算法更优


BF算法详解

运算逻辑

  • 如下图所示,BF算法的内核可以概括为 “逐个比较” ,即先从S串的第一个元素开始比较,若遇到不相同的元素,则代表此子串与T串不匹配,开始从S串的第二个元素比较,然后再和S串的第三第四个元素比较,以此类推:

BF算法

图中绿色代表匹配成功,红色代表匹配失败,灰色代表还尚未被匹配就开始了下一步匹配,深绿色代表T串与S子串匹配成功且长度相等时的最后一个元素。

  • 直到相同的的元素串长度与T串长度相同,则匹配成功

BF算法代码实现

  • BF可以用两个 for() 循环来实现,一个控制T串元素与S子串元素的逐个比较,另一个控制T串开头元素在S串中的对应位置,直到S串被全部遍历完或匹配到目标子串后停止:
//因为在题目要求中,S串可能有不止一个符合条件的子串T,所以我们用顺序表作返回类型
vector<int> BF(const string& s, const string& t) {
    vector<int> res;
    int n = s.length();
    int m = t.length();
    for (int i = 0; i <= n - m; i++) {  //每次匹配都从上次一循环时S串的元素的下一个元素开始匹配
        int j;
        for (j = 0; j < m; j++) {  //逐个比较
            if (s[i + j] != t[j])
                break;
        }
        if (j == m)  //匹配的子串长度j和T串长度m相同则为匹配成功
            res.push_back(i);  //将结果放入顺序表,开始寻找下一个匹配的子串
    }
    return res;
}
至此,BF算法讲解完毕(=

KMP算法详解

运算逻辑

  • 在使用KMP算法匹配时,我们会根据已经匹配过的信息适当的跳过一部分字符,从而减少了不必要的匹配,增加了匹配效率:

跳跃匹配

  • 如上图所示,我们在匹配的过程中已经知道了T串的开头两个字符为 ABS串0、12、3索引处字符也为 A、B ,那么我们在进行第二步匹配时即可直接从S串索引2处开始匹配,而不必将S串BT串A 匹配。
  • 至于代码如何知道应该跳过几个字符,我们将在下文详细解释。

跳跃匹配

  • 这里我们要引入一个KMP算法的重要概念:next数组 。每个T串都有着自己对应的 next数组 ,我们先来解释 next数组 有什么作用
  • 假设我们已经算出了T串next数组

next数组

  • 如图所示,T串的每个字符都对饮next数组中的一个值,在实际匹配中,我们会根据 next数组来 进行跳跃,具体的跳跃规则如下所示:

跳跃方法

结合上图来看,详细的跳跃规则分为以下三步

  1. 正常进行逐字符匹配,遇到不同的字符时,记下T串中不同字符的索引i
  2. 读取next串索引为 i-1 处的值,将此值记为 j
  3. 我们直接将T串索引j 处的字符与上一步匹配失败处的S串字符对齐,然后开始下一步正常匹配。

接下来我们会讲解如何计算这至关重要的next数组

最长相同前后缀

什么是前后缀?
  • 前缀,即为从开头第一个字符开始记,能组成的子串。后缀同理,即从最后一个字符开始记,能组成的子串。前缀与后缀的搭配即称为前后缀。

前后缀的搭配需要满足以下两个条件:
1. 前缀与后缀不能同时使用同一个索引位置的元素;
2. 若子串只有一个元素,则视为没有前后缀。

前后缀

  • 在字符串ABAB中有以上六中前后缀搭配方式,其中,我们将前缀与后缀相同最长组合称为 最长相同前后缀
  • 在以上图中不难看出,ABAA的最长相同前后缀为AB

ps.常见误区: 很多读者会理所当然的认为后缀是从结尾第一个元素开始记,所以要从右往左读,但实际上前缀与后缀都遵循从左向右的规则。

最长相同前后缀与next数组的关系?
  • 我们可以把T串拆分为不同长度的子串。如下图所示,索引 i 处对应的next数组值其实就是T串长度为 i子串最长相同前后缀的长度:

对应方法

如上图所示,例如T串索引4处对应的字符B的next值即为长度为4的T串子串的最长公共前后缀长度,即为2。

  • 如图所示,即可看出T串ABAABAC对应的next数组为 [ 0 , 0 , 1 , 1 , 2 , 3 , 0 ]

KMP算法代码实现

计算next数组
  • 因为规范不同等原因,代码中所使用的next数组的元素比我们常说的next数组的所有元素都小1,如下例所示。但这只是表达的有所区别而已,并不影响我们计算和最终的运行结果。

ABAABAC对应的next数组:

代码中写法:[-1 ,-1 , 0 , 0 , 1 , 2 ,-1 ]
平时的写法:[ 0 , 0 , 1 , 1 , 2 , 3 , 0 ]

KMP的最大难点在于next数组的构建,使用代码构建next数组可以使用三个条件判断完成:

我们令指针 j 指向T串的第一个字符, i 指向T串的第二个字符。当长度为0时,子串没有前后缀,所以直接令T[0]的next值为-1

然后遵循三个条件判断:

  1. 如果 T[i]T[j] 相等:
    说明可以继续匹配后面的字符,将 next[i] 的值设置为 jij 分别后移一位。
  1. 如果 T[i]T[j] 不相等,且 j>0 :
    说明当前位置不是匹配的起始位置,我们需要从更短的前缀处进行尝试。将 j 的值设置为 next[j-1] + 1 ,跳转到下一个可能的匹配前缀的位置。
  1. 如果 T[i]T[j] 不相等,且 j=0 :
    说明无法再进行匹配,我们需要使 next[i]=-1,表示不存在前缀。
  • 如下图所示,我们经历十轮循环后将数组算出。

循环计算next数组

  • 此代码可能较为抽象,建议配合注释阅读:
vector<int> res;
int n = s.length();
int m = t.length();
vector<int> next(m);
int i = 1, j = 0;
next[0] = -1;  //开头第一位直接为-1
while (i < m) {
    //如果T[i]和T[j]相等,那么说明可以继续匹配后面的字符
    //将next[i] = j,i和j分别后移一位
    if (t[i] == t[j]) {
        next[i] = j;
        i++;
        j++;
    }
    //如果T[i]和T[j]不相等,且j > 0,说明当前位置不是匹配的起始位置
    //我们需要从更短的前缀处进行尝试。将j = next[j-1] + 1
    //跳转到下一个可能的匹配前缀的位置
    else if (j > 0)
        j = next[j - 1] + 1;
    //如果T[i]和T[j]不相等,并且j = 0,说明无法再进行匹配
    //我们需要将next[i] = -1,表示不存在前缀。
    else {
        next[i] = -1;
        i++;
    }
}
字符匹配
  • 跳跃匹配的原理在上文已经详细介绍过,此处就不再赘述,读者可以配合下图理解:

字符跳跃匹配

  • 跳跃匹配的难点在于如何跳跃,在下面的代码中,我们用 i 指向S串,用 j 指向T串。当字符匹配不成功时,如图所示,我们直接让 j = next[j - 1],从而完成跳跃。

指针跳跃

  • 需要注意,如果 j = 0T[i]T[j] 仍不相等,则说明T串第一位就与S串匹配不成功,我们就无需跳跃,向后移动一位即可。

按位移动

  • 具体代码实现如下:
i = 0, j = 0;
while (i < n) {
    //如果T[i]和T[j]相等,继续匹配下一个字符。
    if (s[i] == t[j]) {
        i++;
        j++;
        //果j达到了T串的长度m,说明已经匹配成功
        if (j == m) {
            res.push_back(i - m);
            j = next[j - 1] + 1;
        }
    }
    //如果T[i]和T[j]不相等,并且j > 0,说明需要从更短的前缀处进行尝试。
    //将j = next[j-1] + 1,跳转到下一个可能的匹配前缀的位置。
    else if (j > 0) 
        j = next[j - 1] + 1;
    //如果T[i]和T[j]不相等,并且j等于0,说明无法再进行匹配,将i向后移动一位。
    else
        i++;
}
至此,KMP算法讲解完毕(=

完整项目代码

如需提问,可以在评论需留言或私信,通常在12小时内回复。不收费,用爱发电(=

题目:输出T串在S串中的位置。注意,S串中可能有不止一个符合条件的子串。

测试用例:
S串:ABCABBCABACBACCBACBABBCACACACBCCBACAABCABCABAAACCBABC
T串:CCBA

#include <iostream>
#include <vector>
using namespace std;

// BF算法
vector<int> BF(const string& s, const string& t) {
    vector<int> res;
    int n = s.length();
    int m = t.length();
    for (int i = 0; i <= n - m; i++) {  //每次匹配都从上次一循环时S串的元素的下一个元素开始匹配
        int j;
        for (j = 0; j < m; j++) {  //逐个比较
            if (s[i + j] != t[j])
                break;
        }
        if (j == m)  //匹配的子串长度j和T串长度m相同则为匹配成功
            res.push_back(i);  //将结果放入顺序表,开始寻找下一个匹配的子串
    }
    return res;
}

// KMP算法
vector<int> KMP(const string& s, const string& t) {
    vector<int> res;
    int n = s.length();
    int m = t.length();
    
    // 构建next数组
    vector<int> next(m);
    int i = 1, j = 0;
    next[0] = -1;  //开头第一位不要
    while (i < m) {
        //如果T[i]和T[j]相等,那么说明可以继续匹配后面的字符
        //将next[i] = j,i和j分别后移一位
        if (t[i] == t[j]) {
            next[i] = j;
            i++;
            j++;
        }
        //如果T[i]和T[j]不相等,且j > 0,说明当前位置不是匹配的起始位置
        //我们需要从更短的前缀处进行尝试。将j = next[j-1] + 1
        //跳转到下一个可能的匹配前缀的位置
        else if (j > 0)
            j = next[j - 1] + 1;
        //如果T[i]和T[j]不相等,并且j = 0,说明无法再进行匹配
        //我们需要将next[i] = -1,表示不存在前缀。
        else {
            next[i] = -1;
            i++;
        }
    }

    i = 0, j = 0;
    while (i < n) {
        //如果T[i]和T[j]相等,继续匹配下一个字符。
        if (s[i] == t[j]) {
            i++;
            j++;
            //果j达到了T串的长度m,说明已经匹配成功
            if (j == m) {
                res.push_back(i - m);
                j = next[j - 1] + 1;
            }
        }
        //如果T[i]和T[j]不相等,并且j > 0,说明需要从更短的前缀处进行尝试。
        //将j = next[j-1] + 1,跳转到下一个可能的匹配前缀的位置。
        else if (j > 0) 
            j = next[j - 1] + 1;
        //如果T[i]和T[j]不相等,并且j等于0,说明无法再进行匹配,将i向后移动一位。
        else
            i++;
    }
    return res;
}

int main() {
    string S = "ABCABBCABACBACCBACBABBCACACACBCCBACAABCABCABAAACCBABC";
    string T = "CCBA";

    // 使用BF算法进行模式匹配
    vector<int> bf_res = BF(S, T);
    cout << "BF算法匹配结果:";
    for (int i = 0; i < bf_res.size(); i++) {
        cout << bf_res[i] << " ";
    }
    cout << endl;

    // 使用KMP算法进行模式匹配
    vector<int> kmp_res = KMP(S, T);
    cout << "KMP算法匹配结果:";
    for (int i = 0; i < kmp_res.size(); i++) {
        cout << kmp_res[i] << " ";
    }
    cout << endl;

    system("pause");
    return 0;
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Qin3Yu

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

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

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

打赏作者

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

抵扣说明:

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

余额充值