引言
字符串匹配一直是計(jì)算機(jī)科學(xué)領(lǐng)域研究和應(yīng)用的熱門領(lǐng)域,算法的改進(jìn)研究一直是一個(gè)十分困難的課題。作為字符串匹配中的經(jīng)典算法,KMP算法一直以較高的效率和富有美感的構(gòu)思聞名。本文希望由傳統(tǒng)的精確匹配算法入手,層層推進(jìn),研究KMP算法思想的演變歷程。
1.傳統(tǒng)的精確匹配
1.1 精確匹配的含義
字符串的精確匹配就是在文本T中找出模式P的精確副本。這是一個(gè)要么完全匹配要么、完全不匹配的方法。如果模式P和T的一個(gè)子字符串非常類似,但不相同,就拒絕部分匹配。
我們首先約定一些符號(hào):文本T是一系列符號(hào)、字符或字母,|T|表示T的長度,T(i)是T中位置i上的字符,T(i…j)是T中從位置i開始到位置j結(jié)束的子字符串,模式P(指在T中尋找匹配的字串)和文本T中的第一個(gè)字符在位置0上。另外,正則表達(dá)式a^n表示字符串a(chǎn)….a,其中包含n個(gè)a。
1.2 強(qiáng)制匹配
字符串精確匹配最簡單的算法即是直觀的“暴力破解”,這種稱為強(qiáng)制匹配的算法從文本T的第一個(gè)字母和模式P的第一個(gè)字母開始比較P和T,如果不匹配,就從T的第二個(gè)字符開始和模式P的第一個(gè)字母匹配,以此類推,不保留在后續(xù)嘗試中可能有用的信息。偽代碼十分常見,就不在此贅述了。
在最壞情況下,強(qiáng)制匹配的執(zhí)行時(shí)間是O(|T||P|)。
1.3 強(qiáng)制匹配的改進(jìn)
對于強(qiáng)制匹配算法,1992年Hancart提出了一種稱為not-so-naive算法的改進(jìn)。該算法從P的第二個(gè)字符開始比較,直到P的結(jié)尾,最后比較第一個(gè)字符,所以比較過程中的字符順序是P(1),P(2),…….,P(|P|-1),P(0)。
記錄下P的前兩個(gè)字符相等的信息,并在匹配過程中使用。要區(qū)分兩種情況:P(0)=P(1)和P(0)!=P(1)。在第一種情況下,如果P(1)!=T(i+1),就給文本索引i遞增2,因?yàn)镻(0)!=T(i+1);否則,i就遞增1。這類似于第二種情況中的P(1)=T(i+1)。這樣,就可以移動(dòng)兩個(gè)位置。
匹配從第二個(gè)字符開始。如果在P(1)和T(i+1)之間又一個(gè)不匹配的字符,則只要P的前兩個(gè)字符是相同的,P就可以移動(dòng)兩個(gè)位置,然后開始下一次迭代,因?yàn)檫@種不匹配意味著P(0)和T(i+1)也是不同的。但是,當(dāng)內(nèi)層循環(huán)中出現(xiàn)不匹配之后,模式只移動(dòng)一個(gè)位置。 而P的前兩個(gè)字符不同時(shí),采取的措施則相反。
在最壞情況下,該算法的執(zhí)行時(shí)間是O(|T||P|),但如Hancart所述,它的平均執(zhí)行性能比起KMP、Boyer-Moore等字符串匹配算法要好。
2. KMP算法
2.1 可以改進(jìn)什么?
強(qiáng)制算法的效率很低,因?yàn)樗谡也坏狡ヅ涞淖址螅涯J絇移動(dòng)一個(gè)位置。為了加快算法的執(zhí)行速度,Hancart的算法允許移動(dòng)兩個(gè)字符。但是,我們應(yīng)該需要一種算法,可以將P向右移動(dòng)多個(gè)位置,同時(shí)不遺漏任何匹配。
強(qiáng)制算法效率不高的根源是進(jìn)行了多余的比較。要避免這種冗余,應(yīng)該注意模式P在其開頭到不匹配的字符之間包含的相同的子字符串,利用這一事實(shí),可以把P向右移動(dòng)多個(gè)位置,之后開始下一次掃描。
2.2 如何改進(jìn)
從上文可以看出,一般情況下,當(dāng)T(K+1)與P(j+1)發(fā)生不匹配,而P(0…j)都已經(jīng)完成匹配時(shí),我們可以嘗試在P(0…j)中尋找與其后綴相匹配的前綴,假設(shè)匹配的前后綴字串的長度都是len,即P(0…len-1)和P( j-len+1….j )是兩個(gè)相同的字串,前綴從P的開頭起始,后綴以P的結(jié)尾結(jié)束,長度都是len,它們有相同的內(nèi)容。
因?yàn)镻(0….j)已經(jīng)完成了匹配,故T(K-len+1…K)與P(j-len+1….j)是匹配的,而P(j-len+1….j)與P(0…len-1)是匹配的。所以P(0…len-1)與T(K-len+1….K)是匹配的,當(dāng)T(K+1)與P(j+1)不匹配時(shí),我們可以不用像強(qiáng)制算法要求那樣將P(0)與T(K-j+2)重新開始匹配,可以將P(len-1)與T(K)對齊,從P(len)與T(K+1)開始比較。
由之前的論證可以知道,這樣是沒有錯(cuò)誤的,P(0…len-1)已經(jīng)完成匹配。而由于P(len…j-len)中在之前的子串匹配中已知無法與T(K-len+1…K)中某一子串匹配,故無法完成模式匹配,可知P(len-1)與T(K)對齊沒有遺漏或是跳過可能的模式匹配的情況。
在匹配過程中,P與T會(huì)多次不匹配,此時(shí)就需要移動(dòng)P或T使之到指定的位置,上述的信息將會(huì)使用多次。因此,P應(yīng)該進(jìn)行預(yù)處理。重要的是,在這種方法中,只使用有關(guān)P的信息,T中字符的配置無關(guān)緊要。
定義表next:
- 當(dāng)j=0時(shí),next[ j ]= -1;
- 如果k存在,next[ j ]= max{k:0< k <j,P[0...k-1] = P[j-k...j-1] };
- 其他,next[ j ]= 0 。
也就是說,next[ j ]表示子字符串P(0…j-1)中與相同字符串前綴匹配的最長后綴的長度。
條件k<j表示前綴也是一個(gè)正確的后綴,但這里由于P[0...k-1],我們不接受同是前綴和后綴的子串。沒有這個(gè)條件,P(0...2)=aab 的 next[2] 應(yīng)該是2,因?yàn)閍a既是aa的前綴也是后綴,但有了這個(gè)條件,next[2] = 1而不是2。
同是應(yīng)該注意重疊的情況,比如ababa最長匹配的后綴是aba,長度為3。
假設(shè)我們現(xiàn)在已經(jīng)獲得了處理模式P得到的next數(shù)組,至于處理方式,我們稍后再談。由強(qiáng)制匹配算法的偽代碼我們可以較容易的得到Knuth-Morris-Pratt算法的偽代碼:
1.Kunth-MorrisPratt(模式 P,文本 T)
2. findNext(P,next);
3. i = j = 0;
4. while i <= |T| - |P|
5. while j == -1 or (j<|P| and T[ i ] == P[ j ])
6. i++;
7. j++;
8. if j == |P|
9. return 在i - |P|處的匹配;
10. j = next[ j ];
11. return 沒有匹配;
````
i 標(biāo)記了文本T正在匹配的字符位置,j 標(biāo)記了模式P正在匹配的字符位置。當(dāng) j == -1時(shí),正在進(jìn)行模式P的第一個(gè)字符匹配,無字符完成匹配,因此進(jìn)入匹配過程。當(dāng) j < |P| 時(shí),說明匹配未完成, 嘗試對比P[ i ] == P[ j ]。若匹配,則更新i 和 j 的值,執(zhí)行下一對字符的匹配;若失敗,則此次匹配失敗,跳出循環(huán)。跳出循環(huán)后,判斷 j 是否等于 |P|,若等于,則說明P[0…|P|-1]都已經(jīng)完成匹配,即模式P完成精確匹配,從文本T的 i - |P| 處開始達(dá)成精確匹配。若不等于,則匹配未完成,且在P[ j ] 和 T[ i ]處失敗,根據(jù)之前的論述,此時(shí)我們可以查詢next數(shù)組,將P[next[ j ]]與T[ i ]對齊,然后繼續(xù)執(zhí)行匹配直到 i>|T|-|P| ,此時(shí)文本T已比較完,沒有可與模式P匹配的字串,匹配失敗。
注意,在比較過程 i 只會(huì)遞增或是不變,不會(huì)減少。
###2.3 求next數(shù)組
表next仍然沒有確定,我們可以使用強(qiáng)制算法來確定它,對于短模式而言,效率并不算低。還可以使用KMP算法提高確定next的效率。
next包含P的匹配前綴中最長后綴的長度,即P的一些部分與P的其他部分匹配。但匹配問題已經(jīng)使用KMP算法解決了,在這種情況下,P再次與其自身匹配。但是,KMP使用的是目前仍未知的next。所以,必須修改KMP算法,使之使用已經(jīng)找到的值確定next的值。設(shè)next [0] = -1,假定next[ 0 ],…next[i-1],應(yīng)該考慮兩種情況:
+ 在第一種情況下,我們要找出匹配前綴的最長后綴,只要把字符P[i-1]與對應(yīng)于位置next[i-1]的后綴關(guān)聯(lián)起來,當(dāng)P[i-1]=P[next[i-1]]時(shí),它為真:
在這種情況下,當(dāng)前的后綴比前面找到的后綴多一個(gè)字符,所以next[ i ] = next[ i-1 ]+1。
+ 在第二種情況下,P[i-1] != P[next[ i-1 ]]。但這只是一個(gè)不匹配的字符,不匹配可以用表next做處理,這就是要確定它的原因。因?yàn)镻[next[i-1]]是一個(gè)不匹配的字符,所以要檢查next[next[i-1]],確定P[i-1]是否匹配,如果匹配,就給next[i]賦值next[next[i-1]]+1。
否則,就比較P[i-1]和P[next[next[next[i-1]]]],如果字符匹配,就使next[ i ] = next[next[next[i-1]]]+1,否則,繼續(xù)搜索,直到找到一個(gè)匹配,或達(dá)到P的開頭為止。
確定表next的算法如下:
````
1.findNext(模式 P,表 next)
2. next[ 0 ] = -1;
3. i = 0;
4. j = -1;
5. while i < |P|
6. while j==0 或 i < |P| 且 P[i] == P[j]
7. i++;
8. j++;
9. next[ i ] = j;
10. j = next[j];
````
###2.3 next數(shù)組的改進(jìn)
如果去除不必要的比較,就可以改進(jìn)Knuth-Morris-Pratt算法。如果在字符T[i]和P[j]處出現(xiàn)不匹配,下一次就應(yīng)該嘗試匹配字符T[i]和P[next[j]+1],但如果P[j]=P[next[j]+1],則還會(huì)發(fā)生不匹配,這意味著一次多余的比較。
因此,我們應(yīng)該重新設(shè)計(jì)表next,去除這種多余的比較。這里可以使用擴(kuò)展next定義的方法,再加上一個(gè)條件,得到一個(gè)更強(qiáng)健的next列表:
1. 當(dāng)j=0時(shí),next[j]=-1;
2. 如果K存在的話,next[j]=max{k:0<k<j,P[0...k-1]=P[j-k...j-1],
P[k+1]!=P[j]};
3. 其他,next[ j ]= 0。
為了計(jì)算nextS,算法findNext()需要考慮新加的條件,略作修改:
````
1.findNextS(模式 P , 表 nextS)
2. nextS[0] = -1;
3. i = 0;
4. j = -1;
5. while i < |P|
6. while j==-1 或 i < |P| 且 P[ i ] == P[ j ]
7. i++;
8. j++;
9. if P[i] != P[j]
10. nextS[i] = j;
11. else nextS[i] = nextS[j];
12. j = nextS[j];
````
### 3.運(yùn)行時(shí)間分析
為了評估Kunth-MorrisPratt()的運(yùn)行時(shí)間,注意外層循環(huán)執(zhí)行了O(|T|)次。而因?yàn)樵诿看蔚难h(huán)迭代中,i都遞增,根據(jù)外層循環(huán)的條件,i的最大值是|T|-|P|,所以內(nèi)層循環(huán)至多執(zhí)行|T|-|P|。但對于不匹配的字符T[ i ],j賦予新值的次數(shù)是k < |P|。此時(shí),P中第一不匹配的字符與字符T[i+k]對齊。
對于i,可以執(zhí)行|P|次比較,但每個(gè)i不一定都會(huì)執(zhí)行|P|次比較,只有第|P|個(gè)i才會(huì)如此。所以不成功的比較次數(shù)最多為P(|T|/|P|)=|T|。給這個(gè)數(shù)字加上至多|T|-|P|次成功的比較次數(shù),就得到了運(yùn)行時(shí)間O(|T|)。
而findNext()與Kunth-MorrisPratt()十分相似,所以,可以推測出next可以在O(|P|)時(shí)間內(nèi)確定。Kunth-MorrisPratt()中外層while循環(huán)的運(yùn)行時(shí)間是O(T),所以Kunth-MorrisPratt算法,包括findNext()在內(nèi),執(zhí)行時(shí)間是O(|T|+|P|)。
注意,在分析這個(gè)算法的復(fù)雜度時(shí),我們未考慮文本T和模式P中的字符,也就是說,該復(fù)雜度是獨(dú)立于組成P和T的不同字符數(shù)。