1. 程式人生 > >透徹理解KMP演算法

透徹理解KMP演算法

好好打下字串演算法基礎。本篇通俗、透徹地解釋線性時間複雜度的字串匹配演算法:KMP演算法。
之前寫過KMP演算法,但時間久了回顧起來還是要花點兒時間,覺得需要進一步加深;現在就試圖徹底吃透它。若是感興趣豆友們能得到一點點的幫助就更好了~ 別忘了點個贊:-)

KMP 演算法的歷史就不說了,感興趣的去Google 下就有,大名鼎鼎的高德納(Knuth)大神是發明人之一。初次接觸KMP 演算法時往往會被它的線性時間複雜度所征服,如此經典的演算法值得學習。

後面的描述中,S 表示原字串,T 表示目標串(模式串),我們要在S 中搜索T。
令 S[0..m-1] = abcabcabdabba, T[0..n-1] = abcabd,

1,Naive 演算法
字串匹配的naive 演算法時O(n^2) 的:



2,Naive 演算法的問題與進化
naive 演算法的最大問題是,當一輪匹配過後,模式串T 又從頭開始,實際上這回造成很多不必要的比較。我們看下面的情況:



此時,匹配失敗,本輪結束;但實際上,並不需要從頭開始,匹配在S[5] 和 T[5] 處失敗,此時可以直接將S[5] 和 T[2] 對齊,從這裡開始匹配即可。

進一步,之所以可以這樣做,是由於T 的子串T[0..4] 已經與S 的子串S[0..4] 匹配成功,且"ab" 即是T[0..4] 的字尾又是它的字首,我們直接將T[2] 移到S[5] 的位置開始下一步匹配,不會造成任何遺漏。




3,KMP 演算法思想
首先回到naive 匹配演算法,我們之所以每輪過後,都非常“保守”地回溯到T[0] 和S[i+1] 的位置開始下一輪匹配,是為了避免有任何遺漏。而造成遺漏的原因是T 的子串可能存在這樣的情況:它的字首和字尾相等。我們姑且將這樣的前/字尾稱為“前-字尾”,注意這裡這考慮“真”字首/字尾。即對於某個子串,它本身不是我們關心的字首/字尾。
例如,對於上面的T = abcabd,子串T[0..3] 的前-字尾是"a",子串T[0..4] 的前-字尾是"ab",T[0..5] 的前-字尾是空串""(或者說它沒有前-字尾)。
如果一個子串有多個前-字尾,我們只考慮最長的那一個。例如,"aaaaa"的最長前-字尾是"aaaa"(它本身"aaaaa"不是真前/字尾)。

有了最長前-字尾的概念,我們可以開始分析KMP 演算法的原理。
前面提到的例子中,當S[5] 與 T[5] 匹配失敗時,我們直接將S[5] 和T[2] 對齊,正是由於T[0..4] 的最長前-字尾是"ab",它在T[0..4]中對應的字首是T[0..1];既然T[0..4] 與S[0..4] 匹配成功,那麼T[0..1] 必然可以與S[3..4] 完全匹配。
由此,我們很容易聯想到,當S[i] 與T[j] (j>0) 匹配失敗時,如果我們知道T[0..j-1] 的最長前-字尾在T[0..j-1]對應的字首(比如是T[0..k]),那麼我們可以直接將S[i] 與T[k+1] 對齊,開始下一次比較。原因就是T[0..k] 必然已經與S[0..i-1] 的字尾匹配成功。
這就是KMP 演算法的關鍵思路:避免了不必要的回溯。

4,KMP 演算法實現
KMP 演算法的關鍵在於,對於模式串T,我們需要知道它的每個子串T[0..j] (j<n) 的最長前-字尾。通常採用的資料結構是,用一個標記陣列NEXT[0..n-1],NEXT[j] 記錄了T[0..j] 的最長前-字尾對應的字首的下一個位置。例如,如果NEXT[j] = 2,則表示T[0..j] 的最長前-字尾是T[0..1]。
顯然,當匹配在S[i], T[j] 處失敗是,只需將T[NEXT[j-1] 與 S[i] 對齊,開始下一次比較。(注意:j 的位置需要改變,實際上可以先調整j = NEXT[j-1],在繼續比較S[i],T[j] 即可;另外需注意邊界情況的處理)。

至此,我們應該理解了KMP 演算法原理。
已知S[0..m-1], T[0..n-1], 標記資料NEXT[0..n-1];(關於如何獲得NEXT[0..n-1] 在後面闡述)

KMP 匹配演算法:





注意邊界情況的處理:
當j == n 時,說明已經找到一個完全匹配,輸出結果;
當j == 0 時匹配失敗,直接從S[i+1] 處開始匹配;
否則,通過NEXT 陣列獲得T中新的匹配位置j = NEXT[j-1];
需要注意的一點是,在j==n (匹配成功)時,下一次需要匹配的位置也儲存在NEXT[j-1] (即NEXT[n-1])中,無須特殊處理,這也是該演算法的一個優美之處 :-)

這段程式碼的複雜度分析:i++ 最多執行m 次;i++ 沒被執行時,一定有“j=NEXT[j-1]” 被執行,每次j 至少減小1,且不會小於0;而j++ “累計” 執行一定不會比 i++ 多(即不可能超過m 次),因此j 累計減少也不會超過m 次(即j=NEXT[j-1] 執行不會超過m 次)。
綜上,上面程式碼複雜度是 O(m)。

5,標記資料NEXT[0..n-1]
還有一個問題沒有回答:如何獲得NEXT[0..n-1]。

實際上,NEXT 陣列記錄的是T 的子串T[0..j] (0<j<n) 的最長前-字尾的資訊。
用傳統暴力的字串方法求NEXT陣列顯然很低效。這裡也是KMP 演算法最巧妙的地方之一:用KMP 匹配的方法求解NEXT 陣列。
沒錯,預處理工作中,NEXT[0..n-1] 正式通過KMP 匹配方法求出的。相當於在模式串T 自身上使用KMP匹配。
方法是用兩個陣列下標i 和j 來掃描T。i 將T 看做是KMP 匹配中的S,j 依然將T 看做模式串。與KMP 匹配不同的是,每次i 增加前,需要賦予NEXT[i] 合適的值。此時,T[0..j] 已經與T[0..i] 的字尾相等,即T[0..i] 的前-字尾正是T[0..j],因此NEXT[i] = j+1。
匹配失敗或者遇到邊界情況時,處理思路與一般的KMP 匹配類似。

求NEXT[0..n-1] 的程式碼:





根據前面對KMP 演算法匹配過程的分析,求NEXT[0..n-1] 的複雜度顯然是O(n)。
因此,整個KMP 演算法的時間複雜度是:O(m+n)。