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

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

前言

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

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

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

问题引入

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

样例输入:\(\tt{DNA}=\tt{ATGATGCATGCATGAT}\)\(\tt{gene}=\tt{ATGAT}\)
样例输出:\(2\)
解释:在 \(\tt{DNA}\) 序列中出现了两次 \(\tt{ATGAT}\) 序列,如下图:
image

形式化地讲述这个问题:给定字符串 \(\tt{str}\)(以下称为母串)和 \(\tt{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;
} 

暴力破解的缺陷

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

考虑如下输入:\(\tt{str}=\tt{AAAAAAAAAAAAAAAAAAAAAA}\)\(\tt{model}={AAAAB}\)

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

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

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

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

KMP 字符串匹配

什么是 KMP

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

KMP 算法的内容

我们现在以 \(\tt{str}=\tt{ABCACABCABCABD}\)\(\tt{model}={ABCABD}\) 为例,进行字符串匹配。

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

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

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

接下来在匹配的时候,一直匹配到第六个字符时,我们才发现失配了,但我们已经知道了匹配上了 \(\tt{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;
    }
}

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

接下来,在计算第四、第五个字符的时候,发现 \(\tt{AB}\) 是前五个字符的最长公共前后缀,于是 \(\textit{next}[3]=1\)\(\textit{next}[4]=2\)\(j\) 增加到 \(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;
}

时间复杂度分析

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

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

例题

本题目列表会持续更新

posted @ 2022-01-18 17:51  CaO氧化钙  阅读(192)  评论(0编辑  收藏  举报