字串匹配基礎下——KMP 演算法

seniusen發表於2018-12-11

在所有的字串匹配演算法中,KMP 演算法是最知名的,實際上,它和 BM 演算法的本質是一樣的。

1. KMP 演算法基本原理

KMP 演算法是根據三位作者(D.E.Knuth,J.H.Morris 和 V.R.Pratt)的名字來命名的,演算法的全稱是 Knuth Morris Pratt 演算法,簡稱為 KMP 演算法。

KMP 演算法的核心思想和 BM 演算法非常相近,就是在遇到不可匹配字元的時候,我們希望能將模式串向後多滑動幾位,跳過那些肯定不會匹配的情況。

在 KMP 演算法中,我們從前向後對模式串和主串進行對比,不能匹配的字元仍然叫作壞字元,而前面已經匹配的字串叫作好字首

字串匹配基礎下——KMP 演算法

那遇到壞字元時,我們怎麼決定模式串該向後移動幾位呢?

我們需要在好字首的所有字尾子串中,找到一個最長的可以和好字首的字首子串匹配的子串。假設這個最長的可匹配的字首子串為 {v},長度為 k,而壞字元在主串中對應的位置為 i,在模式串中對應的位置為 j,那麼我們就需要將模式串後移 j-k 位,也就相當於把 j 更新為 k(注意下圖的錯誤), i 不變,然後繼續比較。

字串匹配基礎下——KMP 演算法

我們把好字尾的所有字尾子串中,最長的可以和好字首的字首子串匹配的子串,叫作最長可匹配字尾子串,對應的字首子串,叫作最長可匹配字首子串

字串匹配基礎下——KMP 演算法

類似 BM 演算法,我們也可以先對模式串進行預處理,定義一個陣列來儲存模式串中每個字首(這些字首都有可能是好字首)的最長可匹配字首子串的結尾字元下標。我們把這個陣列定義為 next 陣列,很多書中還給這個陣列起了一個名字,叫失效函式(failure function)。

陣列的下標是每個字首結尾字元的下標,陣列的值是這個字首的最長可以匹配的字首子串的結尾字元下標。

字串匹配基礎下——KMP 演算法

有了 next 陣列,KMP 演算法就很容易實現,下面我們先給出一個程式的框架,假設 next 陣列已經計算好了。

int KMP(char str1[], int n, char str2[], int m)
{
    int next[m];
    GenerateNext(str2, next, m);

    int j = 0;

    for (int i = 0; i < n; i++)
    {
        while (j > 0 && str1[i] != str2[j])
        {
            j = next[j-1] + 1; // j 更新為最長可匹配字首子串的長度 k
        }
        if (str1[i] == str2[j]) j++;
        if (j == m) return i - m + 1;
    }

    return -1;
}
複製程式碼

2. 失效函式計算方法

我們可以用非常笨拙的方法來計算 next 陣列。比如,如果要計算下面這個模式串 b 的 next[4],我們就把 b[0, 4] 的所有字尾子串列舉出來,逐個看是否能和模式串的字首子串匹配。這種方法雖然也可以計算出 next 陣列,但是效率非常低。

字串匹配基礎下——KMP 演算法

我們按照下標從小到大,依次計算 next 陣列的值。當我們要計算 next[i] 的時候,前面的 next[0] 到 next[i-1] 都已經計算出來了,我們可以利用前面的值來快速推匯出 next[i] 的值。

如果 next[i-1] = k-1,也就是說,子串 b[0, k-1] 是 b[0, i-1] 的最長可匹配字首子串。如果子串 b[0, k-1] 的下一個字元 b[k],與 b[0, i-1] 的下一個字元 b[i] 匹配,那麼子串 b[0, k] 也就是 b[0, i] 的最長可匹配字首子串。所以 next[i] = k。但是,如果子串 b[0, k-1] 的下一個字元 b[k] 與 b[0, i-1] 的下一個字元 b[i] 不匹配,那就不能簡單地通過 next[i-1] 來得到 next[i] 了。

字串匹配基礎下——KMP 演算法

我們假設 b[0, i] 的最長可匹配字尾子串是 b[r, i]。如果我們把最後一個字元去掉,那 b[r, i-1] 肯定是 b[0, i-1] 的可匹配字尾子串,但不一定是最長可匹配子串。

所以,既然 b[0, i-1] 最長可匹配字尾子串對應的模式串的字首子串的下一個字元並不等於 b[i],那麼我們就可以考察 b[0, i-1] 的次長可匹配字尾子串 b[x, i-1] 對應的可匹配字首子串 b[0, i-1-x] 的下一個字元 b[i-x] 是否等於 b[i]。如果相等,那麼 b[x, i] 就是 b[0, i] 的最長可匹配字尾子串。

字串匹配基礎下——KMP 演算法

可是,如何求得 b[0, i-1] 的次長可匹配字尾子串呢?次長可匹配字尾子串肯定被包含在最長可匹配字尾子串中,而最長可匹配字尾子串又對應最長可匹配字首子串 b[0, y]。於是,查詢 b[0, i-1] 的次長可匹配字尾子串,這個問題就變成了,查詢 b[0, y] 的最長可匹配字尾子串。

字串匹配基礎下——KMP 演算法

因此,我們可以考察所有的 b[0, i-1] 的可匹配字尾子串 b[y, i-1],直到找到一個可匹配的字尾子串,它對應的字首子串的下一個字元等於 b[i],那這個 b[y, i] 就是 b[0, i] 的最長可匹配字尾子串。

void GenerateNext(char str[], int next[], int m)
{
    next[0] = -1;
    int k = -1;

    for (int i = 1; i < m; i++)
    {
        while (k != -1 && str[k+1] != str[i])
        {
            k = next[k];
        }
        if (str[k+1] == str[i]) k++;
        next[i] = k;
    }
}
複製程式碼

3. KMP 演算法複雜度分析

空間複雜度很容易分析,KMP 演算法只用到了一個額外的陣列 next,其大小與模式串長度 m 相同,因此空間複雜度為 O(m)。

KMP 演算法包括兩部分,第一部分是構建 next 陣列,第二部分是藉助 next 陣列進行匹配。

先看第一部分,這部分程式碼由兩個迴圈組成。我們觀察變數 i 和 k 的值,i 從 1 增長到 m,而 k 並不是每次在 for 迴圈裡都增加,k 的值不可能大於 m。在 while 迴圈裡, k = next[k],其值是在減小的,總的減小次數也肯定小於 m。所以 next 陣列計算的時間複雜度為 O(m)。

再看第二部分,方法是類似的。i 從 0 增長到 n,而 j 並不是每次在 for 迴圈裡都增加,j 的值不可能大於 n。在 while 迴圈裡, j = next[j-1] + 1,其值是在減小的,總的減小次數也肯定小於 n。所以匹配的時間複雜度為 O(n)。

綜上所述,KMP 演算法總的時間複雜度為 O(m+n)。

參考資料-極客時間專欄《資料結構與演算法之美》

獲取更多精彩,請關注「seniusen」!

字串匹配基礎下——KMP 演算法

相關文章