串(Sequence)
在本章節(jié)內容中研究的串是開發(fā)中熟悉的字符串,大家都知道,字符串是由若干個字符組成的有限序列。
例如有下圖所示的字符串,可以看到該字符串有5個字符組成
其中,字符串thank的前綴(prefix),真前綴(proper prefix),后綴(suffix),真后綴(proper suffix),表示如下
可以看出,前綴與真前綴的區(qū)別,后綴與真后綴的區(qū)別在于前綴/后綴可以是自己,真前綴/真后綴不可以是自己。
串匹配算法
本章節(jié)主要研究串的匹配問題,例如
通過一個模式串(Pattern)在文本串(Text)中的位置,例如下面代碼
String text = "Hello world";
String pattern = "or";
text.indexOf(pattern);//7
text.indexOf("other");//-1
通過一個模式串,在文本中查找位置,如果找到,則返回對應的索引,如果找不到,返回-1。通過對串匹配算法的討論,研究哪一種算法更加高效。
以下為常見的幾個經典串匹配算法
- 蠻力(Brute Force)
- KMP
- Boyer-Moore
- Rabin-Karp
- Sunday
由于后面內容會經常使用到Text的長度與Pattern的長度,因此后面將Text長度簡寫為tlen,將Pattern長度簡寫為plen。
蠻力(Brute Force)
以字符為單位,從左到右移動模式串,直到匹配成功為止。
例如文本串如下圖所示
模式串如下圖所示
從左到右進行匹配,如果模式串中的第一個字符匹配成功,這繼續(xù)往后進行匹配,如果匹配失敗,則模式串從文本串的下一個字符進行匹配,一直重復。直到匹配成功或者匹配完所有的文本串為止。
根據(jù)這種方式,上面文本串與模式串的匹配流程如下
這種匹配算法,常見的實現(xiàn)方式有如下兩種
方式一:
執(zhí)行過程如下
定義兩個變量pi,ti,其中pi表示正在參與比較的模式串索引,ti表示正在參與比較的文本串索引,如下圖
所以
- pi的取值范圍為[0,plen)
- ti的取值范圍為[0,tlen)
如果當前索引的文本匹配成功,則將兩個索引往后移動1個位置,即
- pi++
- ti++
然后繼續(xù)比較,發(fā)現(xiàn)當前索引的文本依然是成功的,所以會將兩個索引往后移動1個位置,結果如下
發(fā)現(xiàn)在當前索引時,比較依然是成功的,所以會繼續(xù)將兩個索引往后移動1個位置,現(xiàn)在,將注意力放到pi和ti的下一個索引位置,當pi與ti都變?yōu)?時,匹配失敗了
當匹配失敗時,則將pi置為0,ti置為ti - pi + 1;重置了索引以后的結果如下
現(xiàn)在繼續(xù)進行匹配,在當前索引時(pi = 0 ,ti = 1)時,就匹配失敗了,因此再次重置pi與ti的值。
通過這樣一直重復,當pi等于plen時,則說明最終匹配到了所有的文本
最終的返回的索引值為ti - pi
所以,根據(jù)上面的分析步驟,轉換為代碼的結果如下
public static int indexOf(String text, String pattern) {
if (text == null || pattern == null) return -1;
char[] textChars = text.toCharArray();
int tlen = textChars.length;
if (tlen == 0) return -1;
char[] patternChars = pattern.toCharArray();
int plen = patternChars.length;
if (plen == 0) return -1;
if (tlen < plen) return -1;
int pi = 0, ti = 0;
while (pi < plen && ti < tlen) {
if (textChars[ti] == patternChars[pi]) {
ti++;
pi++;
} else {
ti -= pi - 1;
pi = 0;
}
}
if (pi == plen) {
//說明找到了
return ti - pi;
}
return -1;
}
優(yōu)化
前面這種實現(xiàn)方法,其實可以在恰當?shù)臅r候提前退出,這樣可以減少比較次數(shù)
例如在如下圖所示的情況下
此時比較失敗,所以pi和ti的值都會重置,終止后進行比較,最終的結果如下
在這種情況下, 模式串匹配的字符已經超過了文本串的索引,最終的結果一定是失敗,所以在這種情況下,前面的3次比較結果都是無效的
所以,在這種情況下,可以將退出條件從ti < tlen修改為 ti - pi <= tlen - plen.其中ti - pi表示為每一輪比較中Text首個比較字符的位置,所以可以將while循環(huán)條件進行優(yōu)化
了解了蠻力算法的第一種實現(xiàn)以后,繼續(xù)研究這種算法的另外一種實現(xiàn)。
方式二:
首先,與前面的實現(xiàn)一樣,定義兩個變量pi與ti,分別記錄當前正在比較的索引
當比較成功時,前面是pi與ti都進行+1操作,現(xiàn)在不使用這種方式,現(xiàn)在的做法是pi進行+1,文本串中進行比較的索引,利用pi + ti來進行表示;如下圖所示
pi = 1時比較又是成功的,所以繼續(xù)往后比較,結果如下
繼續(xù)進行比較,結果如下
到這一步pi =3時,發(fā)現(xiàn)匹配失敗,所以,則只需要將pi重置為0,ti執(zhí)行+1操作即可,繼續(xù)進行比較,結果如下
又比較失敗,繼續(xù)執(zhí)行pi重置為0,ti +1的操作,一直重復上面的步驟,知道pi == plen時,最終匹配成功,結果如下
最終,如果采用這種做法來實現(xiàn),pi與ti的取值范圍分別如下
- pi 的取值范圍為[0,plen)
- ti 的取值范圍為[0,tlen - plen)
根據(jù)這種思路,實現(xiàn)的代碼如下
public static int indexOf(String text, String pattern) {
if (text == null || pattern == null) return -1;
char[] textChars = text.toCharArray();
int tlen = textChars.length;
if (tlen == 0) return -1;
char[] patternChars = pattern.toCharArray();
int plen = patternChars.length;
if (plen == 0) return -1;
if (tlen < plen) return -1;
int tiMax = tlen - plen;
for (int ti = 0; ti <= tiMax ; ti++) {
int pi = 0;
for (; pi < plen; pi++) {
if (textChars[ti + pi] != patternChars[pi]) break;
}
if (pi == plen) return ti;
}
return -1;
}
蠻力算法-性能分析
下圖中長的部分表示文本串,短的部分表示模式串,模式串中,綠色表示匹配成功,紅色表示匹配失敗,空格表示還未匹配。一旦匹配失敗,模式串會向右移動一個單位,所以在匹配的過程中,可能出現(xiàn)的情況如下
現(xiàn)假設n為文本串的長度,m為模式串的長度,所以
最壞的情況下,會比較n - m +1輪
-
最好的情況為只需要比較一輪就成功,在這種情況下,需要比較m次(m為模式串的長度),所以此時的時間復雜度為O(m)
-
最壞情況為執(zhí)行了n - m + 1輪比較(n為文本串的長度),并且每一輪都要比較到模式串的末字符后,才失敗(每一輪m - 1次成功,1次失敗)
在這種情況下,時間復雜度為O(m*(n - m +1)),由于一般情況下m遠小于n,所以時間復雜度為O(nm)
KMP
前面,通過蠻力算法,可以成功的獲取到模式串是否在文本串中的正確結果,其時間復雜度為O(nm),通過蠻力算法,可以很清晰,簡單的理解算法的整個執(zhí)行過程。研究完蠻力算法以后,現(xiàn)在繼續(xù)研究一個性能更優(yōu)的模式匹配算法,KMP.
KMP 是Knuth-Morris-Pratt的簡稱(取名自3為發(fā)明人的名字),與1977年發(fā)布
蠻力 vs KMP
-
蠻力算法:是經過一系列比對以后,如果在某位置發(fā)現(xiàn),比對失敗,模式串則會從0開始,文本串從下一個位置開始,再次從頭開始比較,一直重復,直到匹配成功或者全部匹配完。
-
KMP算法:KMP算法,在經過一系列比對以后,付過發(fā)現(xiàn)某個位置比對失敗,會直接從文本串的開始位置,直接挪動到某一個位置,然后繼續(xù)開始比較。
對比蠻力算法,可以發(fā)現(xiàn)KMP算法非常的聰明,蠻力算法匹配失敗,一次只會挪動一個位置,但是KMP算法則會一次挪動多個位置,KMP算法可以非常聰明的知道,哪些位置是沒有必要比較的,所以,在KMP看來,蠻力算法的中間三次比較是沒有必要的。
其實,KMP算法對比蠻力算法,其精妙之處在于:充分利用了此前比較過的內容,可以很聰明的跳過一些不必要的比較位置。
KMP中next表的使用
KMP會預先根據(jù)模式串的內容生成一張next表(一般是個數(shù)組);例如下圖是模式串ABCDABCE的next表
假設現(xiàn)在有下圖所示的模式串與文本串,其中文本串已經比較到了ti = 8的位置,模式串比較到了pi = 7 的位置,現(xiàn)在比較失敗了
當比較失敗以后,就會到next表中進行查詢,根據(jù)pi失配的位置7,到next表中進行查詢,得到的元素為3,所以現(xiàn)在就會利用現(xiàn)在pi的索引,去next表中取出對應的值,然后再賦值到pi,即pi = next[7],所以賦值以后,pi的值變?yōu)榱?
總結:一旦發(fā)現(xiàn)pi位置失配,就會將next[pi]中的值賦值給pi,所以賦值完成后,就將pi = 3位置的元素與ti = 8位置的元素記性比較,模式串就會瞬間往右移動一定的位置
向右移動的距離 = pi - next[pi]
為了加深KMP算法對next表的使用原理,結合前面的next表,再利用實例來進行理解
下圖中在pi = 3,ti = 5 的位置失配了
步驟如下:
- 向next表中查表,next[pi] ,即可以得到next[3]的值
- 查表后,得到next[3]的值為0,所以就會將pi的值更新為0
- 利用pi == 0的位置與現(xiàn)在的ti進行比較
KMP的核心原理
下圖為兩個不同的串,其中Text為文本串,Pattern為模式串
- A,B是個子串(兩個子串相等)
- c,d,e是單個字符
現(xiàn)在兩個串在進行比較,當Text中比較到字符d時,Pattern比較到字符e時,比較失敗了。按照KMP算法的原理,可以讓模式串快速的向右移動一段距離,所以當上面的文本串d與模式串e比較失敗以后,就會向右移動一段距離,最終移動后的結果如下
將模式串移動以后,就可以直接將模式串中的字符c與與文本串中的字符d進行比較,這樣就直接跳過了前面的字符,而且由于子串A和子串B是相等的,所以A,B兩個子串在移動后也不會再進行比較
所以,根據(jù)KMP算法的原理,當上圖的d,e失配是,就會讓模式串向右移動一段距離,最后直接從字符d,c進行比較
如果想實現(xiàn)這樣的效果,需要具備的條件為:子串A與子串B相等
并且,如果要得知向右移動的距離,KMP就必須在失配字符e左邊的子串中找出符合條件的A,B
其中,向右移動的距離為:e左邊子串的長度 - A的長度,也等價于:e的索引 - c的索引
結合KMP的設想,也可以得到移動有c的索引;c的索引 == next[e的索引],所以,向右移動的距離 == e的索引 - next[e的索引]
所以,如果在pi位置失配的話,向右移動的距離即為pi - next[pi],并且如果next[pi]得到的值越小,向右移動的距離就會越大
其中next[pi]得到的值是pi左邊子串的真前綴/真后綴的最大公共子串長度
真前綴/真后綴的最大公共子串
下圖表示了不同模式串的真前綴/真后綴,及最大公共子串的長度
即找出模式串中的所有真前綴與真后綴,然后從真前綴/真后綴中找出最大公共子串的長度,然后就可以得到模式串中所有子串的最大公共子串長度,所以如果模式串為ABCDABCE的話,得到的最大公共子串長度結果如下
得到next表
得到最大公共子長度表以后,可以利用該表,得到next表
將最大公共子串長度的值,都向右移動1位,首位置位-1,就可以得到next表,所以利用上面的最大公子串長度表,就可以如下的next表
KMP主算法的實現(xiàn)
KMP主算法的實現(xiàn),其實是基于前面蠻力算法的基礎上,進行改進實現(xiàn)的
結合前面的思路,可以知道,只需要在失配時,將pi重新賦值即可。
但是需要考慮一個問題,就是在pi為0的時候就失配的情況,這種情況的話,只需要將ti進行++操作即可,但是由于在next表中,將next表的首元素值設置為-1,所以需要在首元素失配時,ti++后,又繼續(xù)從pi為0的位置,繼續(xù)進行比較,所以當pi == -1時,需要將pi進行++操作,巧妙的為下一次pi匹配做準備
最終,主算法的實現(xiàn)如下
public static int indexOf(String text, String pattern) {
if (text == null || pattern == null) return -1;
char[] textChars = text.toCharArray();
int tlen = textChars.length;
if (tlen == 0) return -1;
char[] patternChars = pattern.toCharArray();
int plen = patternChars.length;
if (plen == 0) return -1;
if (tlen < plen) return -1;
//定義一個next表
int[] next = next(pattern);
int pi = 0, ti = 0, lenDelta = tlen - plen;
while (pi < plen && ti - pi <= lenDelta) {
//pi小于0,說明是0號位置失配,如果進入if判斷的話,就會執(zhí)行++操作,巧妙的將-1變?yōu)榱?
if (pi < 0 || textChars[ti] == patternChars[pi]) {
ti++;
pi++;
} else {
pi = next[pi];
}
}
if (pi == plen) {
//說明找到了
return ti - pi;
}
return -1;
}
KMP算法中,為什么選擇的是最大公共子串長度
現(xiàn)在假設文本串是AAAAABCDEF,模式串為AAAAB,如果現(xiàn)在在模式串B位置產生失配的話,則需要看失配前的串中,真前綴真后綴的公共子串長度,所以在模式串B位置失配的話,真前綴分別有,A,AA,AAA,AAAA,所以這些真前綴也有自己的真前綴真后綴,所以這些真前綴作為模式串匹配時的真前綴真后綴如下
最終,在失配時,公共子串長度選擇的是3,為什么選擇的是3,而不是1呢?請繼續(xù)看下面的解釋
現(xiàn)有如下圖所示的文本串和模式串在進行匹配
可以發(fā)現(xiàn),當ti為4,pi為4時失配了,現(xiàn)在需要利用pi = 4這個值,到next表中進行查值,最終查到的是3,所以模式串最終會往右移動 4 - 3 = 1個位置,移動后的結果如下
但是前面的表中可以看到,模式串為AAAA時,公共子串長度有3個,分別為1,2,3,假如現(xiàn)在將1賦值給pi的話,得到的結果如下
現(xiàn)在pi的值為1,就是將pi為1位置的值與現(xiàn)在ti為4位置的值進行比較,可以發(fā)現(xiàn)pi值越小,前面跳過的索引就會比較大,最終可能會導致再跳過的過程中,錯過可能匹配的情況,因此有以下結論
- 公共子串長度越小,向右移動的距離會越大,越不安全
- 公共子串長度越大,向右移動的距離會越小,越安全
next表的構造思路
假設現(xiàn)在模式串的表示如下
綠色位置的字符,索引為n,黃色位置的字符,索引為i
再假設兩個紅色方框中的元素相等
這樣就會得到 next[i] == n,即當i位置的字符失配時,i位置前面所有字符的最大公共子串的值,又因為兩個紅框中的值是相等的,所以最大公共子串的長度即為n,所以就有next[i] == n
現(xiàn)有如下假設情況
-
如果模式串的i位置與n位置是相等的,即Pattern[i] == Pattern[n]
- 那么nex[i + 1] == n+ 1(因為i + 1位置前面字符串的最大公共子串長度變?yōu)榱薾 + 1)
-
如果模式串的i位置與n位置不相等,即Pattern[i] != Pattern[n]
找到前綴A中的下一位k為字符
由于子串A是相等的,所以可以知道現(xiàn)在next[n]的值即為k
-
如果現(xiàn)在模式串i位與k位是相等的,即Pattern[i] == Pattern[k]
-
那么說明下圖紅框中的部分子串是相等的
所以就有next[i + 1] = k + 1
-
-
如果現(xiàn)在模式串i為與k為是不相等的,即即Pattern[i] != Pattern[k]
- 那么現(xiàn)在就要繼續(xù)在模式串k位置前面的子串中,繼續(xù)查找子串中真前綴真后綴的最大公共子串,然后將步驟2的n作為k的值,繼續(xù)進行判斷,重復執(zhí)行即可。
-
結合構建思路,得到next表的實現(xiàn)如下
private static int[] next(String pattern) {
char[] chars = pattern.toCharArray();
int[] next = new int[chars.length];
next[0] = -1;
int i = 0;
int n = -1;
int iMax = chars.length - 1;
while (i < iMax) {//i < iMax 是因為后面會做++操作,操作完成后,就變?yōu)榱薸 <= iMax
if (n < 0 || pattern.charAt(i) == pattern.charAt(n)) {
next[++i] = ++n;
} else {
//失配
n = next[n];
}
}
return next;
}
next表的不足之處
假設現(xiàn)在有文本串AAABAAAAB與模式串AAAAB
如果按照前面next表的實現(xiàn)進行計算你的話,生成的next表如下所示
存在的問題在于,第一次出現(xiàn)失配以后,后面相同字符依然會進行重復判斷
所以,可以知道,其實當?shù)谝淮纬霈F(xiàn)失配情況以后,后面所有有的A與B進行比較時,都會出現(xiàn)失配的情況,所以,中間的幾次比較,其實是多余的
所以,如果出現(xiàn)這種情況的話,KMP會顯得比較笨拙
next表的優(yōu)化思路
現(xiàn)通過下圖表示模式串文本串
并且現(xiàn)在已知next[i] == n,next[n] == k
如果現(xiàn)在文本串中的d位置與模式串i位置失配的話,結合下圖比較
有以下的比較情況
- 如果Pattern[i] != d,就讓模式串滑動到next[i](也就是n)位置與d進行比較
- 如果Pattern[n] != d,就讓模式串滑動到next[n](也就是k)位置與d進行比較
- 如果Pattern[i] == Pattern[n],那么當i位置失配時,模式串最終必然會滑動到k位置與d 進行比較
- 在這種情況下,讓next[i]直接存儲next[i](也就是k)即可
通過分析,優(yōu)化后的代碼如下
private static int[] next(String pattern) {
char[] chars = pattern.toCharArray();
int[] next = new int[chars.length];
next[0] = -1;
int i = 0;
int n = -1;
int iMax = chars.length - 1;
while (i < iMax) {//i < iMax 是因為后面會做++操作,操作完成后,就變?yōu)榱薸 <= iMax
if (n < 0 || pattern.charAt(i) == pattern.charAt(n)) {
++i;
++n;
if (pattern.charAt(i) == pattern.charAt(n)) {
next[i] = next[n];
} else {
next[i] = n;
}
} else {
//失配
n = next[n];
}
}
return next;
}
通過優(yōu)化后,模式串AAAAB生成的next表如下
next值發(fā)生變化以后,發(fā)生了如下的效果
因為首先在3號位置失配,所以優(yōu)化后找到的索引為-1,所以會直接向右移動4個位置
KMP性能分析
利用KMP算法進行串匹配時,可能出現(xiàn)的情況如下,其中綠色表示匹配成功,紅色表示失配,白色表示沒有匹配
通過上圖這種一般情況的分析,可以看出,KMP算法一共比較的次數(shù)大約為n(n為文本串長度)次
所以KMP算法主邏輯中
- 最好時間復雜度為:O(m),m為模式串的長度
- 最壞時間復雜度為:O(n),最多不超過O(2n)(因為有些地方可能會重疊)
其中next表的構造過程,與KMP主邏輯很類似,所以
- next表構建的時間復雜度為:O(n)
整體來講,KMP刷反的復雜度為:
- 最好時間復雜度為:O(m)
- 最壞時間復雜度為:O(n+m)
- 空間復雜度為:O(m)
完!