字符串模式匹配之KMP算法圖解與 next 數組原理和實現方案


之前說到,朴素的匹配,每趟比較,都要回溯主串的指針,費事。則 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 

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電子書,資料,幫忙內推,歡迎拍磚!

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM