1. 程式人生 > >從DFA角度理解KMP演算法

從DFA角度理解KMP演算法

KMP 演算法

KMP(Knuth-Morris-Pratt)演算法在字串查詢中是很高效的一種演算法,假設文字字串長度為n,模式字串長度為m,則時間複雜度為O(m+n),最壞情況下能提供線性時間執行時間保證。

《演算法導論》和其他地方在講解KMP演算法的時候,過於數學化且晦澀難懂,我也因此迷惑了很長時間。後來看《演算法(第四版)》部分的講解,對其中最複雜的Next陣列有了重新的認識。我這裡也希望用通俗的語言來將我的理解分享出來。

KMP演算法的本質是構造一個DFA確定性有限狀態自動機),然後通過自動機對輸入的字串進行處理,每接收一個字元,就能轉移到一個新的狀態,如果自動機能夠達到特定的狀態,那麼就能夠匹配字串,否則就匹配失敗。

先來了解一下DFA。
自動機分為兩種:DFA和NFA(非確定性有限狀態自動機),都可以用來匹配字串。很多正則表示式引擎使用的就是NFA。

如果有過FPGA開發經驗,就會很清楚,自動機與硬體描述語言中常見的狀態機類似。狀態機是一種流程控制的模型。一個自動機包含多個狀態,狀態之間可以有條件的進行轉移。這個條件就是輸入。

根據輸入的不同,一個狀態可以轉移到另一個狀態,或者保持當前狀態。自動機可以使用下面的狀態轉移圖來表示。

這裡寫圖片描述

上圖中State0為初始狀態,如果輸入為input0,就繼續保持State0狀態,直到輸入input1,狀態才轉移到State1,其他狀態類似。

下面我再來講KMP演算法。

樸素的字串查詢演算法使用的是暴力搜尋,過程大概是這樣的:
假設要處理的文字字串為s,要在其中查詢字串p(也稱為模式字串)。
先將s與p的首字元對齊,然後一個字元一個字元的對比,如果到某個字元不一樣,就將p字串右移一位,然後s與p的字元指標回退到新的對齊的位置,重新開始對比。
借用《演算法(第四版)》中的程式碼如下,這裡的i和j分別用來跟蹤文字與模式字串,i表示已經匹配過的字元序列的末端:

public static int search(String pat,String txt){
    int j,M = pat.length();
    int i,N = txt.length();
    for
(i = 0, j = 0; i < N && j < M; i++){ if (txt.charAt(i) == pat.charAt(j)){ j++; } else{ i -= j;//go back j = 0; } } if (j == M){ return i - M;//match } else { return N;//not match } }

樸素查詢演算法在查詢的時候,其實文字字串的某些字元在前面就已經能獲取相關的資訊了,但是因為演算法的侷限,為了不漏掉中間能夠匹配的字元,下一次回退的時候,又重新匹配了一次或多次,這樣浪費了一定的時間。

相比樸素的查詢演算法,KMP的優勢在於,基本流程是一致的,不需要進行不必要的回退操作。它是用一個dfa陣列(有地方叫做Next陣列)來指示匹配失敗的時候下一步j應該放到哪,也就是對齊的位置,這個位置不一定是樸素查詢演算法中的右移的一位,可能是多位,效率更高。

所以程式碼變成了下面的:

public static int search(String pat,String txt){
    int j,M = pat.length();
    int i,N = txt.length();
    for (i = 0, j = 0; i < N && j < M; i++){
        if (txt.charAt(i) == pat.charAt(j)){
            j++;
        }
        else{
            j = dfa[txt.charAt(i)][j];//use an array dfa[][]
        }
    }
    if (j == M){
        return i - M;//match
    }
    else {
        return N;//not match
    }
}

程式碼和前面的樸素查詢基本一樣。

關鍵是這個dfa[][]陣列怎麼計算,這也是弄懂KMP的關鍵所在。

其實這個dfa[][]陣列,就是DFA自動機的一個簡單的模型。txt.charAt(i)就是輸入,j作為狀態。

舉個例子:模式為ABABAC
該例子構造的DFA的狀態轉移圖如下:

這裡寫圖片描述

可以看到,最初在狀態0,然後每次輸入新的字元,都會轉移到新的狀態或者保持舊狀態。
dfa[][]陣列對的每一列都對應一個狀態,每一行都對應一種輸入。

怎麼去構造一個模式對應的DFA呢?
這個過程是一個迭代的過程。
程式碼如下:

dfa[pat.charAt(0)][0] = 1;
for (int X = 0, j = 1; j < M; j++)
{ 
    // Compute dfa[][j].
    for (int c = 0; c < R; c++)
        dfa[c][j] = dfa[c][X];
    dfa[pat.charAt(j)][j] = j+1;

    X = dfa[pat.charAt(j)][X];
}

以上程式碼是:
對於每個j,
匹配失敗的時候,dfa[][X]複製到dfa[][j];
匹配成功的時候,dfa[pat.charAt(j)][j]設定為j+1;
更新X。

為什麼這麼做呢?
因為狀態要向後面轉移,必須是接收了與模式匹配的正確的字元,每次這個字元有且只有一種情況,這個字元是pat.charAt(j)。其他的字元只能使狀態保持或者狀態回退。

狀態回退也就是讓DFA重置到適當的狀態,就好像回退過指標一樣,但是我們不想回退指標,我們已經有了足夠的資訊來計算這個重啟的位置。

試想一下,如果真的回退指標,我們需要重新掃描文字字元,並且這些字元就是pat.charAt(1)到pat.charAt(j-1)之間的字元(因為前面都是匹配的),忽略首字母是因為需要右移一位,忽略最後一個字元是因為上次匹配失敗。
重新掃描對比過的文字字串,直到我們的能夠找到一個最長的以pat.charAt(j-1)結尾的子串,能作為模式字串p的字首,然後將模式字串與這個子串的位置對齊,然後重新開始字元對比。

但是實際上,我們不需要回退指標,因為剛才的掃描過程,也是一個狀態轉移的過程,相當於是一個子問題。
我們把pat.charAt(1)到pat.charAt(j-1)之間的字元,一個一個輸入當前的得到的DFA(因為只需要處理j-1個字元,所以當前DFA已經足夠處理,相當於掃描的時候,只用模式的前一部分),到pat.charAt(j-1)的時候,會停留在一個狀態,這個狀態就是下一個j的狀態。
這個過程利用了已經建立好的DFA的資訊,進行迭代,得到新的DFA的資訊,所以可以這樣把整個DFA都建立起來。

模式ABABAC的DFA建立過程如下圖:
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述

這其中還有一個問題就是怎麼確定複製的位置X,也就是重啟的狀態。
其實也很簡單,就是pat.charAt(1)到pat.charAt(j-1)之間的字元能達到的最後一個狀態。由於在計算DFA的過程,也是pat模式串迭代的過程,所以,在j-1的時候,我們可以把最後一個字元輸入pat.chatAt(j-1)輸入到當前的DFA,得到新的狀態,儲存起來下次繼續在該狀態基礎上轉移即可,邊構造邊使用。也是就是下面這行程式碼:

X = dfa[pat.charAt(j)][X];

到這裡,KMP演算法原理基本就清楚了。

《演算法(第四版)》最後提到:

在實際應用中,它比暴力演算法的優勢並不十分明顯,因為極少有應用程式需要在重複性很高的文字中查詢重複性很高的模式。但該方法的一個優點是不需要再輸入中回退。這使得KMP字串查詢演算法更適合在長度不確定的輸入流(例如標準輸入)中查詢進行查詢,需要回退的演算法在這種情況下則需要複雜的緩衝機制。

因為KMP基於DFA自動機,所以會有這樣的優點,說到這裡,硬體中之所以經常採用狀態機進行程式設計,也是因為狀態機不需要緩衝機制的原因,這便於高效地對輸入流進行處理,比如通訊中對乙太網幀的解析,從MAC進來的位元流,可以很方便地通過計數等方法進行狀態識別和轉移,不需要接收完整個幀再進行處理,減少了快取的消耗。

參考書目:

  • 《大話資料結構》
  • 《演算法導論》
  • 《演算法(第四版)》