【算法笔记】K.M.P.字符串匹配

  • 本文总计约 5600 字,阅读大约需要 20 分钟

前言

笔者学习 KMP 字符串匹配的过程实在是一个漫长的过程。本来 KMP 算法就是一个非常玄学的算法,再加上笔者脑子不大好,所以学习这个算法的时候就很迷糊 QwQ。

笔者的作文也经常得低分 QwQ,所以写的不好敬请谅解,不过我还是尽力把这个算法讲清楚的 ~ 如果有什么地方出现了事实错误,也希望读者加以指正。

不过话说回来,KMP 确实是一种充满美感的算法。当笔者第一次了解到这个算法的时候,态度是不屑一顾的;初次学习这个算法时,也迷迷糊糊不知道它在说什么;但当我充分地了解它的时候,我就会对它的思路感到惊奇,特别是对失配子串的处理上十分地美妙;而它 Θ(m+n) 的优秀的时间复杂度,更是让我们能体会到前人智慧之所在。所以,我才会写这样一篇博客来记录它。

问题引入

在生物研究中会遇见这样的问题:给定一个 DNA 母链,上面有一串碱基序列。现在有一个特定的基因序列 gene,我们应该如何确定 gene 这个序列在 DNA 中出现了多少次?

样例输入:DNA=ATGATGCATGCATGATgene=ATGAT
样例输出:2
解释:在 DNA 序列中出现了两次 ATGAT 序列,如下图:
image

形式化地讲述这个问题:给定字符串 str(以下称为母串)和 model(以下称为模式串),求模式串在母串中作为连续的子串出现了多少次?

暴力破解

暴力破解的思路

最好想的一种做法就是暴力枚举。

以母串中每一个字符作为起点,向后截取长度为 m 的子串(m 为模式串的长度),判断能否与模式串相互匹配。如果匹配,则计数器 +1。如下图:
image

这种算法的思路很简单,代码也很好写,如下:

#include <iostream>
#include <cstring>
using namespace std;
const int maxN = 100001;

int ans;
char str[maxN], model[maxN];

/*** 字符串暴力匹配 ***/ 
int ViolentMatch(char* str, char* model) {
    int cnt = 0, n = strlen(str), m = strlen(model);
    for(int i = 0; i + m - 1 < n; ++i) {
        bool failed = false;

        for(int j = 0; j < m; ++j) {
            if(str[i + j] != model[j]) {   //发现失配字符就停止匹配
                failed = true;
                break;
            }
        }

        if(!failed) {      //如果成功匹配则计数器+1
            ++cnt;
        }
    }

    return cnt;
}

int main(void) {
	cin >> str >> model;
	ans = ViolentMatch(str, model);
	cout << ans;
	return 0;
} 

暴力破解的缺陷

这种做法固然非常简单,但是它非常的低效。

考虑如下输入:str=AAAAAAAAAAAAAAAAAAAAAAmodel=AAAAB

计算机每次枚举的时候,会一直匹配到模式串的最后一个字符才会发现失配了。这并不是最糟糕的,糟糕的是失配之后它只会回到第二个字符重新匹配,这就造成了大量的重复搜索。如下:
image

假如母串的长度为 n,模式串的长度为 m,那么上面这种最坏的情况下,程序大概需要在 n×m 步后才能全部匹配完成,即时间复杂度为 O(mn)。所以,当 nm 达到 105 甚至 106 数量级时,计算机就不能快速地得出答案了。

既然这种算法低效的原因是在母串上进行了大量的重复搜索,那么我们有没有一种算法,让母串上的起点位置不回退,扫一遍字符串就能得出答案呢?

答案是肯定的,这就是我们接下来所要介绍的:KMP 字符串匹配

KMP 字符串匹配

什么是 KMP

所谓 KMP 算法,并不是由一个人发明的,而是三位计算机科学家 D.E.KnuthJ.H.MorrisV.R.Pratt 共同发现的,故以三人姓氏的首字母命名。它可以通过一个辅助数组— — next 数组,来实现 Θ(n+m) 的时间复杂度内的字符串匹配。

KMP 算法的内容

我们现在以 str=ABCACABCABCABDmodel=ABCABD 为例,进行字符串匹配。

我们第一步先以母串第一个字符为起点进行匹配,匹配到第五个字符时发现失配了,如下图:
image

但是在此时,我们会发现前面已经匹配上了 ABCA 四个字符,那么就没有必要再倒回第二个字符慢慢来了,而因为模式串中匹配上的部分中,A 是其最长的公共前后缀,所以我们直接将模式串的起始位置移到第四个位置向后匹配。

但我们发现这个时候遇见第二个字符时就失配了,我们就将模式串再向后移动一位,但移动之后我们便发现匹配到第一位就失配了,于是就再向后移动一位,如下图:
image

接下来在匹配的时候,一直匹配到第六个字符时,我们才发现失配了,但我们已经知道了匹配上了 ABCAB 五个字符,这个时候,我们就可以直接跳到使最长公共前后缀重新匹配上的部分,并向后继续匹配。如图:
image

这样,我们就终于找到了字符串中匹配上的部分。

从上面,我们可以看出 KMP 算法的核心,即通过计算字符串每个前缀的最常公共真前后缀长度(这句话有一点绕 QwQ,要好好理解),来实现模式串在母串上一直前移而不回退。

而每个前缀的最长公共真前后缀长度,我们称为部分匹配值,并使用一个 next 数组记录。

生成 next 数组

我们生成 next 数组的方式,可以看做是模式串对模式串本身进行的 KMP 匹配。

先贴代码 QwQ:

const int maxN = 100001;

char str[maxN], model[maxN];
int Next[maxN], ans;

/*** 计算部分匹配值 ***/
void GetNext(char* model) {
    int m = strlen(model), j = 0;             //变量 j 用于统计字符串中当前的部分匹配值

    for(int i = 1; i < m; ++i) {
        while(j && model[j] != model[i]) {    //如果发现失配了,就往回退,直到退至可以匹配的地方
            j = Next[j-1];
        }

        if(model[j] == model[i]) {            //如果匹配上了,就让计数器加一
            ++j;
        }

        Next[i] = j;
    }
}

接下来对这个部分进行解释:以 model=ABCABDABCABC 为例。
前面的 3 个字符组成的前缀,next 值均为 0,即 next[0]=next[1]=next[2]=0。这个时候,统计 next 值的变量 j 始终为 0

接下来,在计算第四、第五个字符的时候,发现 AB 是前五个字符的最长公共前后缀,于是 next[3]=1next[4]=2j 增加到 2。如下图:
image

以此类推,失配的时候就回到可以匹配的最长公共前后就可以了:
image

代码实现

接下来,KMP 字符串匹配的主体部分就没有什么难的了,照着刚才说的算法思路实现就可以了:

#include <iostream>
#include <cstring>
using namespace std;
const int maxN = 1000001;

char str[maxN], model[maxN];
int Next[maxN], ans;

void GetNext(char* model) {                   //计算部分匹配值
    int m = strlen(model), j = 0;             //变量 j 用于统计字符串中当前的部分匹配值

    for(int i = 1; i < m; ++i) {
        while(j && model[j] != model[i]) {    //如果发现失配了,就往回退到可以匹配的地方
            j = Next[j - 1];
        }

        if(model[j] == model[i]) {             //如果匹配上了,就让计数器加一
            ++j;
        }

        Next[i] = j;
    }
}

int KMPMatch(char* str) {                      //KMP 字符串匹配主体
    int n = strlen(str), m = strlen(model), 
        j = 0, cnt = 0;

    for(int i = 0; i < n; ++i) {
        while(j && str[i] != model[j]) {       //如果发现失配,就往回退
            j = Next[j - 1];
        }

        if(str[i] == model[j]) {               //匹配一位,就让指针后移一位
            ++j;
        }

        if(j == m) {                           //发现了完全匹配的模式串,计数器加一
            cout << i - j + 2 << endl;         //输出发现的位置
            ++cnt;
        }
    }
    return cnt;
}

int main(void) {
    cin >> str >> model;

    GetNext(model);
    KMPMatch(str);

    //输出 next 数组
    for(int i = 0; model[i]; ++i) {
        cout << Next[i] << " ";
    }

    return 0;
}

时间复杂度分析

因为每次母串指针后移一位时,模式串的指针至多增加一位,所以此算法的时间复杂度即为 Θ(n+m),比起暴力算法有了很大的优化。

事实上,这种利用前后缀特性,减少反复搜索,就是 KMP 的核心思想,这种思想在许多其它的字符串算法中都很有作用,也是我们应该学习的思想。

例题

本题目列表会持续更新

posted @   CaO氧化钙  阅读(202)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
点击右上角即可分享
微信分享提示