KMP算法
關於字符串匹配的算法,最知名的莫過於KMP算法了,盡管我們日常搬磚幾乎不可能去親手實現一個KMP算法,但作為一種算法學習的鍛煉也是很好的,所以記錄一下。
KMP算法是根據三位作者(D.E.Knuth, J.H.Morris和V.R.Pratt)的名字來命名的,算法的全稱是Knuth Morris Pratt算法,簡稱為KMP算法。
關於字符串匹配,我們假設要在字符串A中查找字符串B,那么我們可以把字符串A叫做主串,把B叫做模式串。所以字符串匹配其實就是要在主串中找到與模式串相同的子串。假設主串長度是n,模式串長度為m,最簡單直接的想法是,我們在主串中檢查起始位置分別是0,1,2...n-m且長度為m的子串,看有沒有跟模式串匹配的。這其實也是字符串匹配BF算法的思想,所謂BF就是Brute Force的縮寫,中文叫做暴力匹配算法,也叫朴素匹配算法。
在BF算法中,如果我們遇到了不匹配的子串,會將模式串向后移動一位並再次進行匹配。而KMP算法的核心思想是,如果遇到了不匹配的字符串的時候嘗試尋找一些規律,將模式向后多移動幾位,跳過那些肯定不會匹配的情況。
好前綴與壞字符
先來看一個例子:
主串 | a | b | a | b | a | e | a | b | a | c |
模式串 | a | b | a | b | a | c | d |
在模式串與主串的匹配過程中,我們把以及匹配好的那部分叫做好前綴(藍色部分),把不能匹配的那個字符叫做壞字符(紅色部分)。當遇到壞字符的時候說明這次匹配失敗了,因此我們要向后移動模式串。KMP的核心思想是不匹配時利用規律向后多移動幾位。觀察一下好前綴本身,在它的后綴子串中,查找到最長的那個可以跟好前綴的前綴子串匹配的子串。上面的文字描述有一些繞口,我們基於上面的表格嘗試將模式傳向后移動兩位就可以達到符合條件的那種效果,結合圖來看一下。
主串 | a | b | a | b | a | e | a | b | a | c |
模式串 | a | b | a | b | a | c | d |
第一次匹配時我們獲得的好前綴是‘ababa’,在它的后綴子串中,最長的可以跟它的前綴子串匹配的字符串是‘aba’(上圖黃色部分)。假設好前綴的長度是L,最長的可匹配的哪部分前綴子串的長度是l,那我們就可以直接把模式傳向后移動L-l位,然后再繼續比較。結合上面的內容,好前綴的長度是5,最長的可以跟它的前綴子串匹配的后綴子串的長度是3,因此可以直接向后移動2位。
在上面的過程中,其實並沒有涉及到主串,只需要模式串本身就可以求解。因此可以提前構建一個數組,用來存儲模式串中每個前綴(這些前綴都有可能是好前綴)的最長可匹配前綴子串的結尾字符下標。這個數組定義為next數組,在一些地方把這個數組稱之為“失效函數”(failure function)。
數組的下標是每個前綴尾部字符的下標,數組的值是這個前綴的最長可匹配前綴子串的結尾字符下標。還是用表格記錄一下:
模式串 | a | b | a | b | a | c | d |
下標 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
模式串前綴 | 前綴結尾字符下標 | 最長可匹配前綴字符串結尾字符下標 |
a | 0 | -1(不存在) |
ab | 1 | -1 |
aba | 2 | 0 |
abab | 3 | 1 |
ababa | 4 | 2 |
ababac | 5 | -1 |
上面表格中,存在最長可匹配前綴字符串的模式串前綴有'aba','abab','ababa'這樣三個,注意這三個前綴的最長可匹配前綴字符串結尾字符下標的值,再加1其實就是我們在匹配過程中遇到壞字符后可以向后移動的長度。
因此,如果我們在匹配之前就可以利用模式串得到一個類似與上面表格的內容,那么匹配過程就變成了這樣:依次比較主串與模式串,直到遇到了壞字符或者整個模式傳匹配完成。如果全部匹配上了就是我們找到了對應的結果。如果遇到了壞字符我們就利用預先求得的內容去數組中查詢應該向后移動幾位,並直接移動模式串,並繼續進行匹配。
1 public int kmp(char[] a, char[] b) //a為主串,b為模式串 2 { 3 int[] next = getNext(b); //利用模式傳預先求得next數組的值。 4 int j = 0; //檢測模式串移動的下標 5 6 for(int i = 0; i <= a.Length; i ++) 7 { 8 /*注意這里,使用while來判斷而不是if,因為可能移動后的下一位,即最長可匹配前綴的下一位仍然與壞字符不匹配的情況,此時需要再次查表,直到找到了匹配的內容或是返回到模式串的首字符。*/ 9 while(j > 0 && a[i] != b[j]) 10 { 11 j = next[j - 1] + 1; 12 } 13 14 if(a[i] == b[j]) 15 { 16 j++; 17 } 18 19 if(j == b.Length) //找到匹配的字符串了 20 { 21 return i - b.Length + 1; 22 } 23 } 24 25 return -1; 26 }
經過上面的內容我們可以看出,KMP較暴力匹配方法高效的原因是可以利用事先求得失效函數的值,在遇到不匹配的字符時快速向后移動多位,因此如何預先求得失效函數的的值變成了問題的關鍵。
為了保證KMP的高效,我們獲取next數組的值的方法也應該盡量高效。這個計算方式其實有一些動態規划的思想,我們按照下標遞增的方式依次計算next數組的值,當計算next[i]的時候,next[0],next[1].....next[i-1]應該已經計算出來了。這里重溫一下,next數組的下標代表模式串的前綴結尾字符下標,值為對應的前綴最長可匹配的前綴子串的字符下標。
先來看一種比較簡單的情況。假設模式串數組為b,我們的目的是求得next[i],那么應該已經求得了next[i-1]的值。假設next[i-1] = k - 1。那么就說明b[0,k-1]也是b[0, i-1]的后綴。那么我們考察b[k]這個字符是否與b[i]這個字符相等,如果相等,那么b[0,k]也就是b[0,i]的后綴,也就求出了next[i] = k。(相等的兩個字符串在末尾分別添加一個相等的字符,新的字符串仍然相等。)
如果b[k] != b[i],那就不能這么計算了。下面的過程有些不好理解,筆者能力一般,水平有限,盡量解釋吧。我們順着剛才的思路,既然不能直接利用next[i-1]的最長可匹配前綴字符串了,我們就嘗試去使用次長可匹配前綴字符串。舉個不恰當的例子,比如我們的模式串b[0, i-1]='ababa',那么它的最長可匹配前綴字符串是’aba‘(最長可匹配后綴字符串也是'aba'),當這個最長的值不能使用時,我們就退而求其次,使用次長字符串a。注意這個次長的值,當它是前綴字符串時,它較最長前綴減少的是末尾,當它是后綴字符串時,它較最長后綴減少的是開頭字符。這個時候我們再去考察這個次長子串的下一位,假設是b[x],如果b[x]與b[i]相等,說明我們找到了結果,那么next[i] = x。否則我們就需找再次的最長前綴。
KMP算法的復雜度
KMP算法的空間復雜度是O(M),M是模式串的長度。
KMP算法的時間復雜度,第一部分計算next數組的時間復雜度是O(M),M是模式串的長度,第二部分匹配的時間復雜度是O(n),n為主串的長度。所以綜合來看,KMP算法的時間復雜度是O(m+n)。