1. 程式人生 > >KMP Algorithm 字串匹配演算法KMP小結

KMP Algorithm 字串匹配演算法KMP小結

這篇小結主要是參考這篇帖子從頭到尾徹底理解KMP,不得不佩服原作者,寫的真是太詳盡了,讓博主產生了一種讀學術論文的錯覺。後來發現原作者是寫書的,不由得更加敬佩了。博主不才,嘗試著簡化一些原帖子的內容,希望能更通俗易懂一些。博主的帖子一貫秉持通俗易懂的風格,使得非CS專業的人士也能讀懂,至少博主自己是這麼認為的-.-|||

KMP演算法,全稱Knuth-Morris-Pratt演算法,根據三個作者Donald Knuth、Vaughan Pratt、James H. Morris的姓氏的首字母拼接而成的。是一種字串匹配的演算法,用於在一個文字串S中查詢模式串P的位置。在講解KMP演算法之前,我們先來看暴力破解法是如何運作的,假如我們有一個文字串S和一個模式串P如下:

文字串: BBC_ABCDAB_ABCDABCDABDE

模式串: ABCDABD

那麼我們首先來找模式串的第一個字母A在文字串出現的位置:

BBC_ABCDAB_ABCDABCDABDE
    ABCDABD

找到後,再來一一比較後面的字母,比較到模式串的D的位置,發現不匹配:

BBC_ABCDAB_ABCDABCDABDE
    ABCDABD

暴力破解的下一步是將模式串後移一步,繼續來匹配開頭的A

BBC_ABCDAB_ABCDABCDABDE
     ABCDABD

直到找到下一個A,然後開始往後一一比較:

BBC_ABCDA
B_ABCDABCDABDE ABCDABD

後面的步驟就不一一列舉了,都是按這種方法來查詢的,這種演算法十分的不高效,時間複雜度是O(m*n),其中m和n分別是文字串和模式串的長度。當m和n都很大的時候,運算速度就會很慢,那麼此時就有請KMP演算法閃亮登場!!

我們再回到暴力破解方法中的一一比較後面的字母那一步,比較到模式串的D的位置,發現不匹配:

BBC_ABCDAB_ABCDABCDABDE
    ABCDABD

此時KMP演算法並不是將模式串向右移動一位,而是向後移動四位,直接到這一步:

BBC_ABCDAB_ABCDABCDABDE
        AB
CDABD

這樣文字串的遍歷位置並不會移回去,而是'_'直接跟'C'匹配,是不是很神奇,它怎麼知道要跟模式串上的哪個字元相比呢,實際上是從next陣列中查的值,再講解next陣列之前,我們先來講一下最大字首字尾公共元素。

所謂最大字首字尾公共元素,就是模式串中最大且相等的字首和字尾,比如aba,有長度為1的相同字首字尾a,再比如,字串acdac有長度為2的相同字首字尾ac,那麼我們可以寫出ABCDABD的每一位上的字首字尾長度:

A   B   C   D   A   B   D
0   0   0   0   1   2   0

由於模式串的尾部可能有重複的字元,所以我們可以得出一個重要的結論:失配時,模式串向右移動的距離 = 已匹配字元數 - 失配字元的上一位字元所對應的最大長度值

我們之前是在字元'D'處失配的,上一位字元是'B',對應的最大長度是2,此時已經成功匹配了6個字元,那麼我們就將模式串向右移動6-2=4位,並繼續匹配即可。

BBC_ABCDAB_ABCDABCDABDE
        ABCDABD

此時我們發現'_'和'C'不匹配,那麼'C'的上一個字元'B'的最大長度為0,此時已經匹配了2個字元,所以模式串向右移動2-0=2位繼續匹配,得到:

BBC_ABCDAB_ABCDABCDABDE
          ABCDABD

此時發現'_'和'A'不匹配,'A'已經是第一個了,不需要查表了,此時將模式串向右移動一位:

BBC_ABCDAB_ABCDABCDABDE
           ABCDABD

發現此時模式串的首字母'A'匹配上了,然後就按順序一路往下匹配,直到最後一個'D'和'C'失配:

BBC_ABCDAB_ABCDABCDABDE
           ABCDABD

我們進行和之前相似的操作,上一位字元是'B',對應的最大長度是2,此時已經成功匹配了6個字元,那麼我們就將模式串向右移動6-2=4位,並繼續匹配即可:

BBC_ABCDAB_ABCDABCDABDE
               ABCDABD

移動後發現模式串的首字母'A'匹配上了,然後就按順序一路往下匹配,最終完成模式串的匹配:

BBC_ABCDAB_ABCDABCDABDE
               ABCDABD

我們發現文字串中的遍歷位置始終沒有退後,一直都是在向前的,這樣使得其比暴力破解法節省了大量的時間,其時間複雜度為O(m+n),簡直碉堡了。讀到這裡是不是有疑問,怎麼演算法都結束了,還沒next陣列什麼事呢,其實next陣列和這裡的最大字首字尾公共元素長度陣列是有關聯的,上面的方法在失配時,要找失配字元前一個字元的最大字首字尾公共元素長度值,那麼如果我們將最大字首字尾公共元素長度陣列整體右移一位,形成next陣列,如下所示:

A   B   C   D   A   B   D
0   0   0   0   1   2   0
-1  0   0   0   0   1   2

上面的中間那行是之前的最大字首字尾公共元素長度陣列,我們將其整體右移一位,多出的位置補上一個-1,就變成了下面的一行。那麼我們此時就直接找失配字元的next值就行了。於是我們就得到了新的結論:失配時,模式串向右移動的距離 = 失配字元所在位置 - 失配字元對應的next值。

讀到這裡是不是對KMP演算法的發明者佩服的五體投地,彆著急,還剩最後一部分,就是用程式碼來遞推計算next陣列。對於next的陣列的計算,可以採用遞推來算。根據上面的分析,我們知道如果模式串當前位置j之前有k個相同的字首字尾,那麼可以表示為next[j] = k,所以如果當模式串的p[j]跟文字串失配後,我們可以用next[j]處的字元繼續和文字串匹配,相當於模式串向右移動了j - next[j]位。那麼問題就來了,如何求出next[j+1]的值呢,我們還是來看例子吧:

模式串:    A  B  C  D  A  B  C  E
next值:   -1  0  0  0  0  1  2  ?  
索引:             k           j

如上所示,模式串為"ABCDABCE",且j=6, k = 2,我們有next[j] = k,這表示j位置上的字元C之前的最大前後綴長度為2,即AB。現在我們要求next[j+1]的值,因為p[k] == p[j],所以next[j+1] = next[j] + 1 = k + 1 = 3。即字母E之前的最大前後綴長度為3,即ABC。

那麼我們再來看p[k] != p[j]的情況下怎麼處理,還是來看例子:

模式串:    A  B  C  D  A  B  D  E
next值:   -1  0  0  0  0  1  2  ?  
索引:             k           j

這個例子把上面例子中的第二個'C'換成了'D',所以字元'E'前面的相同字尾就不再是3了,所以我們希望在k前面找出個k'位置,使得p[k']為D,這樣next[j+1] = k' +1,但是這個例子中不存在這樣的'D',所以next[j+1] = 0。我們看一個能在字首中找到'D'的例子:

模式串:    D  A  B  C  D  A  B  D  E
next值:   -1  0  0  0  0  1  2  3  ?  
索引:                k           j

這個例子上面例子的最前面加上了個'D',此時j = 7, k = 3了,我們有next[j] = k,這表示j位置上的字元3之前的最大前後綴長度為3,即DAB。要求next[j+1]的值,我們發現此時p[k] != p[j],然後我們讓k = next[k] = 0,此時p[0]是D,那麼next[j+1] = k + 1 = 1了,這說明字母E之前的最大前後綴長度為1,即D。綜上所述,我們可以寫出next的生成函式如下:

vector<int> getNext(string p) {
    int n = p.size(), k = -1, j = 0;
    vector<int> next(n, -1);
    while (j < n - 1) {
        if (k == -1 || p[j] == p[k]) {
            ++k; ++j;
            next[j] = k;
        } else {
            k = next[k];
        }
    }
    return next;
}

上面這種計算next陣列的方式可以進一步的優化,可以優化的原因是因為上面的方法存在一個小小的問題,如果用這種方法求模式串ABAB,會得到next陣列為[-1 0 0 1],我們用這個模式串去匹配ABACABABC:

ABACABABC
ABAB

我們會發現C和B失配,那麼根據上面的規則,我們要向右移動j - next[j] = 3 - 1 = 2位,於是有:

ABACABABC
  ABAB

我們右移兩位後發現又是C和B失配了,而我們在上一步中,已知p[3] = B, s[3] = C,就已經失配了,讓p[next[3]] = p[1] = B再去和s[3]比較,肯定還是失配。原因是當p[j] != s[i]時,下一步要用p[next[j]]和s[i]去匹配,而如果p[j] == p[next[j]]了,再用p[next[j]]和s[i]去匹配必然會失配。所以我們要避免出現p[j] == p[next[j]]的情況,一旦出現了這種情況,我們可以再次遞迴,next[j] = next[next[j]],修改後的程式碼如下:

vector<int> getNext(string p) {
    int n = p.size(), k = -1, j = 0;
    vector<int> next(n, -1);
    while (j < n - 1) {
        if (k == -1 || p[j] == p[k]) {
            ++k; ++j;
            next[j] = (p[j] != p[k]) ? k : next[k];
        } else {
            k = next[k];
        }
    }
    return next;
}

講到這裡,KMP演算法的內容就完全講完了,原帖子中還有兩個擴充套件方法,這裡就不講了,感覺能把上述內容吃透就很不容易了,下面貼上完整的KMP的程式碼僅供參考:

#include <iostream>
#include <vector>

using namespace std;

vector<int> getNext(string p) {
    int n = p.size(), k = -1, j = 0;
    vector<int> next(n, -1);
    while (j < n - 1) {
        if (k == -1 || p[j] == p[k]) {
            ++k; ++j;
            next[j] = (p[j] != p[k]) ? k : next[k];
        } else {
            k = next[k];
        }
    }
    return next;
}

int kmp(string s, string p) {
    int m = s.size(), n = p.size(), i = 0, j = 0;
    vector<int> next = getNext(p);
    while (i < m && j < n) {
        if (j == - 1 || s[i] == p[j]) {
            ++i; ++j;
        } else {
            j = next[j];
        }
    }
    return (j == n) ? i - j : -1;
}

int main() {
    cout << kmp("BBC_ABCDAB_ABCDABCDABDE", "ABCDABD") << endl; // Output: 15
}

參考資料: