字串匹配演算法之:有限狀態自動機
什麼叫有限狀態自動機
先看一個圖:
上面這個圖描述的就叫一個有限狀態自動機,圖中兩個圓圈,也叫節點,用於表示狀態,從圖中可以看成,它有兩個狀態,分別叫0和1. 從每個節點出發,都會有若干條邊,當處於某個狀態時,如果輸入的字元跟該節點出發的某條邊的內容一樣,那麼就會引起狀態的轉換。例如,如果當前狀態處於0,輸入是字元a,那麼狀態機就會從狀態0進入狀態1.如果當前狀態是1,輸入字元是b或a,那麼,狀態機就會從狀態1進入狀態0.如果當前所處的狀態,沒有出去的邊可以應對輸入的字元,那麼狀態機便會進入到錯誤狀態。例如,如果當前處於狀態0,輸入字元是c,那麼狀態機就會出錯,因為從狀態0開始,沒有哪條邊對應的字元是c.
狀態機會有一個初始節點,和一個接收節點,以上圖為例,我們可以設定初始節點為0,接收節點為1,當進行一系列的輸入,使得狀態機的狀態不斷變化,只要最後一個輸入使得狀態機處於接收節點,那麼就表明當前輸入可以被狀態機接收。例如對應字串”abaaa”, 從初始節點0開始,狀態機根據該字串的輸入所形成的狀態變化序列為:{0,1,0,1,0,1}。由於最後狀態機處於狀態1,所以該字串可以被狀態機接收。如果輸入的字串是:abbaa, 那麼狀態機的變化序列為:{0,1,0,0,1,0}, 由於最後狀態機處於非接收狀態,因此這個字串被狀態機拒絕。
在程式中,我們一般使用二維表來表示一個狀態機,例如上面的狀態機用二維表來表示如下:
輸入 | a | b |
---|---|---|
狀態0 | 1 | 0 |
狀態1 | 0 | 0 |
通過查表,我們便可知道狀態機的轉換,例如處於狀態0,輸入字元是a時,我們從表中得到的數值是1,也就是說處於狀態0,輸入是字元a,那麼狀態機將轉入狀態節點1.
一個文字匹配流程的描述
接下來我們看看一個文字的匹配流程,假定要查詢的字串為P=”ababaca”, 被查詢的文字為T=”abababacaba”. 一次讀入T的一個字元,用S表示當前讀入的T的字元,一開始讀入一個字元,於是S=a.然後看看,從P開始,連續幾個字元所構成的字串可以成為S的字尾,由於當前S只有一個字元a,於是從P開始,連續1個字元所形成的字串”a”,可以作為S的字尾。把這個字串的長度記為k,於是此時k 等於1. 繼續從T中讀入字元,於是S=”ab”, 此時,從P開始,連續兩個字元所構成的字串”ab”可以作為S的字尾,於是k = 2.反覆這麼操作,於是便有以下序列:
- S=a, k = 1, P[1] 是S的字尾
- S=ab, k = 2, P[1,2] 是S的字尾
- S=aba, k = 3, P[1,2,3]是S的字尾
- S=abab, k= 4, P[1,2,3,4]是S的字尾
- S=ababa, k = 5, P[1,2,3,4,5]是S的字尾
- S=ababab, k = 4, P[1,2,3,4]是S的字尾
- S=abababa, k = 5, P[1,2,3,4,5]是S的字尾
- S=abababac, k = 6, P[1,2,3,4,5,6]是S的字尾
- S=abababaca, k = 7, P[1,2,3,4,5,6,7]是S的字尾
- S=abababacab, k =2, P[1,2] 是S的字尾
- S=abababacaba, k = 3, P[1,2,3] 是S的字尾。
注意看第9步,P的長度是7,整個字串P成為了字串S的字尾,而此時的S是文字T的字首,這不就表明文字T含有字串P了嗎。在每一個步驟中,我們都需要從P的第一個字元開始,看看最多能連續讀取幾個字元,使得他們能成為S的字尾,假設P的字元個數為m, 那麼這個讀取過程最多需要讀取m個字元,於是複雜度為O(m). 如果有某種辦法,使得我們一次就可以知道從P開始,連續讀取幾個字元就可以構成S 的字尾,假設文字T含有n個字元,那麼我們就可以在O(n)的時間內判斷,T是否含有字串P.因為上面的步驟最多可以執行n次。
於是當前問題變成,構造一個方法,使得一次執行便能知道從P開始,連續讀取幾個字元能使,得這幾個字元構成的字串是S的字尾。這個方法,就需要上面我們提到的有限狀態自動機。
用於字串匹配的自動機
假定字串P和文字T只由a,b兩個字元組成,也就是字符集為
輸入 | a | b | c |
---|---|---|---|
狀態0 | 1 | 0 | 0 |
狀態1 | 1 | 2 | 0 |
狀態2 | 3 | 0 | 0 |
狀態3 | 1 | 4 | 0 |
狀態4 | 5 | 0 | 0 |
狀態5 | 1 | 4 | 6 |
狀態6 | 7 | 0 | 0 |
狀態7 | 1 | 2 | 0 |
利用上面的狀態機,依次讀入T的字元,如果狀態機跳轉到狀態q,那就表明從P的第一個字元開始,連續讀取q個字元,所形成的字串可以構成是S的字尾,也就是說,當我們的狀態機跳轉到狀態7時,我們就可以得知文字T,包含字串P.
我們走一遍這個過程,首先狀態機處於狀態0,讀入T[0]=a, S= a, 查表可知進入狀態1,讀入T[1]=b, S=ab, 查表可知,進入狀態2,讀入T[2]=a,查表可知進入狀態3,讀入T[3]=b, S=abab,查表可知進入狀態4,讀入T[4]=a,S=ababa,查表可知進入狀態5,讀入T[5]=b,S=ababab,查表可知進入狀態4,讀入T[6]=a, S=abababa,查表可知進入狀態5,讀入T[7]=c,S=abababac,查表可知進入狀態6,讀入T[8]=a,S=abababaca,查表可知進入狀態7,此時,我們可以得出結論,文字T包含有字串P.
程式碼實現
import java.util.HashMap;
public class StringAutomaton {
private HashMap<Integer, HashMap<Character, Integer>> jumpTable = new HashMap<Integer, HashMap<Character, Integer>>();
String P = "";
private final int alphaSize = 3;
public StringAutomaton(String p) {
this.P = p;
makeJumpTable();
}
private void makeJumpTable() {
int m = P.length();
for (int q = 0; q <= m; q++) {
for (int k = 0; k < alphaSize; k++) {
char c = (char)('a' + k);
String Pq = P.substring(0, q) + c;
int nextState = findSuffix(Pq);
System.out.println("from state " + q + " receive input char " + c + " jump to state " + nextState);
HashMap<Character, Integer> map = jumpTable.get(q);
if (map == null) {
map = new HashMap<Character, Integer>();
}
map.put(c, nextState);
jumpTable.put(q, map);
}
}
}
private int findSuffix(String Pq) {
int suffixLen = 0;
int k = 0;
while(k < Pq.length() && k < P.length()) {
int i = 0;
for (i = 0; i <= k; i++) {
if (Pq.charAt(Pq.length() - 1 - k + i) != P.charAt(i)) {
break;
}
}
if (i - 1 == k) {
suffixLen = k+1;
}
k++;
}
return suffixLen;
}
public int match(String T) {
Integer q = 0;
System.out.println("Begin matching...");
for (int n = 0; n <= T.length(); n++) {
HashMap<Character, Integer> map = jumpTable.get(q);
int oldState = q;
q = map.get(T.charAt(n));
if (q == null) {
return -1;
}
System.out.println("In state " + oldState + " receive input " + T.charAt(n) + " jump to state " + q);
if (q == P.length()) {
return q;
}
}
return -1;
}
}
程式碼中,makeJumpTable呼叫用來構建跳轉表,findSuffix用來查詢最大的數值K, 使得P[1…k] 是字串
match依靠跳轉表來判斷,輸入的字串T是否包含字串P,如果T的最後一個字元輸入狀態機後,從跳轉表得到的狀態的值等於P的長度m,那麼表明T包含字串P.具體的程式除錯過程請參看視訊。
我們只給出了演算法的實現流程,演算法的數學原理比較複雜,我們將在下一節詳解。