1. 程式人生 > >菜鷄日記——KMP演算法及其優化與應用

菜鷄日記——KMP演算法及其優化與應用

一、什麼是KMP演算法

KMP演算法,全稱Knuth-Morris-Pratt演算法,由三位科學家的名字組合命名,是一種效能高效的字串匹配演算法。假設有主串S與模式串T,KMP演算法可以線上性的時間內匹配出S中的T,甚至還能處理由多個模式串組成的字典的匹配問題。

二、KMP演算法原理及實現

普通的匹配演算法:

  1. 首先將S和T首位對齊;
  2. 從前往後掃描T,與S的對應位置匹配;
  3. 若發現某位不匹配則將T後移一位,然後再重複步驟2;
  4. 若T完全匹配S中的某一段或T無法再後移則匹配結束。

KMP演算法的核心在於,一旦遇到某個位置的字元匹配失敗,則利用預處理T得到的部分匹配表對T進行“快速”的“後移”,大量減少不必要的匹配工作。所謂的“快速”,即根據T本身的結構特徵一次移動至少一位以上。部分匹配表用陣列next[1..T.len]表示,next[1]=0,next[i]=k(0<k<i)

當且僅當T[1..k]==T[i-k+1..i]且不存在k<k'<i使得T[1..k']==T[i-k'+1..i],即next[i]表示T[1..i]字首和字尾最大的匹配。

next[1..T.len]的求解過程:

  1. next[1]=0;
  2.  i>1時,假設next[i-1]已經求得,令k'=next[i-1];
  3. 由上述定義可知字首T[1..k']和字尾T[i-k'..i-1]匹配;
  4. 若T[k'+1]==T[i],則T[1..i]的字首T[1..k'+1]和字尾T[i-k'..i]匹配,可得next[i]=k'+1;
  5. 否則令k''=next[k'],可知T[1..k'']和T[1..k']、T[1..i-1]有長度為k''的公共字尾,令k'=k'';
  6. 重複步驟3、4、5直到k'==0但T[1]!=T[i],此時next[i]=0。
void GetNext()
{
    nxt[1] = 0;    // C++11 中 next 是標準庫中的函式名,所以此處用 nxt
    int k = 0;
    for (int i = 2; i <= T.len; i++)
    {
        while (k > 0 && T[k + 1] != T[i]) k = nxt[k];    //k + 1 < i 恆成立
        nxt[i] = (T[k + 1] == T[i]) ? ++k : 0;
    }
}

假設S為abcbaabcbcacbabcacabacb,T為abcbaabccab(下標皆從1開始)。

        abcbaabcbcacbabcacabacb        abcbaabcbcacbabcacabacb

        abcbaabccab                                           abcbaabccab

普通的匹配演算法,對於上例從左邊的狀態到右邊的狀態要對T進行5次右移的操作,並且每一次移動後都要重新從左到右每位匹配。但是KMP演算法只需要對上例的T右移1次即可到達右邊的狀態,並且不需要再從頭開始掃描。可以得到T(abcbaabccab)的部分匹配表為next[1..11]={0,0,0,0,1,1,2,3,0,1,2}。假設作用於S和T的下標標記分別為i和j,則左邊i=j=8,顯然左邊S[i+1]!=T[j+1](b!=c)。因為next[8]=3,根據上面所說即有T[1..3]==T[6..8],所以令j=next[j]=3就相當於一次性將T右移8-3=5個位置得到右邊,此時S[i+1]==T[j+1]則令i++、j++,否則繼續令j=next[j]。

KMP演算法的匹配過程:

  1. 令作用於T的下標標記j=0,從左至右掃描S;
  2. 對於S[i],若T[j+1]!=S[i]則令j=next[j]直到T[j+1]==S[i]或j==0;
  3. 若T[j+1]==S[i]則++j;
  4. 若步驟3執行後j==T.len則表示T能匹配S中的某一段;
  5. 若S掃描完畢卻沒能找到匹配則匹配失敗。
bool KmpMatch()
{
    int j = 0;
    for (int i = 0; i < S.length(); i++)
    {
        while (j > 0 && j < T.len && T[j + 1] != S[i]) j = nxt[j];
        if (j < T.len && T[j + 1] == S[i]) ++j;
        if (j == T.len) return true;
    }
    return false;
}

KMP演算法求取部分匹配表的時間複雜度為O(|T|),匹配過程的時間複雜度為O(|S|),所以總的時間複雜度為O(|S|+|T|),相比於普通匹配演算法的O(|S|*|T|)來看效能高效。

三、KMP演算法的優化

例如T為aaaaaaa,則其部分匹配表為next[1..7]={0,1,2,3,4,5,6},當T與S匹配過程中一直到T的最後一個a(此時j=6)發生不匹配,按照上述的KMP演算法的匹配過程,需要令j=next[j]直到T[j+1]==S[i]或j==0,因此語句j=next[j]需要執行6次。通過觀察發現,假設next[j]=k則有T[1..k]==T[j-k+1..j],若T[k+1]==T[j+1]則當T[j+1]!=S[i]時令j=next[j]仍是意義不大,但是若T[k+1]!=T[j+1]則通過令j=next[j]可能使得T[k+1]==S[i],因此可以通過優化部分匹配表減少無意義的“右移”。用nextval[1..T.len]陣列表示優化後的部分匹配表,上述的部分匹配表構造過程,將“next[i]=k'+1”替換為“當T[k'+2]==T[i+1]時nextval[i]=nextval[k'+1]否則nextval[i]=k'+1”(需要注意的是:因為nextval[k'+1]已經在nextval[i]前求得,所以只要令nextval[i]=nextval[k'+1]就已經能保證T[nextval[k'+1]+1]!=T[k'+2]==T[i+1]而不需要往前掃描)。

void GetNextval()
{
    nextval[1] = 0;
    int k = 0;
    for (int i = 2; i <= T.len; i++)
    {
        while (k > 0 && T[k + 1] != T[i]) k = nextval[k];    //k < i <= T.len 恆成立
        if (T[k + 1] == T[i])
            nextval[i] = (i < T.len && T[k + 2] == T[i + 1]) ? nextval[++k] : ++k;
        else 
            nextval[i] = 0;
    }
}
  a a a a a a a   a b c a a b b a b c a b
next 0 1 2 3 4 5 6   0 0 0 1 1 2 0 1 2 3 4 2
nextval 0 0 0 0 0 0 6   0 0 0 1 0 2 0 0 0 0 4 2

可見對於上例,優化後的部分匹配表可以只執行1次j=nextval[j]就完成了未優化前的6次“右移”。

四、KMP演算法的應用

1、字串匹配

判斷模式串是否在主串中出現、出現的次數、出現的位置;判斷字典中的單次在文字中是否出現、出現的次數、出現的位置。上述程式碼只能判斷模式串是否在主串中出現,如果需要知道模式串出現的次數以及出現的位置,在匹配的過程引入一個用tend[0..S.len-1]的匹配表,tend[i]=k(0<=k<=min(T.len,i))當且僅當T[1..k]==S[i-k+1..i]且不存在k<k'<=min(T.len,i)使得T[1..k']==S[i-k'+1..i],即tend[i]表示T的字首與S[0..i]的字尾的最大匹配,當tend[x]=T.len時表明T在S中成功匹配1次且i-T.len+1即為匹配的位置。由於tend[]陣列和next[]陣列的定義類似,都是關於字首和字尾的最大匹配問題,因此可以類比next[]陣列的求解方法得到tend[]陣列。

void KmpMatch()
{
    int j = 0;
    for (int i = 0; i < S.length(); i++)
    {
        while (j > 0 && j < T.len && T[j + 1] != S[i]) j = nxt[j];
        if (j < T.len && T[j + 1] == S[i]) ++j;
        tend[i] = j;
    }
    for (int i = 0; i < S.length(); i++)
        if (tend[i] == T.len)
            pos[cnt++] = i - T.len + 1;
}

對於字典的情況,給每個詞條都分別生成部分匹配表並與文字匹配即可。

2、字串迴圈節的判斷

根據優化之前的部分匹配表next[]的特徵可以判斷T是否由迴圈節構成,得到最小迴圈節長度和最小迴圈節。令m=T.len、k=next[m],ΔL=m-k,因為T[1..k]為T[1..m]的字尾,所以任意1<=i<=k都有T[i]==T[i+ΔL]。根據等價關係“任意i<j,T[i]等價於T[j]當且僅當ΔL整除j-i“,T可以分成分成ΔL個等價類,代表元分別為T[m-ΔL+1]、T[m-ΔL+2]、…、T[m]。當ΔL|m(ΔL整除m)時,T由迴圈節構成,最小迴圈節長度為ΔL,有m/ΔL個最小迴圈節,最小迴圈節為T[T.len-ΔL+1..T.len]。