1. 程式人生 > >模式串匹配之KMP演算法詳解

模式串匹配之KMP演算法詳解

KMP演算法,是由Knuth,Morris,Pratt共同提出的模式匹配演算法,其對於任何模式和目標序列,都可以線上性時間內完成匹配查詢,而不會發生退化,是一個非常優秀的模式匹配演算法。但是相較於其他模式匹配演算法,該演算法晦澀難懂,第一次接觸該演算法的讀者往往會看得一頭霧水,主要原因是KMP演算法在構造跳轉表next過程中進行了多個層面的優化和抽象,使得KMP演算法進行模式匹配的原理顯得不那麼直白。本文希望能夠深入KMP演算法,將該演算法的各個細節徹底講透,掃除讀者對該演算法的困擾。

KMP演算法對於樸素匹配演算法的改進是引入了一個跳轉表next[]。以模式字串abcabcacab為例,其跳轉表為:

j  1  2  3  4  5  6  7  8  9 10
pattern[j] a b c a b c a c a b
next[j] 0 1 1 0 1 1 0 5 0 1
跳轉表的用途是,當目標串target中的某個子部target[m...m+(i-1)]與pattern串的前i個字元pattern[1...i]相匹配時,如果target[m+i]與pattern[i+1]匹配失敗,程式不會像樸素匹配演算法那樣,將pattern[1]與target[m+1]對其,然後由target[m+1]向後逐一進行匹配,而是會將模式串向後移動i+1 - next[i+1]個字元,使得pattern[next[i+1]]與target[m+i]對齊,然後再由target[m+i]向後與依次執行匹配。

舉例說明,如下是使用上例的模式串對目標串執行匹配的步驟

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
b a b c b a b c a b c a a b c a b c a b c a c a b c
a b c a b c a c a b
a b c a b c a c a b
a b c a b c a c a b
a b c a b c a c a b
a b c a b c a c a b
a b c a b c a c a b
通過模式串的5次移動,完成了對目標串的模式匹配。這裡以匹配的第3步為例,此時pattern串的第1個字母與target[6]對齊,從6向後依次匹配目標串,到target[13]時發現target[13]='a',而pattern[8]='c',匹配失敗,此時next[8]=5,所以將模式串向後移動8-next[8] = 3個字元,將pattern[5]與target[13]對齊,然後由target[13]依次向後執行匹配操作。在整個匹配過程中,無論模式串如何向後滑動,目標串的輸入字元都在不會回溯,直到找到模式串,或者遍歷整個目標串都沒有發現匹配模式為止。

next跳轉表,在進行模式匹配,實現模式串向後移動的過程中,發揮了重要作用。這個表看似神奇,實際從原理上講並不複雜,對於模式串而言,其字首字串,有可能也是模式串中的非字首子串,這個問題我稱之為字首包含問題。以模式串abcabcacab為例,其字首4 abca,正好也是模式串的一個子串abc(abca)cab,所以當目標串與模式串執行匹配的過程中,如果直到第8個字元才匹配失敗,同時也意味著目標串當前字元之前的4個字元,與模式串的前4個字元是相同的,所以當模式串向後移動的時候,可以直接將模式串的第5個字元與當前字元對齊,執行比較,這樣就實現了模式串一次性向前跳躍多個字元。所以next表的關鍵就是解決模式串的字首包含。當然為了保證程式的正確性,對於next表的值,還有一些限制條件,後面會逐一說明。

如何以較小的代價計算KMP演算法中所用到的跳轉表next,是演算法的核心問題。這裡我們引入一個概念f(j),其含義是,對於模式串的第j個字元pattern[j],f(j)是所有滿足使pattern[1...k-1] = pattern[j-(k-1)...j - 1](k < j)成立的k的最大值。還是以模式串abcabcacab為例,當處理到pattern[8] = 'c'時,我們想找到'c'前面的k-1個字元,使得pattern[1...k-1] = pattern[8-(k-1)...7],這裡我們可以使用一個笨法,讓k-1從1到6遞增,然後依次比較,直到找到最大值的k為止,比較過程如下

k-1 字首 關係 子串
1 a == a
2 ab != ca
3 abc != bca
4 abca == abca
5 abcab != cabca
6 abcabc != bcabca
因為要取最大的k,所以k-1=1不是我們要找的結果,最後求出k的最大值為4+1=5。但是這樣的方法比較低效,而且沒有充分利用到之前的計算結果。在我們處理pattern[8] = 'c'之前,pattern[7] = 'a'的最大字首包含問題已經解決,f(7) = 4,也就是說,pattern[4...6] = pattern[1...3],此時我們可以比較pattern[7]與pattern[4],如果pattern[4]=pattern[7],對於pattern[8]而言,說明pattern[1...4]=pattern[4...7],此時,f(8) = f(7) + 1 = 5。再以pattern[9]為例,f(8) = 5,pattern[1...4]=pattern[4...7],但是pattern[8] != pattern[5],所以pattern[1...5]!=pattern[4...8],此時無法利用f(8)的值直接計算出f(9)。
j  1  2  3  4  5  6  7  8  9 10
pattern[j] a b c a b c a c a b
next[j] 0 1 1 0 1 1 0 5 0 1
f(j) 0 1 1 1 2 3 4 5 1 2
我們可能考慮還是使用之前的笨方法來求出f(9),但是且慢,利用之前的結果,我們還可以得到更多的資訊。還是以pattern[8]為例。f(8) = 5,pattern[1...4]=pattern[4...7],此時我們需要關注pattern[8],如果pattern[8] != pattern[5],那麼在匹配演算法如果匹配到pattern[8]才失敗,此時就可以將輸入字元target[n]與pattern[f(8)] = pattern[5]對齊,再向後依次執行匹配,所以此時的next[8] = f(8)(此平移的正確性,後面會作出說明)。而如果pattern[8] = pattern[5],那麼pattern[1...5]=pattern[4...8]如果target[n]與pattern[8]匹配失敗,那麼同時也意味著target[n-5...n]!=pattern[4...8],那麼將target[n]與pattern[5]對齊,target[n-5...n]也必然不等於pattern[1...5],此時我們需要關注f(5) = 2,這意味著pattern[1] = pattern[4],因為pattern[1...4]=pattern[4...7],所以pattern[4]=pattern[7]=pattern[1],此時我們再來比較pattern[8]與pattern[2],如果pattern[8] != pattern[2],就可以將target[n]與pattern[2],然後比較二者是否相等,此時next[8] = next[5] = f(2)。如果pattern[8] = pattern[2],那麼還需要考察pattern[f(2)],直到回溯到模式串頭部為止。下面給出根據f(j)值求next[j]的遞推公式:

如果 pattern[j] != pattern[f(j)],next[j] = f(j);

如果 pattern[j] = pattern[f(j)],next[j] = next[f(j)];

當要求f(9)時,f(8)和next[8]已經可以得到,此時我們可以考察pattern[next[8]],根據前面對於next值的計算方式,我們知道pattern[8] != pattern[next[8]]。我們的目的是要找到pattern[9]的包含字首,而pattern[8] != pattern[5],pattern[1...5]!=pattern[4...8]。我們繼續考察pattern[next[5]]。如果pattern[8] = pattern[next[5]],假設next[5] = 3,說明pattern[1...2] = pattern[6...7],且pattern[3] = pattern[8],此時對於pattern[9]而言,就有pattern[1...3]=pattern[6...8],我們就找到了f(9) = 4。這裡我們考察的是pattern[next[j]],而不是pattern[f(j)],這是因為對於next[]而言,pattern[j] != pattern[next[j]],而對於f()而言,pattern[j]與pattern[f(j)]不一定不相等,而我們的目的就是要在pattern[j] != pattern[f(j)]的情況下,解決f(j+1)的問題,所以使用next[j]向前回溯,是正確的。

現在,我們來總結一下next[j]和f(j)的關係,next[j]是所有滿足pattern[1...k - 1] = pattern[(j - (k - 1))...j -1](k < j),且pattern[k] != pattern[j]的k中,k的最大值。而f(j)是滿足pattern[1...k - 1] = pattern[(j - (k - 1))...j -1](k < j)的k中,k的最大值。還是以上例的模式來說,對於第7個元素,其f(j) = 4, 說明pattern[7]的前3個字元與模式的字首3相同,但是由於pattern[7] = pattern[4], 所以next[7] != 4。

通過以上這些,讀者可能會有疑問,為什麼不用f(j)直接作為KMP演算法的跳轉表呢?實際從程式正確性的角度講是可以的,但是使用next[j]作為跳轉表更加高效。還是以上面的模式為例,當target[n]與pattern[7]發生匹配失敗時,根據f(j),target[n]要繼續與pattern[4]進行比較。但是在計算f(8)的時候,我們會得出pattern[7] = pattern[4],所以target[n]與pattern[4]的比較也必然失敗,所以target[n]與pattern[4]的比較是多餘的,我們需要target[n]與更小的pattern進行比較。當然使用f(j)作為跳轉表也能獲得不錯的效能,但是KMP三人將問題做到了極致。

我們可以利用f(j)作為媒介,來遞推模式的跳轉表next。演算法如下:

  1. inlinevoid BuildNext(constchar* pattern, size_t length, unsigned int* next)  
  2. {  
  3.     unsigned int i, t;  
  4.     i = 1;  
  5.     t = 0;  
  6.     next[1] = 0;  
  7.     while(i < length + 1)  
  8.     {  
  9.         while(t > 0 && pattern[i - 1] != pattern[t - 1])  
  10.         {  
  11.             t = next[t];  
  12.         }  
  13.         ++t;  
  14.         ++i;  
  15.         if(pattern[i - 1] == pattern[t - 1])  
  16.         {  
  17.             next[i] = next[t];  
  18.         }  
  19.         else
  20.         {  
  21.             next[i] = t;  
  22.         }  
  23.     }  
  24.     //pattern末尾的結束符控制,用於尋找目標字串中的所有匹配結果用
  25.     while(t > 0 && pattern[i - 1] != pattern[t - 1])  
  26.     {  
  27.         t = next[t];  
  28.     }  
  29.     ++t;  
  30.     ++i;  
  31.     next[i] = t;  
  32. }  

程式中,9到27行的迴圈需要特別說明一下,我們發現在迴圈開始之後,就沒有再為t賦新值,也就是說,對於計算next[j]時的t值,在計算next[j+1]時,還會用得著。實際這時的t的就等於f(j)。還是以上例的目標串為例,當j等於1,我們可以得出t = f(2) = 1。使用歸納法,當計算完next[j]後,我們假設此時t=f(j),此時第11~14行的迴圈就是要找到滿足pattern[k] = pattern[j]的最大k值。如果這樣的k存在,對於pattern[j+1]而言,其前k個元素,與模式的字首k相同。此時的t+1就是f(j+1)。這時我們就要判斷pattern[j+1]和pattern[t](t = t+1)的關係,然後求出next[j+1]。這裡需要初始條件next[1] = 0。

利用跳轉表實現字串匹配的演算法如下:

  1. unsigned int KMP(constchar* text, size_t text_length, constchar* pattern, size_t pattern_length, unsigned int* matches)  
  2. {  
  3.     unsigned int i, j, n;  
  4.     unsigned int next[pattern_length + 2];  
  5.     BuildNext(pattern, pattern_length, next);  
  6.     i = 0;  
  7.     j = 1;  
  8.     n = 0;  
  9.     while(pattern_length + 1 - j <= text_length - i)  
  10.     {  
  11.         if(text[i] == pattern[j - 1])  
  12.         {  
  13.             ++i;  
  14.             ++j;  
  15.             //發現匹配結果,將匹配子串的位置,加入結果
  16.             if(j == pattern_length + 1)  
  17.             {  
  18.                 matches[n++] = i - pattern_length;  
  19.                 j = next[j];  
  20.             }  
  21.         }  
  22.         else
  23.         {  
  24.             j = next[j];  
  25.             if(j == 0)  
  26.             {  
  27.                 ++i;  
  28.                 ++j;  
  29.             }  
  30.         }  
  31.     }  
  32.     //返回發現的匹配數
  33.     return n;  
  34. }  

該演算法在原有基礎上進行了擴充套件,在原模式串末尾加入了一個“空字元”,“空字元”不等於任何的可輸入字元,當目標串匹配至“空字元”時,說明已經在目標字串中發現了模式,將模式串在目標串中的位置,加入matchs[]陣列中,同時判定為匹配失敗,並根據“空字元”的next值,跳轉到適當位置,這樣演算法就可以識別出字串中所有的匹配子串。

最後,對KMP演算法的正確性做一簡要說明,還是以上文的模式串pattern和目標串target為例,假設已經匹配到第3部的位置,且在target[13]處發現匹配失敗,我們如何決定模式串的滑動步數,來保證既要忽略不必要的多餘比較,又不漏過可能的匹配呢?

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
target b a b c b a b c a b c a a b c a b c a b c a c a b c
pattern a b c a b c a c a b

對於例子中的情況,顯然向後移動多於3個字元有可能會漏過target[9...18]這樣的的可能匹配。但是為什麼向後移動1個或者2個字元是不必要的多餘比較呢?當target[13]與pattern[8]匹配失敗時,同時也意味著,target[6...12] = pattern[1...7],而next[8]=5,意味著,pattern[1...4] = pattern[4...7],pattern[1...5] != pattern[3...7],pattern[1...6] != pattern[2...7]。如果我們將模式串後移1個字元,使pattern[7]與target[13]對齊,此時target[7...12]相當於pattern[2...7],且target[7...12]與pattern[1..6]逐個對應,而我們已經知道pattern[1...6] != pattern[2...7]。所以不管target[13]是否等於pattern[7],此次比較都必然失敗。同理向前移動2個字元也是多餘的比較。由此我們知道當在pattern[j]處發生匹配失敗時,將當前輸入字元與pattern[j]和pattern[next[j]]之間的任何一個字元對齊執行的匹配嘗試都是必然失敗的。這就說明,在模式串從目標串頭移動到目標串末尾的過程中,除了跳過了必然失敗的情況之外,沒有漏掉任何一個可能匹配,所以KMP演算法的正確性是有保證的。

後記:

  • KMP演算法是一個高度優化的精妙演算法,所以初涉該演算法的時候,不要指望一蹴而就,一下子就將KMP演算法理解透,而是應該循序漸進,逐步加深理解。首先應該是發現了模式串字首的自包含問題,然後是提出了f(j)的概念,然後是搞定了如何計算f(j),然後提出了next[j]的概念,然後搞定了如何用f(j)計算next[j+1],然後是隻用f(j)做中間結果直接算出next[j+1]。之所以我會這麼猜測,主要是因為next跳轉表的概念和生成演算法太高階,中間經歷了多個轉換,極難一步到位想出來這麼搞。所以我們也應該按照這個流程來學習KMP演算法,而如何計算f(j)則是整個演算法的精髓所在。
  • 實際上,KMP演算法中所用到的跳轉表next是一個簡化了的DFA,對於DFA而言,其跳轉和輸入的字符集有關,而KMP演算法中的跳轉表,對於模式串中的當前位置j-1,只有兩種跳轉方式pattern[j],和^pattern[j],所以KMP演算法的跳轉功能要弱於DFA,但是其構建速度,又大大快於DFA,在花費較小代價的同時,取得了逼近DFA的效果。