1. 程式人生 > >KMP演算法-從頭到尾徹底理解KMP

KMP演算法-從頭到尾徹底理解KMP

一:背景

給定一個主串(以 S 代替)和模式串(以 P 代替),要求找出 P 在 S 中出現的位置,此即串的模式匹配問題。

Knuth-Morris-Pratt 演算法(簡稱 KMP)是解決這一問題的常用演算法之一,這個演算法是由高德納(Donald Ervin Knuth)和沃恩·普拉特在 1974 年構思,同年詹姆斯·H·莫里斯也獨立地設計出該演算法,最終三人於 1977 年聯合發表。

在繼續下面的內容之前,有必要在這裡介紹下兩個概念:真字首 和 真字尾。

由上圖所得, "真字首"指除了自身以外,一個字串的全部頭部組合;"真字尾"指除了自身以外,一個字串的全部尾部組合。(網上很多部落格,應該說是幾乎所有的部落格,也包括我以前寫的,都是“字首”。嚴格來說,“真字首”和“字首”是不同的,既然不同,還是不要混為一談的好!)

二:樸素字串匹配演算法

初遇串的模式匹配問題,我們腦海中的第一反應,就是樸素字串匹配(即所謂的暴力匹配),程式碼如下:

/* 字串下標始於 0 */
int NaiveStringSearch(string S, string P)
{
	int i = 0;    // S 的下標
	int j = 0;    // P 的下標
	int s_len = S.size();
	int p_len = P.size();

	while (i < s_len && j < p_len)
	{
		if (S[i] == P[j])  // 若相等,都前進一步
		{
			i++;
			j++;
		}
		else               // 不相等
		{
			i = i - j + 1;
			j = 0;
		}
	}

	if (j == p_len)        // 匹配成功
		return i - j;

	return -1;
}

暴力匹配的時間複雜度為 \(O(nm)\),其中 \(n\) 為 S 的長度,\(m\) 為 P 的長度。很明顯,這樣的時間複雜度很難滿足我們的需求。

接下來進入正題:時間複雜度為 \(Θ(n+m)\) 的 KMP 演算法。

三:KMP字串匹配演算法

3.1 演算法流程

以下摘自阮一峰的字串匹配的KMP演算法,並作稍微修改。

(1)

首先,主串"BBC ABCDAB ABCDABCDABDE"的第一個字元與模式串"ABCDABD"的第一個字元,進行比較。因為 B 與 A 不匹配,所以模式串後移一位。

(2)

因為 B 與 A 又不匹配,模式串再往後移。

(3)

就這樣,直到主串有一個字元,與模式串的第一個字元相同為止。

(4)

接著比較主串和模式串的下一個字元,還是相同。

(5)

直到主串有一個字元,與模式串對應的字元不相同為止。

(6)

這時,最自然的反應是,將模式串整個後移一位,再從頭逐個比較。這樣做雖然可行,但是效率很差,因為你要把"搜尋位置"移到已經比較過的位置,重比一遍。

(7)

一個基本事實是,當空格與 D 不匹配時,你其實是已經知道前面六個字元是"ABCDAB"。KMP 演算法的想法是,設法利用這個已知資訊,不要把"搜尋位置"移回已經比較過的位置,而是繼續把它向後移,這樣就提高了效率。

(8)

i 0 1 2 3 4 5 6 7
模式串 A B C D A B D '\0'
next[i] -1 0 0 0 0 1 2 0

怎麼做到這一點呢?可以針對模式串,設定一個跳轉陣列int next[],這個陣列是怎麼計算出來的,後面再介紹,這裡只要會用就可以了。

(9)

已知空格與 D 不匹配時,前面六個字元"ABCDAB"是匹配的。根據跳轉陣列可知,不匹配處 D 的 next 值為 2,因此接下來從模式串下標為 2 的位置開始匹配。

(10)

因為空格與 C 不匹配,C 處的 next 值為 0,因此接下來模式串從下標為 0 處開始匹配。

(11)

因為空格與 A 不匹配,此處 next 值為 -1,表示模式串的第一個字元就不匹配,那麼直接往後移一位。

(12)

逐位比較,直到發現 C 與 D 不匹配。於是,下一步從下標為 2 的地方開始匹配。

(13)

逐位比較,直到模式串的最後一位,發現完全匹配,於是搜尋完成。

3.2 next 陣列是如何求出的

next 陣列的求解基於“真字首”和“真字尾”,即next[i]等於P[0]...P[i - 1]最長的相同真前後綴的長度(請暫時忽視 i 等於 0 時的情況,下面會有解釋)。我們依舊以上述的表格為例,為了方便閱讀,我複製在下方了。

i 0 1 2 3 4 5 6 7
模式串 A B C D A B D '\0'
next[ i ] -1 0 0 0 0 1 2 0
  1. i = 0,對於模式串的首字元,我們統一為next[0] = -1

  2. i = 1,前面的字串為A,其最長相同真前後綴長度為 0,即next[1] = 0

  3. i = 2,前面的字串為AB,其最長相同真前後綴長度為 0,即next[2] = 0

  4. i = 3,前面的字串為ABC,其最長相同真前後綴長度為 0,即next[3] = 0

  5. i = 4,前面的字串為ABCD,其最長相同真前後綴長度為 0,即next[4] = 0

  6. i = 5,前面的字串為ABCDA,其最長相同真前後綴為A,即next[5] = 1

  7. i = 6,前面的字串為ABCDAB,其最長相同真前後綴為AB,即next[6] = 2

  8. i = 7,前面的字串為ABCDABD,其最長相同真前後綴長度為 0,即next[7] = 0

那麼,為什麼根據最長相同真前後綴的長度就可以實現在不匹配情況下的跳轉呢?舉個代表性的例子:假如i = 6時不匹配,此時我們是知道其位置前的字串為ABCDAB,仔細觀察這個字串,首尾都有一個AB,既然在i = 6處的 D 不匹配,我們為何不直接把i = 2處的 C 拿過來繼續比較呢,因為都有一個AB啊,而這個AB就是ABCDAB的最長相同真前後綴,其長度 2 正好是跳轉的下標位置。

有的讀者可能存在疑問,若在i = 5時匹配失敗,按照我講解的思路,此時應該把i = 1處的字元拿過來繼續比較,但是這兩個位置的字元是一樣的啊,都是B,既然一樣,拿過來比較不就是無用功了麼?其實不是我講解的有問題,也不是這個演算法有問題,而是這個演算法還未優化,關於這個問題在下面會詳細說明,不過建議讀者不要在這裡糾結,跳過這個,下面你自然會恍然大悟。

思路如此簡單,接下來就是程式碼實現了,如下:

/* P 為模式串,下標從 0 開始 */
void GetNext(string P, int next[])
{
	int p_len = P.size();
	int i = 0;   // P 的下標
	int j = -1;  
	next[0] = -1;

	while (i < p_len)
	{
		if (j == -1 || P[i] == P[j])
		{
			i++;
			j++;
			next[i] = j;
		}
		else
			j = next[j];
	}
}

一臉懵逼,是不是。。。上述程式碼就是用來求解模式串中每個位置的next[]值。

下面具體分析,我把程式碼分為兩部分來講:

(1):i 和 j 的作用是什麼?

i 和 j 就像是兩個”指標“,一前一後,通過移動它們來找到最長的相同真前後綴。

(2):if...else...語句裡做了什麼?

假設 i 和 j 的位置如上圖,由next[i] = j得,也就是對於位置 i 來說,區段 [0, i - 1] 的最長相同真前後綴分別是 [0, j - 1] 和 [i - j, i - 1],即這兩區段內容相同。

按照演算法流程,if (P[i] == P[j]),則i++; j++; next[i] = j;;若不等,則j = next[j],見下圖:

next[j]代表 [0, j - 1] 區段中最長相同真前後綴的長度。如圖,用左側兩個橢圓來表示這個最長相同真前後綴,即這兩個橢圓代表的區段內容相同;同理,右側也有相同的兩個橢圓。所以 else 語句就是利用第一個橢圓和第四個橢圓內容相同來加快得到 [0, i - 1] 區段的相同真前後綴的長度。

細心的朋友會問 if 語句中j == -1存在的意義是何?第一,程式剛執行時,j 是被初始為 -1,直接進行P[i] == P[j]判斷無疑會邊界溢位;第二,else 語句中j = next[j],j 是不斷後退的,若 j 在後退中被賦值為 -1(也就是j = next[0]),在P[i] == P[j]判斷也會邊界溢位。綜上兩點,其意義就是為了特殊邊界判斷。

四:完整程式碼

#include <iostream>
#include <string>

using namespace std;

/* P 為模式串,下標從 0 開始 */
void GetNext(string P, int next[])
{
	int p_len = P.size();
	int i = 0;   // P 的下標
	int j = -1;  
	next[0] = -1;

	while (i < p_len)
	{
		if (j == -1 || P[i] == P[j])
		{
			i++;
			j++;
			next[i] = j;
		}
		else
			j = next[j];
	}
}

/* 在 S 中找到 P 第一次出現的位置 */
int KMP(string S, string P, int next[])
{
	GetNext(P, next);

	int i = 0;  // S 的下標
	int j = 0;  // P 的下標
	int s_len = S.size();
	int p_len = P.size();

	while (i < s_len && j < p_len) // 因為末尾 '\0' 的存在,所以不會越界
	{
		if (j == -1 || S[i] == P[j])  // P 的第一個字元不匹配或 S[i] == P[j]
		{
			i++;
			j++;
		}
		else
			j = next[j];  // 當前字元匹配失敗,進行跳轉
	}

	if (j == p_len)  // 匹配成功
		return i - j;
	
	return -1;
}

int main()
{
	int next[100] = { 0 };

	cout << KMP("bbc abcdab abcdabcdabde", "abcdabd", next) << endl; // 15
	
	return 0;
}

五:KMP優化

i 0 1 2 3 4 5 6 7
模式串 A B C D A B D '\0'
next[i] -1 0 0 0 0 1 2 0

以 3.2 的表格為例(已複製在上方),若在i = 5時匹配失敗,按照 3.2 的程式碼,此時應該把i = 1處的字元拿過來繼續比較,但是這兩個位置的字元是一樣的,都是B,既然一樣,拿過來比較不就是無用功了麼?這我在 3.2 已經解釋過,之所以會這樣是因為 KMP 還未優化。那怎麼改寫就可以解決這個問題呢?很簡單。

/* P 為模式串,下標從 0 開始 */
void GetNextval(string P, int nextval[])
{
	int p_len = P.size();
	int i = 0;   // P 的下標
	int j = -1;  
	nextval[0] = -1;

	while (i < p_len)
	{
		if (j == -1 || P[i] == P[j])
		{
			i++;
			j++;
          
			if (P[i] != P[j])
			    nextval[i] = j;
			else
			    nextval[i] = nextval[j];  // 既然相同就繼續往前找真字首
		}
		else
			j = nextval[j];
	}
}

六:參考文獻

  • 嚴蔚敏. 資料結構(C 語言版)
  • 阮一峰. 字串匹配的KMP演算法

七:鳴謝

-此篇文章特別感謝EthsonLiu學長的幫助