[資料結構]KMP演算法(含next陣列詳解)

Amαdeus發表於2023-01-25

字串匹配問題

給定一個字串 s 和一個要匹配的模式串 p。模式串 p 有可能在 s 中多次出現,請求出模式串 ps 中所有出現的起始位置。

暴力匹配演算法 BF

演算法思路

在面對字串匹配問題時,很容易想到暴力求解。字串匹配的暴力演算法思路很簡單,即在 s 中列舉起點 i,對於每個起點匹配字串 p
大致步驟為:
(1) 列舉起點i,定義一個狀態值flag = true 以及 k = i,j = 0;
(2) 如果 s[k] == p[j],就繼續k++, j++; 如果 s[k] != p[j],就將flag置flase,並break;
(3) 對於每個起點 i ,如果flag = true,就輸出位置 i 。

暴力演算法程式碼

這裡輸入字串下標從1開始,與後文相統一。

#include<iostream>
#include<cstdio>
using namespace std;

static const int N = 1000010, M = 100010;
char s[N], p[M];
int slen, plen;
int ne[N];

int main(){
    cin >> plen >> p + 1 >> slen >> s + 1;
    for(int i = 1; i <= slen - plen + 1; i++){
        bool flag = true;
        for(int j = 1, k = i; j <= plen; j++, k++){
            if(s[k] != p[j]){
                flag = false;
                break;
            }
        }
        if(flag) printf("%d ", i);
    }
}


KMP演算法

KMP演算法的含義

KMP演算法是一種改進的字串匹配演算法,是由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,所以簡稱KMP演算法。其演算法核心是在匹配失敗後利用next陣列記錄的資訊來一定程度上減少匹配次數,以此提高字串匹配的效率。

KMP演算法涉及的基本概念

(1)s 為模板字串;
(2)p 為模式串,即需要在 s 中匹配的字串;
(3)公共前字尾:假定一個字串的長度為len且下標從1開始,若字串中範圍[1, i]和範圍[j, len]完全匹配,則稱這兩段為此字串的公共前字尾;
(4)next 陣列:next 陣列是KMP演算法的核心,next[i] 記錄的是模式串 p 中前 i 長度範圍內的最長公共前字尾長度,後文會做詳解。KMP演算法在進行字串匹配時若出現失配,模式串 p 可以根據 next 陣列記錄的資訊進行等價的後移。
(5)兩大核心步驟:
1、求解 next 陣列
2、進行字串匹配
注意字串的下標從 1 開始,並且在匹配過程中每次將 s[i]p[i + 1] 進行比較。

KMP演算法的匹配思路

KMP演算法匹配核心圖解

紅色虛線所標註的範圍內為當前已經匹配的部分,紅色空心圓圈為當前比較的兩個字元。

KMP演算法匹配舉例及圖解

(1)

(2)

(3)

(4)

(5)

(6)

(7)

此時出現了失配,需要將j = next[j]。

(8)

容易發現在 p 中前 6 長度的最長公共前字尾長度為2,j = next[6] = 2

(9)

相當於模式串 p 整體向右移動了之前的 j - next[j] 的長度再開始匹配。

(10)

此時再次出現了失配,需要將 j = next[j]

(11)

模式串 p 中前 2 長度的最長公共前字尾長度為0,j = next[2] = 0*。

(12)

相當於模式串 p 整體向右移動了之前的 j - next[j] 的長度再開始匹配。

(13)

以當前 s[i] 這個字元為起點匹配模式串 p 成功。(前面省略了一些)

KMP演算法匹配核心程式碼

    //匹配操作
    for(int i = 1, j = 0; i <= n; i++){
        //失配整體移動之後可能依舊失配,故用while
        while(j && s[i] != p[j + 1]) j = ne[j];
        //當前兩個字元匹配
        if(s[i] == p[j + 1]) j++;
        if(j == m)  {     
            printf("%d ", i - m + 1);  //返回匹配起始位置
            j = ne[j];     //可能存在多個位置匹配,繼續匹配
        }
    }


next陣列詳解

next陣列的含義及求解

next[i] 記錄的是模式串 pi 長度範圍內最長公共前字尾的長度。例如字串 ABDAB,可以發現此字串前 4 長度的最長公共前字尾為 A,那麼對於這個字串,next[4] = 1;同理此字串前 5 長度的最長公共前字尾為 AB,所以 next[5] = 2

next 陣列的求解和上文中字串匹配的過程非常相近,相當於是自己與自己進行匹配。將其中一個 p依舊作為模式串,將另一個 p 視作字串,並將其從下標2開始與模式串 p 進行匹配。

next陣列核心圖解

紅色虛線所標註的範圍內為當前已經匹配的部分,紅色空心圓圈為當前比較的兩個字元。

(1)求解next陣列當前字元匹配

(2)求解next陣列當前字元失配

next陣列舉例及圖解

(1)

(2)

(3)

(4)

此時出現字元匹配

(5)

此時出現字元匹配

(6)

此時出現字元匹配

(7)

求解next陣列完成

next陣列核心程式碼

    //求next[]陣列
    for(int i = 2, j = 0; i <= m; i++){
        //當前字元不匹配
        while(j && p[i] != p[j + 1]) j = ne[j];
        if(p[i] == p[j + 1]) j++;
        //記錄前i長度最長公共前字尾長度j
        ne[i] = j;
    }

字串下標以及next陣列如此定義的原因

上文中字串下標都從 1 開始,要實現也不難,C/C++從1開始輸入即可,其他語言例如python,先插入一個字元即可。下標從 1 開始主要也是和next陣列的定義有關。next陣列記錄的是模式串 p 中一定範圍內的最長公共前字尾的長度,如果下標從0開始,這個KMP演算法模板也可以進行,但是需要將next[0]置-1,而且求得的next陣列有些違背本身的含義(變成記錄的是前i下標最長公共前字尾長度-1)。
以下是下標從0開始的KMP演算法模板:

int main(){
    cin >> m >> p >> n >> s;

    ne[0] = -1;
    for (int i = 1, j = -1; i < m; i ++ ){
        while (j >= 0 && p[j + 1] != p[i]) j = ne[j];
        if (p[j + 1] == p[i]) j ++ ;
        ne[i] = j;
    }

    for (int i = 0, j = -1; i < n; i ++ ){
        while (j != -1 && s[i] != p[j + 1]) j = ne[j];
        if (s[i] == p[j + 1]) j ++ ;
        if (j == m - 1){
            cout << i - j << ' ';
            j = ne[j];
        }
    }
    //輸出next陣列
    //cout<<endl<<"next[]: "<<endl;
    //for(int i = 0; i < m; i++) cout<<ne[i]<<' ';
}

以下是上述下標從0開始模板求得的next陣列結果:

可以看出求得next陣列記錄的值恰好為前i下標最長公共前字尾長度-1。



完整程式

完整程式程式碼

#include<iostream>
using namespace std;

static const int N = 1000010, M = 100010;
char s[N], p[M];
int slen, plen;
int ne[N];

int main(){
    cin >> plen >> p + 1 >> slen >> s + 1;
    
    for(int i = 2, j = 0; i <= plen; i++){
        while(j && p[i] != p[j + 1]) j = ne[j];
        if(p[i] == p[j + 1]) j++;
        ne[i] = j;
    }
    
    for(int i = 1, j = 0; i <= slen; i++){
        while(j && s[i] != p[j + 1]) j = ne[j];
        if(s[i] == p[j + 1]) j++;
        if(j == plen){
            cout<<i - plen + 1<<' ';
            j = ne[j];
        }
    }
    //輸出next陣列
    //cout<<endl<<"next[]: "<<endl;
    //for(int i = 1; i <= plen; i++) cout<<ne[i]<<' ';
}

程式執行測試



後記

網上大部分KMP的寫法與我寫的這篇隨筆中不太相同,next陣列的定義也不太一樣。我所記錄的是我自己認為最好理解的KMP演算法的寫法,沒有任何貶低其他寫法的意思喔?,也許各有各的好處。

相關文章