數據結構與算法--KMP算法查找子字符串
上一節介紹了暴力法查找子字符串,同時也發現了該算法效率并不高。當失配位置之前已經有若干字符匹配時,暴力法很多步驟是多余的。舉個KMP算法的例子,看圖1
可以看到子串p和主串t在紅框處失配了,失配之前的字符串ABC已經匹配。ABC第一個字符A和后面的字符都不同,所以可以放心地直接將子串p的p[0]對齊失配處i
,讓p[0]和t[i]接著比較。如圖2
這是子串p第一個字符和其后的各個字符都不同的情況,如果其后存在相同的字符呢,比如下面圖3
失配處之前的字符串已經匹配且為ABA存在相同相同的字符A。這下我們不敢輕易將p[0]移動到和t[i]對齊比較了。因為有個相同的A,所以應該嘗試著先移動到那個地方,萬一就能匹配上了呢。圖4
不巧,匹配再次失敗了,只是這種情況下失敗了而已。試想如果主串t是ABABADHIJK,子串p還是ABAD,按照上面的步驟,剛好就匹配成功!所以現在知道為什么不要一下就移動到將p[0]與t[i]對齊了吧,因為有可能,所以得嘗試。上一步是必須的,不是多余步驟。接著看類似的例子,圖5
失配處之前的字符串ABCAB已經匹配,且存在相同的字符串AB。按照上面的思路,應該移動到下圖的位置。如圖6
這樣想來,我們可能認為只要子串中有重復字符,就應該像上面那樣移動。再看這個例子,沒圖了抱歉,湊合著看吧。
ABCADEFGHI
ABCADZ
在t串的E和p串的Z處失配,之前的字符串ABCAD已經匹配,且存在相同的字符A,試著移動將p[0]移動到第二個A處,如下
ABCADEFGHI
ABCADZ
可以看到,在子串中雖然存在相同字符A,但是第一個A之后的B即p[1]和第二個A之后的D即p[4]不同,這個信息我們事先就可以知道。所以即使將A對齊了,p[1]和t[4]比較,t[4] == p[4]
也就是p[1]和p[4]比較,肯定不匹配的。這步是多余的!我們可以直接移動到讓p[0]和t[i]對齊。如下
ABCADEFGHI
ABCADZ
所以,存在相同字符并不能作為子串移動位置的判斷條件。實際上,確定子串移動位置的是字符串相同前綴、后綴的最大長度。
字符串相同前綴、后綴的最大長度以及next數組
什么叫字符串的前綴,后綴呢?
- 前綴:除開末尾字符,所有包含首字符的字符串集合;
- 后綴:除開首字符外,所有包含末尾字符的字符串集合
舉個簡單的例子,如字符串ABCA,它的前綴有A, AB, ABC
,它的后綴有BCA, CA, A
,前后綴比較,只有一對相同字符串,且長度為1,所以字符串ABCA相同前綴、后綴的最大長度為1。再看字符串ABCAB,它的前綴有A, AB, ABC, ABCA
,它的后綴有BCAB, CAB, AB, B
,只有一對相同的字符串AB,且長度為2,故最大長度為2。
既然確定模式字符串移動位置的是字符串相同前綴、后綴的最大長度,這里說的字符串具體來說指的是失配位置之前的字符串。即失配位置之前的字符串的相同前綴、后綴的最大長度k,決定了模式串p[k]應該和失配處t[i]對齊。由于在模式字符串的每個位置都可能失配,所以需要求出模式字符串失配位置之前的字符串的相同前后綴的最大長度,用一個數組存儲起來,這個數組稱為next數組。
如模式字符串ABCDABX,如果在X處失配,求出X之前的子字符串ABCDAB的相同前后綴最大長度為2,如表格中最后一行。又X處索引為6,所以next[6] = 2
。如果在D處失配則求出D之前字符串ABC的相同前后綴的最大長度,為0,見表中第3行數據。又D的索引為3,則next[3] = 0
。再看在第二個字符處失配,B之前有一個子字符串A,只有一個字符談不上前后綴,所以相同前后綴的最大長度為0,見表中第1行數據。
那如果在第一個字符A處就失配了呢?由于第一個字符A之前沒有子字符串了,按照約定,我們令其相同前后綴的最大長度為-1。所以next[0] = -1
。下面next數組的代碼實現中會具體說明這個約定的值next[0]的值為什么不是-8, -9或者是0。
按照上面的思路,給出一個字符串就可以寫出它的next數組了。還是上面的ABCDABX
失配字符:索引 | A :0 | B :1 | C :2 | D :3 | A :4 | B :5 | X : 6 |
---|---|---|---|---|---|---|---|
next數組值 | -1 | 0 | 0 | 0 | 0 | 1 | 2 |
這個表格next數組的值和上面的“最大公共元素長度”相比,其實就是先令next[0] = -1
,再將這些最大公共元素長度的值填入next[1]~next[size - 1]
即可。由此得到next數組為[-1, 0, 0, 0, 1, 2]。
好,求出了next數組就好辦了。當某一個字符在位置j
處失配時,next[j]就指示了模式字符串應該移動到哪個位置。根據next[j]移動到哪兒呢?具體來說就是讓next[j]成為新的j
,讓模式字符串移動,直到p[j]與失配處t[i]對齊,然后讓p[j]再和t[i]比較一次。
為了驗證這一說法,再次看圖1
模式字符串ABCE的next數組為[-1, 0, 0, 0],在j = 3
處失配,next[3] = 0
,所以讓p[0]和t[i]對齊比較。
同樣的再看圖5
模式字符串的next數組為[-1, 0, 0, 0, 1, 2],在j = 5
處失配,next[5] = 2
,所以讓p[2]和t[i]對齊比較。
第二遍看這些圖,是不是清晰多了!
next數組的代碼實現
關鍵是如何通過代碼來求模式字符串的next數組,像上面那樣列出字符串的所有前后綴,然后比較出相同前后綴的最大長度嗎?當然不是,那不是最好的方法。求next數組其實可以看成:字符串自己和自己匹配的過程。
private static int[] getNext(String p) {
int M = p.length();
int[] next = new int[M];
next[0] = -1;
int j = 0;
int k = -1;
while (j < M - 1) {
if (k == -1 || p.charAt(k) == p.charAt(j)) {
next[++j] = ++k;
} else {
k = next[k];
}
}
return next;
}
拿字符串ABCA作為例子,next數組是[-1, 0, 0, 1]。
首先next數組的長度應該和模式字符串的長度一樣。所以有int[] next = new int[M];
然后next[0]無腦設置成-1。為什么是-1呢?其實可以發現next[1]也是個定值,為0。這是因為索引1之前只有一個字符,它沒有前綴后綴之說。if
分支里的條件必須是k == -1
,這樣當第一次進入if分支時,才能保證next[0+1] = -1 + 1
,即next[1] = 0
。接下來該填next[2]了,理論上為0。自增后k = 0,j = 1
, 比較p[0]與p[1],不相同,轉向else分支,next[0]賦值給k
,因為要給next[2]填入值,所以必須要進入if分支,要么只有next[0] = -1
賦值給k
后,才能保證一定能進入if分支。從而next[1 + 1] = -1 + 1
即next[2] = 0
。這樣就解釋了為什么以上的代碼實現中,next[0]為什么要設置成-1。
其他的,while
里之所以是j < M - 1
而不是j < M
,是因為下面這句next[++j] = ++k;
是先自增后存入的,這意味著最后能存到next[M - 1]
,剛剛存滿數組。如果條件是j < M
則會越界。另外p.charAt(k)
表示的是前綴的單個字符, p.charAt(j)
表示的是后綴的單個字符。
上面代碼實現中,并不是列出了所有的前后綴再一一比較的。那么這種實現一定正確嗎?我們來看。
next[j] == k
應該很好理解,k代表的就是j
位置前字符串相同前后綴的最大長度,在這里是2。現在比較p[k]和p[j],相同,所以最大長度應該變成3,進入if分支,next[j + 1] = next[j] + 1 = k + 1
。
如果p[k]和p[j]不相同呢?
這時next[j + 1]怎么填寫呢?理論上來說,列出所有前后綴后,一一比較可以得知有相同前后綴為AB,最大長度為2。轉入else分支, k = next[k]
,這是什么意思呢?看起來是一種遞歸,如果遞歸直到k == -1
,則說明找不到相同的前后綴,next[j + 1] = 0
。看下圖,這實際上是模式字符串自己的前綴在和自己的后綴作匹配。ABAC可看作模式字符串,其next數組為[-1, 0, 0, 1],它和DABABC在索引k
處失配,回憶文章開頭,當失配位置之前已經有一些字符匹配,應該怎么移動模式字符串呢?失配處k,k = next[k] = 1
,所以讓ABAC
的第1個位置和j
位置對齊。哈!對齊后剛好B和位置j
的B相同(如右圖),接著進入if分支,則next[j + 1] = k + 1 = 2
,與理論值吻合。現在再看k = next[k]
,是不是一目了然?!
如果模式字符串自己和自己匹配這個事搞不懂,沒關系,我們換個角度看問題。看下圖
已知:
- next[j] = k
- next[k] = 綠色色塊所在的索引
- next[綠色色塊所在的索引] = 黃色色塊所在的索引
由next[j] = k,可知字符串
A1 == A2
由next[k] = 綠色色塊所在的索引,可知
B1 == B2
,又A1 == A2
,所以A1的后綴B2與A2的后綴B3相同。所以B1 == B2 == B3
由next[綠色色塊所在的索引] == 黃色色塊所在的索引,可以得到
C1 == C2
。又B1 == B2 == B3
,可知它們的后綴C2 == C3 ==C4
,綜上有C1 == C2 == C3 == C4
。
現在假如p[k] != p[j]
,則最大長度的前后綴A1和A2分別添加了一個字符后的新字符肯定不是相同的前后綴了,我們退而看看原先第二長的相同前后綴,B1和B3,它們分別加上后一個字符后是否會相同呢,如果相同,則B1與B3加上后一個字符后的新字符就成為了最大長度的相同前后綴;如果不同,再選原先第三長的子串C1和C4,遞歸查找,直到最后k == -1
。如何比較B1和B3后一位字符呢?k = next[k]
就是令新的k值為綠色色塊(也是串B1后一位字符)所在的索引,此時再讓p[k]和p[j]位置對齊(B1和B3重合)比較的就自然是B1與B3的后一位字符了。
KMP算法的實現
好了,next數組怎么求講了,主串和子串匹配時如何根據求得的next數組來移動模式字符串也講過了。是時候上KMP的實現代碼了!
public static int search(String p, String t) {
// 根據模式字符串獲得next數組
int[] next = getNext(p);
int N = t.length();
int M = p.length();
int i = 0;
int j = 0;
while (i < N && j < M) {
if (j == -1 || p.charAt(j) == t.charAt(i)) {
i++;
j++;
} else {
j = next[j];
}
}
if (j == M) {
return i - j;
} else {
return -1;
}
}
先是獲得模式字符串的next數組,然后i, j
分別是指針主串和子串的指針,當然一開始指向0。如果字符相同,則執行if分支,直到遇到失配字符,轉入else分支,next數組指示了子串的哪個位置和失配位置t[i]對齊再次比較。有種情況比較特殊:如果子串在第一個字符(j = 0)處就失配了,那么先轉else讓j = next[0] = -1
,緊接著進入if分支,主串指針i
向右移動一位,子串指針j
回到位置0,這和暴力法是一個做法。i - j
的含義是子串開頭在主串中的索引,我們要返回的的正是這個值。從整個代碼來看,可以發現i從未回退過,這正是KMP算法的優點之一。
KMP算法的優化
以上KMP算法的實現已經比之前的暴力法好多了,但它也存在多余比較的情況,看下圖。
按照上面KMP算法的思想,B != C
在j
處失配。由于j = next[j] = 1
,應該讓p[1]和t[i]對齊繼續比較。如下
可是我們發現,子串移動之后還是B和C比較,我們剛才失配時就得知B不匹配了,這次的B當然還是不匹配,這步就是多余的。如果聰明些,我們應該使用next[1]的值而不是next[3],從而可以直接將子串移動到p[0]與t[i]對齊。也就是說令next[3] = next[1]
。
這種情況發生在當p[j] = p[next[j]]
,而next[j] = k
,條件簡化為p[j] = p[k]
。首先會讓p[k]
與t[i]
對齊,然而這步是不必要的,所以還不如跳過這步,讓p[next[k]
與t[i]
對齊,即用next[k]
的值取代next[j]
的值。說取代還是太麻煩了,為何不一開始就改變next數組,只要遇到某個字符滿足p[j] = p[k]
,next[j]的值就直接使用next[k]的值好了。
由此看來,next數組的求法就得改變了。
看字符串AA,按照原來next數組的求法肯定是[-1, 0],因為前兩位是定值。
我們來檢驗p[j] = p[k]
這個條件。next[0]還是-1這個改不了。在j = 1
處,k = next[1] = 0
,p[1] == p[0]
條件滿足!所以應該用next[0]的值取代next[1]。此時next數組變成[-1, -1]
再看上面ABAB,按照原來的next數組求法是[-1, 0, 0, 1],k = next[1] = 0
,p[1] != p[0]
條件不滿足,next數組值還是0不改變。next[2] = 0
,p[2] == p[0]
條件滿足。所以用next[0]的值取代next[2];k = next[3] = 1
,p[3] == p[1]
條件滿足,應該用next[1]的值取代next[3]。綜上,此時next數組百變成[-1, 0, -1, 0]
好,現在知道怎么求優化后的next數組了。那么用代碼怎么實現呢?其實改動的地方就一處。先上代碼,再解釋。
private static int[] betterGetNext(String p) {
int M = p.length();
int[] next = new int[M];
next[0] = -1;
int j = 0;
int k = -1;
while (j < M - 1) {
if (k == -1 || p.charAt(k) == p.charAt(j)) {
if (p.charAt(k + 1) == p.charAt(j + 1)) {
next[++j] = next[++k];
} else {
next[++j] = ++k;
}
} else {
k = next[k];
}
}
return next;
}
當k == -1
或者當前字符相同時多了一句判斷if (p.charAt(k + 1) == p.charAt(j + 1))
,它緊接著預判下一個字符是否也相等,如果相等,則滿足條件p[j] = p[k]
,想想為什么?
- 當前兩個比較的字符相同的情況下,下一個字符也相同。下圖當前比較的兩個字符
p[k - 1] == p[j - 1]
,預判下一個字符p[k] == p[j]
,且next[j] = k
,滿足條件,所以next[j]應該直接使用next[k]的值。
-
k == -1
時,再舉個例子ABAB字符串,假設當前字符B(第一個B)在j - 1
處,因為k==-1,所以預判下一個字符p[0] == p[j]
,且next[j] = k = 0
,滿足條件,所以next[j]直接使用next[0]的值即-1。
以上兩種情況都滿足next[j] = k
,p[k] = p[j]
。現在應該清楚增加的那句if判斷是怎么工作的了吧。
試試用新的next數組實現測試下。
package Chap5;
public class KMPSearch {
private static int[] getNext(String p) {
int M = p.length();
int[] next = new int[M];
next[0] = -1;
int j = 0;
int k = -1;
while (j < M - 1) {
if (k == -1 || p.charAt(k) == p.charAt(j)) {
next[++j] = ++k;
} else {
k = next[k];
}
}
return next;
}
private static int[] betterGetNext(String p) {
int M = p.length();
int[] next = new int[M];
next[0] = -1;
int j = 0;
int k = -1;
while (j < M - 1) {
if (k == -1 || p.charAt(k) == p.charAt(j)) {
if (p.charAt(k + 1) == p.charAt(j + 1)) {
next[++j] = next[++k];
} else {
next[++j] = ++k;
}
} else {
k = next[k];
}
}
return next;
}
public static int search(String p, String t) {
// 根據模式字符串獲得next數組
int[] next = betterGetNext(p);
int N = t.length();
int M = p.length();
int i = 0;
int j = 0;
while (i < N && j < M) {
if (j == -1 || p.charAt(j) == t.charAt(i)) {
i++;
j++;
} else {
j = next[j];
}
}
if (j == M) {
return i - j;
} else {
return -1;
}
}
public static void main(String[] args) {
int index = search("abab", "abacghababzz");
System.out.println(index);
}
}
輸出6,沒毛病。
by @sunhaiyu
2017.8.4