1. 程式人生 > >詳細解讀KMP模式匹配演算法

詳細解讀KMP模式匹配演算法

首先我們需要了解什麼是模式匹配?

子串定位運算又稱為模式匹配(Pattern Matching)或串匹配(String Matching)。在串匹配中,一般將主串稱為目標串,將子串稱為模式串。本篇部落格統一用S表示目標串,T表示模式串,將從目標串S中查詢模式串T的過程稱為模式匹配。

雖然我們的主角是KMP模式匹配演算法,但我們還是要先從暴力匹配演算法講起,通過發現暴力匹配演算法存在的問題,由此來引出KMP模式匹配演算法。

樸素的模式匹配演算法

【基本思想】

從目標串S的第一個字元開始和模式串T的第一個字元進行比較,如果相等則進一步比較二者的後繼字元,否則從目標串的第二個字元開始再重新與模式串T的第一個字元進行比較,以此類推,直到模式串T與目標串S中的一個子串相等,稱為匹配成功,返回T在S中的位置;或者S中不存在值與T相等的子串,稱匹配失敗,返回-1.此演算法也稱為BF(Brute-Force)演算法。

我們先通過一個簡單的例子,來了解一下BF演算法是怎麼回事。假設有一個目標串S為“ababb”,模式串T為“abb”。由於例子比較簡單,我們可以繪製出整個的匹配過程。如下圖所示:


可以看到匹配流程完全是按照上面給出的基本思想走下來的,首先從目標串S的第一個字元開始和模式串T的第一個字元進行比較(第一趟),如果相等則進一步比較二者的後繼字元(第二趟),否則從目標串的第二個字元開始再重新與模式串T的第一個字元進行比較(第三趟,第四趟)。我們重點來關注一下第三趟,此時,發現S[i] != T[j],則要從目標串S的第二個字元再重新開始,i回溯到i = i - j + 1。因為i - j表示這一趟的起始匹配位置,i - j + 1則意為從這一趟起始比較位置的下一個位置繼續進行比較。同時j要回溯到0,即重新與模式串T的第一個字元進行比較。

【BF演算法實現】

	/*
	 * BF匹配演算法
	 */
	public static int violentMatching(String s, String t) {
		int i = 0;
		int j = 0;
		while (i < s.length() && j < t.length()) {
			if (s.charAt(i) == t.charAt(j)) {
				i++;
				j++;
			} else {
				//i回溯到這一趟起始匹配位置的下一個位置
				i = i - j + 1;
				j = 0;
			}
		}
		//當j==t.length()表示目標串S中的一個子串與模式串T完全匹配
		if (j == t.length()) {
			//返回這一趟起始匹配位置,即T在S中的位置
			return i - j;
		} else {
			return -1;
		}
	}

BF演算法的實現比較簡單,思維方式也很直接,比較容易理解。但是我們發現存在這樣的問題:

第一趟比較結束後,我們可以發現資訊:S[0] =T[0],第二趟比較結束後,得到資訊:S[1] = T[1],第三趟後得到資訊:S[2] != T[2]。接下來我們通過觀察模式串T可以發現T[0] !=T[1]。因此可以立即得出結論T[0] != S[1],所以根本無需進行第四趟的比較。可能由於例子比較簡單,無法鮮明的體現出KMP演算法的優勢,下面我們舉一個稍微複雜些的例子來看看:

假設有一個目標串S為“ababcabcacb”,模式串為“abcac”,當比較到到S[2]與T[2]時出現失配


如果是按照BF演算法,則下一趟應從S[1]與T[0]進行比較開始。但是通過上一趟的比較我們是可以發現:S[0] = T[0],S[1] = T[1],S[2] != T[2]。再觀察模式串T自身我們發現T[0] != T[1],因此可以立即得出結論S[1] != T[0],所以可以省略它們的比較,直接從S[2]與T[0]進行比較開始:


從圖中可以看到,當比較到S[6]和T[4]時,再次出現失配情況。如果繼續按照BF演算法,顯然又會多進行幾次不必要的比較。那麼又應該從目標串和模式串的哪兩個位置開始進行比較呢?

從上圖可以看出比較結束時,有如下資訊:S[2] = T[0],S[3] = T[1],S[4] = T[2],S[5] = T[3],S[6] != T[4]。然後我們在觀察模式串T,可以得到:

(1)T[0] != T[1],因此T[0] != S[3],所以可以省略它們的比較。

(2)T[0] != T[2],因此T[0] != S[4],省略它們的比較。

(3)T[0] = T[3],因此T[0] = S[5],當相等時繼續比較兩個串的後繼字元,所以從S[6]和T[1]開始進行比較。


可以看到應用此方法,只發生了三次重新匹配,就得到了匹配成功的結論,加快了匹配的執行速度。

上面的例子只是大概描述了方法的思路,但是這種方法到底是什麼,到底如何精確的進行描述,以及如何用程式碼實現呢?下面就來解決這些問題。

KMP模式匹配演算法

此演算法是由D.E.Knuth,J.H.Morris和V.R.Pratt同時發現的,因此該演算法被稱為克努斯-莫里斯-普拉特操作,簡稱為KMP演算法。

KMP演算法,是不需要對目標串S進行回溯的模式匹配演算法。讀者可以回顧上面的例子,整個過程中完全沒有對目標串S進行回溯,而只是對模式串T進行了回溯。通過前面的分析,我們發現這種匹配演算法的關鍵在於當出現失配情況時,應能夠決定將模式串T中的哪一個字元與目標串S的失配字元進行比較。所以呢,那三位前輩就通過研究發現,使用模式串T中的哪一個字元進行比較,僅僅依賴於模式串T本身,與目標串S無關。

這裡就要引出KMP演算法的關鍵所在next陣列,next陣列的作用就是當出現失配情況S[i] != T[j]時,next[j]就指示使用T中的以next[j]為下標的字元與S[i]進行比較(注意在KMP演算法中,i是永遠不會進行回溯的)。還需要說明的是當next[j] = -1時,就表示T中的任何字元都不與S[i]進行比較,下一輪比較從T[0]與S[i+1]開始進行。由此可見KMP演算法在進行模式匹配之前需要先求出關於模式串T各個位置上的next函式值。即next[j],j = 0,1,2,3,...n-1。

求解next陣列

根據next陣列的特性,匹配過程中一旦出現S[i] != T[j],則用T[next[j]]與S[i]繼續進行比較,這就相當於將模式串T向右滑行j - next[j]個位置,示意圖如下:


理解上面這幅圖是理解next陣列的關鍵,為了繪圖簡單,使用k 來表示next[j]。圖中,j+1表示模式串T的字元個數,當出現失配情況時,使用T[next[j]]與S[i]進行比較,即圖中T[k]與S[i]進行比較。因此右邊括起來的是T[0]~T[k]共k+1個字元,因此左邊括起來的是j + 1 - (k + 1) = j - k個字元,即向右滑行了j-next[j]個位置。

當上圖中出現失配後可以得到如下資訊:

S[i-j] = T[0],S[i-j+1] = T[1],...,S[i-k] = T[j-k],S[i-k+1] = T[j-k+1],...,S[i-2] = T[j-2],S[i-1] = T[j-1]

模式串T進行右滑後,如圖中所示必須保證:

S[i-k] = T[0],S[i-k+1] = T[1],S[i-k+2] = T[2],...,S[i-2] = T[k-2],S[i-1] = T[k-1]

通過上面兩個式子可得:

T[0] = T[j-k],T[1] = T[j-k+1],T[2] = T[j-k+2],...,T[k-2] = T[j-2],T[k-1] = T[j-1]

它的含義表示對於模式串T中的一個子串T[0]~T[j-1],K的取值需要滿足前K個字元構成的子序列(即T[0]~T[k-1],稱為字首子序列)與後K個字元構成的子序列(即T[j-1]~T[j-k],稱為字尾子序列)相等。滿足這個條件的K值有多個,取最大的那個值。

由此求解next陣列問題,便被轉化成了求解最大字首字尾子序列問題。

再通過一個例子來說明最大字首字尾子序列分別是什麼?

對於子串“aaabcdbaaa”,滿足條件的K值有1,2,3,取最大K值即3做為next[j]的函式值。此時的最大字首子序列為“aaa”,最大字尾子序列為“aaa”。

再比如子串“abcabca”,其相等的最大字首字尾子序列即為“abca”

【求解next陣列的演算法實現】

	public static int[] getNext(String t) {
		int[] next = new int[t.length()];
		next[0] = -1;
		int suffix = 0;  // 字尾
		int prefix = -1;  // 字首
		while (suffix < t.length() - 1) {
			//若字首索引為-1或相等,則字首字尾索引均+1
			if (prefix == -1 || t.charAt(prefix) == t.charAt(suffix)) {
				++prefix;
				++suffix;
				next[suffix] = prefix;  //1  
			} else {
				prefix = next[prefix];  //2
			}
		}
		return next;
	}
程式碼其實並不複雜,整體思路是分別以T[0]~T[suffix]為子串,依次求這些子串的相等的最大字首字尾子序列,即next[suffix]的值。

比較難理解的應該是有兩處,我分別用1和2標示了出來。我們依次來看。初始化的過程如下圖所示,prefix指向-1,suffix指向0,next[0] = -1。


if條件中prefix = -1成立,所以進入if語句,prefix = prefix+1,suffix = suffix+1,此時直接將next[suffix]賦值為prefix。即next[1] = 0。prefix+1到底代表的是什麼?next[suffix]又代表的是什麼?

next[suffix]表示的是不包括suffix即T[0]~T[suffix-1]這個子串的相等最長字首字尾子序列的長度。意思是這個子串前面有next[suffix]個字元,與後面的next[suffix]個字元相等。

程式碼中suffix+1以後值為1,prefix+1以後值為0,next[1]表示的是對於子串“a”,它的相等最長字首字尾子序列的長度,即為prefix,0。prefix一直表示的就是對於子串T[0]~T[suffix-1]前面有prefix個字元與後面prefix個字元相等,就是next[suffix]

繼續往下走,滿足if條件T[0] = T[1],則suffix+1值為2,prefix+1以後值為1,next[2] = 1,表示子串“aa”,有長度為1的相等最長字首字尾子序列“a”。

繼續往下走,滿足if條件T[1] = T[2],則suffix+1值為3,prefix+1以後值為2,next[3] = 2,表示子串“aaa”,有長度為2的相等最長字首字尾子序列“aa”。

當再繼續往下走時會發現T[2] != T[3],不滿足條件,則進入了else語句,prefix進行了回溯,prefix = next[prefix],這就遇到了第二個難點,為什麼要如此進行回溯呢?

借用網上的一張圖來回答這個問題


這張圖網上很多,但是詳細描述這張圖的具體含義的卻很少。圖中的j就對應程式碼中的suffix,k就對應程式碼中的prefix,模式串T圖中用的P表示。

現在它們也遇到了這個問題,Pj] != P[k],然後k進行了回溯,變為next[k]。既然prefix能走到k,suffix能走到j,則至少能保證對於子串P[0] ~ P[j-1],前面有k個字元與後面k個字元相等。即圖中前後方的藍色區域。要是滿足條件P[k] = P[j]則說明對於子串P[0] ~ P[j],前面有k+1個字元與後面k+1個字元相等。但是現在不滿足,則說明對於子串P[0] ~ P[j]不存在長度為k+1的相等最長字首字尾子序列,可能存在比k+1小的最長字首字尾子序列,可能是k,可能是k-1,k -2 , k -3 ...或者根本就沒有是0。那麼我們的正常思路應該是回溯到k再進行判斷,不存在k個則再回溯到k-1個,以此類推,那麼演算法中為什麼是直接回溯到next[k]呢?


為了便於描述,我將圖中的不同區域使用大寫字母進行標註。前面說過正常思路是不存在k+1個,就回溯到k個進行判斷,現在我們來看為什麼不回溯到k?當回溯到k時,需要滿足的條件是X區域的字元與Z區域的字元相等,而我們已知的是X區域的字元與Y區域的字元相等,若要滿足條件,則需要Y區域字元與Z區域字元相等,從圖中可以看到,Y區域與Z區域的字元僅相差一位,實際上比較的是Y區域的第一個字元與第二個字元,第二個字元與第三個字元等等,所以除非是Y區域的字元全部相等,是同一個字元,否則是不可能滿足條件的。然而當Y區域的字元全部相等時,則X區域的字元也全部相等,那麼next[k]就等於k,所以不如直接就回溯到next[k]。

那為什麼直接回溯到next[k]就一定會滿足條件呢?

已知的是X區域等於Y區域,所以B區域一定等於D區域,因為B區域表示X區域的後next[k]個字元,D區域表示Y區域的後next[k]個字元。

而next[k]的含義就是對於子串P[0] ~ P[k-1],前面有next[k]個字元與後面next[k]個字元相等,即A區域等於B區域,所以可以得到A區域一定等於D區域,因此當下次比較滿足條件P[next[k]] = P[j]時,就一定有長度為next[k] + 1的相等最長字首字尾子序列。

next陣列的求解搞清楚了,接下來我們就可以給出KMP演算法的完整實現了

【KMP模式匹配演算法實現】

	public static int KMP(String s, String t) {
		int i = 0;
		int j = 0;
		//得到next陣列
		int[] next = getNext(t);
		while (i < s.length() && j < t.length()) {
			if (j == -1 || s.charAt(i) == t.charAt(j)) {
				i++;
				j++;
			} else {
				//根據next陣列的指示j進行回溯,而i永遠不會回溯
				j = next[j];
			}
		}
		if (j == t.length()) {
			return i - j;
		} else {
			return -1;
		}
	}
程式碼和BF演算法很類似,不同的是在進行回溯時,j是根據next[j]進行回溯,i不回溯。

KMP演算法優化

上面給出的KMP演算法還有一些小問題,比如有一個模式串“aaaaax”,目標串“aaaabcde”。通過上面給出的演算法我們很容易得到模式串T的next陣列,匹配過程如下圖所示:


可以看到當匹配到S[4]和T[4]時出現失配情況,而根據next陣列的指示,next[4],下一步應該比較S[4]和T[3],再次失配,再根據指示,比較S[4]和T[2],再失配,再比較S[4]和T[2],又失配,直到比較到S[0]和T[0]仍然失配,然後next[0] = -1,則表示T中的任何字元都不與S[4]進行比較,下一輪比較從S[5]與T[0]開始進行。對於這種特殊情況,雖然我們使用了next陣列,但效率仍然是低下的。當S[4] != T[4]時,由於T[4] = T[3] = T[2] = T[1] = T[0],所以它們都不會與S[4]相等,因此應該直接用T[0]與S[5]進行比較。

針對這種情況,只需改進next陣列的求解過程即可

【next陣列求解演算法優化實現】

	public static int[] getNext(String t) {
		int[] next = new int[t.length()];
		next[0] = -1;
		int suffix = 0;  // 字尾
		int prefix = -1;  // 字首
		while (suffix < t.length() - 1) {
			//若相等或字首索引為-1,則字首字尾索引均+1
			if (prefix == -1 || t.charAt(prefix) == t.charAt(suffix)) {
				++prefix;
				++suffix;
				//改進的地方
				if (t.charAt(prefix) == t.charAt(suffix)) {
					next[suffix] = next[prefix];
				} else {
					next[suffix] = prefix;
				}
			} else {
				prefix = next[prefix];  
			}
		}
		return next;
	}
改進的地方在於,如果T[suffix] != T[prefix],則仍然遵從之前的處理,next[suffix] = prefix。

若T[suffix] = T[prefix]則可能會出現上面例子所說的特殊情況,使next[suffix] = next[prefix]。其實這就是一個回溯過程,原本應該是next[suffix] = prefix,這裡由prefix回溯到了next[prefix]。可以這樣理解,當再T[suffix]位置出現失配時,本來按照next陣列的指示應採用T[next[suffix]] = T[prefix]進行下一輪比較,但是我們已經得知T[suffix] = T[prefix]所以一定會再次出現失配情況,所以我們下一輪直接使用T[next[prefix]]進行比較。

如有紕漏,敬請海涵。