关于KMP算法

有些话、适合烂在心里 提交于 2020-02-07 09:26:48

KMP算法是非常经典的字符串匹配算法,而且有可能是最经典的一个。同时它也是非常典型的一种优化算法,它把原本暴力法O(mn)的最坏复杂度降低到了O(m+n)(虽然实际上暴力法的执行复杂度期望依然是线性的),其思想非常具有典型性和可借鉴性,值得好好学习。

1 基本思想

KMP算法的基本思想是,借助一个预先计算好的数组pi,在匹配了一定数量的模式的情况下,遇到不匹配的字符时,不像暴力法那样将待匹配的文本下标从上一次匹配的地方向后移动一个位置,并将已匹配的模式个数清零,而是利用已经匹配部分的信息,迅速跳过那些不可能匹配成功的文本开头,减少了暴力法中一些不必要的盲目搜索。

比如,对于模式

0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15 16 17 18 19

a  b  a  b  a  b  c  a  b  c  a  b  c  d  b  c  d  b  c  d

如果从文本中的某一个字符开始,匹配到6号字符c时,出现了不匹配,此时已经匹配了6个字符。如果再从文本中下一个字符b开始,从模式的第0号字符开始继续尝试,就是暴力法的思想。而KMP算法比暴力法先进的地方就在于,它利用模式已匹配部分的信息,跳过那些已遍历的文本中不可能匹配的开头,直接进入有可能会产生匹配的文本开头,并更新相应的已匹配字符长度。比如,匹配到6号字符时,出现不匹配,此时已匹配的部分为ababab。KMP算法会跳到原文本中开头的后面第二个字符开始匹配,因为abab也匹配了模式开头的一部分。同时算法将已匹配长度更新为4,而文本的待匹配字符保持不变不后退。

为了方便起见,先对必须用到的一些符号做一个定义:

  pi[1..n]   前缀数组

  P          模式,需要匹配的字串

  P(k)       P的k长度前缀

理解KMP算法的关键在于如何理解前缀函数pi。pi中的某个元素pi[k]是长度为k的P的前缀P(k)的同时为它自己前缀的最长真后缀的长度。这个说法有点拗口,细说起来,k是P的一个前缀的长度,k定义了一个P的前缀P(k)pi[k]是P(k)一个真后缀的长度值,它定义了P(k)的一个真后缀。真后缀的意思是不等于它自己的后缀(即长度小于它本身)。这个P(k)的真后缀必须满足以下条件:

(1) 它必须是P(k)的前缀(这意味它也是P的前缀)

(2) 它必须是满足条件1的所有真后缀中最长的

P(k)代表在算法出现不匹配时已匹配的那部分。由于每次匹配从模式的头开始,故从匹配的开头到最后一个成功匹配的位置所组成的子串为模式P的前缀。求取真后缀的意思是说,从当前匹配过程的开头后面的某一个位置开始,到最后一个成功匹配的字符所组成的字符串,是当前已匹配模式的一个真后缀(因为当出现不匹配的时候,下一次匹配过程的开头必须从当前的开头向后退)。同时为前缀的意思是说,所要跳到的开头和最后一个成功匹配的字符所组成的子串(即这个真后缀),必须是当前已匹配模式的前缀才有意义,因为如果它不是模式的前缀,那么就说明这段子串无论后面的字符是什么都不可能匹配模式。最长的意思是说,不要跳过那些满足条件1的后缀。

这里给出一个KMP算法实现:

kmp.h

#ifndef _KMP_TEMPLATE_
#define _KMP_TEMPLATE_

#include <vector>
#include <string.h>

static const int NULL_CHAR = 0;

using std::vector;

// 数组P下标从0开始
// pi[k]代表长度为k的P的前缀P[0..k-1]的最长同时为它自己前缀的真后缀的长度
// 在这里,将pi[0]定义为无意义(-1),方便指示循环终止条件
// pi[1]为0,因为P[0..0]的真后缀为空串
template<typename C>
void KMP_PiBuild(C *P, int *pi, int p_len) {
    pi[0] = -1;
    pi[1] = 0;

    for (int i = 2; i - 1 <= p_len; i ++) {
        pi[i] = 0;
        
        for (int k = pi[i - 1]; k != -1; k = pi[k])
            if (P[k] == P[i - 1]) {
                pi[i] = k + 1;
                break;
            }
    }
}

template<typename C>
void KMP_StringMatch(C *text, C *P, int p_len, vector<int> &match) {
    match.clear();

    int *pi = new int[p_len + 1];
    KMP_PiBuild(P, pi, p_len);

    for (int i = 0, m = 0; text[i] != NULL_CHAR;) {
        if (text[i] == P[m]) {
            m ++;
            i ++;
        } else {
            if (m == 0)
                i ++;
            else
                m = pi[m];
        }

        if (m == p_len) {
            match.push_back(i - p_len);
            m = pi[m];
        }
    }
}

#endif // _KMP_TEMPLATE_

main.cpp

#include <iostream>
#include <fstream>
#include <string.h>
#include <vector>
#include "kmp.h"

using namespace std;

int main() {
    ifstream fin("in.txt");
    if (!fin.good()) {
        cout << "! file open failed" << endl;
        return 0;
    }

    char text[5000], P[1000];

    fin.getline(text, 5000);
    fin.getline(P, 1000);

    vector<int> match;
    int len = strlen(P);
    KMP_StringMatch(text, P, len, match);

    if (match.size() == 0) {
        cout << "no match" << endl;
    } else {
        for (int i = 0; i < match.size(); i ++)
            cout << "match index:  " << match[i] << endl;
    }
}

2 前缀函数计算的正确性

对于前缀函数计算过程的正确性证明,算法导论给出的过程由于大量地使用数学符号而显得过于晦涩。想了一段时间,现在给出一个关于前缀函数的较易理解的证明。

首先证明,循环

    for (int k = pi[i - 1]; k != -1; k = pi[k]) {        ....    }

遍历出的序列,是所有同时是其前缀的P(i-1)的真后缀按长度从大到小排列。因为P(pi[pi[i-1]])作为P(pi[i-1])的后缀同时也是P(i-1)的后缀(后缀操作符满足传递性),那么一路遍历过来得到的全部序列就是满足条件的P(i-1)的后缀且长度从大到小排列。用反证法,假设遍历出的后缀PF1, PF2, PF3 ... PFn(这里,为了易于理解长度按从小到大排列)不包括所有。那么,某个PF'可以插入到序列中PFi和PFi+1两个元素的中间。由此可推出PFi+1的最长满足条件的真后缀就不是PFi而是PF'了,显然length(PF')大于length(PFi)。这与pi函数的定义冲突。所以循环得出的后缀PF1, PF2, PF3 ... PFn是所有满足条件的真后缀。

接下来证明,循环

    for (int k = pi[i - 1]; k != -1; k = pi[k])
        if (P[k] == P[i - 1]) {
            pi[i] = k + 1;
            break;
        }

在已知pi[1]..pi[i-1]的情况下,正确求出pi[i]。

上一段循环做的事情是:对P(i-1)的满足条件的真后缀,按长度从大到小遍历,并判断每个真后缀作为P的前缀其下一个字符与P[i-1]是否相等,并将第一个满足条件的这样的P(i-1)的真后缀PRF1与P[i-1]相连视为pi[i]对应的真后缀,其长度为PRF1的长度加1(这段虽然说起来拗口但是找个例子列一下其实很清晰)。

要证明其正确性,也可以使用反证法,假设某个P(i)满足条件的最长真后缀没有被列举,从而推出矛盾,即可得证。需要说明的是,在开头,将pi[0]设为一个无意义的值-1,是为了k=0作为循环的最后一个元素。k=0,代表当前前缀为空串,说明当前列举的P(i)的真后缀为由P[i-1]这个元素单独组成的串。这样能保证列举出所有情况。

由于起始条件为pi[1] = 0,初始条件成立,所以前缀函数计算的正确性得证。

3 匹配过程的正确性

其实第1节介绍匹配的基本思想时已经基本上证明了算法的正确性。证明其正确性的关键在于,每次出现不匹配或者匹配成功时,跳过的那些后面的开头是否真的是应该被忽略的。这里使用反证法,假设某个跳过的开头最终匹配了模式,那从这个开头到当前最后一个成功匹配的位置所组成的真后缀必定也是一个前缀。由第2节的证明可知,回退的过程穷举出了所有的符合条件的真后缀,而假设又推出符合条件的真后缀一个真后缀未被列举,这与事实矛盾,因此假设不成立。即,算法实际上穷举出了所有可能匹配的开头,正确性得证。

 

参考资料:

[1] 算法导论 32.4

[2] Jeffrey J. McConnell, Analysis of Algorithm - An Active Learning Approach, 5.1.2

另外关于KMP和普通暴力法的效率比较,[2]中说实际上KMP仅仅比暴力法好一点,而[1]中也给出了暴力法的执行复杂度的期望,为线性。貌似暴力法是可以忍受的。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!