前言
前幾天,突然聽到一位剛剛面試完應聘者的同事吐槽到“現在的程序員基本功怎么這么差,連一個簡單的KMP算法都搞不定,還好意思開那么高的薪水"。聽到這里,筆者默默的翻出《數據結構》,打開google。本文正是在這樣的背景下對KMP算法的復習與整理。
簡介
該算法是一種改進的字符串匹配算法,由D.E.Knuth與V.R.Pratt和J.H.Morris同時發現,因此稱之為KMP算法。此算法可以在O(n+m)的時間數量級上完成串的模式匹配操作。
思想
舉例來說,有一個字符串"BBC ABCDAB ABCDABCDABDE",我想知道,里面是否包含另一個字符串"ABCDABD"?
首先,字符串"BBC ABCDAB ABCDABCDABDE"的第一個字符與搜索字符串"ABCDABD"的第一個字符,進行比較。因為B與A不匹配,所以搜索詞后移一位。
因為B與A不匹配,搜索字符串再往后移。
就這樣,直到字符串有一個字符,搜索字符串的第一個字符相同為止。
接着比較字符串和搜索字符串的下一個字符,還是相同。
直到字符串有一個字符,與搜索字符串對應的字符不相同為止。
這時,最自然地方式就是將搜索字符串整個后移一位,再從頭逐個比較。這樣做雖然可行,但是效率很差,因為你要把"搜索位置"移到已經比較過的位置,重比一遍。其算法時間復雜度即為O(m*n)。
一個基本事實是,當空格與D不匹配時,你其實知道前面六個字符是"ABCDAB"。KMP算法的關鍵思想就是,設法利用這個已知信息,不要把"搜索位置"移回已經比較過的位置,繼續把它向后移,這樣就提高了效率。
怎么做到這一點呢?可以針對搜索字符串,算出一張《部分匹配表》(Partial Match Table)。這張表是如何產生的,后面再介紹,這里只要會用就可以了。
已知空格與D不匹配時,前面六個字符"ABCDAB"是匹配的。查表可知,最后一個匹配字符B對應的"部分匹配值"為2,因此按照下面的公式算出向后移動的位數:
右移位數 = 已匹配的字符數 - 對應的部分匹配值
6-2=4, 則將搜索字符串后移4位。
因為空格與C不匹配,搜索字符串還要繼續往后移。這時,已匹配的字符數為2("AB"),對應的"部分匹配值"為0。所以,移動位數 = 2 - 0,結果為 2,於是將搜索字符串向后移2位。
因為空格與A不匹配,繼續后移一位。
逐位比較,直到發現C與D不匹配。於是,移動位數 = 6 - 2,繼續將搜索字符串向后移動4位
逐位比較,直到搜索字符串的最后一位,發現完全匹配,於是搜索完成。如果還要繼續搜索(即找出全部匹配),移動位數 = 7 - 0,再將搜索字符串向后移動7位,這里就不再重復了。
部分匹配表的生成
從上面的匹配過程,我們發現部分匹配表是KMP算法的關鍵所在,解下來讓我們看一下部分匹配表是如何生成的。
首先,我們需要了解兩個概念:"前綴"和"后綴"。 "前綴"指除了最后一個字符以外,一個字符串的全部頭部組合;"后綴"指除了第一個字符以外,一個字符串的全部尾部組合。
字符串“string”為例,則“string”的前綴即為: “s", "st", "str", "stri", "strin"。其后綴即為: "g", "ng", "ing", "ring", "tring"。
"部分匹配值"就是"前綴"和"后綴"的最長的共有元素的長度。以"ABCDABD"為例,
字符串 | 前綴 | 后綴 | 部分匹配值 |
A | 空集 | 空集 | 0 |
AB | A | B | 0 |
ABC | A, AB | C, BC | 0 |
ABCD | A, AB, ABC | D, CD, BCD | 0 |
ABCDA | A, AB, ABC, ABCD | A, DA, CDA, BCDA, | 1 |
ABCDAB | A, AB, ABC, ABCD, ABCDA | B, AB, DAB, CDAB, BCDAB | 2 |
ABCDABD | A, AB, ABC, ABCD, ABCDA, ABCDAB | D, BD, ABD, DABD, CDABD, BCDABD | 0 |
"部分匹配"的實質是,有時候,字符串頭部和尾部會有重復。比如,"ABCDAB"之中有兩個"AB",那么它的"部分匹配值"就是2("AB"的長度)。搜索字符串移動的時候,第一個"AB"向后移動4位(字符串長度-部分匹配值),就可以來到第二個"AB"的位置。
實現
在KMP算法中有個數組,叫做前綴數組,也有的叫next數組,每一個子串有一個固定的next數組,它記錄着字符串匹配過程中失配情況下可以向前多跳幾個字符,當然它描述的也是子串的對稱程度,程度越高,值越大,當然之前可能出現再匹配的機會就更大。next數組的求法是KMP算法的關鍵,但是理解next數組並不是一件輕松的事情。
由上文,我們已經知道,字符串“ABCDABD”各個前綴后綴的最大公共元素長度分別為:
而且,根據這個表可以得出下述結論
- 失配時,模式串向右移動的位數為:已匹配字符數 - 失配字符的上一位字符所對應的最大長度值
把next 數組跟之前求得的最大長度表對比后,不難發現,next 數組相當於“最大長度值” 整體向右移動一位,然后初始值賦為-1。意識到了這一點,你會驚呼原來next 數組的求解竟然如此簡單!
換言之,對於給定的模式串:ABCDABD,它的最大長度表及next 數組分別如下:
根據最大長度表求出了next 數組后,從而有
右移位數 = 失配字符所在位置 - 失配字符對應的next 值
而后,你會發現,無論是基於《最大長度表》的匹配,還是基於next 數組的匹配,兩者得出來的向右移動的位數是一樣的。
接下來,咱們來寫代碼求下next 數組。
基於之前的理解,可知計算next函數的方法可以采用遞推,如果對於值k,有p0 p1, ..., pk-1 = pj-k pj-k+1, ..., pj-1,相當於next[j-1] = k。那么對於pattern的前j 個序列字符,得
- 若pattern[k] == pattern[j],則next[j] = next(j-1) + 1 = k + 1
- 若pattern[k ] ≠ pattern[j],相當於在字符p[k]之前不存在前綴"p0 p1, …, pk-1"跟后綴“pj-k pj-k+1, …, pj-1"相等,那么是否可能存在另一個值t<k,使得p0 p1, …, pk-1 = pj-t pj-t+1…pj-1成立呢?這個t 顯然應該是next[k],因為這相當於一個"利用next函數值進行T串和T串的匹配"問題。
求next數組如下:
1 void getNext(const char *pattern, int *next, int pattern_len) 2 { 3 int i = 0; 4 int j = -1; 5 next[0] = -1; 6 7 while (i < pattern_len - 1) 8 { 9 10 if (j == -1 || pattern[i] == pattern[j]) 11 { 12 ++i; 13 ++j; 14 if (pattern[i] != pattern[j]) //正常情況 15 next[i] = j; 16 else //特殊情況,這里即為優化之處。考慮下AAAAB, 防止4個A形成012在匹配時多次迭代。 17 next[i] = next[j]; 18 } 19 else 20 { 21 j = next[j]; 22 } 23 }
完整代碼如下:
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 5 6 static inline void getNext(const char *pattern, int *next, int pattern_len) 7 { 8 int i = 0; 9 int j = -1; 10 next[0] = -1; 11 12 while (i < pattern_len - 1) 13 { 14 15 if (j == -1 || pattern[i] == pattern[j]) 16 { 17 ++i; 18 ++j; 19 if (pattern[i] != pattern[j]) //正常情況 20 next[i] = j; 21 else //特殊情況,這里即為優化之處。考慮下aaaab, 防止4個a形成012在匹配時多次迭代。 22 next[i] = next[j]; 23 } 24 else 25 { 26 j = next[j]; 27 } 28 } 29 } 30 31 static inline bool match(const char *src, const char *pattern) 32 { 33 bool is_match = true; 34 35 int src_index = 0; 36 int pattern_index = 0; 37 int src_len = strlen(src); 38 int pattern_len = strlen(pattern); 39 40 //創建next數組,並初始化 41 int *next = (int *)malloc(pattern_len * sizeof(int)); 42 getNext(pattern, next, pattern_len); 43 44 //匹配主循環體 45 while (pattern_index < pattern_len && src_index < src_len) 46 { 47 //若對應位置字符匹配則右移1位,否則移動pattern 48 if (pattern_index == -1 || src[src_index] == pattern[pattern_index]) 49 { 50 src_index++; 51 pattern_index++; 52 } 53 else 54 { 55 pattern_index = next[pattern_index]; 56 } 57 } 58 59 //若pattern_index未達到串尾,表明pattern未完成匹配。否則即是完成匹配 60 if (pattern_index >= pattern_len) 61 { 62 is_match = true; 63 } 64 else 65 { 66 is_match = false; 67 } 68 69 return is_match; 70 } 71 72 73 int main(void) 74 { 75 char src[] = "aaaabacdeg"; 76 char pattern[] = "aabacd"; 77 78 bool res = match(src, pattern); 79 printf("res: %d\n", (int)res); 80 81 return 0; 82 }
備注
本文有相當份量的內容參考借鑒了網絡上各位網友的熱心分享,特別是一些帶有完全參考的文章,其后附帶的鏈接內容更直接、更豐富,筆者只是做了一下歸納&轉述,在此一並表示感謝。