数据结构- KMP 算法

简介: 数据结构- KMP 算法

文章目录

一、前言

二、KMP 算法

1. 问题背景

2. 暴力匹配

2.1 暴力匹配过程

2.2 暴力匹配实现

3. KMP 算法

3.1 优化思路

3.2 k 值

3.3 KMP 算法实现过程

三、KMP 算法例题—— KMP 字符串

具体实现

1. 模板

1.1 代码注解

1.2 实现代码

2. 下标从0开始的写法(不建议)

2.1 实现代码


一、前言


KMP 算法是由 D.E.Knuth ,J.H.Morris 和 V.R.Pratt 三位学者在 Brute-Force 算法的基础上提出的模式匹配改进算法,因此人们称它为克努特—莫里斯—普拉特操作(简称 KMP 算法)。KMP 算法的核心是利用匹配失败后的信息,尽量减少主串与字符串串的匹配次数以达到快速匹配的目的。具体实现就是通过一个 next() 函数实现,函数本身包含了模式串的局部匹配信息。KMP 算法的时间复杂度 O(m+n) 。


Brute- Force 算法在模式串中有多个字符和主串中的若干个连续字符比较都相等,但最后一个字符比较不相等时,主串的比较位置需要回退。KMP 算法在上述情况下,主串位置不需要回溯,从而可以大大提高效率。


二、KMP 算法

1. 问题背景


如果我们有两个字符串,一个是长字符串 S ,另一个是短字符串 P ,这两个字符串中都只包含大小写英文字符和阿拉伯数字,现在我们要查找 P 在 S 中的位置,如果短字符串 P 在长字符串 S 中出现过,输出第一次出现的位置,否则输出 -1。


例如:长字符串 S = “ababababfab” ,短字符串 P = “ababf”,可以看出 P 在 S 中第一次出现位置是在S[4] 到S[8],所以输出4。

例如:长字符串 S = “abababfab” ,短字符串 P = “ababg”,可以看出 P 在 S 中没有出现过,输出 -1。


2. 暴力匹配

2.1 暴力匹配过程


  • 关于暴力匹配做法,我们依次比较长字符串各个字母为开头的字串是否与短字符串匹配。
  • 依次比较以长字符串各个字母为开头的子串是否与短字符串匹配。
  • 如果有匹配的输出起始位置,如果没有,输出 -1。


  • 例如:以长的字符串 S = “ababababfab” ,短的字符串 P = “ababf” 为例,过程如下:
  • 首先用S[0]开头的子串:ababa 与 P比较,不匹配。
  • 接着用S[1]开头的子串:babab 与 P比较,不匹配。


  • 接着用S[2]开头的子串:ababa 与 P比较,不匹配。
  • 接着用S[3]开头的子串:babab 与 P比较,不匹配。
  • 接着用S[4]开头的子串:ababf 与 P比较,匹配。输出4。


06ec9de1389242f8a69728d7d56f1bea.gif


2.2 暴力匹配实现


首先我们假设,长字符串 S 匹配到 i 位置,短字符串 P 匹配到 j 位置。

如果当前字符串匹配成功(即 S[i] = P[j] ),则 i++ , j++ ,继续匹配下一个字符。

如果当前字符串匹配失败(即 S[i] != P[j] ),则令 i = i - (j - 1),j = 0。相当于每次匹配失败时,i 回溯,j 被置为0。因为 长字符串 S 当中 i - (j - 1) 位置之前的都与短字符串 P 进行对比过了,因此我们只需从该位置继续与短字符串 P 匹配即可。


int ViolentMatch(char* s, char* p)
{
  int sLen = strlen(s);
  int pLen = strlen(p);
  int i = 0;
  int j = 0;
  while (i < sLen && j < pLen)
  {
    if (s[i] == p[j])
    {
      i++;
      j++;
    }
    else
    {
      i = i - j + 1;
      j = 0;
    }
  }
  if (j == pLen)
  {
    return i - j;
  }   
  else
  {
    return -1;
  }   
}


3. KMP 算法

  • 暴力匹配做法的优缺点都很明显,容易被我们想到,但时间复杂度较高。
  • 因此,KMP 算法就是用来优化暴力算法,降低时间复杂度。


3.1 优化思路


  • 此处,仍以长字符串 S = “ababababfab” ,短字符串 P = “ababf” 为例。
  • 首先,用 S[0] 开头的字串:“ababa” 与 P = “ababf” 比较,比较到 S[4] 与 P[4] 的时候,S[4] != P[4]。


061d3d764b814c11bbe54cb64db77650.png


  • 如果按照暴力匹配的思路,此时应该使用 S[1] 开头的字串 “babab” 与 P = "ababf“ 进行匹配。
  • 这时,我们会发现,S[1~3] 与P[0~2] 不匹配,因此,以 S[1] 开头的字符串一定与 P 不匹配,便不需要进行以 S[1] 为开头字符串与 P 的比较了。


2751141afed44704a334362936c95ec7.png


继续比较,我们又会发现,S[2~3] 与 P[0~1] 匹配,则以 S[2] 为开头的字符串在与 P = "ababf“ 进行匹配时,可以直接从 S[4] 与 P[2] 开始比较即可。比较到S[6] 与 P[4] 的时候,S[6] != P[4]。


88cd78b616c9448cb8adb06520fbde39.png

继续比较,我们又会发现,S[3~5] 与 P[0~2] 不匹配,则以 S[3] 为开头的字符串在与 P = "ababf“ 一定匹配失败时,便不需要进行以 S[3] 为开头字符串与 P 的比较了

478897bc8dcb47c9abbb116724681f75.png

-接下来用 S[4] 开头的子串: S[4] = “ababf“ 与 P = “ababf“ 比较。此时,由于 S[4~5] 与 P[0~2] 相同,那以S[4] 为开头的字符串与 P 匹配时,无需从S[4]开始,直接从S[5] 与 P[3] 开始比较即可。比较到S[8] 与 P[5] 的时候,S[8] = P[5],字符串匹配完成,返回起始位置4。


76c5c7e6caed4ce7b9e67c68838d7d00.png

总结一下,当出现了 S[i] != P[j] 时,我们进行检查: S[i-j+1 ~ i-1] 与 P[0 ~ j-2] 是否匹配,S[i-j+2 ~ i-1] 与 P[0 ~ j-3] 是否匹配······。也就是下图中 S 蓝色框里的子串与 P 蓝色框里的子串是否匹配,S 绿色框里的子串与 P 绿色框里的子串是否匹配,S 红色框里的子串与 P红色框里的子串是否匹配。

3ae5fc7f246746ca90447a54829c7d08.png

3.2 k 值


对上述实现过程进行总结,我们发现,只需要一个值 k ,k 是可以满足下面性质的最大值:

长字符串从 i-k 到第 i-1 的字符 S[i-k ~ i-1] 与短字符串前 k 个字符 P[0 ~ k-1] 相同。

对于大于 0 的 x:

(1) 长字符串从 i-k-x 到第 i-1 的字符 S[i-k-x ~ i-1] 与短字符串前 k-x 个字符 P[0 ~ k+x-1] 不相同。

(2) 长字符串从 i-k+x 到第 i-1 的字符 S[i-k+x ~ i-1] 与短字符串前 k-x 个字符 P[0 ~ k-x-1] 相同。


因为 k 是满足 P[0 ~ k-1] 与 S[i-k ~ i-1] 相同的最大值,所以 P[0 ~ k-1+x] 与 S[i-k-x ~ i-1] (x>0) 不同,因此以 S[i-k-x] 为开头的子串与 P 肯定不匹配。下次匹配,i 无需回溯到 i-k-x, 只需回溯到 i-k,j 回溯到 0。

因为 P[0 ~ k-1] 与 S[i-k ~ i-1] 相同,因此这一段无需比较。故 i 直接可以移动到回溯之前的位置,j 直接可以移动到 k 。

总结来看:当遇到不匹配的字符的时候,i 不往前移动,j 移动到 k 即可。


3.3 KMP 算法实现过程


此处,我们仍以长字符串 S = “ababababfab” ,短字符串 P = “ababf” 为例:

第一次出现字符不匹配的时候为:S[4] != P[4]。保证 P[0 ~ k-1] = S[4-k ~ 3] 的 k 的最大值为 2。

当 k = 4 时,匹配没有任何意义,k = 3 时,S[1] = b,与 P[0] 不相同,当 k = 2 时满足条件。9de90fbf37ab4c44becf53c8b6f7c205.png



因为,k 的最大值为 2 ,S[1] 为开头的字符串,x = 1,所以 P[0 ~ 2+1-1] != S[2-1 ~ 3], 即 P[0 ~ 2] != S[1 ~ 3]。因此以 S[1] 为开头的子串与 P 肯定不相同,无需进行后续比较。

接下来判断以 S[2] 为开头的字符串与 P 是否相同。又因为 P[0 ~ 1] = S[2 ~3],所以转化为以 S[2] 为开头的字符串是否与 P 相同,只需要从 P[2] 与 S[4] 开始比较即可。i 之前的位置为 4,现在还是 4,相当于 i 没有回溯。j 之前的位置是 4 ,现在是 2 也就是 k 值,也没有回溯到 0。

KMP 中的最长公共前后缀长度数组:next 。next[j] 含义是:P[j] 前面的字符串的最长公共前后缀长度。使得字符串有最长相等前后缀的前缀的最后一位的下标。

仍以长字符串 S = “ababababfab” ,短字符串 P = “ababf” 为例,P=“ababf” 的最长公共前后缀:

P[0] 前面没有字符串,所以最长公共前后缀长度为 0。

P[1] 前面的字符串为:a,a 没有前后缀 (前后缀长度要小于字符串长度) 。最长公共前后缀长度为 0。

P[2] 前面的字符串为:ab,它的前缀为:a,后缀为b。前缀不等于后缀,所以没有公共前后缀,最长公共前后缀长度为 0。

P[3] 前面的字符串为:aba,aba 的前缀有:a,ab, 后缀有:a,ba。因为 ab 不等于 ba,所以最长公共前后缀为 a,最长公共前后缀长度为 1。

P[4] 前面的字符串为:abab,abab 的前缀有:a,ab,aba,后缀有:a,ab, bab。最长公共前后缀为 ab,长度为 2。

求 next 数组的过程:

初始化 next 数组,令 j = 0,i = 2,因为 next(1) = 0。

让 i 在 2 ~ len 范围内遍历,对每个 i ,执行 3、4,以求解 ne[i]。

直到 j 回退为 0,或是 p[i] = p[j + 1] 成立,否则不断令 j = ne[j]。

如果 p[i] = p[j + 1],则 j = j + 1,之后,再将 j 赋值给 ne[i] 。


for (int i = 2, j = 0; i <= n; i ++ )
    {
        while (j && p[i] != p[j + 1]) 
    {
      j = ne[j];
    }
        if (p[i] == p[j + 1]) 
    {
      j ++ ;
    }
        ne[i] = j;
    }


  • 通过 next 数组有了最长公共前后缀,因此当我们前缀部分匹配失败后,可以直接从后缀开始下一步匹配过程,形成跳跃式匹配的高效模式。
  • KMP 算法的具体实现过程及代码可见例题。



三、KMP 算法例题—— KMP 字符串

题目描述


给定一个字符串 S,以及一个模式串 P,所有字符串中只包含大小写英文字母以及阿拉伯数字。

模式串 P 在字符串 S 中多次作为子串出现。

求出模式串 P 在字符串 S 中所有出现的位置的起始下标。


输入格式

第一行输入整数 N,表示字符串 P 的长度。

第二行输入字符串 P。

第三行输入整数 M,表示字符串 S 的长度。

第四行输入字符串 S。


输出格式

共一行,输出所有出现位置的起始下标(下标从 0 开始计数),整数之间用空格隔开。

数据范围

1 ≤ N ≤ 1e5

1 ≤ M ≤ 1e6


输入样例

3

aba

5

ababa

输出样例

0 2



具体实现

1. 模板

1.1 代码注解


  • 在 C++ 当中,next 数组在某个头文件当中已经被使用过了,因此直接使用 next 数组时有可能会报错。
  • j = ne[j]。 下次匹配的时候,直接移动到相同后缀部分。



1.2 实现代码

#include <bits/stdc++.h> 
using namespace std;
const int N = 100010, M = 1000010;
int n, m;
int ne[N];
char s[M], p[N];
int main()
{
    cin >> n >> p + 1 >> m >> s + 1;
    // 求ne过程 
    for (int i = 2, j = 0; i <= n; i ++ )
    {
        while (j && p[i] != p[j + 1]) 
    {
      j = ne[j];
    }
        if (p[i] == p[j + 1]) 
    {
      j ++ ;
    }
        ne[i] = j;
    }
    // kmp匹配过程 
    for (int i = 1, j = 0; i <= m; i ++ )
    {
        while(j && s[i]!= p[j+1])  //如果不匹配 j表示退无可退了
        {
       j = ne[j];   //表示j退几步
    }         
        if(s[i] == p[j+1])       //如果他俩已经匹配了
        {
        j++;                 //就可以移动到下一个位置
        }
    if(j == n)                //匹配成功
        {
            cout << i-n << ' '; //i-n+1是下标, 但是题意从0开始, 所以还有减去1
            j = ne[j];
        }
    }
    system("pause"); 
    return 0;
}


2. 下标从0开始的写法(不建议)

2.1 实现代码

#include <bits/stdc++.h>
using namespace std;
const int N = 1000010;
int n, m;
char s[N], p[N];
int ne[N];
int main()
{
    cin >> m >> p >> n >> s;
    ne[0] = -1;
    for (int i = 1, j = -1; i < m; i ++ )
    {
        while (j >= 0 && p[j + 1] != p[i])
    {
      j = ne[j];
    }
        if (p[j + 1] == p[i])
    {
      j ++ ;
    }
        ne[i] = j;
    }
    for (int i = 0, j = -1; i < n; i ++ )
    {
        while (j != -1 && s[i] != p[j + 1]) 
    {
      j = ne[j];
    }
        if (s[i] == p[j + 1]) 
    {
      j ++ ;
    }
        if (j == m - 1)
        {
            cout << i - j << ' ';
            j = ne[j];
        }
    }
    system("pause");
    return 0;
}
















相关文章
|
17天前
|
存储 人工智能 算法
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
这篇文章详细介绍了Dijkstra和Floyd算法,这两种算法分别用于解决单源和多源最短路径问题,并且提供了Java语言的实现代码。
50 3
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
|
20天前
|
机器学习/深度学习 存储 缓存
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
文章主要介绍了排序算法的分类、时间复杂度的概念和计算方法,以及常见的时间复杂度级别,并简单提及了空间复杂度。
18 1
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
|
13天前
|
存储 算法 Java
Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性
Java Set因其“无重复”特性在集合框架中独树一帜。本文解析了Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性,并提供了最佳实践建议,包括选择合适的Set实现类和正确实现自定义对象的hashCode()与equals()方法。
29 4
|
20天前
|
搜索推荐 算法
数据结构与算法学习十四:常用排序算法总结和对比
关于常用排序算法的总结和对比,包括稳定性、内排序、外排序、时间复杂度和空间复杂度等术语的解释。
14 0
数据结构与算法学习十四:常用排序算法总结和对比
|
20天前
|
存储 缓存 分布式计算
数据结构与算法学习一:学习前的准备,数据结构的分类,数据结构与算法的关系,实际编程中遇到的问题,几个经典算法问题
这篇文章是关于数据结构与算法的学习指南,涵盖了数据结构的分类、数据结构与算法的关系、实际编程中遇到的问题以及几个经典的算法面试题。
26 0
数据结构与算法学习一:学习前的准备,数据结构的分类,数据结构与算法的关系,实际编程中遇到的问题,几个经典算法问题
|
19天前
|
机器学习/深度学习 搜索推荐 算法
探索数据结构:初入算法之经典排序算法
探索数据结构:初入算法之经典排序算法
|
20天前
|
算法 Java 索引
数据结构与算法学习十五:常用查找算法介绍,线性排序、二分查找(折半查找)算法、差值查找算法、斐波那契(黄金分割法)查找算法
四种常用的查找算法:顺序查找、二分查找(折半查找)、插值查找和斐波那契查找,并提供了Java语言的实现代码和测试结果。
16 0
|
20天前
|
算法 程序员 索引
数据结构与算法学习七:栈、数组模拟栈、单链表模拟栈、栈应用实例 实现 综合计算器
栈的基本概念、应用场景以及如何使用数组和单链表模拟栈,并展示了如何利用栈和中缀表达式实现一个综合计算器。
18 1
数据结构与算法学习七:栈、数组模拟栈、单链表模拟栈、栈应用实例 实现 综合计算器
|
1天前
|
算法 安全 NoSQL
2024重生之回溯数据结构与算法系列学习之栈和队列精题汇总(10)【无论是王道考研人还是IKUN都能包会的;不然别给我家鸽鸽丢脸好嘛?】
数据结构王道第3章之IKUN和I原达人之数据结构与算法系列学习栈与队列精题详解、数据结构、C++、排序算法、java、动态规划你个小黑子;这都学不会;能不能不要给我家鸽鸽丢脸啊~除了会黑我家鸽鸽还会干嘛?!!!
|
20天前
初步认识栈和队列
初步认识栈和队列
47 10