面試必備——KMP字串查詢演算法
寫在前面
字串的一種基本操作是子字串查詢:給定一端長度為N的文字字串text和一個長度為M(M<N)的模式字串pattern,在文字字串中查詢和該模式字串相同的子字串。在這網際網路時代,字串查詢的需求在很多情景都需要,如在文字編輯器或瀏覽器查詢某個單詞、在通訊內容中擷取感興趣的模式文字等等。
子字串查詢最簡單的實現肯定是暴力查詢:
public static int search(String text, String pattern) { int N = text.length(); int M = pattern.length(); for (int i = 0; i < N-M; i++) { int j; for (j = 0; j < M; j++) { if (text.charAt(i+j) != pattern.charAt(j)) break; } if (j == M) return i; } return -1; }
可以看到,暴力查詢的最壞時間複雜度為O(N*M),實際應用中往往文字字串很長(成萬上億個字元),而模式字串很短,這樣暴力演算法的時間複雜度是無法接受的。
為了改進查詢時間,人們發明了很多字串查詢演算法,而今天的主角 KMP (D.E.Knuth,J.H.Morris和V.R.Pratt發明,簡稱KMP演算法)就是其中的一種。
正文
暴力查詢之所以慢是因為它每次的匹配都是從頭開始,並且拋棄了之前已經算好的結果, KMP演算法 就是從這入手: 匹配失敗後,已知曉的一部分文字字串的內容資訊,再利用這些內容資訊避免指標回退到所有這些已知的字元之前,也即減少模式字串與文字字串的匹配次數以達到快速匹配的目的。
舉個例子,在文字字串 “ababaababcabcd” 查詢模式字串 “ababcab” (圖1中,紅色字元代表正在比較的字元):

圖1 KMP演算法子字串查詢軌跡
在圖1中,當匹配到i=4,j=4時,text的“a"不等於pattern的"c",在暴力演算法中,會把j置零,重新開始比較。而KMP演算法先不急把j置零,先嚐試在之前匹配過的字串中查詢 相同的無重疊的最大字首子字串和最大字尾子字串 。如“abab“,相同的無重疊的最大字首子字串和最大字尾子字串為”ab“,記住,是“無重疊的”,字串“ababa”的最大字首子字串和最大字尾子字串是“a”而不是“aba”。
因為之前模式字串的”abab“已經跟文字字串匹配,所以文字字串也存在”abab“,那麼我們顯然不需要再從頭計算,把相同的最大字首子字串對齊最大字尾子字串即可。如圖2所示。

圖2 KMP演算法指標回退
KMP演算法正是通過避免指標完成回退,從而加快匹配效率。j每次匹配失敗時回退的位數取決於模式字串,那麼我們肯定不是每次匹配失敗才去算j需要回退的位數(可能重複計算),我們大可在匹配前先算好模式字串中從0到m(m在區間[0, M]內, M為模式字串的長度)各個子字串中的 相同的無重疊的最大字首子字串和最大字尾子字串 ,從而在匹配過程中直接使用,減少計算次數。
如字串 “ababcab” 的從0到m各個子字串中的相同的無重疊的最大字首子字串和最大字尾子字串的長度k和索引j的關係:
a | ab | aba | abab | ababc | ababca | ababcab | |
---|---|---|---|---|---|---|---|
k | 0 | 0 | 1 | 2 | 0 | 1 | 2 |
j | -1 | -1 | 0 | 1 | -1 | 0 | 1 |
從上表的, j=k-1
,因為在程式語言中,字串的索引是從0開始的,所以減1,而j=-1,表示不存在相同的無重疊的最大字首子字串和最大字尾子字串。我們在程式設計中,用到的是j的值。ok,我們現在來程式設計求出模式字串中從0到m各個子字串中的相同的無重疊的最大字首子字串和最大字尾子字串,我們把值儲存在一個next陣列中。演算法戰5渣的我想出了一個演算法:
/** * 計算模式字串的next陣列 * @param pattern 模式字串 * @return next陣列,next陣列的值對應模式字串中從0到m各個子字串中的相同的無重疊的最大字首子字串和最大字尾子字串 */ private static int[] calNext(final String pattern) { int M = pattern.length(); int[] next = new int[M]; next[0] = -1; for (int i = 1; i < M; i++) { next[i] = calSameSubStr(pattern.substring(0, i+1)); } return next; } private static int calSameSubStr(final String subStr) { int M = subStr.length(); if (M < 1) return -1; int mid = M / 2; int l = 0; // 低位索引 int h = mid; // 高位索引 if (M % 2 == 1) h = mid + 1; // 長度為奇數 while (l < mid && h < M) { if (subStr.charAt(l) == subStr.charAt(h)) l++; else l = 0; h++; } return l-1; }
由於模式字串一般比較簡短,我這演算法在一般場景應該還是能用上,但大神可不喜歡將就... KMP大神們竟然在計算next陣列過程中就使用上了next陣列,非常巧妙:
/** * 計算模式字串的next陣列 * @param pattern 模式字串 * @return next陣列,next陣列的值對應模式字串中從0到m各個子字串中的相同的無重疊的最大字首子字串和最大字尾子字串 */ private static int[] calNext(final String pattern) { int M = pattern.length(); int[] next = new int[M]; next[0] = -1; // 第一個子字串只有一個字元,肯定不存在相同前後綴子字串 int k = -1; // k代表是相同的無重疊的最大字首子字串和最大字尾子字串的長度減1,為-1表示不存在相同子串 for (int i = 1; i < M; i++) { // 這裡k也充當了低位索引,i是高位索引 while(k > -1 && pattern.charAt(k+1) != pattern.charAt(i)) { k = next[k]; // 字元不相等,k需要回溯 } if (pattern.charAt(k+1) == pattern.charAt(i)) { k++; } next[i] = k; } return next; }
大神們設計的這個演算法比較難理解的是最裡面的迴圈while,這裡講解下。k>-1,表示子串pattern[0, k]中已存在相同的無重疊的最大字首子字串和最大字尾子字串,如果此時低位字元(k+1)跟高位字元(i)不匹配,k就需要回溯,回到哪裡?最低位0(我寫的演算法就是回到0)?不是,這裡顯然已經用了我們上面已經講過的思路,把指標回退到模式字串子串[0, k]中的相同的無重疊的最大字首子字串和最大字尾子字串的長度,而這個值之前已經計算過,就是next[k]。這段話可能有點難理解,理解不了的同學建議用個簡短的字串跟著程式走一遍就秒懂了。
這裡求next陣列已經用到上面講解的KMP的核心思路了,後面可以看到,其實匹配時的演算法跟求next陣列的演算法核心思路是一模一樣的!這裡只是 模式字串的子串充當了模式字串,而整個模式字串充當了文字字串。
我們得到了next陣列,每次出現不匹配時,我們就不需要每次都完成回退指標,減少了重複的比較,從而提高查詢效率:
public static int search(final String text, final String pattern) { int[] next = calNext(pattern); int k = -1; int N = text.length(); int M = pattern.length(); for (int i = 0; i < N; i++) { while(k > -1 && pattern.charAt(k+1) != text.charAt(i)) { // 不匹配回溯找最大相同前後綴子字串 k = next[k]; // 回溯方式講解: // 假設: // text:...abac... // pattern:abad... // i指向c時,k指向第二個a,此時k+1指向d不等於c,那麼需要回溯 // 此時我們需要找的是 text[i-1-k, i-1] 子串中最大相同前後綴子字串, // 因為 pattern[0, k] == text[i-1-k, i-1] // 而 pattern[0, k] 的最大相同前後子字串之前已經算過了,是next[k] } if (pattern.charAt(k+1) == text.charAt(i)) k++; if (k == M - 1) return i - M + 1; // 已找到匹配字元 } return -1; // 未找到匹配字元 }
KMP演算法的時間複雜度為O(N+M),這可比暴力演算法的O(NM)好多了。但KMP演算法的效率依賴於模式字串的字元重複度,如果字元無重複時,KMP演算法甚至比暴力演算法效率更低。
至此,KMP字串查詢演算法已經分析完了,其實算是一種比較簡單的演算法,學習起來很快就能搞懂~
寫在後面
KMP演算法要求模式字串具有重複度高的字元才能發揮強大的功力,但實際應用中這種場景並不多,學了似乎沒什麼用?其實我在學習了一些演算法後發現,演算法總是有利也有弊,犧牲時間換空間,或者犧牲空間換時間,總也不能十全十美,我們需要的是厚積薄發,在實際應用中遇到類似的場景,能想到有這麼一種思路可以解決問題。其次,學演算法最重要的是學思路而不是實現,如快排,你懂它的核心思路就行了,沒必要死記硬背,實際工作中也不需要你自己寫一遍,因為系統早已整合好了。而快排的分而治之的思路非常有用,這在工作中經常遇到,當你遇到這情景時,你能回憶起快排的分而治之的思路;“哦!好像這裡可以用快排的分而治之的思路來解決喔!”,然後再去重看快排的具體實現,看下能不能借鑑下,我想,學習快排這演算法就是沒白費了~
而KMP演算法的求next陣列的方案和指標回退的方式都非常有學習價值,值得細細品味,應當收入囊中,期待下次的“哦!這種情景我有辦法解決!”的頓悟。
參考
- 演算法:第四版
- ofollow,noindex">KMP演算法最淺顯理解——一看就明白