KMP算法原理詳解_論文解讀版

1. KMP算法

KMP算法是一種保證線性時間的字符串查找算法,由Knuth、Morris和Pratt三位大神發明,而算法取自這三人名字的首字母,因而得名KMP算法。

那發明這樣的字符串查找算法又有什麼用?在當時計算機本身非常昂貴,計算資源更是極其稀缺,而僅僅進行大文本字符查找的響應時間就很長,沒法充分利用計算資源。計算機可是拿來算更有意義的事的,光爲了找個文本就得浪費這麼多時間,不行啊,這得優化啊。1970年,S.Cook在理論上證明了一個某種特定類型抽象計算機理論。這個理論暗示了一種在最壞情況下時也只是與M+N成正比的解決子字符串查找問題的算法。D.E.Knuth和V.R.Pratt改進了Cook證明定理的框架,並提煉爲一個相對簡單而使用的算法,算法最終在1976年發表。

 

首先一個例子,這裏使用暴力算法進行求解(即每次查找失敗時,移動一個位置,一直查找,直到找到完全匹配的字符):其中,文本txt[0:9]=“AAAAAAAAAB”,查找的字符pat[0:4]=“AAAAB”。

  • i=0時, txt[0:3]=pat[0:3],而txt[4]≠pat[4],匹配失敗
  • i=1時, txt[1:4]=pat[0:3],而txt[5]≠pat[4],匹配失敗
  • ...
  • i=4時, txt[4:7]=pat[0:3],而txt[8]≠pat[4],匹配失敗
  • ...

暴力算法在匹配失敗時每次都要回退到開頭,而其實是可以避免回退這麼多,那麼有沒有什麼方法,在模式匹配失敗時進回退一部分呢?

brute-force-worst-case
圖1 暴力匹配算法

2. KMP原理

KMP算法的主要思想是提前判斷重新開始查找的位置,而這種判斷方式的生成只取決於模式本身。這裏來證明其匹配模式的正確性。
 

先做以下幾個符號定義

  • 待查找文本爲text[1:n],長度爲n
  • 模式字符串爲pat[1:m],長度爲m
  • k爲文本當前所指位置,如text[k]
  • j爲模式串所指位置,如pat[j]

假設文本和模式串匹配的起始位置爲p+1,則有k=p+j,即匹配到當前位置時有text[p+j]=pat[j]

在匹配過程中,有以下兩種情況

  1. j>m,即大於模式串的長度時,表示文本和模式串完全匹配,這裏匹配結束。
  2. 1\leqslant j\leqslant m時,表示還在匹配,但發生了失配,接下來主要討論這種情況。

text[p+1:p+j-1] = pat[1:j-1],但text[p+j]\neq pat[j]時(即匹配了前j-1個字符,但第j個字符不匹配),假設存在一個最小的偏移量(不存在時另外考慮),能滿足pat[1:i-1]=pat[j-i+1:j-1], pat[i]\neq pat[j],即能夠讓偏移後的字符能在在失配處儘可能多的匹配文本。就前面這麼短小精悍的一句話,是整個KMP算法的精髓所在,以下舉兩個例子解釋這裏的意思(例子1可能比較抽象,推薦先看例子2)。

例子1:

i:       1 2 3 4 5 6 7 8 9 10 11

text:  b c a b c a a b c a   b   c

pat:      c a b c a b c a c

匹配失敗時,text[7]\neq pat[6],p=1, j=6 

首先匹配失敗時,當前的模式串爲"c a b c a b",此時文本爲"c a b c a x", 其中x\neq b;爲了能儘可能多的和文本“c a b c a x”的後部分內容進行匹配,需要找到最小的偏移量。

 

爲什麼以這種方式匹配?而且最小偏移量也可能不存在。

原因如下:失配在文本text的"c a b c a x"處,而將模式串pat偏移最小的量,使其再找到一個這樣的位置,滿足再一次模式串和文本的匹配"* * * x"的情況,這時再將文本中的x的值與移動後的模式串進行比較,如下所示。簡單的說,在哪裏跌倒就在哪裏爬起來,只不過需要換一個姿勢。還有一種情況其實是找不到最小偏移量,就將整個模式串大幅向右平移。

i:       1 2 3 4 5 6 7 8 9 10 11

text:  b c a b c a a b c  a   b   c

pat:               c a b c a b c a c

這樣問題就簡化爲如何對於給定模式串,計算其最小偏移量的問題。偏移後字符能滿足這個條件即可進行下一次匹配pat[1:i-1]=pat[j-i+1:j-1], pat[i]\neq pat[j]
例子2:
看了例子1,可能還沒想明白,即爲什麼非要尋找這麼一個最小偏移量不可,這是論文全文中最關鍵也是最精華的地方。

首先做一個很重要的假設:假設存在這麼一個最小偏移量。

對於以下的文本,原先的模式串pat[5]\neq text[6],那麼對於在新位置的模式串{pat}',必須滿足前兩位能和text匹配,本質最終還是逃不過在text[6]處再次進行決一死戰。我想這裏作者們爲了簡化問題,對text[6]失配的情況延後考慮了,避免了text參與偏移量計算造成算法更加複雜,因此只要滿足pat[1:i-1]=pat[j-i+1:j-1], pat[i]\neq pat[j]的條件即可。

到了這裏,問題被簡化爲:轉化爲求模式串前綴和後綴能匹配的最大長度。

只要求出這個長度,就能得出需要偏移的量了!

Wonderful!接下來就是將這個思路化爲程序即可。

3. KMP實例

求模式串前綴和後綴能匹配的最大長度,我使用了以下兩種方式:

  1. 最直接的理解方式,用遞歸的方式獲取子長度進行匹配,如果不合符則縮小子長度,進行下一次匹配,直到長度爲零,詳情見函數“calcLongestFixed”。
  2. 論文中作者所說的方式,先計算pat[1:i-1]=pat[j-i+1:j-1]的情況,再計算pat[i]\neq pat[j]

爲了寫出第三節中的程序,足足花了兩個晚上的時間,來來回回調了N次,就差夢裏也在調了。算法相關爲計算機的關鍵部分,今後繼續加強將算法轉換爲計算機語言的能力!算法下所示,更全面的源碼請見Github

#include <iostream>
#include <cstring>
#include <vector>
#include <cassert>
using namespace std;

// 我的計算方法
int calcLongestFixed(string strMismatch, string pattern, int max_index) {	
	if (max_index < 1)
		return -1;

	int subpos = strMismatch.length() - max_index;
	// 從最長的子字符串開始,進行匹配	
	string subSuffix = strMismatch.substr(subpos, max_index);
	string strPrefix = pattern.substr(0, max_index);

	int M = subSuffix.length();	

	string sub_true_suffix = subSuffix.substr(0, M - 1);
	string sub_true_prefix = strPrefix.substr(0, M - 1);

	char pos_i_char = strPrefix[M - 1]; // 新位置
	char pos_j_char = subSuffix[M - 1]; // 原失配處	

	// 找到pat[1, i - 1] = pat[j - i + 1, j - 1],並滿足
	// pat[i] != pat[j]的情況
	if (sub_true_suffix.compare(sub_true_prefix) == 0
		&& pos_i_char != pos_j_char){
		return sub_true_suffix.length();
	} else {
		return calcLongestFixed(strMismatch, pattern, max_index - 1);
	}
}

int calcLongestFixed(string strMismatch, string pattern ){
	int i = strMismatch.length();
	int max_index = i - 1;
	return calcLongestFixed(strMismatch, pattern, max_index);
}

vector<int> InitVectorNext_my_method(string& pattern) {
	vector<int> vecNext;
	for (int i = 0; i < pattern.length(); i++) {
		string substring = pattern.substr(0, i + 1);
		int pos = calcLongestFixed(substring, pattern);

		vecNext.push_back(pos);
	}
	return vecNext;
}

// 作者論文中所描述的方法
vector<int> InitVectorNext_author_method(string &pattern)
{
	int N = pattern.length();
	vector<int> next;	
	next.resize(N, 0); 
	// 初始條件:j=0時,i肯定是不存在的定義爲-1,其他位置值任意。
	next[0] = -1;

	// 優化前的代碼
	vector<int> f;
	f.resize(N, -1);
	// 初始條件:j=0時,i肯定是不存在的定義爲-1,其他位置值任意。
	f[0] = -1;

	for (int j = 0; j < N-1;) {
		// 先找到pat[1,i-1]=pat[j-i+1,j-1]的情況
		int t = f[j];
		while (t > -1 && pattern[j] != pattern[t])
			t = next[t];
		f[j + 1] = t + 1;
		j++;

		// 判斷pat[i]和pat[j]的情況
		if (pattern[j] == pattern[f[j]])
			next[j] = next[f[j]];
		else
		{
			next[j] = f[j];
		}
	}		
	return next;
}

int search(string& strText, string& pattern, vector<int> &vecNext) {
	int i = 0, j = 0;
	int N = strText.length();
	int M = pattern.length();
	for (; i < N && j < M;) {
		if (j == -1 || strText[i] == pattern[j]) {
			j++; i++;
			if (j >= M)
				return i - M;
		} else {
			j = vecNext[j];
		}
	}
	return -1;
}

void testCalcLongestFixed();

int main()
{
	testCalcLongestFixed();
	
	/////////////////////////////////////////////////////////////////
	cout << "test 1" << endl;
	cout << "Expected: -1 0 0 0 -1 0 2" << endl;
	string patter_ryf = "ABCDABD";
	vector<int> vecNextRYF = InitVectorNext_author_method(patter_ryf);
	for (int i = 0; i < vecNextRYF.size(); i++) {
		cout << vecNextRYF[i] << " ";
	}
	cout << endl;
	/////////////////////////////////////////////////////////////////
	cout << "test 2" << endl;
	cout << "Expected: -1 0 0 -1 0 0 -1 4 -1 0" << endl << "Actual: ";
	string pattern_paper = "abcabcacab";
	vector<int> vecNext_author = InitVectorNext_author_method(pattern_paper);
	for (int i = 0; i < vecNext_author.size(); i++) {
		cout << vecNext_author[i] << " ";
	}
	cout << endl;
	/////////////////////////////////////////////////////////////////
	{
		cout << "========My method============" << endl;
		string txt1 = "aabracadabra abacadabrabracabracadabrabrabracad";
		string pattern1 = "abracadabra";
		cout << "===========================" << endl;
		vector<int> vecNext = InitVectorNext_my_method(pattern1);
		cout << search(txt1, pattern1, vecNext) << endl;

		string txt2 = "abacadabrabracabracadabrabrabracad";
		//string txt2 = "rrabasdsfsdasdfra";
		string pattern2 = "rab";
		cout << "===========================" << endl;
		vector<int> vecNext2 = InitVectorNext_my_method(pattern2);
		cout << search(txt2, pattern2, vecNext2) << endl;
	}	
	{
		cout << "========Author's method============" << endl;
		string txt1 = "aabracadabra abacadabrabracabracadabrabrabracad";
		string pattern1 = "abracadabra";
		cout << "===========================" << endl;
		vector<int> vecNext = InitVectorNext_author_method(pattern1);
		cout << search(txt1, pattern1, vecNext) << endl;

		string txt2 = "rrarabasdsfsdasdfra";
		string pattern2 = "rab";
		cout << "===========================" << endl;
		vector<int> vecNext2 = InitVectorNext_author_method(pattern2);
		cout << search(txt2, pattern2, vecNext2) << endl;
	}	
}

void testCalcLongestFixed()
{
	string pattern = "aaabc";
	string s1 = "aaac"; // aaax處失配
	assert(calcLongestFixed(s1, pattern) == 2);

	string s2 = "aaabd"; // aaabx處失配
	cout << calcLongestFixed(s2, pattern);
	assert(calcLongestFixed(s2, pattern) == 0);
}

4.小結

KMP算法主要優化字符查找的效率出發,通過觀察和假設,將問題轉化爲尋找一個最小偏移量的問題,之後進一步將問題轉化爲尋找模式串中前綴和後綴的最大匹配長度。最後通過這個最大匹配長度,反向計算出最小偏移量,得到的問題的解。問題的轉化和化簡,循序漸進,最終得到了這個問題的一個高效解O(M+N)!要不是前前後後翻來覆去的看論文的前幾節的描述,差點這個過程擦肩而過了。

歡迎一起探討相關問題!

5. 引用文獻

1. 論文:FAST PATTERN MATCHING IN STRINGS, DONALD E. KNUTHf, JAMES H. MORRIS

2. 字符串匹配的KMP算法——阮一峯

3. 從頭到尾徹底理解KMP(2014年8月22日版)——高閱讀量的,不過我感覺還是沒看明白

4. KMP算法證明

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章