字串模式匹配——KMP
Warning:本文從常見的字串模式匹配開始,以通俗易懂的語言闡述了KMP演算法原理和適用的場景,編寫儘量避免使用晦澀的語言及複雜的數學公式,只為作為學習筆記記錄個人的理解過程,追求理論的同學請繞行到《演算法導論》。
ps:本文是小編一字一字的碼出來的,程式碼是一行一行敲出來的,歡迎轉載,但請務必註明出處。
在開發過程中,經常會遇到字串模式匹配的問題,即定位一個子串在主字串中位置,很多高階語言內建了字串模式匹配的API。比如在JAVA中,java.lang.String提供了indexOf()、lastIndexOf()方法供開發者呼叫。但是如果不使用JDK內建的模式匹配函式,我們該如何實現一個高效的模式匹配演算法呢?從一個簡單的示例開始我們的KMP探索之旅。
一、簡單字串匹配演算法
假設我們要從主串T=”goodgoole”中,找到子串P=”goole”的出現的位置。最基本的思路是:主串T和子串P分別定義遊標:i、j,兩個遊標從0開始移動,逐位進行比較,當某個位置的字元不匹配時,將主串游標i回溯到本次比較開始的下一位,將子串的遊標j回溯到0繼續比較。步驟如下:
i=0;j=0;從主串T的第一位開始,T與S的前三個字串都匹配成功,但是第四個字元不相等,則主串第一位定位失敗。如下圖:
此時,遊標i從第四個字元位置回溯到第二個字元,遊標j從第四個字元回溯到第一個字元。i=1;j=0;從主串T第二位開始比較,T[1] = o , P[0] = g,T[1] != P[0] 。如下圖:
子串首位字元與主串第二位字元不相等,向後移動主串的遊標i到第三個字元。i=2;j=0;從主串T第三位開始比較,T[2] =o,P[0]=g, T[2] != P[0] 。如下圖:
i=3;j=0;從主串T第四位開始比較,T[3] =d,P[0]=g, T[3] != P[0] 。如下圖:
i=4;j=0;從主串T第五位開始比較,T[5] =d,P[0]=g, T[5] == P[5] 。如下圖:
同時移動主串游標i、子串游標j。i = 5、j = 1, T[5]= P[1] ,以此類推,直到 T[9]==P[5]; 比較結束。
綜上所述,簡單的說,就是對主串進行大迴圈,以主串T的每一個字元作為開頭與子串的首字元進行比較。如果匹配則同時移動主串游標和子串游標;如果不匹配,則將主串的遊標回溯到第一個比較位的下一個位置,子串則回溯到0,進行下一輪比較,直到遊標達到邊界值為止。
程式碼實現如下:
/**
* 簡單的字串模式匹配
* @param target 目標字串
* @param pattern 模式字串
* @return
*/
int simpleIndex(String target, String pattern) {
int i=0, j=0; //i:主串游標、j:子串游標
while(i < target.length() && j < pattern.length()) {
if(target.charAt(i) == pattern.charAt(j)) {
i++;
j++;
} else {
i = i-j+1;
j=0;
}
}
if(j == target.length()) {
return i-target.length();
} else {
return -1;
}
}
分析一下上面的演算法時間複雜度,最好的情況就是一開始就匹配成功,比如主串為“hello world”,長度為n,子串“hello”,長度為m,時間複雜度為O(m);最壞的情況比如主串為“00000000000001”,子串為”000001“,則時間複雜度為O((n-m+1)*m)。可見這種演算法的效率非常低下,我們把這種演算法稱之為”簡單字串匹配演算法“。
二、KMP演算法
通過前面介紹的簡單字串匹配演算法時間複雜度發現,這種演算法之所以低效是因為不斷的回溯遊標,逐位進行比較,這是非常糟糕的。大牛們不甘忍受這種低效的匹配演算法,在想如果想提高演算法的效率,有沒有可以減少遊標回溯的方法呢?於是有三位前輩:克努特、莫里斯、普拉特發表了一個高效的模式匹配演算法——KMP演算法。
為了將講清楚KMP演算法,我們從兩個示例開始,比較KMP演算法與樸素匹配演算法。
示例一
如果主串為”abcdefgabc“,要匹配的子串為”abcdx“,如果按照樸素匹配演算法,則實現步驟如下:
主串前四位與子串的前四位匹配成功,直到第五位e與x不匹配,則移動主串的遊標到第二位,與子串首位進行比較,以此類推:2、3、4、5、6步驟比較的結果都是首位字元不匹配。仔細分析發現,對於要匹配的子串P=”abcdx“,首位字元a與之後的任意一位字元都不相等,而主串和子串的前四位完全匹配,則意味著P的首位不可能與主串的2、3、4位字元相等,當第五位字元不相等時,則不需要用子串的首位與主串的2、3、4位進行比較,由此可見,上圖中2、3、4步驟可以跳過,這是理解KMP演算法的關鍵。
示例二
示例一的特殊性在於要匹配的子串P=”abcdx”中不包含重複的字串,如果待匹配的字串中包含重複的字元該如何處理呢?如果主串為”abcababcabx“,要匹配的子串為”abcabx“,如果按照樸素匹配演算法,則實現步驟如下:
在步驟1中,主串T的前五位與子串的前五位匹配,第六位不匹配,則回溯主串的指標到第二位字元,與子串的第0位開始比較。到步驟4時,主串的第4位、第5位字元與子串的前兩位匹配,第6位字元與子串的第三位不匹配,則繼續回溯指標,直到指標達到邊界值位置。
首先分析子串T=”abcabx”,前兩位ab與四五位ab相同。在步驟1中,主串前五位與子串前五位匹配,但是T的首位a與自身的第二位第三位不匹配,則子串的首位與主串的第二、三位一定不匹配,所以第2、3步驟是多餘的操作。
在步驟4中首先比較用主串的第四個字元a與子串的第0個字元a比較(a==a);步驟5中用第五個字元b與子串第一個字元b比較(b==b);步驟6中第六個字元(a)與子串第三個字元(c)比較(a!=c),則比較結束。但是我們通過步驟1可知,子串的第四個字元與第五個字元和主串第四個字元與第五個字元相等,同時子串的第四個字元和第五位字元與自身第一個和第二個位字元相等,則步驟4、步驟5不需要比較可以跳過,直接比較步驟6即可。
在步驟7中,通過步驟1、步驟6都可得出,主串第5個字元b與子串第二個字元相等,而子串第2個字元與自身第1個字元不相等,則主串第5個字元一定與子串第1個字元不相等,可以第步驟7也是多餘的操作。
通過上面的兩個示例分析發現,去掉多餘的比較步驟之後,主串的遊標i沒有任何回溯操作。如在示例1中,去掉多餘的步驟2、3、4之後,遊標i=4與子串的遊標j=0進行比較;在示例2中,去掉多餘的步驟2、3、4、5、7之後,遊標i沒有回溯。而對於遊標j,在示例一中每次回溯到0,在示例二中每次回溯的位置取決於自身當前位置前面字元與自身開頭字元的匹配程度,如在步驟1中,第6個字元x與主串不相等,而第6個字元前面有2個字元與自身開頭匹配,跳過多餘的步驟2、3、4、5,在第六步驟中,直接用第三個字元(即j=2)與主串比較,當第3個字元c(j=2)與主串第6個字元a(i=5)不相等時,第三個字串前面字元與開頭字元不相等,則j回溯到0。因此,子串游標j的回溯程度與自身字元的重複程度有關,有主串無關。
這就是KMP演算法的核心思想:避免不必要的回溯,主串游標不進行回溯,子串游標在每個位置的回溯位數與當前位置之前字元與自身開始幾位的字元匹配程度來決定。在實現一個KMP的演算法關鍵,是推匯出待匹配的字串(即子串)各個位置應該回溯到的位置,我們把各個位置放到一個next陣列中。
推導next陣列
例1
如果待匹配子串P=”abcde”,按照如下步驟推導:
(1) 當 j = 0 時,記為-1(特殊標記,程式碼實現時當回溯到 j = 0時,回溯結束)next[0] = -1;
(2) 當 j = 1 時,前面只有一個字元a,則next[1] = 0;
(3) 當 j = 2 時,前面有兩個字元a和b,a != b, 則next[2] = 0;
(4) 當 j = 3 時,前面字串abc,無相等字元,則next[3] = 0;
以此類推,最後推匯出陣列next = { -1, 0, 0, 0, 0, 0 }
例2
如果待匹配子串為P=”abcabx”(示例二中的子串),按照如下步驟推導:
(1) 當 j = 0 時,next[0] = -1;
(2) 當 j = 1 時,前面只有一個字元a,則next[1] = 0;
(3) 當 j = 2 時,前面有兩個字元a和b,a != b, 則next[2] = 0;
(4) 當 j = 3 時,前面字串abc,無相等字元,則next[3] = 0;
(5) 當 j = 4 時,前面字元abca,第4位字元與首位相等,則next[4] = 1;
(6) 當 j = 5 時,前面字元abcab,第4、5位字元與第1、2位相等,則next[4] = 2;
以此類推,最後推匯出陣列next = { -1, 0, 0, 0, 1, 2 }
通俗的說,就是當前位置之前有幾個連續字元與開頭的連續字元相等,則回溯位置記為相等的字元數。用例2推匯出的next陣列,結合示例二驗證next陣列是否正確,示例二的步驟1中i = 5,j = 5,此時T[i] != T[j],則回溯j的位置,回溯到的位置即next[5] = 2,跳過多餘的步驟2、3、4、5,在第六步中j = 2開始比較。而例1中j則每次都回到第0位開始比較,大家可以結合示例1自行驗證。
KMP演算法程式碼(Java版)
KMP演算法的關鍵是推匯出next陣列(即待匹配字串各個位置的回溯位置),推導程式碼如下:
int[] getNext(String pattern) {
int[] next = new int[pattern.length()];
next[0] = -1;
int i=0, j=-1;
while(i < pattern.length()-1) {
if(j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
i++;
j++;
next[i] = j;
} else {
j = next[j];
}
}
return next;
}
有了上面推導next陣列的結果,KMP匹配演算法程式碼如下:
/**
* KMP匹配字串
* @param source 源字串
* @param subStr 模式字串
* @return
*/
int kmpIndexOf(String target, String pattern) {
int[] next = getNext(pattern);
int i=0, j=0;
while(i < target.length() && j < pattern.length()) {
//j==-1是關鍵,表示已經回溯到了第0位,這就是在上面推導next陣列時使用-1的原因
if(j==-1 || target.charAt(i) == pattern.charAt(j)) {
i++;
j++;
} else {
j = next[j];
}
}
if(j >= target.length()) {
return i - target.length();
}
return -1;
}
KMP演算法的程式碼實現與前面簡單字串匹配演算法比較,改動並不大,增加了j==-1的判斷,並增加了j =next[j](回溯到合適的位置),但是整個演算法的時間複雜度變成了O(n+m),相比較於簡單字串匹配模式時間複雜度O((n-m+1)*m)要好很多。
三、總結
KMP演算法通過避免主串和子串位置的回溯,提高了演算法的時間效率。但是如果主串是”abcdef“,子串是”123456“,則主串游標i、子串游標j的移動步驟與簡單字串相同,如果使用KMP演算法還需要推導next的時間消耗,此時KMP演算法效率低於簡單字串。但是KMP演算法並不是一個銀彈,它的適用場景是待匹配的子串與主串存在大量重複匹配的字元時,演算法的高效才得以體現。