1. 程式人生 > >“淺析kmp演算法”

“淺析kmp演算法”

首先,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陣列表示什麼呢?看下面的表格:

搜尋詞 A B C D A B D
next -1 0 0 0 0 1 2

和上面的部分匹配表對比一下,你會發現,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();

    }
}

參考資料