【演算法】KMP演算法解析

於明昊發表於2013-10-29

KMP演算法是一個很精妙的字串演算法,個人認為這個演算法十分符合程式設計美學:十分簡潔,而又極難理解。筆者演算法學的很爛,所以接觸到這個演算法的時候也是一頭霧水,去網上看各種帖子,發現寫著各種KMP演算法詳解的轉載帖子上面基本都會附上一句:“我也看的頭暈”——這種訴苦聲一片的錯覺彷彿人生苦旅中找到知音,讓我幾乎放棄了這個演算法的理解,準備把它直接記在腦海裡了事。

但是後來在背了忘忘了背的反覆過程中發現一個真理:任何對於演算法的直接記憶都是徒勞無功的,基本上忘得比記的要快。後來看到劉未鵬先生的這篇文章:知其所以然(三):為什麼演算法這麼難?才知道不去理解,而硬生生的背誦演算法是多麼困難的一件事情。因此我儘可能的嘗試理解KMP的演算法,並用自己的語言描述一下這個優雅演算法的思維過程。

1. 明確問題

我們首先要明確,我們要做的事情是什麼:給定字串M和N(M.length >= N.length),請找出N在M中出現的匹配位置。說白了,就是一個簡單的字串匹配。或許你會說這項工作沒什麼難度啊,其實只要從頭開始比較兩個字串對應字元相等與否,不相等就再從M的下一位開始比較就好了麼。是的,這就是一個傳統的思路,總結起來其思想如下:

  1. m[j] === n[i] 時,i與j同時+1;
  2. m[j] !== n[i] 時,j回溯到j-i+1,i回溯到0,然後回到第一步;
  3. 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]比較,知道其相等為止。我們依然用一張圖來說明這個問題:

迴圈求取next(i)

假設上圖中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演算法究竟是個什麼東西了。

參考資料:

相關文章