【演算法】KMP演算法解析
KMP演算法是一個很精妙的字串演算法,個人認為這個演算法十分符合程式設計美學:十分簡潔,而又極難理解。筆者演算法學的很爛,所以接觸到這個演算法的時候也是一頭霧水,去網上看各種帖子,發現寫著各種KMP演算法詳解的轉載帖子上面基本都會附上一句:“我也看的頭暈”——這種訴苦聲一片的錯覺彷彿人生苦旅中找到知音,讓我幾乎放棄了這個演算法的理解,準備把它直接記在腦海裡了事。
但是後來在背了忘忘了背的反覆過程中發現一個真理:任何對於演算法的直接記憶都是徒勞無功的,基本上忘得比記的要快。後來看到劉未鵬先生的這篇文章:知其所以然(三):為什麼演算法這麼難?才知道不去理解,而硬生生的背誦演算法是多麼困難的一件事情。因此我儘可能的嘗試理解KMP的演算法,並用自己的語言描述一下這個優雅演算法的思維過程。
1. 明確問題
我們首先要明確,我們要做的事情是什麼:給定字串M和N(M.length >= N.length),請找出N在M中出現的匹配位置。說白了,就是一個簡單的字串匹配。或許你會說這項工作沒什麼難度啊,其實只要從頭開始比較兩個字串對應字元相等與否,不相等就再從M的下一位開始比較就好了麼。是的,這就是一個傳統的思路,總結起來其思想如下:
- 當
m[j] === n[i]
時,i與j同時+1; - 當
m[j] !== n[i]
時,j回溯到j-i+1,i回溯到0,然後回到第一步; - 當
i === len(n)
時,說明匹配完成,輸出一個匹配位置,之後回到第二步,查詢下一個匹配點。
我們舉個例子來演示一下這個比較的方法,給定字串M - abcdabcdabcde,找出N - abcde這個字串。傳統思路解法如下:
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 匹配四位成功後發現a、e不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 發現 a、b不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 發現 a、c不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 發現 a、d不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 匹配四位成功後發現a、e不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 發現 a、b不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 發現 a、c不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 發現 a、d不匹配
i: 0 1 2 3 4 5 6 7 8 9 0 1 2
M: a b c d a b c d a b c d e
N: a b c d e // 匹配成功
嗯,看起來蠻不錯,匹配出了正確的結果。但是我們可以從N的角度上來看待一下這個匹配的過程:N串發現第一次的匹配其實挺完美的,就差一步就可以匹配到位了——結果第4位的a、e不匹配。這種功虧一簣的挫敗感深深的影響了字串N,指向它的指標不得不回到它的頭部,開始與M的下一個字元匹配。“b不匹配、c不匹配、d不匹配……”這種感覺簡直糟糕透了,直到N又發現一個a,繼而又發現了接下來的b、c、d——這讓N彷彿找到了第一次的感覺。可當指標走到第四位時,悲劇還是發生了。懊惱的N再次將指標指向自己的頭部,開始與M的下一個字元進行匹配。“b不匹配、c不匹配、d不匹配……” N嘟囔著這句彷彿說過一遍的話,直到遇見了下一個a。這次N一點欣喜都沒有,儘管匹配獲得了成功,但是它總覺得上兩次對它的打擊實在是太大了。
“有沒有什麼改進的辦法呢?如果一開始就沒有產生匹配成功,只能下移一位進行重新匹配,這一點毋庸置疑。但是產生了部分匹配之後再發現不匹配,還需要再從頭回溯嗎?前兩次的匹配我已經很努力的得出了匹配結果,難道因為一位的不匹配便要拋棄一切從頭再來嗎?”N努力思考著這個問題,然後回顧了一下剛才的匹配過程,“剛才在每一次回溯匹配的過程中,我都經歷了b、c、d的不匹配,這是重複的啊!等等,b、c、d這三個字元好像很面熟啊,這……不是我本身嗎?噢噢對的,因為之前我已經部分匹配成功了麼,所以M中的這些字元肯定就和我本身匹配成功的那一部分是一樣的啊,也就是說,如果產生了部分匹配成功,那麼再次回溯就會和我本身進行比較;如果產生了多次部分匹配成功的情況,那就要多次與自己本身進行比較。這明顯產生了冗餘嗎!”
能不能解決這個冗餘呢?N想了一會兒,然後篤定的得出了一個結論:既然要多次比較自身,那不如先將自身比較一遍,得出比較結果儲存起來,下次使用時直接呼叫就好了啊!
如果有讀者跟不上字串N的思路看的雲裡霧裡,那麼我就直接給出一個不難記住的結論好了:減少匹配冗餘步數的精髓在於對字串N進行預處理,通常我們把處理結果儲存在一個叫做模式值(如果你看過別的文章,裡面可能會有一個奇怪的看不懂的陣列,那就是這個模式值陣列了,又稱作backtracking、Next[n]、T[n]、失效函式等等)的陣列中。
2. 模式值陣列與最長首尾匹配
可能有讀者因上一節的匹配太繚亂而直接跳到這裡,那筆者再重複一遍已經得到的結論:我們需要對字串N進行預處理,得到一個叫做模式值陣列的東西。那麼我們怎樣處理字串N呢?
這個東西如果我們能思考出來,那我們就可以在KMP演算法後面多寫一個字母了(KMP演算法是以其發現者Knuth, Morris, Pratt三人的名字首字母命名的)。我們首先感謝這三位大拿不辭辛勞的研究,然後直接給出這個處理的方法:尋找最長首尾匹配位置。
這是什麼意思呢?首尾匹配位置就是說,給定一個字串N(長度為n,即N由N[0]...N[n]組成),找出是否存在這樣的i,使得N[0]=N[n-i],N1=N[n-i-1],……,N[i]=N[n],不存在返回-1。如下圖所示:
圖中綠色的部分完全相等,滿足首尾匹配。且不會找出一點k,k>i且滿足N[0]=N[n-k],N1=N[n-k-1],……,N[k]=N[n]。我們假設確定最長首尾匹配的位置的函式為next,即 next(N[n])=i
當在匹配的過程中,發現N的j+1位不匹配時,回溯到第 next(N[j])+1
位來進行查詢是最優的,換言之,next(N[j])+1
位是最早可能產生匹配的位置,之前的位都不可能產生匹配。證明如下:
- 證明匹配:我們設 next(N[j]) = e,則滿足N[0...e] = N[j-e...j]。當N[j+1] != M[y+1]時,可知已經完成匹配:M[y-j...y] = N[0...j],則M[y-e...y] = N[j-e...j]。由此可以推知N[0...e] = M[y-e...y],即將N後移至首尾相等位置,仍然可以滿足匹配,接下來只需要檢視N[e+1]與M[y+1]是否相等即可。
- 證明最優:依然用反證法,假設存在f,f>e,滿足N[0...f] = M[y-f...y],即其匹配位置出現在更早的位置,則由於M[y-j...y] = N[0...j],則M[y-f...y] = N[j-f...j],則滿足N[j-f...j] = N[0...f],則e就不是最長的首尾匹配點,與原假設不符。因此e點時最早可能產生匹配的位置。如圖所示:
經過以上重重繁瑣證明,我們終於得出了這樣的結論——當部分匹配成功N[0...j],發現不匹配N[j+1]要進行回溯時,回溯到next(N[j])是最優的。而next()就是求取字串N[0...j]中最長首尾匹配位置的函式。如果你把這一系列的值求取出來,儲存到一個陣列裡,如next[j] = next(N[j]),那麼這個陣列就是所謂的模式值陣列。
3. 模式值陣列的求取
我知道又有讀者會直接跳到這一段——沒關係,我們複述一下我們前兩節得到的結論:一切的問題都歸結於如何進行最長首尾匹配。我們把問題簡化如下:對於給定的字串N,如何返回其最長首尾匹配位置?如abca,返回0,表示第0位與最後一位匹配;abcab,返回1,表示N[0,1]=N[n-1,n];abc,返回-1,表示沒有首尾匹配,等等。
簡單的想一下這個問題,發現用遞迴求取是一個不錯的辦法。首先我們假設N[j]已經求出了next(next(N[0...j]) = i),那麼對於N[j+1]的next應該怎麼求呢?
三種情況:
N[j+1] == N[i+1]
:這個情況十分的樂觀,我們可以直接說next(N[0...j+1]) = i+1。至於證明則依然用反證法,可以很容易的得出這個結論。N[j+1] != N[i+1]
:這個情況就比較複雜,我們就需要迴圈查詢i的next,即i = next(N[0...i]),之後再用N[j+1]與N[i+1]比較,知道其相等為止。我們依然用一張圖來說明這個問題:
假設上圖中k = next(i),那麼我們說如果N[k+1] == N[j+1],那麼k+1就是最長的首尾匹配位置,即next(N[j+1]) = k+1。你很快會發現這個證明模式與剛才的證明模式非常相同:首先我們證明其匹配,對於N[0...k]來說,其與N[i-k...i]匹配,同時由於N[0...i]與N[j-i...j]匹配,則N[i-k...i]與N[j-k...j]匹配,則N[0...k]與N[j-k...j]匹配。則如果N[k+1] == N[j+1],我們就可以說k+1是一個首尾匹配位置。如果要證明其實最長,那麼可以依然用反證法,得出這個結論。
- 最後,如果未能發現相等,返回-1。證明新的字串N[0...j+1]無法產生首尾匹配。
我們用js程式碼實現以下這個演算法,這裡我們規定如果字串只有一位,如a,其返回值也是-1,作為遞迴的終止條件。程式碼如下所示:
function next(N, j) {
if (j == 0) return -1 // 遞迴終止條件
var i = next(N, j-1) // 獲取上一位next
if (N[i+1] == N[j]) return i+1 // 情況1
else {
while (N[i+1] != N[j] && i >= 0) i = next(N, i)
if (N[i+1] == N[j]) return i+1 // 情況2
else return -1 // 情況3
}
}
我們來看一下這段程式碼有沒有可以精簡之處,情況1實際上與情況2是重複的,我們在while迴圈裡已經做了這樣的判斷,所以我們可以將這個if-else分支剪掉合併成一個,如下所示:
function next(N, j) {
if (j == 0) return -1 // 遞迴終止條件
var i = next(N, j-1) // 獲取上一位next
while (N[i+1] != N[j] && i >= 0) i = next(N, i)
if (N[i+1] == N[j]) return i+1 // 情況1、2
else return -1 // 情況3
}
好的,我們已經有了求取next陣列的函式,接下來我們就可以進行next[i] = next(i)的賦值操啦~等一下,既然我們本來的目的就是要儲存一個next陣列,而在遞迴期間也會重複用到前面儲存的內容(next(N, i))那我們為什麼還要用遞迴啊,直接從頭儲存不就好了麼!
於是我們直接修改遞迴函式如下,開闢一個陣列儲存遞迴的結果:
function getnext(N) {
var next = [-1]
, n = N.length
, j = 1 // 從第二位開始儲存
, i
for (; j < n; j++) {
i = next[j-1]
while (N[i+1] != N[j] && i >= 0) i = next[i]
if (N[i+1] == N[j]) next[j] = i+1 // 情況1、2
else next[j] = -1 // 情況3
}
return next
}
我們再來看一下這個程式的 i = next[j-1]
的這個賦值。其實在每次迴圈結束後,i的值都有兩種可能:
- 情況1、2:則i = next[j]-1,當j++時,i == next[j-1]-1
- 情況3:情況3是因為i < 0而跳出while迴圈,所以i的值為-1,而next[j]=-1,也就是說j++時,i ==next[j-1]
所以我們可以把迴圈改成這樣:
var i = -1
for (; j < n; j++) {
while (N[i+1] != N[j] && i >= 0) i = next[i]
if (N[i+1] == N[j]) i++ // 情況1、2
next[j] = i // 情況3
}
大功告成!這樣我們就得出了可以求取模式值陣列next的函式,那麼在具體的匹配過程中怎樣進行呢?
4. KMP匹配
經過上面的努力我們求取了next陣列——next[i]儲存的是N[0...i]的最長首尾匹配位置。在進行字串匹配的時候,我們在N[j+1]位不匹配時,只需要回溯到N[next[j]+1]位進行匹配即可。這裡的證明我們已經在第二節中給出,所以這裡直接按照證明寫出程式:
function kmp(M, N) {
var next = getnext(N)
, match = []
, m = M.length
, n = N.length
, j = 0
, i = -1
for (; j < m; j++) {
while (N[i+1] != M[j] && i >= 0) i = next[i] // 2. 否則回溯到next點繼續匹配
if (N[i+1] == M[j]) i++ // 1. 如果相等繼續匹配
if (i == n-1) {match.push(j-i); i = next[i]} // 如果發現匹配完成輸出成功匹配位置
// 否則返回i=-1,繼續從頭匹配
}
return match
}
這裡的kmp程式是縮減過的,其邏輯與 getnext()
函式相同,因為都是在進行字串匹配,只不過一個是匹配自身,一個是兩個對比而已。我們來分析一下這段程式碼的時間複雜度,其中有一個for迴圈和一個while迴圈,對於整個迴圈中的while來說,其每次回溯最多回溯i步(因為當i < 0時停止回溯),而i在整個迴圈中的遞增量最多為m(當匹配相等時遞增)故while迴圈最多執行m次;按照平攤分析的說法,攤還到每一個for迴圈中時間複雜度為O(1),總共的時間複雜度即為O(m)。同理可知,getnext()
函式的時間複雜度為O(n),所以整個KMP演算法的時間複雜度即為O(m+n)。
筆者認為寫完這篇文章以後,筆者再也不會忘記KMP演算法究竟是個什麼東西了。
參考資料:
- KMP演算法詳解:據稱是最容易理解的一篇文章;
- Matrix67: KMP演算法詳解:筆者認為是程式碼最簡潔的一片文章;
- 從頭到尾理解KMP演算法:認為是圖表最多比較清晰的一篇文章;
- Knuth–Morris–Pratt algorithm:KMP英文wiki。
相關文章
- 【演算法】KMP演算法演算法KMP
- KMP演算法KMP演算法
- KMP 演算法KMP演算法
- 演算法(2)KMP演算法演算法KMP
- Manacher演算法、KMP演算法演算法KMP
- 演算法之KMP演算法KMP
- 白話 KMP 演算法KMP演算法
- KMP演算法詳解KMP演算法
- 解讀KMP演算法KMP演算法
- 【演算法】KMP初識演算法KMP
- 【模板】【字串】KMP演算法字串KMP演算法
- 模式匹配-KMP演算法模式KMP演算法
- 字串匹配-BF演算法和KMP演算法字串匹配演算法KMP
- 字串匹配演算法(三)-KMP演算法字串匹配演算法KMP
- KMP演算法和bfprt演算法總結KMP演算法
- KMP Algorithm 字串匹配演算法KMP小結KMPGo字串匹配演算法
- KMP模式匹配演算法KMP模式演算法
- 字串匹配演算法:KMP字串匹配演算法KMP
- KMP字串匹配演算法KMP字串匹配演算法
- 我理解的 KMP 演算法KMP演算法
- 字串匹配KMP演算法初探字串匹配KMP演算法
- KMP演算法 Java實現KMP演算法Java
- hihocoder 1015 KMP演算法 (KMP模板)KMP演算法
- [譯] Swift 演算法學院 - KMP 字串搜尋演算法Swift演算法KMP字串
- 模式匹配kmp演算法(c++)模式KMP演算法C++
- 字串匹配之KMP《演算法很美》字串匹配KMP演算法
- 字串匹配問題——KMP演算法字串匹配KMP演算法
- 【總結】理解KMP演算法思想KMP演算法
- KMP演算法詳解 轉帖KMP演算法
- 演算法·理論:KMP 筆記演算法KMP筆記
- 資料結構-KMP模式演算法資料結構KMP模式演算法
- kmp字串匹配,A星尋路演算法KMP字串匹配演算法
- 字串匹配基礎下——KMP 演算法字串匹配KMP演算法
- 字串演算法--$\mathcal{KMP,Trie}$樹字串演算法KMP
- 把KMP演算法嚼碎!(C++)KMP演算法C++
- 圖解 KMP 演算法(JavaScript 實現)圖解KMP演算法JavaScript
- KMP字串匹配演算法 通俗理解KMP字串匹配演算法
- 一個需求引發的演算法及優化(KMP演算法)演算法優化KMP