模式串匹配之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 |
舉例說明,如下是使用上例的模式串對目標串執行匹配的步驟
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 |
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 |
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 |
如果 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。演算法如下:
- inlinevoid BuildNext(constchar* pattern, size_t length, unsigned int* next)
- {
- unsigned int i, t;
- i = 1;
- t = 0;
- next[1] = 0;
- while(i < length + 1)
- {
- while(t > 0 && pattern[i - 1] != pattern[t - 1])
- {
- t = next[t];
- }
- ++t;
- ++i;
- if(pattern[i - 1] == pattern[t - 1])
- {
- next[i] = next[t];
- }
- else
- {
- next[i] = t;
- }
- }
- //pattern末尾的結束符控制,用於尋找目標字串中的所有匹配結果用
- while(t > 0 && pattern[i - 1] != pattern[t - 1])
- {
- t = next[t];
- }
- ++t;
- ++i;
- next[i] = t;
- }
程式中,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。
利用跳轉表實現字串匹配的演算法如下:
- unsigned int KMP(constchar* text, size_t text_length, constchar* pattern, size_t pattern_length, unsigned int* matches)
- {
- unsigned int i, j, n;
- unsigned int next[pattern_length + 2];
- BuildNext(pattern, pattern_length, next);
- i = 0;
- j = 1;
- n = 0;
- while(pattern_length + 1 - j <= text_length - i)
- {
- if(text[i] == pattern[j - 1])
- {
- ++i;
- ++j;
- //發現匹配結果,將匹配子串的位置,加入結果
- if(j == pattern_length + 1)
- {
- matches[n++] = i - pattern_length;
- j = next[j];
- }
- }
- else
- {
- j = next[j];
- if(j == 0)
- {
- ++i;
- ++j;
- }
- }
- }
- //返回發現的匹配數
- return n;
- }
該演算法在原有基礎上進行了擴充套件,在原模式串末尾加入了一個“空字元”,“空字元”不等於任何的可輸入字元,當目標串匹配至“空字元”時,說明已經在目標字串中發現了模式,將模式串在目標串中的位置,加入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的效果。