神奇的KMP——線性時間匹配算法(初學者請進)


前言

我其實一直非常渴望一種線性時間的字符串匹配算法,我之前曾經做過一些自己的解釋器。要知道解釋器可是需要大量的字符串匹配工作的,但是當時我所知道的最快的匹配算法也不過就是“朴素”,這就導致了我的解釋器效率顯得比較低。最近我終於見到了夢寐以求的線性時間匹配算法——KMP。但是,這個算法很抽象、很難理解,看了很多博客也沒看懂。所以,我決心寫一篇博客,一篇算法初學者也能看得懂的算法博客。

(p.s:解釋器(英語:Interpreter),又譯為直譯器,是一種電腦程序,能夠把高級編程語言一行一行直接轉譯運行。解釋器不會一次把整個程序轉譯出來,只像一位”中間人”,每次運行程序時都要先轉成另一種語言再作運行,因此解釋器的程序運行速度比較緩慢。它每轉譯一行程序敘述就立刻運行,然后再轉譯下一行,再運行,如此不停地進行下去。)

(感興趣的同學可以自己做個解釋器試試,不過強烈建議進修一下“AC自動機”算法,我過一會也去進修一下。)

(另外,我的文章中小括號中的內容是非常重要的,請大家務必注意!)

1.朴素字符串匹配算法

首先,字符串匹配算法是做什么的呢?插入一段百度百科的定義:

字符串匹配是計算機科學中最古老、研究最廣泛的問題之一。一個字符串是一個定義在有限字母表∑上的字符序列。例如,ATCTAGAGA是字母表∑ = {A,C,G,T}上的一個字符串。字符串匹配問題就是在一個大的字符串T中搜索某個字符串P的所有出現位置。其中,T稱為文本,P稱為模式,T和P都定義在同一個字母表∑上。

請忽略掉上文中除了加粗部分外的所有文字。

比如:我要在字符串T=“abcabcdeabcd”中做字符串P=“bcd”的匹配,計算結果就為:4和9。你可能會問:“不應該是5和10嗎?”這沒有錯,在C語言(或是C++語言中)字符串是從“0”數起的,第一個字符的“下標”為“0”,第二個字符的“下標”為1,以此類推。字符串出現的位置用這個子串的“串首索引數”表示。

然后我們再說朴素算法。

void strSearch(char T[],char P[])//在T中做P的匹配
{
int start=0;//表示當前的匹配是從start位置處開始的
int i=0,j=0;//i,j分別表示T和P的當前位置
while(T[i]!=0)
{
if(P[j]==0)//如果P[j]走到了字符串尾
{//說明這次匹配是成功的
cout<<start<<endl;//輸出當前子串的索引數
start++;
i=start;
j=0;//從T[start+1]和P[0]處重新匹配
}
if(T[i]==P[j])//當前為滿足匹配
{
i++;
j++;//繼續判斷下一位
}else{//當前位不滿足匹配
start++;
i=start;
j=0;//從T[start+1]和P[0]處重新匹配
}
}
if(P[j]==0)
cout<<start<<endl;//如果不加這一行代碼可能會忽略最后一次匹配的成果
}

朴素算法的原理是很好解釋的:讓start的值從0開始,對比從T中從start處開始的子串與P,一旦發現了不同的字符就說明start並不是一個我想要的解。然后start++,循環執行下去,復雜度為O(mn),m、n為這兩個字符串的長度。


2.KMP算法思想

其實KMP的算法思想對於我們這些“小白”來說很抽象。我試着去理解了很長一段時間,才對KMP有了一點點初步的認識。再加上博客大神不能理解我們“小白”的痛苦,所以我決心要研究出一個所有人都能看得懂的講解。

首先,想一想為什么朴素算法的復雜度那么高。因為,每一次“失配”(匹配失敗)的時候,都要“從零開始”重頭再來,從j=0開始進行匹配。假設有這么一種情況:我要在一個字符串中匹配P=“aabaaa”。然后我在母串中找到了一個“aabaaa”(如圖),最后一位失配,那么我剛剛做的那么多次(7次)的比較運算其實就相當於是白算了。那能不能找到一種方法讓我之前大量的計算不白算呢?

出現了一次失配

如果我能把剛才的那種情況直接轉化為下面這張圖片的情形,其實就可以很好地解決這個問題:

我想要達到的跳轉狀態

現在是你開動起腦筋的時刻了,接下來的內容真的不是很好想。P是一個6位長的字符串,到目前為止它的前五位已經被成功配對,但是它的第六位配對失敗。我們就要利用我們已經得到的結果——就是畢竟它成功地匹配了五位。這五位的后兩位字符為“aa”,而匹配串的前兩位也為“aa”,如果我從這里開始是不是可以默認前兩個“aa”已經完成進行了配對,而從第三個字符開始繼續匹配。也就是說,當前已經匹配成功的部分有一個(不為整個子串的)后綴,為P的一個前綴。為了保證我的計算結果一定是正確的,我每一次都要找到子串中最長的(而且不為整個子串的)、並且為匹配串P的前綴的一個后綴,然后把它們相同的位“對齊”到一塊,再繼續進行匹配。

這便是KMP的算法思想了:

KMP算法是一種用於字符串匹配的算法,這個算法的高效之處在於當在某個位置匹配不成功的時候可以根據之前的匹配結果從模式字符串的另一個位置開始,而不必從頭開始匹配字符串。——360百科

你可能會覺得:“每次你還要計算一個最長的后綴為匹配串的前綴,這不是更慢嗎?”是的,所以說我們可以把這個移動的量做一個預處理,先把它們計算出來,再進行T和P的匹配。而我知道我所找到的“子串”的前幾位其實就是和P是相同的,所以這就使得這個移動量與T(也就是母串)沒有了半毛錢關系。

其實這個預處理的過程其實倒更像是用匹配串P 自己匹配自己 的過程。
(這句話不懂沒關系,請同學們慢慢體會。)


3.KMP的實現

我們可以定義一個數組,叫做“失配函數”f,f[k]用來表示當j=k匹配失敗之后,我們就讓j=f[k]從而繼續匹配。分析這樣兩個問題:一、f[0]一定要等於0(這是我們人為規定的)。二、f[1]也一定等於0,因為1號字符是字符串中的第2個字符如果它失配之后,只能把首位移動過來(我們每一次移動都是選取這個字符左側的一個位置移動到當前位置繼續匹配,而1號結點左側只有一個字符,就是0號字符),因此f[1]=0。

(接下來的部分很不好理解,我盡量說得明白一些。)

有了這兩個邊界條件我們就可以進行遞推了:對於第i位的失配f[i],已知如果f[i-1]=j(也就是j的位置之前的P串為匹配到i-1號字符而產生的子串的一個后綴),如果P[i]恰好等於P[j],是不是就說明f[i]就應該等於j+1。因為P前i-1的部分已經可以和Pj前的部分形成匹配,而且P[i]==P[j],這就說明P前i的部分可以和j+1前的部分形成匹配。但如果P[i]!=P[j],那么我們就要令j=f[j](根據遞推的原理我們目前正在計算f[i+1]而j一定小於等於i,所以f[j]一定已經計算完成,所以可以直接調用),(你可以把這想想成是P[i]與P[j]的失配)然后繼續進行匹配。直到進行到j=0為止。(因為如果已經計算到j=0,這已經是最前面的一個字符了,不可能再找到一個更靠前的字符作為“移動量”。又因為f[0]=0,如果繼續循環地計算j=f[j]的話就會出現死循環,所以一定要進行判斷然后退出循環。)

現在是時候給大家看一看代碼了:

int f[PLenMax]={};//失配函數,PLenMax表示P的最大長度
void getFail(char* P,int* f) //建立失配函數
{
int m=strlen(P);//文本串的長度
f[0]=0;
f[1]=0;//遞推的邊界值
for(int i=1;i<m;i++)//對於除了起始點外的每一位,遞推計算f[i+1]
{
int j=f[i];
while(j!=0 && P[i]!=P[j])
j=f[j];
//沿着失配邊走直到走到零或一個不為零的點滿足P[i]等於P[j]
f[i+1]=(P[i]==P[j])?j+1:0;//p.s:應該沒有“問號冒號表達式”不懂的同學吧...
//如果P[i]等於P[j]則F[i+1]=j+1
//否則f[i+1]=0;
}
}

void find(char* T,char* P,int* f)//KMP的匹配函數
{
int n=strlen(T),m=strlen(P);//分別求出兩個串的長度
getFail(P,f);//計算失配函數
int j=0;
for(int i=0;i<n;i++)
{
while(j!=0 && P[j]!=T[i])//如果失配,按照失配函數找到上一次匹配
j=f[j];
if(P[j]==T[i])//如果匹配成功就把j+1匹配下一位
j++;
if(j==m)//找到匹配
printf("%d\n",i-m+1);//輸出結論
}
}

這樣KMP就實現了,是不是特別的激動,反正我是特別的激動!


4.后續的一些分析

關於這個算法的時間復雜度分析:這個算法分成兩部分,初始化部分和匹配部分。為了便於分析我們令T的長度為n,P的長度為m。在find函數中有一個i的循環,它的循環次數是n。在這個循環中無論j如何變化,每次循環i都會加一,j的變化的時間復雜度看成O(1),所以說find函數的復雜度為O(n)。同理getFail函數的復雜度為O(m),所以總時間復雜度為O(m+n)。(可能說得比較扯淡啊,望大家諒解。)

(其實我們平時都叫一口一個“KMP”、一口一個“KMP”地叫着這個算法,其實這個算法並不是真正的KMP,它只是“MP”,而KMP需要進一步的優化處理,感興趣的同學可以自己度娘一下。)

新手上路,請多關照。如有謬誤,敬請諒解!

(網上給出的解釋實在是看不懂,所以我決定把它粘在最后面,有感興趣的同學可以研究一下!)

(“KMP算法”是)一種由Knuth(D.E.Knuth)、Morris(J.H.Morris)和Pratt(V.R.Pratt)三人設計的線性時間字符串匹配算法。這個算法不用計算變遷函數δ,匹配時間為Θ(n),只用到輔助函數π[1,m],它是在Θ(m)時間內,根據模式預先計算出來的。數組π使得我們可以按需要,”現場”有效的計算(在平攤意義上來說)變遷函數δ。粗略地說,對任意狀態q=0,1,…,m和任意字符a∈Σ,π[q]的值包含了與a無關但在計算δ(q,a)時需要的信息。由於數組π只有m個元素,而δ有Θ(m∣Σ∣)個值,所以通過預先計算π而不是δ,使得時間減少了一個Σ因子。


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2021 ITdaan.com