KMP算法-从头到尾彻底理解KMP
一:背景
给定一个主串(以 S 代替)和模式串(以 P 代替),要求找出 P 在 S 中出现的位置,此即串的模式匹配问题。
Knuth-Morris-Pratt 算法(简称 KMP)是解决这一问题的常用算法之一,这个算法是由高德纳(Donald Ervin Knuth)和沃恩·普拉特在 1974 年构思,同年詹姆斯·H·莫里斯也独立地设计出该算法,最终三人于 1977 年联合发表。
在继续下面的内容之前,有必要在这里介绍下两个概念:真前缀 和 真后缀。

由上图所得, "真前缀"指除了自身以外,一个字符串的全部头部组合;"真后缀"指除了自身以外,一个字符串的全部尾部组合。(网上很多博客,应该说是几乎所有的博客,也包括我以前写的,都是“前缀”。严格来说,“真前缀”和“前缀”是不同的,既然不同,还是不要混为一谈的好!)
二:朴素字符串匹配算法
初遇串的模式匹配问题,我们脑海中的第一反应,就是朴素字符串匹配(即所谓的暴力匹配),代码如下:
/* 字符串下标始于 0 */
int NaiveStringSearch(string S, string P)
{
int i = 0; // S 的下标
int j = 0; // P 的下标
int s_len = S.size();
int p_len = P.size();
while (i < s_len && j < p_len)
{
if (S[i] == P[j]) // 若相等,都前进一步
{
i++;
j++;
}
else // 不相等
{
i = i - j + 1;
j = 0;
}
}
if (j == p_len) // 匹配成功
return i - j;
return -1;
}
暴力匹配的时间复杂度为 \(O(nm)\),其中 \(n\) 为 S 的长度,\(m\) 为 P 的长度。很明显,这样的时间复杂度很难满足我们的需求。
接下来进入正题:时间复杂度为 \(Θ(n+m)\) 的 KMP 算法。
三:KMP字符串匹配算法
3.1 算法流程
以下摘自阮一峰的字符串匹配的KMP算法,并作稍微修改。
(1)

首先,主串"BBC ABCDAB ABCDABCDABDE"的第一个字符与模式串"ABCDABD"的第一个字符,进行比较。因为 B 与 A 不匹配,所以模式串后移一位。
(2)

因为 B 与 A 又不匹配,模式串再往后移。
(3)

就这样,直到主串有一个字符,与模式串的第一个字符相同为止。
(4)

接着比较主串和模式串的下一个字符,还是相同。
(5)

直到主串有一个字符,与模式串对应的字符不相同为止。
(6)

这时,最自然的反应是,将模式串整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把"搜索位置"移到已经比较过的位置,重比一遍。
(7)

一个基本事实是,当空格与 D 不匹配时,你其实是已经知道前面六个字符是"ABCDAB"。KMP 算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,而是继续把它向后移,这样就提高了效率。
(8)
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 模式串 | A | B | C | D | A | B | D | '\0' |
| next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
怎么做到这一点呢?可以针对模式串,设置一个跳转数组int next[],这个数组是怎么计算出来的,后面再介绍,这里只要会用就可以了。
(9)

已知空格与 D 不匹配时,前面六个字符"ABCDAB"是匹配的。根据跳转数组可知,不匹配处 D 的 next 值为 2,因此接下来从模式串下标为 2 的位置开始匹配。
(10)

因为空格与 C 不匹配,C 处的 next 值为 0,因此接下来模式串从下标为 0 处开始匹配。
(11)

因为空格与 A 不匹配,此处 next 值为 -1,表示模式串的第一个字符就不匹配,那么直接往后移一位。
(12)

逐位比较,直到发现 C 与 D 不匹配。于是,下一步从下标为 2 的地方开始匹配。
(13)

逐位比较,直到模式串的最后一位,发现完全匹配,于是搜索完成。
3.2 next 数组是如何求出的
next 数组的求解基于“真前缀”和“真后缀”,即next[i]等于P[0]...P[i - 1]最长的相同真前后缀的长度(请暂时忽视 i 等于 0 时的情况,下面会有解释)。我们依旧以上述的表格为例,为了方便阅读,我复制在下方了。
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 模式串 | A | B | C | D | A | B | D | '\0' |
| next[ i ] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
i = 0,对于模式串的首字符,我们统一为
next[0] = -1;i = 1,前面的字符串为
A,其最长相同真前后缀长度为 0,即next[1] = 0;i = 2,前面的字符串为
AB,其最长相同真前后缀长度为 0,即next[2] = 0;i = 3,前面的字符串为
ABC,其最长相同真前后缀长度为 0,即next[3] = 0;i = 4,前面的字符串为
ABCD,其最长相同真前后缀长度为 0,即next[4] = 0;i = 5,前面的字符串为
ABCDA,其最长相同真前后缀为A,即next[5] = 1;i = 6,前面的字符串为
ABCDAB,其最长相同真前后缀为AB,即next[6] = 2;i = 7,前面的字符串为
ABCDABD,其最长相同真前后缀长度为 0,即next[7] = 0。
那么,为什么根据最长相同真前后缀的长度就可以实现在不匹配情况下的跳转呢?举个代表性的例子:假如i = 6时不匹配,此时我们是知道其位置前的字符串为ABCDAB,仔细观察这个字符串,首尾都有一个AB,既然在i = 6处的 D 不匹配,我们为何不直接把i = 2处的 C 拿过来继续比较呢,因为都有一个AB啊,而这个AB就是ABCDAB的最长相同真前后缀,其长度 2 正好是跳转的下标位置。
有的读者可能存在疑问,若在i = 5时匹配失败,按照我讲解的思路,此时应该把i = 1处的字符拿过来继续比较,但是这两个位置的字符是一样的啊,都是B,既然一样,拿过来比较不就是无用功了么?其实不是我讲解的有问题,也不是这个算法有问题,而是这个算法还未优化,关于这个问题在下面会详细说明,不过建议读者不要在这里纠结,跳过这个,下面你自然会恍然大悟。
思路如此简单,接下来就是代码实现了,如下:
/* P 为模式串,下标从 0 开始 */
void GetNext(string P, int next[])
{
int p_len = P.size();
int i = 0; // P 的下标
int j = -1;
next[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
next[i] = j;
}
else
j = next[j];
}
}
一脸懵逼,是不是。。。上述代码就是用来求解模式串中每个位置的next[]值。
下面具体分析,我把代码分为两部分来讲:
(1):i 和 j 的作用是什么?
i 和 j 就像是两个”指针“,一前一后,通过移动它们来找到最长的相同真前后缀。
(2):if...else...语句里做了什么?

假设 i 和 j 的位置如上图,由next[i] = j得,也就是对于位置 i 来说,区段 [0, i - 1] 的最长相同真前后缀分别是 [0, j - 1] 和 [i - j, i - 1],即这两区段内容相同。
按照算法流程,if (P[i] == P[j]),则i++; j++; next[i] = j;;若不等,则j = next[j],见下图:

next[j]代表 [0, j - 1] 区段中最长相同真前后缀的长度。如图,用左侧两个椭圆来表示这个最长相同真前后缀,即这两个椭圆代表的区段内容相同;同理,右侧也有相同的两个椭圆。所以 else 语句就是利用第一个椭圆和第四个椭圆内容相同来加快得到 [0, i - 1] 区段的相同真前后缀的长度。
细心的朋友会问 if 语句中j == -1存在的意义是何?第一,程序刚运行时,j 是被初始为 -1,直接进行P[i] == P[j]判断无疑会边界溢出;第二,else 语句中j = next[j],j 是不断后退的,若 j 在后退中被赋值为 -1(也就是j = next[0]),在P[i] == P[j]判断也会边界溢出。综上两点,其意义就是为了特殊边界判断。
四:完整代码
#include <iostream>
#include <string>
using namespace std;
/* P 为模式串,下标从 0 开始 */
void GetNext(string P, int next[])
{
int p_len = P.size();
int i = 0; // P 的下标
int j = -1;
next[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
next[i] = j;
}
else
j = next[j];
}
}
/* 在 S 中找到 P 第一次出现的位置 */
int KMP(string S, string P, int next[])
{
GetNext(P, next);
int i = 0; // S 的下标
int j = 0; // P 的下标
int s_len = S.size();
int p_len = P.size();
while (i < s_len && j < p_len) // 因为末尾 '\0' 的存在,所以不会越界
{
if (j == -1 || S[i] == P[j]) // P 的第一个字符不匹配或 S[i] == P[j]
{
i++;
j++;
}
else
j = next[j]; // 当前字符匹配失败,进行跳转
}
if (j == p_len) // 匹配成功
return i - j;
return -1;
}
int main()
{
int next[100] = { 0 };
cout << KMP("bbc abcdab abcdabcdabde", "abcdabd", next) << endl; // 15
return 0;
}
五:KMP优化
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 模式串 | A | B | C | D | A | B | D | '\0' |
| next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
以 3.2 的表格为例(已复制在上方),若在i = 5时匹配失败,按照 3.2 的代码,此时应该把i = 1处的字符拿过来继续比较,但是这两个位置的字符是一样的,都是B,既然一样,拿过来比较不就是无用功了么?这我在 3.2 已经解释过,之所以会这样是因为 KMP 还未优化。那怎么改写就可以解决这个问题呢?很简单。
/* P 为模式串,下标从 0 开始 */
void GetNextval(string P, int nextval[])
{
int p_len = P.size();
int i = 0; // P 的下标
int j = -1;
nextval[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
if (P[i] != P[j])
nextval[i] = j;
else
nextval[i] = nextval[j]; // 既然相同就继续往前找真前缀
}
else
j = nextval[j];
}
}
六:参考文献
- 严蔚敏. 数据结构(C 语言版)
- 阮一峰. 字符串匹配的KMP算法
七:鸣谢
-此篇文章特别感谢EthsonLiu学长的帮助!
KMP算法-从头到尾彻底理解KMP的更多相关文章
- 从头到尾彻底理解KMP
从头到尾彻底理解KMP 作者:July 时间:最初写于2011年12月,2014年7月21日晚10点 全部删除重写成此文,随后的半个多月不断反复改进. 1. 引言 本KMP原文最初写于2年多前的201 ...
- 【July】从头到尾彻底理解KMP
从头到尾彻底理解KMP 作者:July时间:最初写于2011年12月,2014年7月21日晚10点 全部删除重写成此文,随后的半个多月不断反复改进. 1. 引言 本KMP原文最初写于2年多前的2011 ...
- 【转载】从头到尾彻底理解KMP
转自:http://blog.csdn.net/v_july_v/article/details/7041827 从头到尾彻底理解KMP 作者:July 时间:最初写于2011年12月,2014年7月 ...
- 转:[置顶] 从头到尾彻底理解KMP(2014年8月22日版)
[置顶] 从头到尾彻底理解KMP(2014年8月22日版)
- kmp算法模板及理解
kmp算法是复杂度为O(n+m)的字符串匹配算法; 首先kmp算法的核心是在模式串中获得next数组,这个数组表示模式串的子串的前缀和后缀相同的最长长度; 这样在匹配的过程中如果指到不匹配的位置,模式 ...
- 从头到尾测地理解KMP算法【转】
本文转载自:http://blog.csdn.net/v_july_v/article/details/7041827 1. 引言 本KMP原文最初写于2年多前的2011年12月,因当时初次接触KMP ...
- KMP算法简明扼要的理解
KMP算法也算是相当经典,但是对于初学者来说确实有点绕,大学时候弄明白过后来几年不看又忘记了,然后再弄明白过了两年又忘记了,好在之前理解到了关键点,看了一遍马上又能理解上来.关于这个算法的详解网上文章 ...
- 【转】从头到尾彻底理解KMP
很好,讲得很清晰,值得学习. 作者:July时间:最初写于2011年12月,2014年7月21日晚10点 全部删除重写成此文,随后的半个月从早到晚不断改进. 1. 引言 本KMP原文最初写于2年多前的 ...
- 从头到尾彻底理解KMP(转)
引言 KMP原文最初写于2年多前的2011年12月,因当时初次接触KMP,思路混乱导致写也写得非常混乱,如此,留言也是骂声一片.所以一直想找机会重新写下KMP,但苦于一直以来对KMP的理解始终不够,故 ...
随机推荐
- Spring教程检视阅读
Spring教程检视阅读 地址 可供参考的教程 <菜鸟学 SSH> <Spring Boot 那些事> <初识 Spring Security> <Sprin ...
- go极其ide的安装
一.下载软件开发包 官网:https://golang.google.cn/ 二. 安装和配置SDK windows使用.msi一键安装 配置环境变量 GOROOT,自动的,默认将go安装到C:/ ...
- C# 时间戳(TimeStamp)与时间(DateTime)的互相转换
什么是时间戳:时间戳是指格林威治时间1970年01月01日00时00分00秒(北京时间1970年01月01日08时00分00秒)起至现在的总秒数. 时间戳在线转换网址:https://tool.lu/ ...
- 一段很简单的PHP代码,用于手机拨号
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title> ...
- Android UI性能测试——使用 Gfxinfo 衡量性能
Android官方文档翻译 原文地址:https://developer.android.com/training/testing/performance参考:https://www.jianshu. ...
- Journal of Proteome Research | Mining the Proteome Associated with Rheumatic and Autoimmune Diseases(挖掘风湿和自身免疫疾病相关的蛋白组)(解读人:黄旭蕾)
期刊名:JPR 发表时间:(2019年12月) IF:3.780 单位:Grupo de Investigación de Reumatología (GIR), Unidad de Proteó ...
- java web 获取 网页访问次数
ServletContext context = request.getServletContext(); /** * 从ServletContext中获取计数器对象 */Integer count ...
- 强智教务系统验证码识别 Tensorflow CNN
强智教务系统验证码识别 Tensorflow CNN 一直都是使用API取得数据,但是API提供的数据较少,且为了防止API关闭,先把验证码问题解决 使用Tensorflow训练模型,强智教务系统的验 ...
- 被问到到http的时候你就这么回答!
大家好,我是标题党,啊不,我是小雨小雨,致力于分享有趣的.实用的技术文章. 内容分为翻译和原创,如果有问题,欢迎随时评论或私信,希望和大家一起进步. 分享不易,希望能够得到大家的支持和关注. 什么是互 ...
- ThreadPoolTaskExecutor 中 corePoolSize vs. maxPoolSize
1. 概览 Spring中的 ThreadPoolTaskExecutor 是一个 JavaBean ,提供围绕java.util.concurrent.ThreadPoolExecutor 的抽象实 ...