之前說到,朴素的匹配,每趟比較,都要回溯主串的指針,費事。則 KMP 就是對朴素匹配的一種改進。正好復習一下。
KMP 算法其改進思想在於:
每當一趟匹配過程中出現字符比較不相等時,不需要回溯主串的 i指針,而是利用已經得到的“部分匹配”的結果將模式子串向右“滑動”盡可能遠的一段距離后,繼續進行比較。如果 ok,那么主串的指示指針不回溯!算法的時間復雜度只和子串有關!很好。
KMP算法的關鍵是利用匹配失敗后的信息,盡量減少模式串與主串的匹配次數以達到快速匹配的目的,很自然的,需要一個函數來存儲匹配失敗的信息。
先理解一個概念:前后綴字符串
比如"ababa"
前綴:a,ab,aba,abab,除了最后一個字符
后綴:a,ba,aba,baba,除了第一個 字符
比如"abcd"
前綴:a,ab,abc
后綴:d,cd,bcd
圖解kmp 算法對朴素匹配改進的過程;
同樣如圖1,發生不匹配,朴素的做法是 j 到開頭1出,i 到上次開始比較的位置的下一位2處(i回溯)
圖1
2
但是發現一個問題,那就是在 圖1的3處,不匹配的時候,前面的字符已知是匹配的,ab 是模式串里臨時匹配的串,如果 i 回溯,那么等於是白白去比較,因為要把"搜索位置"移到已經比較過的位置,重比一遍。無用功,如果此時 i 不動,直接就可以減少無用的比較次數(所謂無用是說以最少的比較次數,找出完全的匹配串,盡量少做不匹配比較,通過之前的信息來計算和判斷),如上圖2,i 不動,j 回溯到1,匹配,ij繼續走……一直都是匹配的,直到圖4
3
4
那么不匹配了,臨時的匹配串是 abca,如果 j 還是回到1,i 回溯到4(朴素的),我們發現1和4比較后不匹配,那么 i 繼續右移,j 還是1,直到 i 到了6,才和 j=1處的 a 匹配,是不是之前的比較都是無用功?為什么不可以直接就和6比較呢?怎么解決呢?
發現一個規律:如果臨時匹配串里,前后綴有重復,那么其實模式串的j,沒必要每次都回到1,仔細思考是這樣的。有一定規律可尋。
5
6
整個過程結束,最后結果和朴素一樣,但減少了比較次數,改進了時間復雜度,讓 o(t) 只和模式串t有關,因為主串s是給定的,且 i 不回溯,一直往前走!
到這里,就要思考,如何找出 j 回溯的邏輯。
換一個角度思考;
之前我們的做法是都觀察 i j 的回溯!如圖,現在我們移動模式串來觀察。
7
8
圖7不匹配, i 到2,j 到1,其實這相當於 T 右移,看圖8。T 繼續右移直到發生匹配,順次比較,直到圖9,紅色標出,發現了不匹配,那么,按照之前的朴素做法,i 到4,j 還是1,相當於 T還是 右移一位!如下下圖10。
9
10
比較之后,不匹配,T 繼續友誼,直到T 1處移動到S6處,才發生匹配,之后繼續順次比較,ok!找出了匹配串。
11
那么通過觀察來思考:每當臨時匹配串(已知的)前后有重復的時候,那么只需 把模式串 T 直接移動到后綴剛剛開始有重復的位置(設移動距離為 d),i 不回溯。也就是j 直接反向的回溯距離 d,亮着等價的。為什么這樣?
因為,在字符串搜索和匹配的時候,經常有前后重復的時候,前綴和后綴重復!如倒數第3圖的已知的臨時匹配串abca,前后綴 a 重復!此時沒必要再用模式串的首位a去和S 的 b 比較了,直接和前后綴子串第一次有重復的位置比較(設子串右移距離 d),也就是 a 處。如果還那么順次比較,是做無用功!此時思考如何實現這個邏輯,找到對應的j 回溯的距離 d。這個距離 d 就是前后重復的字符串的距離。
設置一個數組next來返回模式串的 j 應該回溯的位置
若令數組next[j]=k,則next[j]表明當模式中第j個字符與主串中相應字符失配時,在模式中需要重新和主串中該字符進行比較的字符的位置。
得到 KMP 模式匹配算法的實現思路(區別就是 next 函數)
那么問題來了,如何實現 next數組 ,生成對於的 j回溯的位置,從前面的討論可知,next數組值僅取決於模式串本身,而與主串無關。
j 的回溯距離d 等於模式串中臨時匹配串長(也就是j) 減去 相同的前后綴子串中的最大子串長度S(兩個最大子串的距離),next 的值就是 j - j + S =S因此要計算next函數的返回值,就要找出前綴和后綴相同的最大子串的長度。
這個查找過程實際上仍然是模式匹配,只是匹配的模式與目標在這里是同一個串S。(這里遵守 c 的規定,數組都是默認下標0開始存儲)
//計算 next 數組:根據待匹配的字符串,求出對應每一位的最大相同前后綴的長度 void computeNext(char *str, int next[]) { } int strKMPCompare(char *strMain, char *strSub, int index, int next[]) { int iMain = index; int jSub = 0; int lenMain = getLength(strMain); int lenSub = getLength(strSub); while ((iMain >= 0 && iMain <= lenMain - 1) && ((jSub >= 0 && jSub <= lenSub - 1))){ if (strMain[iMain] == strSub[jSub]) { iMain++; jSub++; }else{ //主串的 i 不回溯! //計算 next 數組 computeNext(strSub, &next[0]); jSub = next[jSub]; } } //如果匹配 ok,肯定子串先比完。 if (jSub > lenSub - 1) { return iMain - lenSub;//得到的就是匹配 ok 后,主串里第一個和模式串第一個字符匹配的字符的位置 }else{ return 0;//匹配失敗 } }
那么最大的問題來了,如何實現 next數組?
next 數組遞推的圖解如下,已知,模式串的長度為 L ,j=0的時候,也就是第一個就不匹配, 規定next[0]=-1成立!其實在匹配過程中,若發生不匹配的情況,如果next[j]>=0,則目標串的指針i不變,將模式串的指針j移動到next[j]的位置繼續進行匹配;若next[j]=-1,則將i右移1位,並將j置0,繼續進行比較。
假設執行到某步,求出此時的 j (也就是第 j+1項發生不匹配)的 next[j] = k,k 是程序執行到這 步時,最長前后綴子串的長度。
如下是最長前綴子串:
P(0) P(1) …… P(K-1)
如下是模式串:
P(0) P(1) …… P(K-1) P(K) P(K+1) …… P(J-1) P(J) …… P(L - 1)
因為前后綴子串長度相等為 k,那么得到:
P(0) P(1) …… P(K-1) = P(J - K) …… P(J-1)
其中P(J - K) …… P(J-1)是最長后綴子串,長度是 k,注意滿足j-1-j+k=k-1,直觀的看就是:
P(0) P(1) …… P(K-1)
|| || || ||
P(J - K) …… P(J-1)
如果,對於模式串,繼續求 j+1的 next[J+1],如下:
P(0) P(1) …… P(K-1) P(K) P(K+1) …… P(J - K) …… P(J-1) P(J)
如果 p(k)=p(j),那么有:
P(0) P(1) …… P(K-1) P(K)
|| || || || ||
P(J - K) …… P(J-1) P(J)
此時,next[j+1]=k+1=next[j]+1
如果,p(k)!=p(j),那么需要從新檢查,不過不論怎樣計算,最后還是能得到一個正確的結果,即:最大重復前后綴字符串的前綴子字符串開頭一定是 p(0),后綴字符串結尾一定是 p(j)。 但是在得到這個正確結果之前,我們總會經歷相思的步驟,因為是找前后綴相等的子字符串,那么一般情況下總會經歷這樣的過程:前綴子字符串開頭一定是 p(0),而已經知道后綴字符串的倒數第二項等於 p(k-1),那么前綴字符串的倒數第二項也應該等於 p(k-1),現在設為 p(m-1)來表示,又我們假設的是求出了next[j]=k,
P(0) P(1) …… p(m-1) …… P(K-1) P(K) P(K+1) …… P(J - K) …… P(J-1)
那么 j 之前的 每一位對應的 next 也都求出了,自然得到next[k-1]=m,此時前綴的結尾 p(m)要么滿足和 p(j)相等,要么不相等,如相等還是m++處理,不等還是如上的過程,這樣遞歸下去直到成功找到。
本質上則可以把其看做模式匹配的問題,即匹配失敗的時候,k值如何移動
next[k-1]=m,next[m-1]=n,……,next[0]=0 =》
next[next[next[k-1]-1]-1……]=next[j+1] 且 next[j]=k
或
k++
實現代碼
1 //計算 next 數組:根據待匹配的字符串,求出對應每一位的最大相同前后綴的長度 2 void computeNext(char *str, int next[]) 3 { 4 int k = -1;//記錄最長前后綴字符串的長 5 int i = 1;// 6 //next【0】=-1,肯定要遍歷模式串 7 next[0] = -1; 8 //模式串長度 9 int len = getLength(str); 10 //第一岑循環控制計算到模式串的每一位 11 while (i < len) { 12 //第二層循環,控制每次計算到某位置時,遞歸求解 k 的過程 13 //next[next[next[k-1]-1]-1……]=next[j+1] 且 next[j]=k 14 while (k > 0 && str[i] != str[k]) { 15 k = next[k - 1];//遞歸,逐層深入,調用 16 } 17 //i 變化,如果 stri=strk,退出遞歸循環,直接+1求解,否則一直遞歸到為k<=0退出 18 if (k == -1 || str[i] == str[k]) { 19 k++; 20 } 21 //所有情況都處理完畢,存儲結果 22 next[i] = k; 23 i++; 24 } 25 }
需要注意幾個點,因為規定了,next[0]=-1,那么 k 最小應該為-1,且若 next 返回-1的話,說明第一個不匹配,那么這里注意下,把 i++,j=0設置!在 kmp 函數里注意。
jSub = next[jSub]; if (jSub == -1 ) { jSub = 0; iMain++; }
在next 函數的 if 語句中,當 strk==stri, k++,但是還要注意,k==-1的情況!也要k++,否則緊跟 下面的 賦值,會把-1付給next【i】
完整代碼
1 //計算 next 數組:根據待匹配的字符串,求出對應每一位的最大相同前后綴的長度 2 void computeNext(char *str, int next[]) 3 { 4 int k = -1;//記錄最長前后綴字符串的長 5 int i = 1;// 6 //next【0】=-1,肯定要遍歷模式串 7 next[0] = -1; 8 //模式串長度 9 int len = getLength(str); 10 //第一岑循環控制計算到模式串的每一位 11 while (i < len) { 12 //第二層循環,控制每次計算到某位置時,遞歸求解 k 的過程 13 //next[next[next[k-1]-1]-1……]=next[j+1] 且 next[j]=k 14 while (k > 0 && str[i] != str[k]) { 15 k = next[k - 1];//遞歸,逐層深入,調用 16 } 17 //i 變化,如果 stri=strk,退出遞歸循環,直接+1求解,否則一直遞歸到為k<=0退出 18 if (k == -1 || str[i] == str[k]) { 19 k++; 20 } 21 //所有情況都處理完畢,存儲結果 22 next[i] = k; 23 i++; 24 } 25 } 26 27 int strKMPCompare(char *strMain, char *strSub, int index, int next[]) 28 { 29 int iMain = index; 30 int jSub = 0; 31 int lenMain = getLength(strMain); 32 int lenSub = getLength(strSub); 33 34 while ((iMain >= 0 && iMain <= lenMain - 1) && ((jSub >= 0 && jSub <= lenSub - 1))){ 35 if (strMain[iMain] == strSub[jSub]) { 36 iMain++; 37 jSub++; 38 }else{ 39 //主串的 i 不回溯! 40 //計算 next 數組 41 computeNext(strSub, &next[0]); 42 jSub = next[jSub]; 43 if (jSub == -1 ) { 44 jSub = 0; 45 iMain++; 46 } 47 } 48 } 49 //如果匹配 ok,肯定子串先比完。 50 if (jSub > lenSub - 1) { 51 return iMain - lenSub;//得到的就是匹配 ok 后,主串里第一個和模式串第一個字符匹配的字符的位置 52 }else{ 53 return 0;//匹配失敗 54 } 55 } 56 57 int main(int argc, const char * argv[]) { 58 char *str1 = "avcbababcc"; 59 char *str2 = "bab"; 60 int next[100] = {0}; 61 62 int i = strKMPCompare(str1, str2, 0 , &next[0]); 63 64 for (int i = 0; i < 11; i++) { 65 printf("%d \n", next[i]); 66 } 67 68 printf("%d\n", i); 69 70 return 0; 71 }
-1
0
1
0
0
0
0
0
0
0
0
3
Program ended with exit code: 0
補充:next數組的直接求法,上面說的是遞歸思想,遞推關系。其實也可以直接求解。
思考:關鍵是如何比較,肯定需要知道模式串長 len,還需要頭部一個標記,尾部一個標記。直接想到循環去遍歷兩個頭,頭++,尾部++,這是求的模式串的某個位置的 next 數組值。故還需要一個外循環控制依次遍歷模式串。在這個外循環里,每趟循環,調用一次比較函數,求出 next[x]=?,通過 break 退出內部循環,實現一次調用。
1 //i 是前綴長,后綴長=i,故后綴第一個下標是 j-i ,通過比較函數內部的約束來調整參數 2 bool equal(char *str, int i, int j) 3 { 4 int head = 0;//假設是 0 1 …… i-1 和 j-i …… j-1,依靠,前后綴相等的特征! 5 int tailHead = j - i;//參數 i 是前綴字符串的尾部下標,而前綴字符串長度就是 i,等於后綴字符串長度,故用末位 j-i 就是后綴字符串的開頭 6 7 for (; head <= i - 1 && tailHead <= j -1; head++, tailHead++) { 8 if (str[head] == str[tailHead]) { 9 return true; 10 } 11 } 12 13 return false; 14 } 15 16 void getNext(char *str, int next[]) 17 { 18 int nextI = 0; 19 int head = 0; 20 21 for (; nextI < getLength(str) ; nextI++) { 22 //規定0是-1 23 if (0 == nextI) { 24 next[nextI] = -1; 25 }else if (nextI == 1){ 26 next[nextI] = 0; 27 } 28 else{ 29 //進行比較,需要一個循環控制 尾部字符串的處理 30 for (head = nextI - 1; head > 0; head--) { 31 //head 是頭字符串的終點,nextI 是臨時模式串的終點 32 if (equal(str, head, nextI)) { 33 next[nextI] = head; 34 break; 35 } 36 } 37 //別忘了要習慣性的思考邊界的特例,如果 head--為0了,自動退出內循環,要處理下,其實是說明字符串沒有任何重復字符出現的情況。 38 if (0 == head) { 39 next[nextI] = 0; 40 } 41 } 42 } 43 }
改進的 next數組 算法
在看一個例子:
主串:’aaabaaab’;
子串: aaaa3b
當子串中的第四個字符’a’與主串中的第四個字符’b’失配后,按照之前的 next 數組求法,的 next[3]=2如果用子串中的第3個字符’a’繼續與主串中的第四個字符’b’比較,將是做無用功。以此類推。說明:kmp 算法里的關鍵next函數仍有改進的地方。改進next函數:當子串中的第j個字符與主串中的第i個字符失配后,如果有next[j]=k;且在子串中有pj=pk;那么pk肯定也與主串中的第i個字符不等,所以,直接讓
next[j]=next[k];
直到他們不等或next[j] = -1為止。這個很好理解。當子串中的第四個字符’a’與主串中的第四個字符’b’失配后,next[3]=next[2]=1,則next[3]=next[1] = next[0] = -1。這樣效率又高了些。
//所有情況都處理完畢,存儲結果 if(str[i] == str[k]) { //本質還是把一個 k 賦值給了 next【i】,然后回到 while 處從新循環 next[i] = next[k]; }else{ next[i] = k; }
KMP算法只有在主串和模式串"部分匹配"時才會才會體現出他的優勢,否則兩者差異不大 ,KMP算法應用(可借鑒和參考的思想) 首先next數組代表每個字符在匹配失敗時,回溯的位置,我們可以通過next數組找到每個字符的前綴和后綴
歡迎關注
dashuai的博客是終身學習踐行者,大廠程序員,且專注於工作經驗、學習筆記的分享和日常吐槽,包括但不限於互聯網行業,附帶分享一些PDF電子書,資料,幫忙內推,歡迎拍磚!