- KMP 確實很難理解,查找相關資料進行說明:
以下引用為知乎上作者的文章,
作者:咸魚白
鏈接:https://www.zhihu.com/question/21923021/answer/642165149
來源:知乎
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
詳解KMP算法
引子
這幾天自學了KMP算法,也在網上看了很多相關的博文,然而他們對next數組的求解方法的解釋都比較模糊,難于讓讀者理解,故參考幾位前輩的博文,加以優化,撰此博文,分享一下自己的理解。
簡述BF算法
講述KMP算法的原理之前,BF算法是繞不開的話題,也只有了解了BF算法,才能知道KMP算法的優勢。
先來看一個例子:給出兩個字符串A和B,求解A中是否包含B?如果包含,包含了幾個?
BF算法的原理是一位一位地比較,比較到失配位的時候,將B串的向后移動一個單位,再從頭一位一位地進行匹配。
如圖2:
</noscript>
在比較到第6個字符時(字符索引:5),不匹配了,此時就要將B串后移一個單位,從頭開始匹配(將原本指向A串第六個字符的指針i指向第二個字符,指向B串第六個字符的指針j重新指向B串開頭):
</noscript>
然而此時我們一眼可以看出第二次匹配也是必然失敗的,但計算機并不知道,它只會按照BF算法一位一位的比較下去(在很多情況下要比較很多位才能發現不匹配),這種暴力求解的算法效率是極低的,所以我們有沒有辦法讓計算機根據已經匹配過的部分知道自己從頭匹配的時候應該忽略哪些部分,省去不必要的匹配?(在此例中即為從頭跳過第二位的b從第三位開始新的匹配,例子不夠極端,可能并不是很好理解跳過的必要性,請耐心看后續講解)為了解決這個問題,KMP算法便誕生了。
KMP算法
1、前綴與后綴
首先我們要了解幾個概念:前綴、后綴、相同前綴后綴的最大長度(為表述方便,下文均用公共最大長指代),為了直觀一點,我們直接舉例:
abcdef的前綴:a、ab、abc、abcd、abcde(注意:abcdef不是前綴)
abcdef的后綴:f、ef、def、cdef、bcdef(注意:abcdef不是后綴)
abcdef的公共最大長:0(因為其前綴與后綴沒有相同的)
ababa的前綴:a、ab、aba、abab
ababa的后綴:a、ba、aba、baba
ababa的公共最大長:3(因為他們的公共前綴后綴中最長的為aba,長度3)
2、利用相同前綴后綴的最大長度(公共最大長)對匹配過程進行優化
如圖4:
</noscript>
index行是字符在B串中的位置索引值。
B串行則記錄了所有字符。
next行則記錄了當前從B串頭部到當前位置的這一子串的公共最大長。(我們先不用管這些公共最大長是如何得到的,暫且假設是上帝告訴了我們)
new行記錄的值則是相應的公共最大長減去1。
好的,我們現在可以再次進行匹配了,還是開頭的例子,B串在第6個字符處(索引5)失配,此時我們可以確認的是B串的前五個字符已經匹配成功了,讓我們根據上面那個表格查找一下已經匹配成功的子串的公共最大長吧(請注意是已經匹配成功的,我們在第6個字符處失配,所以應當去查找第五個字符或者說索引4的位置記錄的公共最大長)。
<noscript></noscript>
很明顯,已匹配成功的子串(我們稱之為C串吧)的公共最大長為2,這說明了什么?想一想,B串匹配成功的部分和A串失配處之前的一小部分子串都是C串,C串的公共最大長為2,C串最前面的兩個字符(也就是B串的開頭兩個字符)和C串最后面的兩個字符(也就是A串失配位前面兩個字符)是相同的,這就意味著我們重新進行匹配的時候可以直接將B串的頭部2個字符和A串匹配成功的部分的最后兩個字符對齊。然后開始對比B串的第三個字符與A串的失配字符,進行新一輪的匹配
關于對齊,計算機運行時是怎么做的?我們在匹配時分別用指針i和j指向字符串當前匹配的位置,失配之后指針i不變,繼續指向A串的失配處,指針j則指向B串第三個位置(公共最大長的后面一位,索引為公共最大長)。
如圖6所示:
</noscript>
藍色的部分即是通過公共最大長直接匹配的位置,紅色部分是重新開始匹配的位置(兩個指針直接指向的位置),相較于BF算法,我們在這一步跳過了A串的第二個字符“b”,第三個字符“a”,直接將B串頭部對齊了第四個字符,并從B串的第三個字符開始重新與此前失配的字符進行新一輪的匹配。實現就是如此簡單,重新匹配的過程省去了一大堆不必要的匹配,為我們節省了很多時間。
也許有人會疑問,憑什么就應該跳過A串的第三個字符“a”去直接和第四個字符“a”進行匹配呢?難道從第三個開始匹配就不能成功嗎?請回憶一下什么是公共最大長吧?是相同前綴后綴的最大長度。請對照著圖6看完我下面的解釋:我在前面有加粗這么一句話“B串的這部分子串和A串失配處之前的一小部分是相同的”,現在我們假設是從第三個字符“a”開始重新匹配的,如果要與B一直匹配成功到第五個字符“b”,也就是匹配成功了三個字符,這意味著什么?意味著第五個字符位對應的公共最大長應該是3,這顯然是和事實的公共最大長為2是不符合的,以此類推,重新開始匹配時參考公共最大長是合理的最優解。再梳理一下其中的邏輯,現在你應該能理解公共最大長度的意義了。
等一等,我們好像忘了什么?原理懂是懂了,可上帝在哪?我們之前假設上帝告訴了我們next行(next數組中記錄的值),可事實上上帝并不存在,還是得靠我們自己求值鴨!所以,怎么求next數組呢?
3、NEXT(NEW)數組的求解
在此我要先解釋一下new數組中值的意義,正如表示公共最大長的next的數組的值同時表示了對應的最大前綴(為方便表示,我們將最大相同前綴后綴中的前綴稱為最大前綴,最大相同前綴后綴中的后綴稱為最大后綴)的后面一位的索引,new數組中的值等于next數組中的值減去1,我們同時用其表示了對應的最大前綴的最后一位的索引,這是為了后續的程序表達的便利,所以我們此處講解對new數組的求解,因為求出了new數組等于求出來next數組。
先把求解new數組的類c偽代碼貼在這里(B即為B字符串,new即為new數組,不要急于看懂代碼,先看明白我的解釋,代碼看不懂也不礙事,反正是偽代碼):
new[0] = -1
for (int i=1;i<n;i++) /* n為B串長度 */
{
int j=new[i-1]; /* j為待計算位置前一位對應的new值,也就是最大前綴最后一位對應的索引 */
while ((B[j+1]!=B[i])&&(j>=0)) /* 任何一個最大前綴后一位與當前求值字符相同時或者向前繼續尋找的索引為-1時停止循環 */
j=new[j];
if (B[j+1]==B[i]) /* 字符相同,公共最大長+1,new值+1 */
new[i]=j+1;
else /* 最終尋找到的索引為-1,公共最大長歸零 */
new[i]=-1;
}
首先,我們可以知道B串的第一個字符對應的公共最大長一定是0,在new數組中則為-1,所以new[0] = -1。接下來,我們不從第二個字符開始類推,而是選取一個位置靠后的具有普適性的例子以便更好的理解推導過程。
如圖7所示:
假設我們已經一步步推導得出了前面0-9索引對應的new值,現在要求解索引10對應new值(再次提醒:這個值是公共最大長-1)。
我們首先需要考慮添加了字符b之后的公共最大長是否會增加1,該如何判斷呢?
將索引10對應的字符和前面已經求得解的最長字符串“abaabbabaa”的最大前綴后面一位字符比較,如果二者相同,說明了最大前綴添加一位后產生的字符串和最大后綴添加了字符’b’產生的字符串相同,此時索引10位置對應的公共最大長應該在前面一位的基礎上加1。
那么這個字符串“abaabbaba”的最大前綴的后一位的索引值該如何找到?
這個值是已經求得解的最長字符串的公共最大長的值,即為next[9],或者說是new[9]+1。(next[9]對應了公共最大長的值,也表示著最大前綴后一位的索引)
這個值具體是什么?
是new[9]+1 = 3+1 = 4。我們繼續尋找索引4對應的字符,是’b’,和索引10對應的字符相同,所以索引10對應的公共最大長較之前一位加1,new值加1,所以new[10] = new[9]+1 = 3+1 = 4。
可是,如果B[10]不是’b’呢,如果B[10]=’a’呢?怎么辦?
<noscript>如圖8所示:
很明顯,按之前的推理,在當前情況下,B[10]=’a’和B[4]=’b’是不等的,所以公共最大長不可能增加了,我們只能考慮其與前一位相等甚至減少的情況了,此時該怎么求呢?我們現在要找的是最大前綴的前綴,與“最大后綴加‘a’字符”這一組合的后綴的公共最大長了,我們暫且將索引10前一位對應的子串的最大前綴與最大后綴稱為C串吧(因為二者是相同的字符串),此時我們要求解的問題其實轉化為了C串后面添加一個字符’a’對應的公共最大長,于是我們先利用索引10前面一位索引9對應的new值找到C串(從頭數起)的最后一位,并重復上面的過程來推測此時公共最大長應該朝什么方向變化(這就是代碼中循環的意義),如果向前找到的最后一位的索引是-1,即公共最大長已經減到0的時候,循環終止。現在回頭再看看代碼和注釋,就應當能夠理解了(看懂了的看官們賞個贊再走叭,嚶嚶嚶)。
- 暴風算法
int matchIndex(char *s ,int sL,char *T,int TL){
int count = 0;
//bf1算法
for (int i =0; i<= sL-TL; i++) {
int j = 0;
for ( j =0; j<TL; j++) {
count ++;
if (s[i+j] != T[j]) {
break;
}
}
if (j == TL) {
printf("---[%d]\n",count);
return i;
}
}
printf("---[%d]\n",count);
return -1;
}
- rk算法
int jinzhi26(int n){
int res = 1;
while (n-->0) {
res = 26*res;
}
return res;
}
int calulateHash(char *s,int start ,int end){
int add = 0;
while (start <= end) {
add += (s[start] - 'a')*jinzhi26(end - start);
start++;
}
return add;
}
//根據上一個hash值計算當前hash值
int calulateHash2(char *s,int start ,int end,int preHash){
//前一個c*進制,除了第一個,其他都是我們想要的數據,利用了之前已做好的計算,減去不要的,加上最后一個還沒加的
int add = preHash *jinzhi26(1) - (s[start-1] - 'a')*jinzhi26(end-start+1) + s[end]-'a';
return add;
}
int matchIndex(char *s ,int sL,char *T,int TL){
int count = 0;
//rk算法 hash
int target = calulateHash(T, 0, TL);
int temp = 0 ;
for (int i =0; i<= sL-TL; i++) {
if (temp == 0) {
temp = calulateHash(s, i, i+TL);
}else{
temp = calulateHash2(s, i, i+TL, temp);
}
if (temp == target) {
printf("---[%d]\n",count);
return i;
}
count++;
}
printf("---[%d]\n",count);
return -1;
}
- kmp算法
//沒有相同前綴是-1,有一個是0,以此類推
//如果要換成數組坐標,只需+1即可
void getNext(char* s,int len, int * out){
out[0] = -1;//-1的設計是因為最小共有長度 +1 可以回到第一個字母的下標
for (int i = 1; i<len; i++) {
int preNext = out[i-1];
//根據前面已求的長度推斷現在的
while (s[preNext+1] != s[i] && preNext >=0) {//preNext >=0表示至少有一個相同前綴
//不相同的話就繼續探索更小的前綴是否可以跟目前這個比較
preNext = out[preNext];
}
if (s[preNext+1] == s[i]) {
out[i] = preNext+1;
}else{
out[i] = -1;
}
}
}
//繼續優化
void getNext2(char* s,int len, int * out){
// 這個是求當前位置的前面字符的共同前后綴
//0,表示無效,1表示沒有,2表示一個,以此類推
//第0個用不到,從1開始,1一定是1;
out[0] = -1;//看了半天才弄明白,原來這里是0,是因為字符串的字符是從第二個開始的,第一個位置是存的長度
out[1] = 0;
int i = 0;
int j = 1;
while (j < len -1) {
if (i == -1 ||s[i] == s[j] ) {
i++;
j++;
out[j] = i;
}else{
i = out[i];//回溯,找更小的共同前后綴坐標
}
}
}
//繼續優化,修正
void getNext3(char* s,int len, int * out){
// 這個是求當前位置的前面字符的共同前后綴
//0,表示無效,1表示沒有,2表示一個,以此類推
//第0個用不到,從1開始,1一定是1;
out[0] = -1;//看了半天才弄明白,原來這里是0,是因為字符串的字符是從第二個開始的,第一個位置是存的長度
out[1] = 0;
int i = 0;
int j = 1;
while (j < len -1) {
if (i == -1 ||s[i] == s[j] ) {
i++;
j++;
//將接下來的字符串判斷直接在這里判斷,優化下標
if (s[i] != s[j]) {
out[j] = i;
}else{
//優化重疊部分,理解:如果比較的時候,來到這里,可以多跳幾步重復比較
out [j] = out[i];
}
}else{
i = out[i];//回溯,找更小的共同前后綴坐標
}
}
}
int matchIndex(char *s ,int sL,char *T,int TL){
int count = 0;
int next[6] = {0};
getNext3(T, TL, next);//0無效,1.無,2,有一個
int i=0,j=0;
while (i < sL && j< TL) {
count++;
if (s[i] == T[j] || j == 0) {
i++;
j++;
}else{
j = next[j];//j為跳到第幾個位置下標
}
}
if (j == TL) {
printf("---[%d]\n",count);
return i-TL;
}
return -1;
}