KMP字串匹配演算法

popcoount發表於2023-10-01

挑戰最通俗的KMP演算法講解

什麼是 \(KMP\)

KMP是一種用於模式串匹配問題的演算法。
給出一個文字串和模式串,查詢模式串在文字串中的(出現次數、出現位置等等)的問題稱為“模式串匹配問題”。
KMP演算法的本質是:針對模式串構建一個特定的陣列,用於在匹配失敗時減少後續匹配過程中的無用比較,可以將時間複雜度最佳化到線性

\(next\) 陣列

設文字串為 \(s\),長度為 \(n\);模式串為 \(t\),長度為 \(m\)
預處理一個 \(next\) 陣列,對於 \(next[i]\),它表示在 \(t\) 的前 \(i\) 個字母中,最長公共前字尾的長度。
什麼意思呢?我們舉個例子:
比如 \(t\)\(ababaca\),則對應的 \(next\) 陣列如下所示:

\(i\) \(1\) \(2\) \(3\) \(4\) \(5\) \(6\) \(7\)
\(next[i]\) \(0\) \(0\) \(1\) \(2\) \(3\) \(0\) \(1\)

\(next[1]\):字首:{空};字尾:{空}。\(next\)\(0\)
\(next[2]\):字首:{\(a\)};字尾:{\(b\)}。\(next\)\(0\)
\(next[3]\):字首:{\(\color{red}a\), \(ab\)};字尾:{\(\color{red}a\), \(ba\)}。\(next\)\(1\)
\(next[4]\):字首:{\(a\), \(\color{red}ab\), \(aba\)};字尾:{\(b\), \(\color{red}ab\), \(bab\)}。\(next\)\(2\)
\(next[5]\):字首:{\(a\), \(ab\), \(\color{red}aba\), \(abab\)};字尾:{\(a\), \(ba\), \(\color{red}aba\), \(baba\)}。\(next\)\(3\)
\(next[6]\):字首:{\(a\), \(ab\), \(aba\), \(abab\), \(ababa\)};字尾:{\(c\), \(ac\), \(bac\), \(abac\), \(babac\)}。\(next\)\(0\)
\(next[7]\):字首:{\(\color{red}a\), \(ab\), \(aba\), \(abab\), \(ababac\)};字尾:{\(\color{red}a\), \(ca\), \(aca\), \(baca\), \(abaca\), \(babaca\)}。\(next\)\(1\)

如何進行匹配?

這裡我們先假設我們已經求出了 \(next\) 陣列(馬上講怎麼求),我們來看看怎麼進行匹配。
因為“next”這個詞有可能被判斷為關鍵字,所以接下來我們用“ne”來表示。
首先我們建立兩個指標,分別為文字串的和模式串的。

int i = 0, j = 0;

然後,當文字串的指標還沒有到達終點時,我們就接著匹配。

while (i < s.size()) {
    ...
}

如果當前的字母匹配,那就把兩個指標都往後移一個字元。

if (s[i] == t[j]) i++, j++;

如果不匹配,那就把 \(j\) 挪動 \(next[j - 1]\) 格。

else if (j > 0) j = ne[j - 1];

如果還是不行,說明這是第一格就不匹配,把 \(i\) 往後挪。

else i++;

如果 \(j\) 到了終點,說明匹配成功。返回模式串匹配的起始座標即可。

if (j == t.size()) return i - j;

\(next\) 陣列的原理 & 如何求 \(next\) 陣列?

剛才我們說了:如果不匹配,那就把 \(j\) 挪動 \(next[j - 1]\) 格。為什麼可以這麼做呢?
image

(這裡用了一個b站大佬的圖)
如上圖,可以發現,我們成功匹配的畫黃線的兩個 \(AB\),和前面跳過的畫藍線的兩個 \(AB\) 是完全一樣的。所以我們可以直接跳過後兩個 \(AB\) 接著匹配;這也就證實了 \(next\) 陣列的本質:最長公共前字尾的長度(注意:最長公共前字尾不可以是子串本身,否則我們的移動就沒有意義了)。

我們可以用遞推的方式來求解 \(next\) 陣列,比如我們現在已經知道當前的公共前字尾了,接下來分兩種情況討論:

  1. 下一個字元還是相同,直接構成了一個更長的公共前字尾;
  2. 下一個字元不相同了,那我們就再次察看左邊的字首,與右面的字尾再次進行檢查下一個字元是否相同,就可以得到下一個字元的 \(next\) 陣列了!

那麼這樣我們的程式碼實現就很簡單了:

void buildnext(string t) {
	memset(ne, 0, sizeof ne);
	int len = 0; // 當前公共前字尾的長度
	int i = 1;
	while (i < t.size()) { // 依次生成每個next數值
		if (t[len] == t[i]) { // 下一個字元依然相同
			len++; // 長度+1
			ne[i] = len;
			i++;
		} else {
			if (len == 0) {
				ne[i] = 0; // 不存在直接為0
				i++;
			} else len = ne[len - 1]; // 是否存在更短的前字尾
		}
	}
}

最後放一下題和程式碼:

image

#include <bits/stdc++.h>
using namespace std;
int ne[100010];
void buildnext(string t) {
	memset(ne, 0, sizeof ne);
	int len = 0;
	int i = 1;
	while (i < t.size()) {
		if (t[len] == t[i]) {
			len++;
			ne[i] = len;
			i++;
		} else {
			if (len == 0) {
				ne[i] = 0;
				i++;
			} else len = ne[len - 1];
		}
	}
}
void kmp(string s, string t) {
	buildnext(t);
	int i = 0, j = 0;
	while (i < s.size()) {
		if (s[i] == t[j]) i++, j++;
		else if (j > 0) j = ne[j - 1];
		else i++;
		if (j == t.size()) cout << i - j << ' ';
	}
}
int main() {
	int n, m;
	string s, t;
	cin >> n >> t >> m >> s;
	kmp(s, t);
	return 0;
}

碼字不易qwq
如果覺得這篇文章還不錯的話,請點個贊吧!
如果有任何問題,歡迎在評論區提問,我會盡可能的第一時間回覆!

相關文章