菜鷄日記——KMP演算法及其優化與應用
一、什麼是KMP演算法
KMP演算法,全稱Knuth-Morris-Pratt演算法,由三位科學家的名字組合命名,是一種效能高效的字串匹配演算法。假設有主串S與模式串T,KMP演算法可以線上性的時間內匹配出S中的T,甚至還能處理由多個模式串組成的字典的匹配問題。
二、KMP演算法原理及實現
普通的匹配演算法:
- 首先將S和T首位對齊;
- 從前往後掃描T,與S的對應位置匹配;
- 若發現某位不匹配則將T後移一位,然後再重複步驟2;
- 若T完全匹配S中的某一段或T無法再後移則匹配結束。
KMP演算法的核心在於,一旦遇到某個位置的字元匹配失敗,則利用預處理T得到的部分匹配表對T進行“快速”的“後移”,大量減少不必要的匹配工作。所謂的“快速”,即根據T本身的結構特徵一次移動至少一位以上。部分匹配表用陣列next[1..T.len]表示,next[1]=0,next[i]=k(0<k<i)
next[1..T.len]的求解過程:
- next[1]=0;
- i>1時,假設next[i-1]已經求得,令k'=next[i-1];
- 由上述定義可知字首T[1..k']和字尾T[i-k'..i-1]匹配;
- 若T[k'+1]==T[i],則T[1..i]的字首T[1..k'+1]和字尾T[i-k'..i]匹配,可得next[i]=k'+1;
- 否則令k''=next[k'],可知T[1..k'']和T[1..k']、T[1..i-1]有長度為k''的公共字尾,令k'=k'';
- 重複步驟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演算法的匹配過程:
- 令作用於T的下標標記j=0,從左至右掃描S;
- 對於S[i],若T[j+1]!=S[i]則令j=next[j]直到T[j+1]==S[i]或j==0;
- 若T[j+1]==S[i]則++j;
- 若步驟3執行後j==T.len則表示T能匹配S中的某一段;
- 若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]。