標籤:kmp algorithm 字串匹配
“淺析kmp演算法”
By 鐘桓
9月 16 2014 更新日期:9月 16 2014
文章目錄
- 1. 暴力匹配:
- 2. 真首碼和真尾碼,部分匹配值
- 3. 如何使用部分匹配值呢?
- 4. 尋找部分匹配值
- 5. 拓展
- 5.1. 最小覆蓋字串
- 6. 參考資料
首先,KMP是一個字串匹配演算法,什麼是字串匹配呢?簡單地說,有一個字串“BBC ABCDAB ABCDABCDABDE”,我想知道這個字串裡面是否有“ABCDABD”;我想,你的腦海中馬上就浮現了一個簡單的暴力演算法,是的,它也有名字,叫做暴力匹配,就是從頭開始進行匹配,如果不行的話,就從主字串的下一個繼續。看下面的圖結合文字會更清晰些:
暴力匹配:
1
首先,字串”BBC ABCDAB ABCDABCDABDE”的第一個字元與搜尋字詞”ABCDABD”的第一個字元,進行比較。因為B與A不匹配,所以搜尋字詞後移一位。
2
因為B與A不匹配,搜尋字詞再往後移。
3
就這樣,直到字串有一個字元,與搜尋字詞的第一個字元相同。
4
接著比較字串和搜尋字詞的下一個字元,還是相同。
5
直到字串有一個字元,與搜尋字詞對應的字元不相同為止。
6
這時,最自然的反應是,將搜尋字詞整個後移一位,再從頭逐個比較。
雖然這樣做可行,但是你有沒有想過這樣的效率很差,因為你要把”搜尋位置”移到已經比較過的位置,重比一遍。
真首碼和真尾碼,部分匹配值
上面說了,暴力匹配的效率是非常低下的,但是我們有什麼辦法讓效率提升呢?讓我們先來瞭解三個概念,“真首碼”和“真尾碼”;這個比較好理解,看下面就可以理解了。
- "A"的真首碼和真尾碼都為空白集,共有元素的長度為0; - "AB"的真首碼為[A],真尾碼為[B],共有元素的長度為0; - "ABC"的真首碼為[A, AB],真尾碼為[BC, C],共有元素的長度0; - "ABCD"的真首碼為[A, AB, ABC],真尾碼為[BCD, CD, D],共有元素的長度為0; - "ABCDA"的真首碼為[A, AB, ABC, ABCD],真尾碼為[BCDA, CDA, DA, A],共有元素為"A",長度為1; - "ABCDAB"的真首碼為[A, AB, ABC, ABCD, ABCDA],真尾碼為[BCDAB, CDAB, DAB, AB, B],共有元素為"AB",長度為2; - "ABCDABD"的真首碼為[A, AB, ABC, ABCD, ABCDA, ABCDAB],真尾碼為[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的長度為0。
從上面的例子可以體會到吧,真首碼就是從字串第一個字元開始的所有字串,但是不包括它自身;對於真尾碼同理。
那麼什麼是部分匹配值呢?注意到上面提到的共有元素的長度了嗎?部分匹配值的意思就是當前串的真首碼和真尾碼中字串相同的最大長度。“AB”的真首碼和真尾碼中沒有相同的,所以部分匹配值是0;“ABAB”的部分匹配值是2,因為真首碼中的“AB”和真尾碼中的“AB”匹配,長度為2,所以部分匹配值是2。
如何使用部分匹配值呢?
讓我們來看一些前面的例子,在“BBC ABCDAB ABCDABCDABDE”中匹配“ABCDABD”。
首先看一下ABCDABD的部分匹配表:
部分匹配表中的每一個值,對應的都是每一個字元為結尾的子串的部分匹配值。像“AB”,部分匹配值是0,所以對應的表裡的值是0;“ABCDAB”,部分匹配值是2,所以對應的表裡的值是2;
那麼我們如何來用它呢?上面的暴力匹配我們說了,當ABCDABD的最後一個D和“ ”不匹配時,暴力匹配方式只會把ABCDABD右移一位,然後繼續匹配。我們前面也說了,這樣的方式沒有充分利用一些資訊。
那麼我們該如何利用上面的資訊呢?
比如前面我們說的情況,看下面的圖:
前面的這個時候,我們只是讓“ABCDABD”右移一位。但是有沒有發現,其實前面已經匹配上的“ABCDAB”這一部分的資訊都知道,所以我們知道“ABCDAB”右移一位依然無法匹配,這個時候,我們只需要考慮ABCDAB的真首碼和真尾碼匹配最多,如果我們知道這個真首碼和真尾碼,那麼我們就知道如何移動了。只需要移動至真首碼和真尾碼部分匹配即可。而這裡就是需要考慮部分匹配值了。
為什麼是這樣呢?我們可以簡單地證明一下。我們知道“ABCDAB”的部分匹配值,2,也就是說真首碼和真尾碼最大的匹配長度是“AB”這一部分。我們只需要將“ABCDAB”的首碼的“AB”移動至和尾碼的“AB”匹配。假設我們不移動到它們匹配,在前面部分也可能匹配,那麼它們的部分匹配值應該更大,但是這裡最大就是2了。所以,假設不成立。所以我們只需要將最長的 真首碼和真尾碼 匹配即可。
匹配的時候,我們可以利用部分匹配值。
移動位元 = 已匹配的字元數 - 對應的部分匹配值
對於“ABCDAB”,部分匹配值2,6-2=4;所以將搜尋字詞向後移動4位即可。
因為空白格與C不匹配,搜尋字詞還要繼續往後移。這時,已匹配的字元數為2(”AB”),對應的”部分匹配值”為0。所以,移動位元 = 2 - 0,結果為 2,於是將搜尋字詞向後移2位。
因為空白格與A不匹配,繼續後移一位。
逐位比較,直到發現C與D不匹配。於是,移動位元 = 6 - 2,繼續將搜尋字詞向後移動4位。
逐位比較,直到搜尋字詞的最後一位,發現完全符合,於是搜尋完成。如果還要繼續搜尋(即找出全部匹配),移動位元 = 7 - 0,再將搜尋字詞向後移動7位,這裡就不再重複了。
尋找部分匹配值
現在的問題是,我們如何來尋求這個部分匹配值,在上面的過程中,我們可以發現,只要我們知道部分匹配值了,就能夠讓匹配的速度加快。而對於部分匹配值,我們關心的其實就是那個搜尋字詞。所以從搜尋字詞入手。
我們定義這樣一個數組next[],T標示匹配字串,P標示搜尋字詞。
那麼next數組表示什麼呢?看下面的表格:
| 搜尋字詞 |
A |
B |
C |
D |
A |
B |
D |
| next |
-1 |
0 |
0 |
0 |
0 |
1 |
2 |
和上面的部分匹配表對比一下,你會發現,next數組就是 部分匹配值 整體向右移動了一位, 然後初始值賦值為 -1。
其實next數組也有含義,next[j]的值表示,當P[j] != T[i]時,指標 j 的下一步移動位置。
當j=0時不匹配怎麼辦?這個時候next[j]= -1;表示T需要左移1位。
所以當 P[j] != T[i] 時, 另 j = next[j] ,然後繼續匹配。
當 P[j] == T[i] 時,i和j 分別都前進一位。
那麼next數組該怎麼求解呢?
當P[k] == P[i] 時,有 next[j+1] = next[j] +1;
當P[k] != P[i] 時,有 k = next[k]; 然後繼續匹配。
如果 k == -1; 那麼這個時候,表示P的第0字元都和現在的第i個字元不匹配,則 next[i] = 0; k++, i++;
所以,綜上,便有了下面的程式。下面的getNext是獲得next數組,KMP是進行匹配,下面的程式是poj3461 的樣本程式。
import java.util.Scanner;public class Main{ public int[] getNext(String P){ int[] next = new int[P.length()]; // next 數組表示的是當 P[i]和P[k]不匹配時,k應該跳轉到哪一個位置 //這裡的i時尾碼指標, k是首碼指標 next[0]=-1; // 因為開頭的比較特殊,如果它不匹配,那麼移動的應該是T,T應該左移,-1標示T左移 int i=0,k=-1; while(i < P.length()-1) { if(k<0 || P.charAt(i) == P.charAt(k)) { next[++i] = ++k; }else k = next[k]; } return next; } public int KMP(String T, String P){ int res=0; int[] next = getNext(P); int i=0,j=0; while(true) { if(i >= T.length()) break; if( j==-1 || T.charAt(i) == P.charAt(j)) { j++; if(j == P.length()) { res++; j = next[j-1]; }else i++; }else j = next[j]; } return res; } public void run(){ Scanner scan = new Scanner(System.in); int n = scan.nextInt(); scan.nextLine(); while(n>0){ String P = scan.nextLine(); String T = scan.nextLine(); System.out.println(KMP(T,P)); n--; } } public static void main(String args[]){ new Main().run(); }}
拓展最小覆蓋字串
最小覆蓋子串(串尾多一小段時,用首碼覆蓋)長度為n-next[n](n-pre[n]),n為串長。
證明分兩部分:
1-長為n-next[n]的首碼必為覆蓋子串。
當next[n]<n-next[n]時,a,長為next[n]的首碼A與長為next[n]的尾碼B相等,故長為n-next[n]的首碼C必覆蓋尾碼B;
當next[n]>n-next[n]時,b,將原串X向後移n-next[n]個單位得到Y串,根據next的定義,知長為next[n]的尾碼串A與長為首碼串B相等,X串中的長為n-next[n]的首碼C與Y串中的首碼D相等,而X串中的串E又與Y串中的D相等……可見X串中的長為n-next[n]的首碼C可覆蓋全串。
2-長為n-next[n]的首碼是最短的。
c,串A是長為n-next[n]的首碼,串B是長為next[n]的尾碼,假設存在長度小於n-next[n]的首碼C能覆蓋全串,則將原串X截去前面一段C,得到新串Y,則Y必與原串長度大於next[n]的首碼相等,與next數組的定義(使str[1..i]前k個字母與後k個字母相等的最大k值。)矛盾。得證!有人問,為什麼Y與原串長大於next[n]的首碼相等?由假設知原串的構成必為CCC……E(E為C的首碼),串Y的構成必為CC……E(比原串少一個C),懂了吧!
一個字串A(1 <= |A| <= 1000000)可以寫成某一個子串B重複N次所得,記為A = B^N,求最大的N。
演算法分析:
令L = |A|,容易發現,用KMP自匹配後L - p[L]即得到最小覆蓋子串的長度。
下面我們要證明一個問題:一個字串的覆蓋子串長度,一定是它的最小覆蓋子串長度的倍數。
設最小覆蓋子串長度d整除L, 假設存在u > d滿足u整除L且d不整除u。
易得,Ai = A(i + d),Ai = A(i + u),則A(i + d) = A(i + u),即Ai = A(i + u - d),不斷進行可得到A_i = A(i + u - kd)(k為正整數)。
因為d不整除u,那麼必然存在k使得0 < u - kd < d,與d是最小迴圈子串長度矛盾。
所以,最小覆蓋子串長度若為L的約數則得解否則輸出1。時間複雜度O(L)。
最小覆蓋字串的例題 poj2406 , 代碼可以參考以下:
import java.util.Scanner;public class Main{ public int getNext(String P){ int[] next = new int[P.length()+10]; // next 數組表示的是當 P[i]和P[k]不匹配時,k應該跳轉到哪一個位置 //這裡的i時尾碼指標, k是首碼指標 next[0]=-1; // 因為開頭的比較特殊,如果它不匹配,那麼移動的應該是T,T應該左移,-1標示T左移 int i=0,k=-1; while(i < P.length()) { if(k<0 || P.charAt(i) == P.charAt(k)) { next[++i] = ++k; }else k = next[k]; } return P.length()-next[P.length()]; } public void run(){ Scanner scan = new Scanner(System.in); while(scan.hasNext()){ String P = scan.nextLine(); if(P.charAt(0)==‘.‘) break; int t = getNext(P); int len = P.length(); if(len%t == 0) { System.out.println(len/t); }else System.out.println(1); } } public static void main(String args[]){ new Main().run(); }}
參考資料
“淺析kmp演算法”