字符串匹配算法之KMP算法

一、前言

说到字符串匹配算法,可能大家很容易想到的是暴力法,就是每一次移动一个字符,对比模板串和文本串是否相同,这种方法也称之为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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值