前言
串匹配問題是解決許多應用(文本編輯器,數據庫檢索,C++模板匹配,模式識別等等)的重要技術。
這個問題有兩個輸入,第一個是文本(Text),第二個是模式(Pattern),目的是要在文本中尋找模式。通常而言文本要遠大於模式。
T : now is the time for all good people to come (長度為n)
P :people (長度為m)
串匹配問題可分為四種類型:
- detection : P是否出現?
- location : P首次出現在哪里?
- counting : P出現了多少次?
- enumeration : 各出現在哪里?
顯然,解決location是最重要的,如果監測到了,就表明出現了(detection),出現多少次,只要將未比較的字符串根據同樣的方法求得下一次首次出現的位置,直到整個文本結束,出現在哪里只要記錄位置做標記即可。
下面開始介紹串匹配算法。
暴力匹配
思想是自左而右,以字符為單位,依次移動模式串,直到某個位置發生匹配。
這個算法最好的情況是第一次就比對成功,最好情況的上邊界則是每次比對時,第一個字符都不匹配,這樣就移動一格,最好情況的復雜度就等於\(\Omega(n)\), n為文本的長度。最壞的情況是每次比較模式最后一個字符的時候才發現不匹配,這樣就會導致最壞情況,時間復雜度為\(\mathcal{O}(n \cdot m)\).
C++實現版本1:
int match(string P, string T) {
size_t n = T.size(), i = 0;
size_t m = P.size(), j = 0;
while (i < n - m + 1 && j < m) //自左向右逐次比較
if ( T[i] == P[j]) { i++; j++;} // 若匹配,則轉到下一對字符
else { i -= j - 1; j = 0;} // 否則,T回退,P復位
return i - j;
}
C++實現版本2:
int match(string P, string T) {
size_t n = T.size(), i;
size_t m = P.size(), j;
for ( i = 0; i < n - m + 1; i++) { //T[i]與P[0]對齊
for ( j = 0; j < m; j++) //逐次匹配
if ( T[i+j] != P[j]) break; //失配則轉到下一位置
if ( m <= j) break; //匹配成功,退出,返回i
}
return i;
}
兩個實現版本的返回值都是位置信息,當i等於n - m + 1的時候說明未找到模式,否則就是找到了。
KMP :模式記憶
暴力匹配算法存在着冗余的問題,當最壞情況時,最后一個字符匹配失敗,模式串和文本串的指針都要發生回退。
KMP算法的原理是利用Pattern構建一個查詢表,根據查詢表進行來指導移動位數,並且文本的索引不需要回退。理解這種算法我推薦阮一峰老師的KMP博客(真心推薦看看),講得非常清晰,非常直觀。
假設你看過阮老師的博客知道原理了,現在來看next表的構建代碼:
vector<int> buildNext(string P) { //構造模式串P的next表
size_t m = P.size(), j = 0; //“主”串指針
vector<int> N(m, 0); //next表
int t = N[0] = -1; //模式串指針(通配符*)
while ( j < m - 1 ) //j是不會減小的,j會在循環內變為m-1,此時退出
if ( 0 > t || P[j] == P[t] ) { //當出現通配符也就是0>t, 當前j自加1,next表對應j為0。
//當不是通配符時,比較是否相等,相等則next表對應j自加1
j++; t++;
N[j] = t;
}
else
t = N[t]; //失配,根據前面得到的next,來看應該從那里開始比較,比如下面的匹配等於4的時候,e不等於c,查表知e所在的位置為0,也就是沒有相同的前后綴,所以從0開始繼續匹配,如果大於0,說明有共同前后綴,此時應該不從0開始,因為有共同前后綴,可以避開節省時間。
return N;
}
這里需要注意的一點是,阮一峰老師的博客中當前next表是代表當前j的公共最大前后綴的長度,而這個實現中當前next表是代表j-1的公共最大前后綴的長度。
關於t = N[t]可以見下圖,當X不匹配Y的時候,此時我們根據next表,由當前next表的值知,P[0, t)和P[j - t, j)是相同的,此時應該移動j-t,也就是從第t位開始比較,也就是N(t)的長度。有一種特殊情況需要考慮,當N(t)等於0時,此時從0開始比較,如果第0位也不等於當前j,根據性質,t此時就等於-1了,此時就進入0>t的條件,自增j,自增t,當前j沒有共同前后綴。這里開始設N[0]等於-1以及t等於-1,有兩層作用,第一層是為了首輪比較時,需要隔開一位比較。第二層作用是為了防止后面與第一位不相等時,可以根據-1這個條件進入if條件,防止卡死。很是巧妙。
下面有一個事例:
有了next表的構造方法,接下來就是根據next表進行匹配了。匹配代碼如下:
int match(string P, string T) {
vector<int> next = buildNext(P);
size_t n = T.size(), i = 0;
size_t m = P.size(), j = 0;
while (j < m && i < n)
if (0 > j || T[i] == P[j]) { i++; j++;}
else j = next[j];
return i - j;
}
理解了next表的構造原理,其實就理解了匹配過程,next構造過程就是模式串的自我匹配。當失配時,如果next表的值大於0,說明有公共的前后綴,那么就不需要從0開始比較,直接從公共前后綴的后一個字符與當前文本的第j個字符開始比較。
KMP再改進
考慮下面這個情況,明知T[4]不等於P[4]且P[1] = P[2] = P[3] = P[4],還要比對剩余的P[1], P[2], P[3], 這是沒有必要的,這是需要改進next表。
改進只需要把next中的N[j] = t
換成N[j] = ( P[++j] != P[++t] ? t : N[t] )
即可。如下所示:
因為相同,所以可以直接跳過他們,更快。
KMP算法的時間復雜度是\(O(m + n)\), 空間復雜度是\(O(m+n)\). 匹配過程令k = 2i- j,k每次循環至少加1,判斷為真則i加1,判斷為假,j至少減1,所以k <= 2n - 1; 同理next過程也是如此。
KMP小結:
- 以判斷公共前后綴來進行模式串的移動,有公共前后綴,移動到前綴的下一位即可,沒有公共前后綴則移動到頭部。
- 通過通配符來有效構造next表,表的第一位為-1,當第一位對齊不相等的時候,這時通配符匹配,使文本串(也包括模式串的自我匹配)可以移動起來,不至於卡死。
- 當發生重復串的時候,跳過他們,不進行比較。
BM算法
對於BM算法的介紹,我同樣推薦看阮一峰老師的BM博客(真心推薦看看),講的十分清楚。同樣假設你看過博客知道原理了,就知道BM算法有兩個next表,一個是壞字符(bad character)bc表,另一個是好后綴(good suffix)gs表,現在來看看如何構造這兩個表。
bc表
對於壞字符表,構造起來很簡單,它是記錄模式串中每種字符最后出現的位置,代碼如下:
vector<int> buildBC(string P){
vector<int> bc(256, -1);
for(size_t m = P.size(), j = 0; j < m; j++)
bc[ P[j] ] = j;
return bc;
}
壞字符移動規則: 后移位數 = 壞字符的位置- 搜索詞中的上一次出現位置
基於BM-DC的算法最好情況就是\(O(n/m)\), 最壞情況是\(O(m*n)\)。
最好情況:
最壞情況:
gs表
相比於bc表,gs表就很不好構造了。首先來看看一個概念,最大匹配后綴長度表,通過它來構建ss(suffix size)表,然后通過ss表來構造gs表。
最大匹配后綴長度的意思是在P[0,j)的所有綴中,與P的某一后綴匹配最長者。例如下面的P[0, 3) = ICE, 與末尾的ICE最長匹配,則P[0, 3)的末尾就為最長匹配長度3,RICE同理。(ss表的值就等於最大匹配長度)
ss表末尾的值就是整個模式串的長度,簡單的想法是遍歷每一個字符向后遞減,與后綴開始一一比較(暴力搜索),這樣做的復雜度為\(O(m^2)\), 很好的做法是下面的代碼(從后往前遍歷),時間復雜度只有\(O(m)\)。
vector<int> buildSS ( string P ) { //構造最大匹配后綴長度表:O(m)
int m = P.size();
vector<int> ss(m, 0); //Suffix Size表
ss[m - 1] = m; //對最后一個字符而言,與之匹配的最長后綴就是整個P串
// 以下,從倒數第二個字符起自右向左掃描P,依次計算出ss[]其余各項
for ( int lo = m - 1, hi = m - 1, j = lo - 1; j >= 0; j -- )
if ( ( lo < j ) && ( ss[m - hi + j - 1] <= j - lo ) ) //情況一:該情況處於最大匹配后綴后的字符,例如,RICE中的R,I,C.
ss[j] = ss[m - hi + j - 1]; //直接利用此前已計算出的ss[]
else { //情況二: 遇到匹配項,依次遞減進行匹配
hi = j; lo = min ( lo, hi );
while ( ( 0 <= lo ) && ( P[lo] == P[m - hi + lo - 1] ) )
lo--; //逐個對比處於(lo, hi]前端的字符
ss[j] = hi - lo; // 高位減去遞減后的低位,得到最長匹配長度
}
return ss;
}
知道ss表后,gs表可有ss表推導出,有兩種情況:
對應的代碼如下:
vector<int> buildGS ( string P ) { //構造好后綴位移量表:O(m)
vector<int> ss = buildSS ( P ); //Suffix Size表
size_t m = P.size();
vector<int> gs(m, m); //Good Suffix shift table
for ( size_t i = 0, j = m - 1; j < UINT_MAX; j -- ) //逆向逐一掃描各字符P[j]
if ( j + 1 == ss[j] ) //若P[0, j] = P[m - j - 1, m),則
while ( i < m - j - 1 ) //對於P[m - j - 1]左側的每個字符P[i]而言
gs[i++] = m - j - 1; //m - j - 1都是gs[i]的一種選擇
for ( size_t j = 0; j < m - 1; j ++ ) //正向掃描P[]各字符,gs[j]不斷遞減,直至最小
gs[m - ss[j] - 1] = m - j - 1; //m - j - 1必是其gs[m - ss[j] - 1]值的一種選擇
return gs;
}
BM_BC+GS
知道了bc表和gs表,接下來就是匹配過程了,與阮老師的博客上說的一致,取兩個表的最大值。代碼如下:
int match ( string P, string T ) { //Boyer-Morre算法(完全版,兼顧Bad Character與Good Suffix)
vector<int> bc = buildBC ( P ), gs = buildGS ( P ); //構造BC表和GS表
size_t i = 0; //模式串相對於文本串的起始位置(初始時與文本串左對齊)
while ( T.size() >= i + P.size() ) { //不斷右移(距離可能不止一個字符)模式串
int j = P.size() - 1; //從模式串最末尾的字符開始
while ( P[j] == T[i + j] ) //自右向左比對
if ( 0 > --j ) break;
if ( 0 > j ) //若極大匹配后綴 == 整個模式串(說明已經完全匹配)
break; //返回匹配位置
else //否則,適當地移動模式串
i += max ( gs[j], j - bc[ T[i + j] ] ); //位移量根據BC表和GS表選擇大者
}
return i;
}
基於BM_BC+GS算法最好情況是\(O(n/m)\),最壞情況由於有了gs表,變為了\(O(m+n)\).
綜合性能
各種模式匹配算法的時間復雜度如下所示: