【算法笔记】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}\) 序列,如下图:
形式化地讲述这个问题:给定字符串 \(\tt{str}\)(以下称为母串)和 \(\tt{model}\)(以下称为模式串),求模式串在母串中作为连续的子串出现了多少次?
暴力破解
暴力破解的思路
最好想的一种做法就是暴力枚举。
以母串中每一个字符作为起点,向后截取长度为 \(m\) 的子串(\(m\) 为模式串的长度),判断能否与模式串相互匹配。如果匹配,则计数器 \(+1\)。如下图:
这种算法的思路很简单,代码也很好写,如下:
#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}\)。
计算机每次枚举的时候,会一直匹配到模式串的最后一个字符才会发现失配了。这并不是最糟糕的,糟糕的是失配之后它只会回到第二个字符重新匹配,这就造成了大量的重复搜索。如下:
假如母串的长度为 \(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}\) 为例,进行字符串匹配。
我们第一步先以母串第一个字符为起点进行匹配,匹配到第五个字符时发现失配了,如下图:
但是在此时,我们会发现前面已经匹配上了 \(\tt{ABCA}\) 四个字符,那么就没有必要再倒回第二个字符慢慢来了,而因为模式串中匹配上的部分中,\(\tt{A}\) 是其最长的公共前后缀,所以我们直接将模式串的起始位置移到第四个位置向后匹配。
但我们发现这个时候遇见第二个字符时就失配了,我们就将模式串再向后移动一位,但移动之后我们便发现匹配到第一位就失配了,于是就再向后移动一位,如下图:
接下来在匹配的时候,一直匹配到第六个字符时,我们才发现失配了,但我们已经知道了匹配上了 \(\tt{ABCAB}\) 五个字符,这个时候,我们就可以直接跳到使最长公共前后缀重新匹配上的部分,并向后继续匹配。如图:
这样,我们就终于找到了字符串中匹配上的部分。
从上面,我们可以看出 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\)。如下图:
以此类推,失配的时候就回到可以匹配的最长公共前后就可以了:
代码实现
接下来,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 的核心思想,这种思想在许多其它的字符串算法中都很有作用,也是我们应该学习的思想。
例题
本题目列表会持续更新。