首页 > 代码库 > “浅析kmp算法”

“浅析kmp算法”

“浅析kmp算法”

 9月 16 2014 更新日期:9月 16 2014

文章目录
  1. 1. 暴力匹配:
  2. 2. 真前缀和真后缀,部分匹配值
  3. 3. 如何使用部分匹配值呢?
  4. 4. 寻找部分匹配值
  5. 5. 拓展
    1. 5.1. 最小覆盖字串
  6. 6. 参考资料

首先,KMP是一个字符串匹配算法,什么是字符串匹配呢?简单地说,有一个字符串“BBC ABCDAB ABCDABCDABDE”,我想知道这个字符串里面是否有“ABCDABD”;我想,你的脑海中马上就浮现了一个简单的暴力算法,是的,它也有名字,叫做暴力匹配,就是从头开始进行匹配,如果不行的话,就从主字符串的下一个继续。看下面的图结合文字会更清晰些:


暴力匹配:

1

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

2
因为B与A不匹配,搜索词再往后移。

3
就这样,直到字符串有一个字符,与搜索词的第一个字符相同。

4
接着比较字符串和搜索词的下一个字符,还是相同。

5
直到字符串有一个字符,与搜索词对应的字符不相同为止。

6
这时,最自然的反应是,将搜索词整个后移一位,再从头逐个比较。

虽然这样做可行,但是你有没有想过这样的效率很差,因为你要把”搜索位置”移到已经比较过的位置,重比一遍。


真前缀和真后缀,部分匹配值

上面说了,暴力匹配的效率是非常低下的,但是我们有什么办法让效率提升呢?让我们先来了解三个概念,“真前缀”和“真后缀”;这个比较好理解,看下面就可以理解了。

  - "A"的真前缀和真后缀都为空集,共有元素的长度为0;
  - "AB"的真前缀为[A],真后缀为[B],共有元素的长度为0;
  - "ABC"的真前缀为[A, AB],真后缀为[BC, C],共有元素的长度0;
  - "ABCD"的真前缀为[A, AB, ABC],真后缀为[BCD, CD, D],共有元素的长度为0;
  - "ABCDA"的真前缀为[A, AB, ABC, ABCD],真后缀为[BCDA, CDA, DA, A],共有元素为"A",长度为1;
  - "ABCDAB"的真前缀为[A, AB, ABC, ABCD, ABCDA],真后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为"AB",长度为2;
  - "ABCDABD"的真前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],真后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0

从上面的例子可以体会到吧,真前缀就是从字符串第一个字符开始的所有字符串,但是不包括它自身;对于真后缀同理。

那么什么是部分匹配值呢?注意到上面提到的共有元素的长度了吗?部分匹配值的意思就是当前串的真前缀和真后缀中字符串相同的最大长度。“AB”的真前缀和真后缀中没有相同的,所以部分匹配值是0;“ABAB”的部分匹配值是2,因为真前缀中的“AB”和真后缀中的“AB”匹配,长度为2,所以部分匹配值是2。


如何使用部分匹配值呢?

让我们来看一些前面的例子,在“BBC ABCDAB ABCDABCDABDE”中匹配“ABCDABD”。

首先看一下ABCDABD的部分匹配表:

部分匹配表中的每一个值,对应的都是每一个字符为结尾的子串的部分匹配值。像“AB”,部分匹配值是0,所以对应的表里的值是0;“ABCDAB”,部分匹配值是2,所以对应的表里的值是2;

那么我们如何来用它呢?上面的暴力匹配我们说了,当ABCDABD的最后一个D和“ ”不匹配时,暴力匹配方式只会把ABCDABD右移一位,然后继续匹配。我们前面也说了,这样的方式没有充分利用一些信息。

那么我们该如何利用上面的信息呢?

比如前面我们说的情况,看下面的图:

前面的这个时候,我们只是让“ABCDABD”右移一位。但是有没有发现,其实前面已经匹配上的“ABCDAB”这一部分的信息都知道,所以我们知道“ABCDAB”右移一位依然无法匹配,这个时候,我们只需要考虑ABCDAB的真前缀和真后缀匹配最多,如果我们知道这个真前缀和真后缀,那么我们就知道如何移动了。只需要移动至真前缀和真后缀部分匹配即可。而这里就是需要考虑部分匹配值了。

为什么是这样呢?我们可以简单地证明一下。我们知道“ABCDAB”的部分匹配值,2,也就是说真前缀和真后缀最大的匹配长度是“AB”这一部分。我们只需要将“ABCDAB”的前缀的“AB”移动至和后缀的“AB”匹配。假设我们不移动到它们匹配,在前面部分也可能匹配,那么它们的部分匹配值应该更大,但是这里最大就是2了。所以,假设不成立。所以我们只需要将最长的 真前缀和真后缀 匹配即可。

匹配的时候,我们可以利用部分匹配值。

移动位数 = 已匹配的字符数 - 对应的部分匹配值

对于“ABCDAB”,部分匹配值2,6-2=4;所以将搜索词向后移动4位即可。

因为空格与C不匹配,搜索词还要继续往后移。这时,已匹配的字符数为2(”AB”),对应的”部分匹配值”为0。所以,移动位数 = 2 - 0,结果为 2,于是将搜索词向后移2位。

因为空格与A不匹配,继续后移一位。

逐位比较,直到发现C与D不匹配。于是,移动位数 = 6 - 2,继续将搜索词向后移动4位。

逐位比较,直到搜索词的最后一位,发现完全匹配,于是搜索完成。如果还要继续搜索(即找出全部匹配),移动位数 = 7 - 0,再将搜索词向后移动7位,这里就不再重复了。


寻找部分匹配值

现在的问题是,我们如何来寻求这个部分匹配值,在上面的过程中,我们可以发现,只要我们知道部分匹配值了,就能够让匹配的速度加快。而对于部分匹配值,我们关心的其实就是那个搜索词。所以从搜索词入手。

我们定义这样一个数组next[],T标示匹配字符串,P标示搜索词。

那么next数组表示什么呢?看下面的表格:

搜索词ABCDABD
next-1000012

和上面的部分匹配表对比一下,你会发现,next数组就是 部分匹配值 整体向右移动了一位, 然后初始值赋值为 -1。

其实next数组也有含义,next[j]的值表示,当P[j] != T[i]时,指针 j 的下一步移动位置。

当j=0时不匹配怎么办?这个时候next[j]= -1;表示T需要左移1位。

所以当 P[j] != T[i] 时, 另 j = next[j] ,然后继续匹配。

当 P[j] == T[i] 时,i和j 分别都前进一位。

那么next数组该怎么求解呢?

当P[k] == P[i] 时,有 next[j+1] = next[j] +1;

当P[k] != P[i] 时,有 k = next[k]; 然后继续匹配。

如果 k == -1; 那么这个时候,表示P的第0字符都和现在的第i个字符不匹配,则 next[i] = 0; k++, i++;

所以,综上,便有了下面的程序。下面的getNext是获得next数组,KMP是进行匹配,下面的程序是poj3461 的示例程序。

import  java.util.Scanner;

public class Main{

    public int[] getNext(String P){
        int[] next = new int[P.length()];  // next 数组表示的是当 P[i]和P[k]不匹配时,k应该跳转到哪一个位置
                                            //这里的i时后缀指针,  k是前缀指针

        next[0]=-1;  // 因为开头的比较特殊,如果它不匹配,那么移动的应该是T,T应该左移,-1标示T左移

        int i=0,k=-1;

        while(i < P.length()-1)
        {
            if(k<0 || P.charAt(i) == P.charAt(k))
            {
                next[++i] = ++k;
            }else
                k = next[k];
        }

        return next;

    }

    public int KMP(String T, String P){

        int res=0;

        int[] next = getNext(P);

        int i=0,j=0;

        while(true)
        {

            if(i >= T.length())
                break;
            if( j==-1 || T.charAt(i) == P.charAt(j))
            {
                j++;
                if(j == P.length())
                {
                    res++;
                    j = next[j-1];
                }else
                    i++;
            }else
                j = next[j];

        }

        return res;
    }


    public void run(){
        Scanner scan = new Scanner(System.in);
        int n = scan.nextInt();
        scan.nextLine();
        while(n>0){
            String P = scan.nextLine();
            String T = scan.nextLine();

            System.out.println(KMP(T,P));

            n--;
        }

    }

    public static  void main(String args[]){

        new Main().run();

    }
}

拓展

最小覆盖字串

最小覆盖子串(串尾多一小段时,用前缀覆盖)长度为n-next[n](n-pre[n]),n为串长。

证明分两部分:

1-长为n-next[n]的前缀必为覆盖子串。

当next[n]<n-next[n]时,如图a,长为next[n]的前缀A与长为next[n]的后缀B相等,故长为n-next[n]的前缀C必覆盖后缀B;

当next[n]>n-next[n]时,如图b,将原串X向后移n-next[n]个单位得到Y串,根据next的定义,知长为next[n]的后缀串A与长为前缀串B相等,X串中的长为n-next[n]的前缀C与Y串中的前缀D相等,而X串中的串E又与Y串中的D相等……可见X串中的长为n-next[n]的前缀C可覆盖全串。

2-长为n-next[n]的前缀是最短的。

如图c,串A是长为n-next[n]的前缀,串B是长为next[n]的后缀,假设存在长度小于n-next[n]的前缀C能覆盖全串,则将原串X截去前面一段C,得到新串Y,则Y必与原串长度大于next[n]的前缀相等,与next数组的定义(使str[1..i]前k个字母与后k个字母相等的最大k值。)矛盾。得证!有人问,为什么Y与原串长大于next[n]的前缀相等?由假设知原串的构成必为CCC……E(E为C的前缀),串Y的构成必为CC……E(比原串少一个C),懂了吧!

一个字符串A(1 <= |A| <= 1000000)可以写成某一个子串B重复N次所得,记为A = B^N,求最大的N。

算法分析:

令L = |A|,容易发现,用KMP自匹配后L - p[L]即得到最小覆盖子串的长度。
下面我们要证明一个问题:一个字符串的覆盖子串长度,一定是它的最小覆盖子串长度的倍数。
设最小覆盖子串长度d整除L, 假设存在u > d满足u整除L且d不整除u。
易得,Ai = A(i + d),Ai = A(i + u),则A(i + d) = A(i + u),即Ai = A(i + u - d),不断进行可得到A_i = A(i + u - kd)(k为正整数)。
因为d不整除u,那么必然存在k使得0 < u - kd < d,与d是最小循环子串长度矛盾。
所以,最小覆盖子串长度若为L的约数则得解否则输出1。时间复杂度O(L)。

最小覆盖字串的例题 poj2406 , 代码可以参考以下:

import  java.util.Scanner;

public class Main{

    public int getNext(String P){
        int[] next = new int[P.length()+10];  // next 数组表示的是当 P[i]和P[k]不匹配时,k应该跳转到哪一个位置
                                            //这里的i时后缀指针,  k是前缀指针
        next[0]=-1;  // 因为开头的比较特殊,如果它不匹配,那么移动的应该是T,T应该左移,-1标示T左移

        int i=0,k=-1;

        while(i < P.length())
        {
            if(k<0 || P.charAt(i) == P.charAt(k))
            {
                next[++i] = ++k;
            }else
                k = next[k];
        }

        return P.length()-next[P.length()];
    }

    public void run(){
        Scanner scan = new Scanner(System.in);
        while(scan.hasNext()){
            String P = scan.nextLine();

            if(P.charAt(0)==‘.‘)
                break;

            int t = getNext(P);
            int len = P.length();

            if(len%t == 0)
            {
                System.out.println(len/t);
            }else
                System.out.println(1);
        }

    }

    public static  void main(String args[]){

        new Main().run();

    }
}

参考资料

  • The Knuth-Morris-Pratt Algorithm in my own words : [推荐]

  • 字符串匹配的KMP算法 : [推荐]

  • 从头到尾彻底理解KMP : [推荐]

  • 详解KMP算法

  • Knuth–Morris–Pratt algorithm [推荐]

  • KMP与最小覆盖子串

“浅析kmp算法”