【算法笔记】K.M.P.字符串匹配
- 本文总计约 5600 字,阅读大约需要 20 分钟。
前言
笔者学习 KMP 字符串匹配的过程实在是一个漫长的过程。本来 KMP 算法就是一个非常玄学的算法,再加上笔者脑子不大好,所以学习这个算法的时候就很迷糊 QwQ。
笔者的作文也经常得低分 QwQ,所以写的不好敬请谅解,不过我还是尽力把这个算法讲清楚的 ~ 如果有什么地方出现了事实错误,也希望读者加以指正。
不过话说回来,KMP 确实是一种充满美感的算法。当笔者第一次了解到这个算法的时候,态度是不屑一顾的;初次学习这个算法时,也迷迷糊糊不知道它在说什么;但当我充分地了解它的时候,我就会对它的思路感到惊奇,特别是对失配子串的处理上十分地美妙;而它 的优秀的时间复杂度,更是让我们能体会到前人智慧之所在。所以,我才会写这样一篇博客来记录它。
问题引入
在生物研究中会遇见这样的问题:给定一个 母链,上面有一串碱基序列。现在有一个特定的基因序列 ,我们应该如何确定 这个序列在 中出现了多少次?
样例输入:,。
样例输出:。
解释:在 序列中出现了两次 序列,如下图:
形式化地讲述这个问题:给定字符串 (以下称为母串)和 (以下称为模式串),求模式串在母串中作为连续的子串出现了多少次?
暴力破解
暴力破解的思路
最好想的一种做法就是暴力枚举。
以母串中每一个字符作为起点,向后截取长度为 的子串( 为模式串的长度),判断能否与模式串相互匹配。如果匹配,则计数器 。如下图:
这种算法的思路很简单,代码也很好写,如下:
#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;
}
暴力破解的缺陷
这种做法固然非常简单,但是它非常的低效。
考虑如下输入:,。
计算机每次枚举的时候,会一直匹配到模式串的最后一个字符才会发现失配了。这并不是最糟糕的,糟糕的是失配之后它只会回到第二个字符重新匹配,这就造成了大量的重复搜索。如下:
假如母串的长度为 ,模式串的长度为 ,那么上面这种最坏的情况下,程序大概需要在 步后才能全部匹配完成,即时间复杂度为 。所以,当 和 达到 甚至 数量级时,计算机就不能快速地得出答案了。
既然这种算法低效的原因是在母串上进行了大量的重复搜索,那么我们有没有一种算法,让母串上的起点位置不回退,扫一遍字符串就能得出答案呢?
答案是肯定的,这就是我们接下来所要介绍的:KMP 字符串匹配。
KMP 字符串匹配
什么是 KMP
所谓 KMP 算法,并不是由一个人发明的,而是三位计算机科学家 , 和 共同发现的,故以三人姓氏的首字母命名。它可以通过一个辅助数组— — next 数组,来实现 的时间复杂度内的字符串匹配。
KMP 算法的内容
我们现在以 , 为例,进行字符串匹配。
我们第一步先以母串第一个字符为起点进行匹配,匹配到第五个字符时发现失配了,如下图:
但是在此时,我们会发现前面已经匹配上了 四个字符,那么就没有必要再倒回第二个字符慢慢来了,而因为模式串中匹配上的部分中, 是其最长的公共前后缀,所以我们直接将模式串的起始位置移到第四个位置向后匹配。
但我们发现这个时候遇见第二个字符时就失配了,我们就将模式串再向后移动一位,但移动之后我们便发现匹配到第一位就失配了,于是就再向后移动一位,如下图:
接下来在匹配的时候,一直匹配到第六个字符时,我们才发现失配了,但我们已经知道了匹配上了 五个字符,这个时候,我们就可以直接跳到使最长公共前后缀重新匹配上的部分,并向后继续匹配。如图:
这样,我们就终于找到了字符串中匹配上的部分。
从上面,我们可以看出 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;
}
}
接下来对这个部分进行解释:以 为例。
前面的 个字符组成的前缀,next 值均为 ,即 。这个时候,统计 next 值的变量 始终为 。
接下来,在计算第四、第五个字符的时候,发现 是前五个字符的最长公共前后缀,于是 ,, 增加到 。如下图:
以此类推,失配的时候就回到可以匹配的最长公共前后就可以了:
代码实现
接下来,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;
}
时间复杂度分析
因为每次母串指针后移一位时,模式串的指针至多增加一位,所以此算法的时间复杂度即为 ,比起暴力算法有了很大的优化。
事实上,这种利用前后缀特性,减少反复搜索,就是 KMP 的核心思想,这种思想在许多其它的字符串算法中都很有作用,也是我们应该学习的思想。
例题
本题目列表会持续更新。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效