一、背景

给定一个主串(以 S 代替)和模式串(以 P 代替),要求找出 P 在 S 中出现的位置,此即串的模式匹配问题。

Knuth-Morris-Pratt 算法(简称 KMP)是解决这一问题的常用算法之一,这个算法是由高德纳(Donald Ervin Knuth)和沃恩·普拉特在1974年构思,同年詹姆斯·H·莫里斯也独立地设计出该算法,最终三人于1977年联合发表。

在继续下面的内容之前,有必要在这里介绍下两个概念:真前缀真后缀

字符串算法之KMP(字符串匹配)_字符串

由上图所得, “真前缀”指除了自身以外,一个字符串的全部头部组合;”真后缀”指除了自身以外,一个字符串的全部尾部组合。

二、KMP字符串匹配算法

1、算法流程

(1)

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

字符串算法之KMP(字符串匹配)_next数组_02

(2)

直到主串有一个字符,与模式串的第一个字符相同为止。

字符串算法之KMP(字符串匹配)_next数组_03

(3)

接着比较主串和模式串的下一个字符,直到主串有一个字符,与模式串对应的字符不相同为止。

字符串算法之KMP(字符串匹配)_字符串_04

一个基本事实是,当空格与D不匹配时,你其实是已经知道前面六个字符是”ABCDAB”。KMP算法的想法是,设法利用这个已知信息,不要把”搜索位置”移回已经比较过的位置,而是继续把它向后移,这样就提高了效率。

(4)

怎么做到这一点呢?可以针对模式串,设置一个跳转数组int next[],这个数组是怎么计算出来的,后面再介绍,这里只要会用就可以了。

字符串算法之KMP(字符串匹配)_next数组_05

(5)

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

字符串算法之KMP(字符串匹配)_KMP_06

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

(6)

字符串算法之KMP(字符串匹配)_后缀_07

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

(7)

字符串算法之KMP(字符串匹配)_字符串_08

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

(8)

逐位比较,直到模式串的最后一位,发现完全匹配,于是搜索完成。

字符串算法之KMP(字符串匹配)_后缀_09

2、next数组是如何求出的

next数组的求解基于“真前缀”和“真后缀”,即next[i]等于P[0]…P[i - 1]最长的相同真前后缀的长度(请暂时忽视i等于0时的情况,下面会有解释)。

字符串算法之KMP(字符串匹配)_next数组_10

字符串算法之KMP(字符串匹配)_next数组_11


字符串算法之KMP(字符串匹配)_后缀_12

那么,为什么根据最长相同真前后缀的长度就可以实现在不匹配情况下的跳转呢?举个代表性的例子:假如i = 6时不匹配,此时我们是知道其位置前的字符串为ABCDAB,仔细观察这个字符串,首尾都有一个AB,既然在i = 6处的D不匹配,我们为何不直接把i = 2处的C拿过来继续比较呢,因为都有一个AB啊,而这个AB就是ABCDAB的最长相同真前后缀,其长度2正好是跳转的下标位置。

3、next数组的实现

void cal_next(string &str, vector<int> &next)
{
    const int len = str.size();
    next[0] = -1;
    int k = -1;
    int j = 0;
    while (j < len - 1)
    {
        if (k == -1 || str[j] == str[k])
        {
            ++k;
            ++j;
            next[j] = k;//表示第j个字符有k个匹配(“最大长度值” 整体向右移动一位,然后初始值赋为-1)
        }
        else
            k = next[k];//往前回溯
    }
}

(1)i和j的作用

i和j就像是两个”指针“,一前一后,通过移动它们来找到最长的相同真前后缀。

(2)if…else…语句里做了什么?

字符串算法之KMP(字符串匹配)_next数组_13

假设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],见下图:

字符串算法之KMP(字符串匹配)_next数组_14

next[j]代表[0, j - 1]区段中最长相同真前后缀的长度。如图,用左侧两个椭圆来表示这个最长相同真前后缀,即这两个椭圆代表的区段内容相同;同理,右侧也有相同的两个椭圆。所以else语句就是利用第一个椭圆和第四个椭圆内容相同来加快得到[0, i - 1]区段的相同真前后缀的长度。

j == -1意义就是为了特殊边界判断。

三、KMP算法demo

#include <iostream>
#include <string>
#include <vector>
using namespace std;

//部分匹配表
void cal_next(string &str, vector<int> &next)
{
    const int len = str.size();
    next[0] = -1;
    int k = -1;
    int j = 0;
    while (j < len - 1)
    {
        if (k == -1 || str[j] == str[k])
        {
            ++k;
            ++j;
            next[j] = k;//表示第j个字符有k个匹配(“最大长度值” 整体向右移动一位,然后初始值赋为-1)
        }
        else
            k = next[k];//往前回溯
    }
}

vector<int> KMP(string &str1, string &str2, vector<int> &next)
{
    vector<int> vec;
    cal_next(str2, next);
    int i = 0;//i是str1的下标
    int j = 0;//j是str2的下标
    int str1_size = str1.size();
    int str2_size = str2.size();
    while (i < str1_size && j < str2_size)
    {
        //如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),
        //都令i++,j++. 注意:这里判断顺序不能调换!
        if (j == -1 || str1[i] == str2[j])
        {
            ++i;
            ++j;
        }
        else
            j = next[j];//当前字符匹配失败,直接从str[j]开始比较,i的位置不变
        if (j == str2_size)//匹配成功
        {
            vec.push_back(i - j);//记录下完全匹配最开始的位置
            j = -1;//重置
        }
    }
    return vec;
}

int main(int argc, char const *argv[])
{
    vector<int> vec(20, 0);
    vector<int> vec_test;
    string str1 = "bacbababadababacambabacaddababacasdsd";
    string str2 = "ababaca";
    vec_test = KMP(str1, str2, vec);
    for (const auto v : vec_test)
        cout << v << endl;
    return 0;
}

KMP时间复杂度:O(m+n)。

四、KMP算法的应用

1、求其中出现重复的任意一个字符

先求next数组,next[j]=k,k > 0 时,就返回j,p[j]就是出现重复的字符。

字符串算法之KMP(字符串匹配)_KMP_15

2、求最长的重复子串

求最长的重复子串,就是求next[j]=k,求出k的最大值。

字符串算法之KMP(字符串匹配)_next数组_16