字串匹配問題
給定一個字串 s 和一個要匹配的模式串 p。模式串 p 有可能在 s 中多次出現,請求出模式串 p 在 s 中所有出現的起始位置。
暴力匹配演算法 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] 記錄的是模式串 p 前 i 長度範圍內最長公共前字尾的長度。例如字串 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演算法的寫法,沒有任何貶低其他寫法的意思喔?,也許各有各的好處。