一、前言
说到字符串匹配算法,可能大家很容易想到的是暴力法,就是每一次移动一个字符,对比模板串和文本串是否相同,这种方法也称之为BF算法(Brute Force)。
下面介绍的是KMP算法,它是由Knuth和Pratt师徒,以及Morris同时发明的,所以联名发表了这个算法,称为KMP算法。
二、KMP算法
1. 算法的整体思路
KMP算法的整体思路是什么样子呢?让我们来看一组例子:
KMP算法和BF算法的“开局”是一样的,同样是把主串和模式串的首位对齐,从左到右对逐个字符进行比较。
第一轮,模式串和主串的第一个等长子串比较,发现前5个字符都是匹配的,第6个字符不匹配,是一个“坏字符”:
这时候,如何有效利用已匹配的前缀 “GTGTG” 呢?
我们可以发现,在前缀“GTGTG”当中,后三个字符“GTG”和前三位字符“GTG”是相同的:
在下一轮的比较时,只有把这两个相同的片段对齐,才有可能出现匹配。这两个字符串片段,分别叫做最长可匹配后缀子串和最长可匹配前缀子串。
第二轮,我们直接把模式串向后移动两位,让两个“GTG”对齐,继续从刚才主串的坏字符A开始进行比较:
显然,主串的字符A仍然是坏字符,这时候的匹配前缀缩短成了GTG:
按照第一轮的思路,我们来重新确定最长可匹配后缀子串和最长可匹配前缀子串:
第三轮,我们再次把模式串向后移动两位,让两个“G”对齐,继续从刚才主串的坏字符A开始进行比较:
以上就是KMP算法的整体思路:在已匹配的前缀当中寻找到最长可匹配后缀子串(真后缀)和最长可匹配前缀子串(真前缀),在下一轮直接把两者对齐,从而实现模式串的快速移动。注意,这里我们为了确保不漏过任何可能的匹配,这里我们只能找最长的自匹配真前缀和真后缀(能确保不漏检)。
那我们怎么知道最长可匹配前缀子串是多少呢?也就是更直白的说,当我们第一次移动的时候,我们应该将模板串向前移动多少呢?
这一切都是通过构造next表实现的
2. next表
next数组到底是个什么鬼呢?这是一个一维整型数组,next[i]的下标 i 表示在第一次发生不匹配时的下标(在上面例子中时C的下标5),那next[5]的是什么呢?继续用上面的例子,next[5]首先应该是3。所以next数组代表的是当我模板串中下标为 i 的字符跟主串相对应的字符不匹配时,这个时候我应该拿第下标为next[i]的字符继续跟主串的字符比较。
下面继续说一下next表的构造。也就是next[5]怎么就等于3?
首先我们知道next[0]=0, 即当模板串的下标为0的字符跟主串为的字符不匹配时,只能主串向前走,继续跟pattern[0]比较。(pattern代表模板串)
其次next[1]=0,即当模板串的下标为1的字符跟主串的字符冲突时,下标 i 可以回退到0.
那么对于i >1, 如何求next[i]呢?这里使用了动态规划的思想。
定义 j = next[i-1], 那么我们知道在pattern[0,i-1)当中,自匹配的真前缀和真后缀的最大长度应该为j,比如下面这个例子:
next[i-1]的意思就是当模板串中的G(下标为2)和主串的相应字符不匹配的时候,我们应该回退到哪个字符在此进行比较?这里next[i-1]=0,也就是在G(下标为2)前面的字符,不存在自匹配的真前缀和真后缀,因为G!=T(指下标为0的G)。
现在再求next[i]的值,假设说前三个字符都匹配了,第四个不匹配,我们应该回退到哪个字符?存在一种这样关系,我们已经知道下标为i-1的字符G,它前面的字符序列能够自匹配的真前缀和真后缀的长度为 j =0。假设说这个时候pattern[j]字符(即紧接着真前缀后面的字符)和pattern[i-1]字符(即紧接着真后缀后面的字符)相等,即pattern[j]=pattern[i-1], 那么下标为i的字符T前面的字符序列能够自匹配的真前缀和真后缀长度 等于 下标为i-1的字符前面的字符序列真前缀相长度 + 1。
所以当pattern[j]=pattern[i-1]时,next[i] = next[i-1] + 1 = j+1。
那么当pattern[j] != pattern[i-1]时,next[i]是多少呢?这个时候next[i]的候选者应该是next[j] + 1, next[next[j]] + 1...。这是为什么呢?我们下面这个为例子
这里就j = next[i-1] = 3。 这个时候下标为i的字符跟主串不匹配,我们应该回退到哪个字符呢?这个时候 pattern[j]!=pattern[i-1],即T != C。我们不能说F前面的自匹配的真前缀和真后缀是C的相应值+1。
这个时候我们应该变换j,使得 j =next[j](如下图所示),我们知道 j 之前的GTG是一个能够自匹配的真前缀Prefix1,它和C之前的真后缀GTG匹配(记为Postfix1)。而next[j] = 1之前的G也是一个真前缀Prefix2,它对应一个真后缀Postfix2。
(注:上图为示意图,真实状况下prefix1和postfix可能会重合,为清晰表示,仅展示不重合的情况,不影响理解)
易知Prefix1包含Prefix2和Postfix2, 而Prefix1 = Postfix1
所以Postfix1也包含Prefix2和Postfix2, 且Prefix2 = Postfix2
所以我们可以把Prefix1中的Prefix2部分对应到Postfix1中的Postfix2部分。这时候我们再看pattern[next[j]]是否等于pattern[i-1],如果相等,那么很高兴,我们可以得到跟第一种情况相似的结论,即next[i] = next[j] + 1。
如果pattern[next[j]]不等于pattern[i-1],我们只能按照刚才的思路再执行一次寻找更小的真前缀,这种迭代搜寻直到next[j]为0时才停止。
3. 优化
在构造next表的过程,我们可以做一个优化,就是在第一种情况,
当pattern[j]=pattern[i-1]时,next[i] = next[i-1] + 1 = j+1。
如果这个时候j+1对应的字符和i的字符相同的话(如例子中都是T),我们后退到j+1对应的字符T,再跟主串比较,仍然是不相等的,所以如果我们发现pattern[j+1] == pattern[i]的话,我们可以把next[i]赋值为next[j+1]而不是j+1。
4. 代码实现
#include<string.h>
#include<cstdlib>
#include<iostream>
using namespace std;
int* getNexts(string pattern)
{
int* next = new int[max(int(pattern.length()),2)];
int j = 0;
next[0] = 0;
next[1] = 0;
for (int i = 2; i < pattern.length(); i++)
{
// j = next[i-1]; 在做了优化的情况下不能有这句
if (pattern[j] == pattern[i - 1])
{
j++;
// next[i] = j; // 非优化实现
next[i] = pattern[i]!=pattern[j]?j:next[j]; // 优化实现
}
else{
while (j != 0 && pattern[j] != pattern[i - 1])
{ //从next[i+1]的求解回溯到next[j]
j = next[j];
}
// next[i] = j; // 非优化实现
next[i] = pattern[i]!=pattern[j]?j:next[j]; // 优化实现
}
}
return next;
}
int kmp(string str, string pattern)
{
//预处理,生成next数组
int* next = getNexts(pattern);
int j = 0;
int i = 0;
//主循环,遍历主串字符
while(i < str.length())
{
if (str[i] == pattern[j])
{
j++;i++;
}
else if(j!=0) // 如果不是跟第0个比较,那么可以回退
{
while (j!=0 && str[i] != pattern[j])
{ //遇到坏字符时,查询next数组并改变模式串的起点
j = next[j];
}
}else{ // 否则不能回退,直接主串+1
i++;
}
if (j == pattern.length())
{ //匹配成功,返回下标
return i - pattern.length();
}
}
return -1;
}
int main()
{
string str = "ATGTGAGCTGGTGTGTGCFAA";
string pattern = "GTGTGCF";
int index = kmp(str, pattern);
printf("首次出现位置:%d" , index);
return 0;
}
5. 简洁版
#include<string.h>
#include<cstdlib>
#include<iostream>
using namespace std;
int* getNexts(string pattern)
{
int* next = new int[max(int(pattern.length()),2)];
int j = 0;
next[0] = 0;
next[1] = 1;
for (int i = 2; i < pattern.length(); i++)
{
while (j != 0 && pattern[j] != pattern[i - 1])
{ //从next[i+1]的求解回溯到next[j]
j = next[j];
}
if (pattern[j] == pattern[i - 1])
{
j++;
}
// next[i] = j; // 非优化实现
next[i] = pattern[i]!=pattern[j]?j:next[j]; // 优化实现
}
return next;
}
int kmp(string str, string pattern)
{
//预处理,生成next数组
int* next = getNexts(pattern);
int j = 0;
//主循环,遍历主串字符
for (int i = 0; i < str.length(); i++)
{
while (j > 0 && str[i] != pattern[j])
{ //遇到坏字符时,查询next数组并改变模式串的起点
j = next[j];
}
if (str[i] == pattern[j])
{
j++;
}
if (j == pattern.length())
{ //匹配成功,返回下标
return i - pattern.length() + 1;
}
}
return -1;
}
int main()
{
string str = "ATGTGAGCTGGTGTGTGCFAA";
string pattern = "GTGTGCF";
int index = kmp(str, pattern);
printf("首次出现位置:%d" , index);
return 0;
}
三、参考资料
1. 数据结构(C++语言版)
2. https://baijiahao.baidu.com/s?id=1659735837100760934&wfr=spider&for=pc